This guide details the process of migrating a Spring Boot application to GraalVM Native Image.
The application integrates Spring Cloud and Spring Cloud AWS. Be prepared for potential runtime errors and the need for manual adjustments during the image-building process.
For this guide, we will be making use of the native Paketo buildpacks to build the native image.
Upgrading to a Native-Compatible Setup
First, upgrade to the latest versions of Spring Boot 3.x.x, Spring Cloud, and Spring Cloud AWS on which the application depends.
Make sure to check the compatibility matrix for these dependencies:
In this case, upgrading was straightforward since the Spring Boot 2 to 3 upgrade was already completed.
Simply update the versions and run integration and regression tests to ensure the service remains intact.
Enable Native Support
Now that you're running on compatible versions, set up your project for Ahead-of-Time (AOT) processing and native image building.
If you are using the Spring Boot parent starter, a native profile is already included. The following setup demonstrates manually adding the profile in case you have a custom parent POM.
Spring AOT
Introduction
The goal of Spring AOT Compilation is to provide GraalVM with the necessary metadata for Spring's dynamic features.
These features, previously processed at runtime, need to be initialized during compile time for native images.
AOT processing provides:
Source code for bean definitions at build time.
Bytecode for dynamic proxies.
Hint files for:
Reflective access metadata.
Serialization metadata.
Resources to include.
Proxy configuration.
Handling Profiles and Conditional Bean Creation
Feeding all dynamic aspects into GraalVM during native image build would undermine the goal of maintaining a clean runtime:
It increases size, negating benefits like reduced footprint and optimization.
It compromises a reduced attack surface, as runtime wouldn't be restricted to a profile or tailored for specific properties.
As a result, building native images introduces restrictions on profiles and conditional bean loading (e.g., @Conditional). The application context during AOT compilation mirrors the build-time configuration.
In Practice
Since we covered in detail the Spring AOT in Native profile
Let's jump directly to the profile that we are using for this build.
As with the previous build, we will be manually providing the profile in the POM because we use a custom parent.
We can see the buildpack for native image being used:
[INFO] [creator] Paketo Buildpack for Native Image 5.15.0
[INFO] [creator] https://github.com/paketo-buildpacks/native-image
[INFO] [creator] Build Configuration:
[INFO] [creator] $BP_BINARY_COMPRESSION_METHOD Compression mechanism used to reduce binary size. Options: `none` (default), `upx` or `gzexe`
[INFO] [creator] $BP_NATIVE_IMAGE enable native image build
[INFO] [creator] $BP_NATIVE_IMAGE_BUILD_ARGUMENTS arguments to pass to the native-image command
[INFO] [creator] $BP_NATIVE_IMAGE_BUILD_ARGUMENTS_FILE a file with arguments to pass to the native-image command
[INFO] [creator] $BP_NATIVE_IMAGE_BUILT_ARTIFACT the built application artifact explicitly, required if building from a JAR
First hurdle, the build fails with the following error:
[INFO] [creator] Fatal error: com.oracle.graal.pointsto.constraints.UnsupportedFeatureException: An object of type 'ch.qos.logback.classic.Logger' was found in the image heap. This type, however, is marked for initialization at image run time for the following reason: classes are initialized at run time by default.
[INFO] [creator] This is not allowed for correctness reasons: All objects that are stored in the image heap must be initialized at build time.
[INFO] [creator]
Now that we have the initial dry run, let's move on to debugging, providing the right hints, and configuration to Spring Boot Paketo Buildpack for Native Image.
Customizing the Spring Boot Maven Plugin
Let's start by customizing the Maven Spring Boot plugin to suit our needs for the native image build:
#todo add custom env vars and try to get it to work logback etc
In the native profile, let's pass env vars to the buildpack:
Even better, let's make an AOT instrumented Spring Boot docker image to get metadata that is tailored to the application's runtime active Spring profiles:
Let's go through some of the XML tags used in the profile:
<builder>paketobuildpacks/builder-jammy-buildpackless-base</builder><!--required for AArch64/M1 support -->
<buildpacks>
<buildpack>paketobuildpacks/graalvm</buildpack>
<buildpack>paketobuildpacks/java</buildpack>
</buildpacks>
These tags will allow us to use GraalVM as a JVM rather than a native image builder as described in
This setup will allow us to collect metadata from the instrumented Spring Boot AOT enabled app.
At the time of writing, the default Paketo Jammy builders only supported ARM64 for the tiny variant, I am using the base variant to stick to amd64 architecture.
I had issues with the tiny variant and the Mac's ARM64 architecture that seem to be linked to:
To append to the JAVA_TOOL_OPTIONS environment variable at runtime, use the tags above.
Build with the JVM AOT profile:
mvn -Pjvm-aot package
Run the docker image with to expose the port for running a couple of queries against your build and collect metadata from the trace agent.
In this example, we mounted the collected data from the container on the host machine under '$(pwd)/tmp':
docker run -p 8080:8080 -v $(pwd)/tmp:/tmp:z app-jvm-aot
You can now inspect the trace agent output in the /tmp directory:
Trace Agent Output
You can add the generated reachability-metadata.json to the native image build just by adding it under the src/main/resources/META-INF/native-image directory.
You can use a namespace of your choice, as in this example 'from-jvm-aot':
Similar to the previous article where we used the Native Build Tools plugin, I still needed to supplement custom hints to get to a successful context launch and weed out runtime errors.
I had to cherry-pick some reflection and resource configurations from the agent run and include them in the native image build for some custom POJOs as well as JAXB.
It looks like XSDs that reference other ones are not being picked up by the agent, which you can resolve by providing for example:
After adding the necessary hints, the context launches successfully:
2025-02-28T19:49:42.339Z INFO 1 --- [ main] com.myapp.MainApplication : Started MainApplication in 0.898 seconds (process running for 0.902)
We'll cover more practical debugging and runtime issues next.
Debugging the Native Image
Getting to a context launch is half the battle :D
Though some POJOs used in JSON serialization/deserialization were picked up by the trace agent, I still had to loosen reflection configurations in bulk for serialization and deserialization to work.
At this time, unfortunately, there is no support for wildcard for packages in the reachability-metadata.json file, so you have to list each class individually.
Let's make use of Intellij's JSONPath Plugin to bulk change reflection configuration:
For instance, you can pull all classes ending in Impl under the package com.myapp.x.y: