DEV Community

Vadym Kazulkin for AWS Heroes

Posted on

Micronaut 4 application on AWS Lambda- Part 5 Measuring Lambda cold and warm starts with GraalVM Native Image

Introduction

In the part 1 of our series about how to develop, run and optimize Micronaut web application on AWS Lambda, we demonstrated how to write a sample application which uses the Mironaut framework, AWS Lambda, Amazon API Gateway and Amazon DynamoDB. We also made the first Lambda performance (cold and warm start time) measurements and observed quite a big cold start time.

In the part 2 of the series, we introduced Lambda SnapStart and measured how its enabling reduces the Lambda cold start time by far more than 50% depending on the percentile.

In the part 3 of the series, we introduced how to apply Lambda SnapStart priming techniques by starting with DynamoDB request priming with the goal to even further improve the performance of our Lambda functions. We saw that by doing this kind of priming by writing some additional code we could significantly further reduce (depending on the percentiles by more than 50%) the Lambda cold start times compared to simply activating the SnapStart.

In the part, we introduced another Lambda SnapStart priming technique which is API Gateway request event priming to reduce the cold start times even further

We also clearly observed the impact of the AWS SnapStart Snapshot tiered cache in all our measurements so far.

In this part of our series, we'll introduce how to adjust our sample application to one from which we can build the GraalVM Native Image and deploy it as a Lambda Custom Runtime. We'll then measure the Lambda performance with it and compare the results with other already introduced approaches.

Micronaut 4 on AWS Lambda with GraalVM Native Image (rebuilt) sample application

This article assumes prior knowledge of GraalVM and its native image capabilities. For a concise overview about them and how to get both installed, please refer to the following articles: Introduction to GraalVM, GraalVM Architecture and GraalVM Native Image or read my article Introduction to GraalVM and its native image capabilities.

Let's take a look at the rebuilt sample application and the differences to our previous sample application.

First, of all I upgraded the application dependencies to use the newest versions at the time of writing (for example Micronaut 4.9.3). We also use the newest GraalVM 25 release. I launched EC2 Amazon Linux 23 instance to install it

# install sdkman
curl -s "https://get.sdkman.io" | bash
source "/home/ec2-user/.sdkman/bin/sdkman-init.sh"

#install graalvm 25
sdk install java 25-graal

# install native timage
sudo yum install gcc glibc-devel zlib-devel 
sudo dnf install gcc glibc-devel zlib-devel libstdc++-static

## install git and maven (and optionally Docker)
Enter fullscreen mode Exit fullscreen mode

As far as the source code of the application is concerned, the GraalConfig class has been added. In this class, we use ReflectionConfig annotation to define the classes that are only loaded at runtime.

@ReflectionConfig(type = APIGatewayProxyRequestEvent.class)
@ReflectionConfig(type = APIGatewayProxyRequestEvent.ProxyRequestContext.class)
@ReflectionConfig(type = APIGatewayProxyRequestEvent.RequestIdentity.class)
@ReflectionConfig(type = HashSet.class)
@ReflectionConfig(type = DateTime.class)

public class GraalConfig {

}
Enter fullscreen mode Exit fullscreen mode

Since GraalVM uses Native Image Ahead-of-Time compilation, we need to provide such classes in advance, otherwise ClassNotFound errors will be thrown at runtime. This includes some AWS dependencies to APIGateway Proxy Event Request (from the artifact id aws-lambda-java-events from pom.xml), DateTime class to convert timestamp from JSON to Java object and some other classes. It sometimes takes several attempts to deploy and run the application first to find all such classes.

In the article Generate reflection metadata for GraalVM Native Image in the section "11. Handling Reflection", we can find more options on how to generate reflection metadata for GraalVM Native Image.

In the pom.xml there are a few more additional declarations necessary:

First of all, we need to declare the dependency to the micronaut-function-aws-custom-runtime to express that the native image will be deployed as Custom Runtime on AWS Lambda

