Running a simple Java method in AWS Lambda with GraalVM (Part 2)

Altimetrik Poland Tech Blog
8 min readJun 6, 2024

--

In the previous Part 1, I introduced AWS Lambda with an example using a Java method running in the JVM. In this Part 2, I am going to provide the necessary steps for setting up everything for our Java method to run on GraalVM. We’ll explore and tackle the complexities of the process to streamline the development process and compare how our Java method performs on the standard JVM versus GraalVM on AWS Lambda.

What is GraalVM?

GraalVM is an advanced JVM that offers significant performance improvements, primarily through its Just-In-Time (JIT) and Ahead-Of-Time (AOT) compilers. The AOT compiler is particularly beneficial for reducing startup times and memory footprint in environments like AWS Lambda, where it compiles Java bytecode directly into native machine code, like other compiled languages.

While GraalVM can reduce startup times and enhance performance, it is crucial to be aware of potential compatibility issues. Some libraries that rely on JVM-specific features may not be supported by GraalVM, which requires a thorough evaluation before deployment in production environments.

The benefits of running a Java method compiled to native code in AWS Lambda are clear: the startup time and resource usage are reduced.

Creating a native Java method

To create a native Java method that runs on AWS Lambda, there are multiple steps that need to be completed. Essentially, a ZIP package must be created, containing the binary/native image of the Java method along with a bootstrap file that will serve as a custom runtime.

Building a native image

The process of building a native image of our Java code means that we need the GraalVM AOT compiler to produce a binary file. Although this step may sound straightforward, it is more complex than it initially appears.

The first reason for the increased complexity comes from the dynamic language features of the JVM. One notable dynamic feature is reflection, which allows interaction at runtime with fields, constructors, methods, or classes. There are many other dynamic features supported by the JVM (like resources, serialization or dynamic proxies). The GraalVM native-image compiler is a static code analyzer and cannot always infer the execution flow of a Java program due to the dynamic features mentioned before.

Because of this, the native-image compiler requires additional help in the form of “Reachability Metadata” (or simply metadata). The metadata needed by the native-image compiler helps determine how a program behaves at runtime despite the dynamic features. GraalVM offers different ways of providing the needed metadata, but I’m going to focus on the generation of metadata in JSON format.

The metadata in JSON format must be passed to the native-image compiler so that the Java code can effectively be translated into native machine code.

To generate the metadata in JSON format, I run my Java code with the native-image agent attached, so that it generates the needed metadata for the compilation process:.jar my.package.MyClass

java -jar -agentlib:native-image-agent=config-output-dir=/my/output/directory /path/to/mylambdacode.jar my.package.MyClass

Although I am using the java command, this is the Java binary that comes with the GraalVM distribution. I need to pass an output directory where the metadata will be saved, the JAR file containing my method to run in AWS Lambda, and the package location and class of my method.

However, this command will not work with the JAR built at the beginning of the article because the Java class MyFirstLambda does not include any entry point (i.e., a public static void main method). So, I added an entry point to my class but instead of converting a String as I did in myLambdaMethod, I am going to invoke myLambdaMethod in the main method using reflection. Note that the input string that we will pass to the method will come as a command-line argument.

Now, we need a new JAR (or at least the compiled MyFirstLambda.class) with the updated class, as shown before. Then, with the new JAR, it is possible to attach the native-image agent (or simply, tracing agent) and generate the metadata. Once the new metadata is ready, we can attempt to generate a binary/native image of the Java code.

native-image -H:Name=nativeapp -H:ConfigurationFileDirectories=/my/output/directory -jar /path/to/mylambdacode.jar

ConfigurationFileDirectories includes any JSON metadata needed, but if necessary, you can specify individual parameters for each generated JSON (like ReflectionConfigurationFiles, JNIConfigurationFiles, etc.)

The native-image command will generate a binary named “nativeapp” (or in Windows, nativeapp.exe). If we execute the binary (in the case of the main method I provided, I run the binary from the command-line passing an argument), we see it works!

So, it seems this should be all we need to package it along with a custom runtime to upload it to AWS. But unless we created the native image of the Java code on a machine with Linux (with a similar or compatible distribution to Amazon Linux 2), the generated native image (whether it was on Windows or macOS) will not run in AWS Lambda.

