Going Native With Spring Boot 3 GA | by Wenqi Glantz | Nov, 2022

A practical guide on how to use Spring Boot 3 native image support

Photo by author

Spring Boot 3 goes GA today, Thanksgiving Day 2022! A huge milestone for the entire Spring community indeed! The Spring and GraalVM teams have done wonderful work bringing native image support into Spring Boot 3. I have witnessed firsthand how the issues I raised in their GitHub repositories in the last month or two were promptly addressed and fixed during my POC work of upgrading some of my microservices to Spring Boot 3 to utilize its native image support.

A few months ago, I published Spring Native: the Wings that Make Spring Boot Fly. It was eye-opening to explore how Spring Native boosts applications built on Spring Boot 2 framework. With Spring Boot 3, we now have GraalVM native image support baked in! What does that mean for us application developers practically? Let’s take a practical approach to convert a Spring Boot 2.7.x app into Spring Boot 3 and explore the native image support that comes with Spring Boot 3.

Native image support is mostly about making it possible for an application and its libraries to analyze at build time to configure what’s required or not at runtime. The goal is to do that optimally to have a minimal footprint. With native image support, applications built on Spring Boot 3 have an instant startup, reduced memory consumption, and instant peak performance. For more details on the boost native image support brings to applications built on the Spring Boot framework, refer to my article Spring Native: The Wings That Make Spring Boot Fly.

We will use a demo app named customer-service, built on Spring Boot 2.7.5. The first step is to upgrade it to Spring Boot 3. Follow my Notes on Spring Boot 3 Upgrade for detailed instructions on the upgrade. Be sure to upgrade Spring Boot to the GA release 3.0.0.

Remember, we had to add spring-native as a new dependency in our root pom to enable native support in Spring Boot 2 apps and add spring-aot-maven-plugin? With Spring Boot 3, native support is baked in, which means you no longer need to import spring-native dependency, nor add spring-aot-maven-plugin, all you need to do in root pom is to upgrade the version for spring-boot-starter-parent to 3.0.0. Your pom’s section should look like this:


org.springframework.boot
spring-boot-starter-parent
3.0.0

The spring-boot-starter-parent declares a native profile that configures the executions that need to run to create a native image. You can activate profiles using the -P flag on the command line, such as mvn clean -Pnative spring-boot:build-image. Yes, it’s that simple by calling the native maven profile! What exactly does native profile do? Three things per Spring’s documentation:

  1. Execution of process-aot when the Spring Boot Maven Plugin is applied to a project.

2. Suitable settings so that build-image generates a native image.

3. Sensible defaults for the Native Build Tools Maven Plugin, in particular:

  • Ensure the plugin uses the raw classpath and not the main jar file, as it does not understand our repackaged jar format.
  • Validate that a suitable GraalVM version is available.
  • Download third-party reachability metadata.

To benefit from the native profile, a module representing an application should define two plugins. See the sample below:


org.graalvm.buildtools
native-maven-plugin


org.springframework.boot
spring-boot-maven-plugin

For details on configuring plugins for multi-modules projects, refer to Spring’s documentation Using the Native Profile.

There are two ways to build native images:

  • Cloud-Native Buildpacks: an incubating project in the Cloud Native Computing Foundation (CNCF) that provides a mechanism to transform your application source code into an Open Container Initiative (OCI) compliant container image without using a Dockerfile. We will use Buildpacks in the sample code below to build our customer-service native image.
  • Native Build Tools: If you want to generate a native executable directly without Docker, you can use GraalVM Native Build Tools. Native Build Tools are plugins shipped by GraalVM.

Given the long build time to produce a native image, it is not practical to build a native image whenever a pull request is raised or the code is pushed to the main/master branch. So, how should we tackle JVM build vs the native image build? Two workflows:

  • CI workflow for JVM: without native image support
  • CI workflow for GraalVM native image: with native image support

Let’s take a closer look at each workflow.