<dependency>
    <groupId>io.micronaut.aws</groupId>
    <artifactId>micronaut-function-aws-custom-runtime</artifactId>
    <scope>compile</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

For Lambda responding to HTTP triggers, Micronaut AWS ships with several implementations of AbstractMicronautLambdaRuntime (which we can configure as a main class):

For our use case we need a custom implementation of the Micronaut Lambda Runtime, which we provided as FunctionLambdaRuntime:

public class FunctionLambdaRuntime extends AbstractMicronautLambdaRuntime<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent, APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>
{
    public static void main(String[] args) {
        try {
            new FunctionLambdaRuntime().run(args);
        } catch (MalformedURLException e) {
            e.printStackTrace();
        }
    }

    @Override
    @Nullable
    protected RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> createRequestHandler(String... args) {
       return new GetProductByIdHandler();
    }
}
Enter fullscreen mode Exit fullscreen mode

This class inherits io.micronaut.function.aws.runtime.AbstractMicronautLambdaRuntime and invokes GetProductByIdHandler in the createRequestHandler method.
Unfortunately, this approach doesn't scale, as we often deploy multiple Lambda functions together (for example behind the same API Gateway) by providing the same deployment artifact. I haven't found any way yet, so that the runtime implementation can automatically delegate to the right Lambda handler implementation. I even expect that we even don't need to implement such runtime by our own, but use the implementation provided by my Micronaut framework (like io.micronaut.function.aws.runtime.MicronautLambdaRuntime in case we use Controllers instead of Lambda functions). I created the GitHub issue for it.

There are 2 options to build the GraalVM Native Image:

1) In the property section the of pom.xml we declare some more properties: main class pointing to the FunctionLambdaRuntime and micronaut runtime set to the value of lambda :

<properties>
  <exec.mainClass>
     software.amazonaws.example.product.handler.FunctionLambdaRuntime
  </exec.mainClass>
  <micronaut.runtime>lambda</micronaut.runtime>
</properties>
Enter fullscreen mode Exit fullscreen mode

Default native image name will be equal to artifact id declared in pom.xml - aws-lambda-as-graalvm-native-image-micronaut-4.9

2) (what we do) customize the build step by declaring native-maven-plugin plugin:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <version>0.11.0</version>
    <configuration>
    <imageName>
            aws-lambda-as-graalvm-native-image-micronaut
        </imageName>                 
        <mainClass>
          software.amazonaws.example.product.handler.FunctionLambdaRuntime
        </mainClass>
    <buildArgs>
              <buildArg>--no-fallback</buildArg>
              <buildArg>--enable-http</buildArg>         
      </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

With this we can override native image name, main class and declare build arguments.

With this we can override native image name, main class and declare build arguments. Here is further information about GraalVM and AWS Custom runtimes.

If we execute the steps above with only these changes, native image can be built, deployed, but we'll run into the following runtime error when invoking our Lambda function:

2025-09-21T11:51:27.569Z
io.micronaut.context.exceptions.NoSuchBeanException: No bean of type [io.micronaut.http.client.HttpClient] exists.
2025-09-21T11:51:27.569Z
* [HttpClient] is disabled because it is within the package
 [io.micronaut.http.client] which is disabled due to bean requirements:
