The next innovation in Spring Boot: GraalVM Native Images
For many years, the Spring Boot framework has made it easy to develop and deploy java web applications. The ease of configuring spring beans through auto-configuration, the addition of active profiles and application properties and deploying jar files with embedded web containers have made life simple for java developers. But on the other hand, Spring web applications often start slowly due to the number of auto-configurations, many of which are unused or unnecessary. The memory footprint of such applications is often large; web servers or containers with a memory usage upwards to 1GB are quite common these days. GraalVM gives us a solution to these problems.
GraalVM and Spring Boot 3.0
GraalVM gives us a solution to this problem by offering a JDK distribution that includes a native image builder to compile our java code into a native executable image. This image contains all classes and dependencies, but does not require a Java runtime to run. This image is platform-dependent; it needs to be created specifically for the system we want it to run on.
GraalVM requires static metadata to create a native image. This means that frameworks such as Spring Boot, which use auto-configuration technologies to start up, require extra steps to make the native image builder aware of these auto-configurations in advance. We call this process-aot. Spring Boot 3.0 introduces support for native images by default, which we will use to create Spring native images.
Getting started with GraalVM
The first step in getting started with GraalVM is installing a GraalVM JDK. For this purpose, we will use the Liberica GraalVM 22.3 distribution for JDK17. For more information about the Liberica Native Image Kit (NIK) visit: https://bell-sw.com/liberica-native-image-kit/
I recommend using an SDK manager to easily switch between GraalVM and other JDKs. For example:
sdk install java 22.3.r17-nik
Create a fresh Spring Boot project from https://start.spring.io/ with parent version 3.0.0 or later. You can choose any language or build project you like. However, I will be using maven in the examples. With Spring Boot 3, the spring boot parent will have the native maven plugin included to help us build native images:
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
This plugin will provide us with a profile that executes the two steps that we need: process-aot and build-image. If you have never used this native plugin before, you might need to add a version management in your project, to prevent maven from being unable to find the plugin.
Building the native image is made simple by this plugin; just run:
./mvnw clean package -Pnative
This will run the process-aot and build-image steps and generate a binary image for your OS. Depending on your OS, run the native executable. For example, on MacOS:
$ target/springbootnativedemo

Tip: if start-up is still slow on MacOS, MacOS might have issues resolving the connection to localhost. Make sure to add the name of your machine to /etc/hosts. For example, in my case Erwins-MacBook-Pro.local.
To run tests on a native image, the native maven plugin also supports running process-test-aot by using:
mvn clean package -Pnative,nativeTest
Be aware that since building the native image can take upwards of several minutes depending on additional configuration you might have for the process-aot stage, running tests locally on a native image might not be optimal.
You have now learned how to generate a native image for your project. Unfortunately, this native executable works only on your OS, so of course this is not useful for deployment servers.
Limitations of native images
Since processing everything the application could do ahead of time would be incredibly slow, there are some limitations to native images that the ahead-of-time processor needs to be made aware of before using them, including but not limited to:
- Reflection needs to be registered with runtime hints before being usable within a native application
- Resource files need to be registered with runtime hints to be accessible within a native application
- Active spring profiles cannot be changed at runtime anymore, so the advice is to stop using them, or process them ahead of time in the application.properties
- Conditional beans cannot be changed at runtime, so conditional beans must be set ahead of time and properties that affect such auto-configurations need to be set ahead of time in application properties
To learn how to register these or other unmentioned limitations in the process-aot step for spring boot applications, check the official spring boot reference docs: https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/#aot
Building native images for deployment servers
If we want to utilize native images on our deployment servers or (cloud) containers, we will need to generate the native binary on a docker image or a build server. To show how to get started with that, we can use a dockerfile to create a container with maven, GraalVM and build-essentials for native images installed. For example, if you are using Ubuntu linux:
FROM ubuntu:latest
COPY . /app
WORKDIR /app
RUN apt-get -y update
RUN apt-get -y install maven
RUN apt-get -y install build-essential
RUN tar -xzf liberica-openjdk17.0.5-graalvm22.3.0.tar.gz
You will need to download the liberica tar.gz file for GraalVM 22.3+ on JDK17 here: https://bell-sw.com/pages/downloads/native-image-kit/#/nik-22-17
Build and start the container:
Docker build –t springbootnative .
docker run -it springbootnative
This will copy the app into a docker container and install necessary command line tools. The last line will unzip the GraalVM that we need. Last step is to set the JAVA_HOME and PATH variables to work with the GraalVM:
export JAVA_HOME=/app/bellsoft-liberica-vm-core-openjdk17-22.3.0
export PATH=/app/bellsoft-liberica-vm-core-openjdk17-22.3.0/bin/:$PATH
This should allow you to run the build once more:
./mvnw clean package -Pnative
You can also run the shell script that was imported into the container to run these steps.
Run the native Ubuntu image like:
$ ./target/springbootnativedemo

You have now generated a native image for Ubuntu Linux! We can run this binary on any comparable Linux OS system without the need for any other libraries, JDK or any content of our docker container. You can export this native binary to be executed on an Ubuntu deployment server. To do this, you must look up the name of the container and copy the executable to the host:
Docker ps
docker cp <containerId>:/app/target/springbootnativedemo /host/path/target
Shipping libraries with static images
Unfortunately, this native binary will only work on Ubuntu distributions that are compatible with the Ubuntu version used in the dockerfile. This means that with this type of image, you need to have control over the OS version of your deployment server or have build servers with identical OS as the deployment server.
If this is not the case, we must export the OS libraries as part of the native binary we create. We call this mostly static, or static images. A mostly static image is created by adding an optional parameter to the arguments in the maven plugin (overriding the parent is required to do this):
native-image -H:+StaticExecutableWithDynamicLibC
This will not work for some libraries such as zlib. If your native executable fails to run due to incompatibility of zlib libraries, you must create the native image using a toolchain.
For more information about static images, see: https://www.graalvm.org/22.0/reference-manual/native-image/StaticImages/
Conclusions
If start-up times, memory footprint or processing times of spring-boot or java apps, have been holding you back on delivering non-functional requirements to your customers, GraalVM native images can be a solution for your application.
However, the time invested in building and maintaining the right native image compatible with your environment might vary depending on what sort of operations your team uses. If you control the OS yourself, building the right native binary is easy, but if this isn’t the case, more time is needed to create static images.
Depending on the complexity of your application, you might need to investigate which parts to stop using. One example is the active profiles, since they are only registered at compile time and cannot be changed. Other limitations such as resource files or auto-configuration must be configured ahead of time.