This is the default CI workflow for developers, auto-triggered by PR or code push. If you are already using GitHub Actions for your CI/CD, your CI workflow for JVM should still be your default CI workflow. See below my sample JVM CI workflow. The main steps of this workflow include the following:

  • build with Maven and Buildpacks
  • tag and push image to AWS Elastic Container Registry (ECR)
  • scan image with Trivy vulnerability scanner

Depending on your project needs, you can add other typical CI steps, such as Sonar scan, etc., into this CI workflow.

This is the CI workflow for the GraalVM native image. Since it takes longer to build the native image than to build the app on JVM, we can configure this workflow to be triggered manually or run a nightly build. The main difference between this workflow and the JVM CI workflow is that maven build using Buildpacks added a -Pnative parameter to enable spring-boot-starter-parent’s native profile (line 57), and also it installed GraalVM in this workflow (lines 38–44).

Homework for those interested: I have blogged about GitHub Actions’ reusable workflows in my previous article, A Deep Dive into GitHub Actions’ Reusable Workflows. I encourage readers interested in converting these two workflows into reusable workflows and call them from your application’s CI workflows accordingly.

For our customer-service app, I ran both native image CI workflow and JVM CI workflow, and two images were pushed to ECR. Native image size is less than half that of JVM image; another promise of Spring Boot 3 native support producing a much smaller image footprint.

Please Note:

The image comparison above is merely for exploring native image vs JVM image. You should never mix these two images in the same ECR repository. Depending on your application, if it works well with native image support and your team has decided to proceed with native image into production, then do remove the last step of Tag and push image to AWS ECR in your ci-jvm.yml, so that JVM CI workflow only does the build and image scan, but it won’t push the image to ECR.

But if, for some reason, your app runs into multiple issues, mainly dealing with third-party libraries’ compatibility with native support, you and your team have finally decided that your app is not ready for native support adoption yet, then you need to remove the last step of Tag and push image to AWS ECR in your ci-native-image.yml. Periodically you can manually trigger the native image CI workflow to see if any issues related to third-party native support compatibility have been resolved.

Once our native image is built successfully, let’s launch our app:

docker run --rm -p 8500:8500 docker.io/library/customer-service:latest

Yay! customer-service app started in 0.256 seconds!

Let’s verify the APIs by creating a new customer:

As you can see, the customer creation POST call was successful, as the customerId was retrieved from the database, and the customer object was successfully returned in the response. So, our app is indeed functioning as expected.

I have to confess that building our demo app’s native image wasn’t a completely smooth experience. I ran into a few hurdles, which took me a little research to find the workarounds. I’d like to share my troubleshooting experience.

Native image build for customer-service had no issue, but when I tried to launch the app, I ran into the following error:

I was puzzled as that file exists in the right directory. Why is GraalVM not able to see it? Ah! It must be with our app’s resource-config.json since db.changelog-master.xml is a resource file, and it’s code from our own app. By tracing the target folder’s classes\META-INF\native-image\com.github.wenqiglantz.service\customerservice\resource-config.json, see screenshot below, the discrepancy is obvious! In that resource-config.json file, it’s referring to db.changelog-master.yaml, not its XML file, which is why GraalVM cannot find the XML file.

Why is this happening? The Native Image tool relies on the static analysis of an application’s reachable code at runtime. However, the analysis cannot always predict all usages of the Java Native Interface (JNI), Java Reflection, Dynamic Proxy objects, or classpath resources. Undetected usages of these dynamic features must be provided to the native-image tool in the form of metadata (precomputed in code or as JSON configuration files).

How to fix it? Basically, we need a way to complement the auto-generated resource-config.json for our app with the correct resource entries, especially to include our Liquibase configuration files db.changelog-master.xml and db.changelog-1.0.xml. Let’s explore two approaches:

  • Tracing Agent
  • RuntimeHintsRegistrar

GraalVM provides a Tracing Agent to gather metadata and prepare configuration files easily. The agent tracks all usages of dynamic features during application execution on a regular Java VM. For our customer-service app, I use the command below to run Tracing Agent at the project root:

java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar .\target\customerservice-0.0.1-SNAPSHOT.jar

Note: -agentlib must be specified before a -jar option or a class name or any application parameters as part of the java command.

