Introduction
In the part 6, we learned how to develop a pure Micronaut REST application and deploy it on AWS Lambda.
In this part of our series, we'll introduce how to adjust our sample application which uses REST API 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 sample REST API application on AWS Lambda with GraalVM Native Image
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 from part 6.
The steps to be taken to convert the Micronaut 4 REST Application on AWS Lambda to GraalVM Native image which can be deployed and run as a Custom Runtime on AWS Lambda are similar to those described in the part 5 of our article series Measuring Lambda cold and warm starts with GraalVM Native Image where we described such steps for the standard Micronaut 4 application on AWS Lambda.
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 optional Docker)
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 {
}
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 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>
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 io.micronaut.function.aws.runtime.MicronautLambdaRuntime (which comes from the micronaut-function-aws-custom-runtime declared above) and Micronaut runtime set the value of lambda :
<properties>
<exec.mainClass>
io.micronaut.function.aws.runtime.MicronautLambdaRuntime
</exec.mainClass>
<micronaut.runtime>lambda</micronaut.runtime>
</properties>
Default native image name should be equal to artifact id declared in pom.xml - aws-lambda-as-graalvm-native-image-micronaut-4.9-rest-api
2) Customize the build step by declaring native-maven-plugin plugin (exactly how we implement it):
<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>
io.micronaut.function.aws.runtime.MicronautLambdaRuntime
</mainClass>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-http</buildArg>
</configuration>
</plugin>
With this we can override native image name, main class and declare build arguments. Here is further information about GraalVM and AWS Custom runtimes.
For now, we need to know that for Lambda responding to HTTP triggers, Micronaut AWS ships with several implementations of AbstractMicronautLambdaRuntime (which we can configure as a main class):
- MicronautLambdaRuntime for Payload 1.0 (which we configured for our use case).
- APIGatewayV2HTTPEventMicronautLambdaRuntime for Payload 2.0.
- ApplicationLoadBalancerMicronautLambdaRuntime for Application Load Balancers.
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.
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>
Read more about it in the article Micronaut HTTP Client.
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'll find native image 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>
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:
Handler: io.micronaut.function.aws.proxy.payload1.ApiGatewayProxyRequestEventFunction
CodeUri: target/function.zip
Runtime: provided.al2023
....
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 must send the following as HTTP header: "X-API-Key: a6ZbcDefQW12BN56WED49Rest", 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: a6ZbcDefQW12BN56WED49Rest" 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: a6ZbcDefQW12BN56WED49Rest" 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: a6ZbcDefQW12BN56WED49Rest" 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 772 | 783 | 799 | 914 | 925 | 925 | 5.13 | 5.82 | 7.39 | 13.09 | 72.66 | 340 |
Conclusion
In this part of our series, we introduced how to adjust our sample REST API 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 some mixed results: the results vary depending on the percentiles for warm start times. But generally, we can achieve a bit lower cold start times with SnapStart and priming techniques (especially those introduced in the parts 3 and 4).
You can also 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.
Top comments (0)