2025-09-21T11:51:27.569Z
- No bean of type [io.micronaut.http.client.HttpClientRegistry] 
present within context
2025-09-21T11:51:27.573Z
at io.micronaut.context.DefaultBeanContext.newNoSuchBeanException(DefaultBeanContext.java:2804)
2025-09-21T11:51:27.573Z
at io.micronaut.context.DefaultApplicationContext.newNoSuchBeanException(DefaultApplicationContext.java:338)
2025-09-21T11:51:27.573Z
at io.micronaut.context.DefaultBeanContext.createBean(DefaultBeanContext.java:1074)
2025-09-21T11:51:27.573Z
at io.micronaut.context.BeanContext.createBean(BeanContext.java:157)
2025-09-21T11:51:27.573Z
at io.micronaut.function.aws.runtime.AbstractMicronautLambdaRuntime.startRuntimeApiEventLoop(AbstractMicronautLambdaRuntime.java:399)
2025-09-21T11:51:27.573Z
at io.micronaut.function.aws.runtime.AbstractMicronautLambdaRuntime.run(AbstractMicronautLambdaRuntime.java:167)
2025-09-21T11:51:27.573Z
at io.micronaut.function.aws.runtime.MicronautLambdaRuntime.main(MicronautLambdaRuntime.java:41)
2025-09-21T11:51:27.573Z
at java.base@25/java.lang.invoke.LambdaForm$DMH/sa346b79c.invokeStaticInit(LambdaForm$DMH)
2025-09-21T11:51:27.573Z
Request loop failed with: No bean of type [io.micronaut.http.client.HttpClient] exists.
Enter fullscreen mode Exit fullscreen mode

Micronaut Framework needs an HTTP client to poll the Lambda Runtime when you deploy the artifact as a native executable. Add the following dependency to satisfy this requirement. To solve this problem, we need to define one more dependency in the pom.xml:

<dependency>
   <groupId>io.micronaut</groupId>
   <artifactId>micronaut-http-client-jdk</artifactId>
   <scope>compile</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Read more about it in the article Micronaut HTTP Client.

One more important thing to make this example work, is to remove the private in the declaration of the injected fields in the Lambda function, for example in GetProductByIdHandler so that the fields have package protected scope:

@Inject
ProductDao productDao;

@Inject
JsonMapper objectMapper;
Enter fullscreen mode Exit fullscreen mode

Otherwise we'll run into the runtime error like this:

2025-09-21T20:30:31.084Z
[main] ERROR io.micronaut.function.aws.MicronautRequestHandler -
 Exception initializing handler
2025-09-21T20:30:31.084Z
io.micronaut.context.exceptions.DependencyInjectionException: 
Error instantiating bean of type [software.amazonaws.example.product.handler.GetProductByIdHandler]
2025-09-21T20:30:31.084Z
Message: Error setting field value: No field 'productDao' found for type: 
software.amazonaws.example.product.handler.GetProductByIdHandler
2025-09-21T20:30:31.095Z
at io.micronaut.context.AbstractInitializableBeanDefinition.setFieldWithReflection(AbstractInitializableBeanDefinition.java:961)
2025-09-21T20:30:31.095Z
at software.amazonaws.example.product.handler.$GetProductByIdHandler$Definition.inject(Unknown Source)
Enter fullscreen mode Exit fullscreen mode

Another solution for this problem might keeping the field's modifiers private but annotating the injected fields with @ReflectiveAccess as described in this article in the section 11.4.

Now we're ready to build a GraalVM native image with the following command:

mvn clean package -Dpackaging=native-image -Dmicronaut.runtime=lambda -Pgraalvm -Denforcer.skip=true

After the native image is built, we' see native image fle in the /target subfolder. It'd be nice if after the package step we function.zip with the native image will be created directly ready to be deployed as a Lambda Custom Runtime (I created the GitHub issue for it), but we can fix it by declaring maven-assembly-plugin in the
pom.xml which will take care of creating function.zip (see finalName in the plugin configuration and native.xml) with the native image:

<plugin>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
     <execution>
        <id>native-zip</id>
        <phase>package</phase>
        <goals>
            <goal>single</goal>
        </goals>
        <inherited>false</inherited>
    </execution>
    </executions>
    <configuration>
     <finalName>function</finalName>
     <appendAssemblyId>false</appendAssemblyId>
     <descriptors>
        <descriptor>src/assembly/native.xml</descriptor>
        </descriptors>
   </configuration>
</plugin>
Enter fullscreen mode Exit fullscreen mode

Alternatively, we'can build the native image with the following command: mvn clean package -Dpackaging=docker-native -Dmicronaut.runtime=lambda -Pgraalvm -Denforcer.skip=true.

