Iqbal´s DLQ main Help

Road to Native Image (Spring Boot 3+)

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, I used the GraalVM Native Build Tool directly. Other approaches, such as converting the JAR directly or using Paketo Buildpacks, are not covered.

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

Java

23

N/A

GraalVM

Oracle GraalVM 23.0.1+11.1

N/A

Spring Boot

3.4.1

N/A

Spring Cloud

2024.0.0

Link

Spring Cloud AWS

3.3.0-RC1

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

Add the AOT plugin to your POM:

<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> </executions> </plugin>

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

GraalVm Native Build Tools

This project will provide you with a maven plugin to support building the native image via maven (or gradle).

There are useful flags for instance to speed up development and ignore some optimizations graalvm applies to the artifact.

you can also pass args to native-image command directly as you'll see below.

check the full documentation for references.

for the basic setup, add to the pom :

<plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <configuration> <buildArgs> <arg>-Ob</arg> </buildArgs> <classesDirectory>${project.build.outputDirectory}</classesDirectory> <requiredVersion>22.3</requiredVersion> <imageName>executable-name</imageName> <fallback>false</fallback> <verbose>true</verbose> </configuration> <extensions>true</extensions> <executions> <execution> <id>build-native</id> <goals> <goal>compile-no-fork</goal> </goals> <phase>package</phase> </execution> <execution> <id>test-native</id> <goals> <goal>test</goal> </goals> <phase>test</phase> </execution> <execution> <id>add-reachability-metadata</id> <goals> <goal>add-reachability-metadata</goal> </goals> </execution> </executions> </plugin>

Native profile

Let's combine under a profile called native so that both steps run when we package the application:

<profile> <id>native</id> <build> <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> <configuration> <profiles> <profile>local</profile> <profile>jcache</profile> </profiles> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <configuration> <buildArgs> <arg>-Ob</arg> <arg>--initialize-at-run-time=sun.net.dns.ResolverConfigurationImpl</arg> </buildArgs> <classesDirectory>${project.build.outputDirectory}</classesDirectory> <requiredVersion>22.3</requiredVersion> <imageName>executable-name</imageName> <fallback>false</fallback> <verbose>true</verbose> <agent> <enabled>false</enabled> </agent> </configuration> <extensions>true</extensions> <executions> <execution> <id>build-native</id> <goals> <goal>compile-no-fork</goal> </goals> <phase>package</phase> </execution> <execution> <id>test-native</id> <goals> <goal>test</goal> </goals> <phase>test</phase> </execution> <execution> <id>add-reachability-metadata</id> <goals> <goal>add-reachability-metadata</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </profile>

Building the image

Let's run with this minimum setup and try to figure out the problems that come along

mvn -Pnative package -DskipTests
graal_cli_native_image_console_output.png

After the build is finished, Native Image provides you with a binary that you can run:

spring_profiles_active=local ./target/executable-name.exe

Using the agent

My initial build fails to launch the spring application context.

You can supply your own metadata during the image build.

Let's make use of the Native image agent to get the metadata directly from a JVM run of the application!

Running The Native Agent During Tests

What we will do in this section is to run tests in JVM mode and use the agent to collect metadata from those tests

we can specifically skip just the native tests in that case with -DskipNativeTests

Let's Set up the agent to run along JVM UTs

mvn clean install -Pnative package -DskipTests=false -DskipNativeTests=true -Djacoco.skip=true

I'm skipping jacoco as it looks like the generated sources from aot is counting towards coverage and causing it to fail.

Let's plug in the agent, see native build tool section under the native profile :

<agent> <enabled>true</enabled> </agent>

When rerunning our native build, you can see console output merging agent metadata during image build :

agent_merge_metadata.png

generated hints collected during test runs

test_hints_agent.png

Running The Native Agent

In this phase, we'll collect metadata with the help of the agent by running

spring_profiles_active=local java -agentlib:native-image-agent=config-output-dir=agent-run-config -jar target/app-1.1.jar

I only collected up to context launch, but you keep running it exercising different paths according to which the application runs in prod.

the agent generates files in the specified directory which you can copy under src/main/resources/META-INF/ for the plugin to pick them up during the next build:

supply_agent_meta_data_fixed.png

Debugging the native image

With this initial setup, Spring's application context fails to launch due to :

  • missing reflection configuration

  • missing resources

  • missing jni config

I had to cherry-pick reflection and resource configurations from the agent run and include it in native image builds for some custom pojos as well as JaxB :

you can use JSONPath to only get the metadata you need