What to do now?

Building a native image with Docker

One of the options to build a native image that runs on AWS Lambda is to build it with Docker in an Amazon Linux container.

I decided to automate the native image creation with Maven so that Maven will run within a Docker container. In essence, the Docker container will run a Maven build that follows the same three steps I described in the section before:

  1. Build a JAR file with the code
  2. Generate the metadata in JSON format
  3. Build the native image with the generated metadata

The Maven configuration to build the JAR is simply the same as described when building a shaded JAR.

The next step is to generate the metadata in JSON for GraalVM, and for this, we chose the maven-shade-plugin, which will run the Java code JAR with the GraalVM agent to generate the metadata.

And the last step for Maven is to build the binary-native image of the Java code. For this, I use GraalVM’s native-image-maven-plugin.

For this last step, I specified to the plugin the package location of the main class that contains the Java code to run in AWS, as well as passing the JSON metadata. The resulting binary will be named “nativeimage”.

Now it is time to run Maven within a container. The Docker image I have chosen is naturally based on Amazon Linux 2, and here is a simplified Dockerfile.

I omitted here the entire Dockerfile for simplicity, but the container also installs GraalVM and Maven. Looking at the shortened Dockerfile, it copies the Java code source directory along with the pom.xml, a custom runtime (bootstrap file) for AWS Lambda, and another script to build the ZIP file that will be uploaded to AWS.

AWS Lambda extracts the native image and the custom runtime, which is responsible for detecting when there is an event, and if so, executes the native image.

Once we have our ZIP with the native image compiled to run on Amazon Linux, it is time to upload it to AWS. Again, let’s go to AWS, navigate to Lambda, and click “Create function”. On the “Create function” page, select “Author from scratch” and in the basic information section, give a function name, select “Amazon Linux 2” as the runtime, and in architecture, select the same architecture the machine you used to build the native image (in my case, it was an ARM Mac, so I have chosen “arm64”).

Click on “Create function”. On the next screen, upload the ZIP as shown earlier.

Once uploaded, we can see the summary of the lambda:

In the first AWS Lambda function, it was necessary to set the handler to point to the Java method, while here by default the handler value is “hello.handler”. In this case, it is not necessary to modify the handler. The reason is that this lambda function is going to use the custom runtime, which AWS identifies as a file named “bootstrap”. Our custom runtime knows the name of the native image. So, there is no need to specify a handler.

Finally, everything is ready to run the Java code compiled as a native image in AWS Lambda.

To do so, click on the “Test” tab, select “Create new event”, give an event name and, like before, introduce a string of text in the “Event JSON” to pass it to the native image and click “Test”.

We can see the “init duration” is only 25.19 ms, while the first lambda (in Part 1) using the JVM needed 431.76 ms. On the other hand, the billed duration for the native lambda was 1052 ms, while with the JVM this value was just 19 ms. However, both lambdas are not the same because the second lambda uses the reflection API to invoke “myLambdaMethod”, whereas the first lambda directly calls the lambda method. Another factor to consider is the custom runtime, which has a notable impact on the billed duration.

Conclusions

As we have seen, the integration of Java methods with AWS Lambda through GraalVM has significantly enhanced startup times and optimized resource utilization.

However, while GraalVM reduces initialization times and can lead to more efficient resource usage, it is important to note that in the presented case, the billed duration for the native Java method has surpassed that of its JVM counterpart.

For applications where performance and low latency are priorities, the higher cost might be justifiable. However, for projects where cost efficiency is a critical factor, the traditional JVM might represent a more pragmatic approach.

To explore more, I have put everything together and published the source code in a GitHub repository for you to try GraalVM and AWS Lambda by yourself.

Words by Bernardo Alvarez, Senior Engineer in Altimetrik Poland

--

--

Altimetrik Poland Tech Blog
Altimetrik Poland Tech Blog

Written by Altimetrik Poland Tech Blog

This is a Technical Blog of Altimetrik Poland team. We focus on subjects like: Java, Data, Mobile, Blockchain and Recruitment. Waiting for your feedback!

Responses (1)