The only difference is the packaging variable which now has the value docker-native instead of native-image. For it we need Docker to be installed, but we don't have a control which version of GraalVM Native Image will be used for building the image. By using native-image packaging it's always the locally installed one.

The last part of the changes concerns AWS SAM template.yaml. Since there is no managed Lambda GraalVM runtime environment, the question arises how we can deploy our native GraalVM image on AWS Lambda. This is possible if we choose Lambda Custom Runtime as the runtime environment (this currently only supports Linux) and deploy the built .zip file as a deployment artifact. You can find out more about this in the article Building a custom runtime for AWS Lambda. This is exactly what we define in template.yaml as follows:

Globals:
  Function:
    CodeUri: target/function.zip
    Runtime: provided.al2023  
    ....
Enter fullscreen mode Exit fullscreen mode

With Runtime provided.al2023 we define Lambda runtime environment as Amazon Linux 2023 Custom Runtime, generic Lambda Handler io.micronaut.function.aws.proxy.payload1.ApiGatewayProxyRequestEventFunction and with CodeUri target/function.zip we define the path to the deployment artifact compiled with Maven in the previous step. Deployment works the same way with sam deploy -g. The API is secured with the API key. We have to send the following as HTTP header: "X-API-Key: a6ZbcDefQW12BN56WED49", see MyApiKey definition in template.yaml. To create the product with ID=1, we can use the following curl query:

curl -m PUT -d '{ "id": 1, "name": "Print 10x13", "price": 0.15 }' -H "X-API-Key: a6ZbcDefQW12BN56WED49" https://{$API_GATEWAY_URL}/prod/products

For example, to query the existing product with ID=1, we can use the following curl query:

curl -H "X-API-Key: a6ZbcDefQW12BN56WED49" https://{$API_GATEWAY_URL}/prod/products/1

In both cases, we need to replace the {$API_GATEWAY_URL} with the individual Amazon API Gateway URL that is returned by the sam deploy -g command. We can also search for this URL when navigating to our API in the Amazon API Gateway service in the AWS console.

Measurements of the Lambda cold and warm start times of our application with GraalVM Native Image

In the following, we will measure the performance of our GetProductByIdFunction Lambda function, which we will trigger by invoking curl -H "X-API-Key: a6ZbcDefQW12BN56WED49" https://{$API_GATEWAY_URL}/prod/products/1.

The results of the experiment are based on reproducing more than 100 cold starts and about 100,000 warm starts with the Lambda function GetProductByIdFunction (we ask for the already existing product with ID=1) for a duration of about 1 hour. We give Lambda function 1024 MB memory, which is a good trade-off between performance and cost. We also use (default) x86 Lambda architecture. For the load tests I used the load test tool hey, but you can use whatever tool you want, like Serverless-artillery or Postman.

Cold (c) and warm (w) start time in ms:

c p50 c p75 c p90 c p99 c p99.9 c max w p50 w p75 w p90 w p99 w p99.9 w max
687 709 750 884 906 906 5.12 5.86 6.88 13.33 71.59 338

Conclusion

In this part of our series, we explained how to adjust our sample application to one from which we can build the GraalVM Native Image and deploy it as a Lambda Custom Runtime. Comparing to the measurements done with the managed Java 21 Lambda runtime including enabling Lambda SnapStart and applying priming summarized in the part 4 we see that the results vary depending on the percentiles for warm start times but are very close. Generally, with native image we can achieve comparable cold start times with SnapStart when applying priming techniques (like those introduced in parts 3 and 4).

You can als try to give Lambda less memory than 1024 MB (something between 512 and 768 MB) when using GraalVM Native Image and re-measure performance because this approach offers quite stable results also with the lower Lambda memory setting which leads to the lower Lambda cost.

In the end I'd like to refer you to the article Detaching GraalVM from the Java Ecosystem Train, so we clearly see that further development will be a part of Project Leyden which is already part of the Oracle JDK 25. For me AOT and CDS caches which Leyden seems to rely on, are not the same as native image and won't be as performant, so I'm really curious what the future of GraalVM Native Image would be.

Top comments (0)