$[?(@.name && @.name contains 'com.app.xml.annotated.pojos')] $[?(@.name && @.name contains 'org.glassfish.jaxb.runtime.v2.runtime')] $[?(@.name && @.name contains 'com.sun.org.apache.xerces.internal')]

The AOT process is not flawless, here is how to provide your own hints

XML Linked Issues

```javastacktrace 2025-01-01T10:36:15.879+01:00 WARN 39400 --- [ main] w.s.c.ServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'annotationActionEndpointMapping': Instantiation of supplied bean failed Application run failed org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'annotationActionEndpointMapping': Instantiation of supplied bean failed at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.obtainFromSupplier(AbstractAutowireCapableBeanFactory.java:1245) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1182) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) ... at com.app.myApplication.main(myApplication.java:28) at java.base@22.0.2/java.lang.invoke.LambdaForm$DMH/sa346b79c.invokeStaticInit(LambdaForm$DMH) Caused by: org.springframework.xml.xpath.XPathParseException: Could not compile [normalize-space(wsa:To)] to a XPathExpression: javax.xml.transform.TransformerException: com.sun.org.apache.xpath.internal.functions.FuncNormalizeSpace.<init>() at org.springframework.xml.xpath.Jaxp13XPathExpressionFactory.createXPathExpression(Jaxp13XPathExpressionFactory.java:82) at org.springframework.xml.xpath.XPathExpressionFactory.createXPathExpression(XPathExpressionFactory.java:72) at org.springframework.ws.soap.addressing.version.AbstractAddressingVersion.createNormalizedExpression(AbstractAddressingVersion.java:115) at org.springframework.ws.soap.addressing.version.AbstractAddressingVersion.<init>(AbstractAddressingVersion.java:89) at org.springframework.ws.soap.addressing.version.Addressing200408.<init>(Addressing200408.java:38) ... ... 18 more Caused by: javax.xml.xpath.XPathExpressionException: javax.xml.transform.TransformerException: com.sun.org.apache.xpath.internal.functions.FuncNormalizeSpace.<init>() at java.xml@22.0.2/com.sun.org.apache.xpath.internal.jaxp.XPathImpl.compile(XPathImpl.java:174) at org.springframework.xml.xpath.Jaxp13XPathExpressionFactory.createXPathExpression(Jaxp13XPathExpressionFactory.java:78) ... 44 more Caused by: javax.xml.transform.TransformerException: com.sun.org.apache.xpath.internal.functions.FuncNormalizeSpace.<init>() at java.xml@22.0.2/com.sun.org.apache.xpath.internal.compiler.FunctionTable.getFunction(FunctionTable.java:353) at java.xml@22.0.2/com.sun.org.apache.xpath.internal.compiler.Compiler.compileFunction(Compiler.java:1060) at java.xml@22.0.2/com.sun.org.apache.xpath.internal.compiler.Compiler.compile(Compiler.java:204) at java.xml@22.0.2/com.sun.org.apache.xpath.internal.compiler.Compiler.compile(Compiler.java:152) at java.xml@22.0.2/com.sun.org.apache.xpath.internal.compiler.Compiler.compileExpression(Compiler.java:126) at java.xml@22.0.2/com.sun.org.apache.xpath.internal.XPath.<init>(XPath.java:215) at java.xml@22.0.2/com.sun.org.apache.xpath.internal.jaxp.XPathImpl.compile(XPathImpl.java:166) ... 45 more

I've added a custom hint under src/main/resources/META-INF/native-image/custom-hints/reflect-config.json

[ { "name" : "com.sun.org.apache.xpath.internal.functions.FuncNormalizeSpace", "methods" : [{"name":"<init>","parameterTypes":[] }] } ]

Missing Bundle

Caused by: java.lang.ExceptionInInitializerError at org.glassfish.jaxb.runtime.v2.ContextFactory.createContext(ContextFactory.java:241) at org.glassfish.jaxb.runtime.v2.JAXBContextFactory.createContext(JAXBContextFactory.java:58) at jakarta.xml.bind.ContextFinder.find(ContextFinder.java:322) at jakarta.xml.bind.JAXBContext.newInstance(JAXBContext.java:392) ... 69 more Caused by: java.util.MissingResourceException: Can't find bundle for base name org.glassfish.jaxb.core.v2.Messages, locale en_GB at java.base@22.0.2/java.util.ResourceBundle.throwMissingResourceException(ResourceBundle.java:2052) ... 95 more

You can provide the missing bundle in your custom resource-config.json:

... "bundles":[{ "name":"org.glassfish.jaxb.core.v2.Messages", "locales":["en-GB"] }]

Missing resources

Caused by: java.lang.IllegalStateException: Could not load 'class path resource [org/springframework/ws/transport/http/MessageDispatcherServlet.properties]': class path resource [org/springframework/ws/transport/http/MessageDispatcherServlet.properties] cannot be opened because it does not exist at org.springframework.ws.support.DefaultStrategiesHelper.<init>(DefaultStrategiesHelper.java:78) at org.springframework.ws.support.DefaultStrategiesHelper.<init>(DefaultStrategiesHelper.java:88) at org.springframework.ws.transport.http.MessageDispatcherServlet.<init>(MessageDispatcherServlet.java:175) at org.springframework.ws.transport.http.MessageDispatcherServlet.<init>(MessageDispatcherServlet.java:129) at org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration.messageDispatcherServlet(WebServicesAutoConfiguration.java:72) at org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration__BeanDefinitions.lambda$getMessageDispatcherServletInstanceSupplier$0(WebServicesAutoConfiguration__BeanDefinitions.java:32)

This one was straightforward, let's include the missing resource :

I've added a custom hint under src/main/resources/META-INF/native-image/custom-hints/resource-config.json

{ "resources":{ "includes":[{ "pattern":"\\Qorg/springframework/ws/transport/http/MessageDispatcherServlet.properties\\E" }] } }

Netty issues

2025-01-03T14:42:02.819+01:00 INFO 33628 --- [ main] o.apache.catalina.core.StandardService : Stopping service [Tomcat] Application run failed org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'lettuceClientResources': Instantiation of supplied bean failed at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.obtainFromSupplier(AbstractAutowireCapableBeanFactory.java:1245) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1182) ... at com.app.MainApplication.main(MainApplication.java:28) at java.base@23.0.1/java.lang.invoke.LambdaForm$DMH/sa346b79c.invokeStaticInit(LambdaForm$DMH) Caused by: java.lang.ExceptionInInitializerError at io.netty.resolver.dns.DnsServerAddressStreamProviders$DefaultProviderHolder$1.provider(DnsServerAddressStreamProviders.java:150) at io.netty.resolver.dns.DnsServerAddressStreamProviders$DefaultProviderHolder$1.<init>(DnsServerAddressStreamProviders.java:130) at io.netty.resolver.dns.DnsServerAddressStreamProviders$DefaultProviderHolder.<clinit>(DnsServerAddressStreamProviders.java:128) at io.netty.resolver.dns.DnsServerAddressStreamProviders.unixDefault(DnsServerAddressStreamProviders.java:117) at io.netty.resolver.dns.DnsServerAddressStreamProviders.platformDefault(DnsServerAddressStreamProviders.java:113) at io.netty.resolver.dns.DnsNameResolverBuilder.<init>(DnsNameResolverBuilder.java:71) at io.lettuce.core.resource.AddressResolverGroupProvider$DefaultDnsAddressResolverGroupWrapper.<clinit>(AddressResolverGroupProvider.java:56) ... ... 20 more

Add to native image plugin to force initializing until runtime (see Native profile):

--initialize-at-run-time=sun.net.dns.ResolverConfigurationImpl

And you also need to provide a JNI config for:

Application run failed org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'lettuceClientResources': Instantiation of supplied bean failed at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.obtainFromSupplier(AbstractAutowireCapableBeanFactory.java:1245) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1182) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:336) at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:289) ... at com.app.MainApplication.main(MainApplication.java:28) at java.base@23.0.1/java.lang.invoke.LambdaForm$DMH/sa346b79c.invokeStaticInit(LambdaForm$DMH) Caused by: java.lang.NoSuchFieldError: sun.net.dns.ResolverConfigurationImpl.os_searchlist at org.graalvm.nativeimage.builder/com.oracle.svm.core.jni.functions.JNIFunctions$Support.getFieldID(JNIFunctions.java:1867) at org.graalvm.nativeimage.builder/com.oracle.svm.core.jni.functions.JNIFunctions.GetStaticFieldID(JNIFunctions.java:467) at java.base@23.0.1/sun.net.dns.ResolverConfigurationImpl.init0(Native Method) ...

adding these to jni-config.com

[ { "name":"sun.net.dns.ResolverConfigurationImpl", "fields":[ {"name":"os_searchlist"}, {"name":"os_nameservers"} ] } ]

Spring Context Launches

After going through the debugging section, I was able to get the spring application context to launch !

2025-01-03T15:19:11.080+01:00 INFO 39192 --- [ main] com.app.MainApplication : Started MainApplication in 0.346 seconds (process running for 0.357)
Last modified: 22 January 2025