Iqbal´s DLQ Help

Native Image (Spring Boot 3+) - Paketo Buildpacks Route

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:

Dependency

Used version

Compatibility Matrix

Comments

Java

23.0.2+9

N/A

Liberica-NIK (Native Image Builder)

Liberica-NIK-24.1.2-1

N/A

Native Image Builder

GraalVM (Trace Agent Metadata Collection)

GraalVM for JDK 23.0.1

N/A

Used as a JVM for instrumenting with Trace Agent

Spring Boot

3.4.2

N/A

Spring Cloud

2024.0.0

Link

Spring Cloud AWS

3.3.0

Link

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:

  1. Source code for bean definitions at build time.

  2. Bytecode for dynamic proxies.

  3. 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.

Default profile from the spring parent project:

<profile> <id>native</id> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifestEntries> <Spring-Boot-Native-Processed>true</Spring-Boot-Native-Processed> </manifestEntries> </archive> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>process-aot</id> <goals> <goal>process-aot</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <configuration> <classesDirectory>${project.build.outputDirectory}</classesDirectory> <requiredVersion>22.3</requiredVersion> </configuration> <executions> <execution> <id>add-reachability-metadata</id> <goals> <goal>add-reachability-metadata</goal> </goals> </execution> </executions> </plugin> </plugins> </pluginManagement> </build> </profile>

You can run only Spring AOT with:

mvn spring-boot:process-aot

Inspect:

  • GraalVM hints under target/spring-aot/main/resources/META-INF/native-image/com.myapp.business/myapp/*:

GraalVM Hints
  • Generated source code under target/spring-aot/main/sources:

Spring AOT Generated Source Code

These hints will be part of our final native image build.

Paketo Buildpacks (Native Run)

Dry Run

Cloud Native Buildpacks are a standard way to package applications for deployment. They provide a consistent and secure way to build OCI images.

Spring Boot 3 applications can be built into native images using Paketo Buildpack for Spring Boot

With the native profile in place, we can now move on to building the native image through an initial dry run.

mvn -Pnative spring-boot:build-image -Djacoco.skip -DskipTests

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:

<profile> <id>native</id> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifestEntries> <Spring-Boot-Native-Processed>true</Spring-Boot-Native-Processed> </manifestEntries> </archive> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <image> <env> <BP_JVM_VERSION>23</BP_JVM_VERSION> <BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE> <BP_NATIVE_IMAGE_BUILD_ARGUMENTS>-O0</BP_NATIVE_IMAGE_BUILD_ARGUMENTS> </env> </image> <imageName>app-native-build</imageName> </configuration> <executions> <execution> <id>process-aot</id> <goals> <goal>process-aot</goal> </goals> <configuration> <profiles>local</profiles> </configuration> </execution> <execution> <goals> <goal>build-image-no-fork</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <configuration> <classesDirectory>${project.build.outputDirectory}</classesDirectory> <requiredVersion>22.3</requiredVersion> </configuration> <executions> <execution> <id>add-reachability-metadata</id> <goals> <goal>add-reachability-metadata</goal> </goals> </execution> </executions> </plugin> </plugins> </pluginManagement> </build> </profile>

Debugging the Build

After correctly adding the reachability metadata issue, the build is now successful:

Now the context is failing to launch due to missing reflection configuration.

2025-02-15 17:31:34 Caused by: javax.xml.transform.TransformerException: com.sun.org.apache.xpath.internal.functions.FuncNormalizeSpace.<init>()

Let's start debugging the built image and provide the necessary hints to get the application context to start.

Trace Agent with Paketo Buildpacks JVM Build

The trace agent is a tool that can be used to collect runtime information from the application.

Let's configure a regular JVM profile for a source-to-image build and instrument it with the trace agent:

mvn -Pjvm package -Djacoco.skip -DskipTests -DskipNativeTests=true

JVM Profile

<profile> <id>jvm</id> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>build-image-no-fork</goal> </goals> </execution> </executions> <configuration> <image> <env> <BP_JVM_VERSION>23</BP_JVM_VERSION> <BP_NATIVE_IMAGE>false</BP_NATIVE_IMAGE> <JAVA_TOOL_OPTIONS>-agentlib:native-image-agent=config-output-dir=/iqbal-output-dir-{pid}-{datetime}/</JAVA_TOOL_OPTIONS> </env> </image> <imageName>app-jvm-build</imageName> </configuration> </plugin> </plugins> </pluginManagement> </build> </profile>

JVM AOT Profile

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:

<profile> <id>jvm-aot</id> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>process-aot</id> <goals> <goal>process-aot</goal> </goals> <configuration> <profiles>local</profiles> </configuration> </execution> <execution> <goals> <goal>build-image-no-fork</goal> </goals> </execution> </executions> <configuration> <image> <builder>paketobuildpacks/builder-jammy-buildpackless-base</builder><!--required for AArch64/M1 support --> <buildpacks> <buildpack>paketobuildpacks/graalvm</buildpack> <buildpack>paketobuildpacks/java</buildpack> </buildpacks> <env> <BP_JVM_VERSION>23</BP_JVM_VERSION> <BP_JVM_TYPE>JDK</BP_JVM_TYPE> <BP_NATIVE_IMAGE>false</BP_NATIVE_IMAGE> <BP_SPRING_AOT_ENABLED>true</BP_SPRING_AOT_ENABLED> <BPE_DELIM_JAVA_TOOL_OPTIONS xml:space="preserve"> </BPE_DELIM_JAVA_TOOL_OPTIONS> <BPE_APPEND_JAVA_TOOL_OPTIONS>-agentlib:native-image-agent=config-output-dir=/tmp/trace-agent/config-{pid}-{datetime}/</BPE_APPEND_JAVA_TOOL_OPTIONS> </env> </image> <cleanCache>false</cleanCache> <imageName>app-jvm-aot</imageName> </configuration> </plugin> </plugins> </pluginManagement> </build> </profile>

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

https://www.graalvm.org/latest/reference-manual/native-image/guides/build-spring-boot-app-into-native-executable/

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:

Enable the Trace Agent
<BPE_DELIM_JAVA_TOOL_OPTIONS xml:space="preserve"> </BPE_DELIM_JAVA_TOOL_OPTIONS> <BPE_APPEND_JAVA_TOOL_OPTIONS>-agentlib:native-image-agent=config-output-dir=/tmp/trace-agent/config-{pid}-{datetime}/</BPE_APPEND_JAVA_TOOL_OPTIONS>

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:

graalvm native image agent in container.png

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':

aot-metadata-added.png

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:

"resources": [ { "glob": "path/to/xsd/files/*" } ],

and some POJOs which the agent effectively picked up, but I still had to loosen up the reflection configuration for them:

Here is an example for this case:

{ "type": "com.myapp.MyJacksonSerializedPojo", "allDeclaredConstructors": true, "allPublicConstructors": true, "allDeclaredMethods": true, "allPublicMethods": true },

Spring Context Launch Succeeds

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:

$.reflection[?(@.type =~ /^.*com\.myapp\.x\.y\..*Impl$/)]

and add looser reflection configurations for them:

{ "type": "com.myapp.x.y.MyImpl", "allDeclaredConstructors": true, "allPublicConstructors": true, "allDeclaredMethods": true, "allPublicMethods": true },
Last modified: 09 March 2025