When run, the agent looks up classes, methods, fields, and resources for which the native-image tool needs additional information. When the application completes and the JVM exits, the agent writes metadata to JSON files in the specified output directory src/main/resources/META-INF/native-image.

It may be necessary to run the application more than once (with different execution paths) for improved coverage of dynamic features. The config-merge-dir option adds to an existing set of configuration files, as follows:

java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image -jar .\target\customerservice-0.0.1-SNAPSHOT.jar

This directory (or any of its subdirectories) is searched for files with the names jni-config.json, reflect-config.json, proxy-config.json, resource-config.json, predefined-classes-config.json, serialization-config.json which are then automatically included in the build process.

Let’s see what we find after running the Tracing Agent:

Yes, Tracing Agent did generate the list of JSON files as shown in the screenshot above and looking in resource-config.json, we can see that our two Liquibase configuration files indeed have been added, which means they will be included in our native image build! This approach works! Now let’s look at the other approach using RuntimeHintsRegistrar.

Since we need to provide our own hints for resources in this particular case, we can use the RuntimeHintsRegistrar API, which comes with Spring Boot 3. All we need to do is to create a class that implements the RuntimeHintsRegistrar interface, then make appropriate calls to the provided RuntimeHints instance. We can then use @ImportRuntimeHints on any @Configuration class to activate those hints. See below my implementation in CustomerController class.

  • Line 3: activate the hints by @ImportRuntimeHints.
  • Line 29–41: create a class named CustomerControllerRuntimeHints that implements RuntimeHintsRegistrar. Particularly pay attention to lines 38 and 39, where those two Liquibase configuration files are being “hinted.”

Now let’s build our native image again by running the command below locally and then examine the files generated by our AOT compiler under the target directory. See the image below. Yes, it works! Our Liquibase XML files are now successfully included in resource-config.json, along with the default YAML file, highlighted in blue.

mvn clean -Pnative spring-boot:build-image -Dmaven.test.skip

Despite the many advantages of Spring Boot 3 surrounding its native support, the ecosystem around Spring Boot 3 is yet to catch up. Spring Team is pretty open about the limitations of Spring Boot 3, as documented here.

Some popular testing frameworks, such as Mockito and WireMock, do not yet have native support. For those interested in contributing to this effort in bringing Spring Boot 3 ecosystem up to date, GraalVM created Reachability Metadata Repository to encourage the open source community to contribute to this vital effort.

When you use GraalVM Native Image to build a native executable, it only includes the elements reachable from your application entry point, its dependent libraries, and the JDK classes discovered through static analysis. However, the reachability of some elements (such as classes, methods, or fields) may not be discoverable due to Java’s dynamic features, including reflection, resource access, dynamic proxies, and serialization.

If an element is not reachable, it is not included in the generated executable, which can lead to run time failures. To include elements whose reachability is undiscoverable, the Native Image builder requires externally provided reachability metadata.

This repository provides reachability metadata for libraries that do not support GraalVM Native Image.

Reachability metadata is enabled for native image build by default. For each library included in the native image, the plugin native-maven-plugin will automatically search for GraalVM reachability metadata in the repository that was released together with the plugin.

With Spring Boot 3 GA release, there is much anticipation for its GraalVM native image support. We took a deep dive in this story to explore the steps in upgrading a Spring Boot 2 application to Spring Boot 3, activating its native image support, building native image CI pipeline using GitHub Actions workflows, and exploring troubleshooting tips, including how to use Tracing Agent and RuntimeHintsRegistrar. There is enormous potential in Spring Boot 3 native image support, and we also discussed the limitation around its ecosystem, which is yet to catch up, and the call for contribution and support from Spring open source community. I hope you find this article helpful.

Feel free to check out my source code for our demo app customer-service in my GitHub repository.

Happy coding!

P.S. Many thanks to Stéphane Nicoll, project lead of Spring Boot, for reviewing this story and providing valuable inputs and guidance! There must be a million things on his plate on this big day of Spring Boot GA release, yet he took the time to review this article and provide corrections and guidance. I am so humbled and honored!

Leave a Reply