Contents

How to migrate a Spring Boot app to a native image?

Szymon Przebierowski

12 Jun 2023.15 minutes read

How to migrate a Spring Boot app to a native image? webp image

In the previous article about spring boot 3 features, we described the new spring-boot-docker-compose module. Today we’re going to take a completely different path, and we will take a look at GraalVM Native Image Support. There are a lot of articles about this particular feature. Unfortunately, a lot of them are outdated because previously, we could generate native images for spring boot apps by using spring-native module, which is now deprecated. Additionally, most of the materials describe this native image generation on very simple apps, and this can hide some details from us. In this article, we will describe how to do this migration for something bigger than hello world.

What is the plan?

As we said, we want to show you how you can migrate your spring boot app to a native application. Now you may ask yourself, “Why do I want to run my app as a native image?”. Well… the main two reasons are faster application startup (who doesn’t want to have an application on production starting in a couple of milliseconds) and a lower memory footprint. There are also some drawbacks to this approach, and as we are going to go through the migration, you will see by yourself what are the main pain points, back to our plan.

First of all, we need to have an application that we will migrate, and it has to be a real application, not just a simple hello world. A too-simple application can hide some very important details from us. We won’t write this application from scratch, we will use one of the RealWorld applications, which uses many spring boot modules. That way, we can only focus on what changes we need to make to compile our application to a native one. There is an ideal candidate for our migration: https://github.com/shirohoo/realworld-java17-springboot3 running on a java 17 with spring boot 3.0. We will modify this application so we can build a native image of it and publish it on our GitHub repository. This application uses many modules which are commonly used in our applications, for example, spring-boot-starter-jpa, spring-boot-starter-security, spring-boot-starter-web, hibernate, h2 database, and some other smaller tools like Lombok so it should be sufficient to show you what problems we may face when migrating.

Next, we are going to go through the migration together, and we will show you best practices and how you can solve some of the problems with migration in your applications.

Before migration

If you plan to build your spring boot application as a native image you should start by reading this document about known limitations to find out if your application uses something from that list. If you find that something in your application is not yet supported, you have two options: wait for official support or migrate this specific thing to something else. For example, as log4j2 is not supported in native images, you should migrate to logback before even trying to build the native image.

Migration steps

In the next sections, we will go through the migration process together.

Structure of our application

Let’s familiarize ourselves a little bit with our application structure:

├── api
├── build
├── build.gradle.kts
├── database
├── gradle
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
  • api - contains open api definition, with requests to our api in the form of a postman collection and a simple script to test our api with newman, which will be used to test our application
  • build - directory with all our build artifacts
  • build.gradle.kts, settings.gradle.kts - configuration of gradle project in kotlin dsl (btw. did you know that kotlin dsl is now the default for new gradle builds?)
  • gradle, gradlew, gradlew.bat - gradle wrapper
  • database - directory with our database schema
  • src - sources of our application

Prerequisites

We need to have a GraalVM distribution so we can build native images using the Native Build Tools. Please, please use sdkman to manage your distributions instead of downloading them manually. We’re going to use Liberica distribution based on GraalVM in version 22.3.2.r17

$ sdk install java 22.3.2.r17-nik
$ sdk use java 22.3.2.r17-nik

Run your application with AOT enabled

Before jumping straight to the native-image compilation, we should start by testing whether our application is working correctly on a JVM with the usage of AOT generated initialization code. That way, we can identify any potential errors much faster than when running the whole native image compilation process.

First, we need to enable the generation of initialization code. Why is that? You probably know that spring does a lot of work when your application starts, and all this work cannot be executed at runtime if you want to have a native image, that’s why we have this generation of initialization code phase on which all the things that happen at runtime in regular spring application will be translated to generated code at build time.

Ok, so to generate this code with gradle we just need to add org.graalvm.buildtools.native plugin to our plugins section:

plugins {
    java
    id("com.diffplug.spotless") version "6.18.0"
    id("org.springframework.boot") version "3.0.5"
    id("io.spring.dependency-management") version "1.1.0"
    id("org.graalvm.buildtools.native") version "0.9.20"
}

Now we can build our application:

$ ./gradlew build

Aaaand we got our first small error:

Execution failed for task ':spotlessJavaCheck'.
> The following files had format violations:
      build/generated/aotSources/com/github/gavlyukovskiy/boot/jdbc/decorator/DataSourceDecoratorAutoConfiguration__BeanDefinitions.java

It looks like spotless is also verifying my generated classes in the build directory… Let’s disable this with a simple change in spotless configuration:

spotless {
    java {
        target("src/main/java/**/*.java")
        palantirJavaFormat()
        indentWithSpaces()

And we can run ./gradlew build again. Now we have our application built with AOT generated code inside it. Let's try to run it, with the usage of AOT generated initialization code. You do that by enabling the spring.aot.enabled property.

$ java -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

We should see at the beginning of the logs that our AOT-processed application is starting:

Starting AOT-processed RealworldApplication using Java 17.0.7 with PID....

To verify if everything works fine, we can execute our api tests:

$ ./api/run-api-tests.sh

And it looks like everything works. So far, so good.

Let’s go native

First try

If your application runs without any problem with AOT generated code, we can start trying to build our application in native mode. Buuuut you probably shouldn’t do that as a first step. Let me show you why. To start our native compilation process, we just need to run:

$ ./gradlew nativeCompile

Let’s go for a coffee… This process will take a couple of minutes.

the nr 1 programmer excuse 

source

BUILD SUCCESSFUL in 4m 24s

Finally, we have our native image!!!! Let’s run it!

$ ./build/native/nativeCompile/realworld 

And it crashed…

Caused by: java.lang.ExceptionInInitializerError: null
        at com.github.gavlyukovskiy.boot.jdbc.decorator.p6spy.P6SpyConfiguration.init(P6SpyConfiguration.java:112) ~[realworld:na]

So somewhere in our dependencies, we have a code that couldn’t initialize some variables. So we have a null object which is accessed later in the code, and that’s why we have a NullPointerException. Why wasn't it initialized? Let’s go back to the beginning…

We want to have a native application, mostly because it gives us two very cool features, fast application startup and low memory footprint. To give us these two features, GraalVM works with an assumption of a closed-world in which all code has to be known at build time. Thanks to that, in the final application, we are going to have only those classes, which are used in our application (low memory footprint). Secondly, while we compile our application native-image compiler also knows what objects are created at the start of our application, and it will prepopulate a heap with them for us, thus where the fast application startup comes from.

Back to our problem. Because at the build time, native-image compiler couldn’t find that we were trying to load some classes dynamically, so it didn’t put it in the final application. The code that was loading those classes initialized the variable to null and at runtime, we got a NullPointerException.

How to fix it? Because it is accessed through reflections, we have to tell the compiler that this class should be loaded at build time and available at runtime for our application, and to do that we need to define our reachability metadata in the form of JSON files. Yeah… we can probably do that, but what should We put inside these files? Do we need to analyze all of the libraries to find out what they are doing?!

After 5 minutes of build time, we have the first error. We can try to define this reachability metadata by ourselves, which will definitely take more than 5 minutes, then we will have to recompile our application, run it, fix errors, recompile, run it, fix errors… Who has time for this? Do you see why you shouldn’t do that? In such complex applications defining all these reachability metadata by hand is almost impossible. There is a better way to do it.

Run tracing agent to collect reachability metadata

Instead of defining this reachability metadata by hand, we can run our application with a tracing agent attached to it, so it will collect all these reachability metadata for us. But as we will collect the metadata at runtime, we need to be sure that we will execute all the code paths in our application so this agent will collect all the required information and we won’t have any surprises when we run our native image.

Once again, let’s build our application as a jar file.

$ ./gradlew build

And now we can run our application with native-image-agent attached:

$ java -agentlib:native-image-agent=config-output-dir=./src/main/resources/META-INF/native-image/ -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

As we can see, we added the configuration option config-output-dir=./src/main/resources/META-INF/native-image/ to our agent because once our reachability metadata is placed in this directory, then native image compiler will pick up this metadata automatically for us when we run ./gradlew nativeCompile.

Now we need to go through our application so the agent will collect all those reachability metadata for us. To do that, we can use our api tests placed in ./api directory.

$ ./api/run-api-tests.sh

Now we can close our application, and we can see that we have a lot of configuration in our JSON files inside src/main/resources/META-INF/native-image directory. Imagine writing this all by hand… In our repo you can see all the files from the final version of the build.

Build a native image

Now we’re ready to build our native image. Let’s do it!

./gradlew nativeCompile

a few minutes later 

source

BUILD SUCCESSFUL in 4m 46s

And let’s run it!

$ ./build/native/nativeCompile/realworld

It’s alive!!! Finally, we got it running. See how fast it started! Half a second for a spring boot application? This is awesome!

Started RealworldApplication in 0.453 seconds (process running for 0.463)

But wait a second, let’s run our API tests to confirm that everything works fine.

$ ./api/run-api-tests.sh

And unfortunately, we have some errors…

|                    | executed | failed |
|--------------------|----------|--------|
| iterations         | 1        | 0      |
| requests           | 32       | 0      |
| test-scripts       | 48       | 2      |
| prerequest-scripts | 18       | 0      |
| assertions         | 194      | 69     |

And it looks like we have some problems with hibernate:

Caused by: org.hibernate.HibernateException: Generation of HibernateProxy instances at runtime is not allowed when the configured BytecodeProvider is 'none'; your model requires a more advanced BytecodeProvider to be enabled.

Ok, so hibernate tried to generate some proxies, for example, for dirty checking or lazy initialization, but we can’t do that in a native image because the whole bytecode provider in hibernate needs to be disabled. We can’t do bytecode manipulation at runtime.

Code snippet from org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider

static {
    if (NativeDetector.inNativeImage()) {
        System.setProperty(Environment.BYTECODE_PROVIDER, Environment.BYTECODE_PROVIDER_NAME_NONE);
    }
}

So our only option is to also disable this hibernate proxy generation at runtime, and we will need to generate all those proxies at build time. We have a dedicated gradle plugin for this: Hibernate Enhance Plugin. You can read about its options in this great article by Vlad Mihalcea. There are two things to do in build.gradle.kts to generate this code at build time. First, we need to add this to our plugins:

id("org.hibernate.orm") version ("6.2.2.Final")

and then we need to configure our code enhancements:

hibernate {
    enhancement {
        enableDirtyTracking.set(true)
        enableLazyInitialization.set(true)
        enableExtendedEnhancement.set(false)
    }
}

Now we can repeat the whole cycle to check if everything works: build a standard application, run it with native-image-agent, run tests to collect reachability metadata, and finally build the native image.

But before this, we need to do one more thing to resolve all problems with hibernate. We also need to disable the bytecode provider (which is used by hibernate) while running our regular application: There are two reasons for that: we want to run our application with an agent in the same way as it will run in a native environment and secondly, we want to have our regular application working, in the same way, a native one so we can spot any errors that could occur in a native environment much faster during our regular application testing.

To do this, we can put hibernate.properties inside src/main/resources with the following configuration:

hibernate.bytecode.provider=none
hibernate.bytecode.use_reflection_optimizer=false

The second line in this configuration disables the ReflectionOptimizer because if we won’t disable it our tests will fail with an exception:

org.hibernate.HibernateException: Using the ReflectionOptimizer is not possible when the configured BytecodeProvider is 'none'. Disable hibernate.bytecode.use_reflection_optimizer or use a different BytecodeProvider

Ok, let’s run the whole cycle (build a standard application, run it with native-image-agent, run tests to collect reachability metadata, and build a native image) once again…

one eternity later 

source

Now we can run our application aaaand we see another error… This is tiring…

Caused by: com.oracle.svm.core.jdk.UnsupportedFeatureError: No classes have been predefined during the image build to load from bytecodes at runtime.
        at org.graalvm.nativeimage.builder/com.oracle.svm.core.util.VMError.unsupportedFeature(VMError.java:89) ~[na:na]
        at org.graalvm.nativeimage.builder/com.oracle.svm.core.hub.PredefinedClassesSupport.throwNoBytecodeClasses(PredefinedClassesSupport.java:76) ~[na:na]
        at org.graalvm.nativeimage.builder/com.oracle.svm.core.hub.PredefinedClassesSupport.loadClass(PredefinedClassesSupport.java:130) ~[na:na]
        at java.base@17.0.7/java.lang.ClassLoader.defineClass(ClassLoader.java:294) ~[realworld:na]
        at net.bytebuddy.utility.dispatcher.JavaDispatcher$DynamicClassLoader.invoker(JavaDispatcher.java:1383) ~[na:na]

This one tells us that the enhancer plugin does some bytecode manipulation at runtime. But we can’t do any bytecode manipulation at runtime, our bytebuddy is disabled. How can we solve this problem then?

To overcome this problem we need to use one of GraalVM's experimental features: Support for Predefined Classes. So we will modify our command to run the application with native-image-agent.

This is a new version:

$ java -agentlib:native-image-agent=config-output-dir=./src/main/resources/META-INF/native-image/,**experimental-class-define-support** -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

Now the native-image-agent will save some bytecode as our reachability metadata inside our src/main/resources/META-INF/native-image directory, and we can try to build our application.

But if we try to do that, we will have some problems with JaCoCo this time. JaCoCo is a test coverage analyzer and it works by instrumenting our bytecode, and because now we will generate some bytecode inside our ./src/main/resources, it will start analyzing this code also, and it will throw an error that there are already analyzed classes with the same name (one class was from our regular sources, and the second one was from our reachability metadata). The easiest workaround is to put this generated code somewhere else but then we need to reconfigure our native-image so it will pick our reachability metadata from a different directory.

So let’s create an aot directory in our root project directory and configure native-image-agent output directory

$ java -agentlib:native-image-agent=config-output-dir=**./aot/META-INF/native-image/**,experimental-class-define-support -Dspring.aot.enabled=true -jar ./build/libs/realworld.jar

And now, we need to add this aot directory to the classpath for our native-image tool. Let’s do that in build.gradle.kts

graalvmNative {
    binaries {
        names.forEach { binaryName ->
            named(binaryName){
                classpath("./aot/")
            }
        }
    }
}

So we added this aot directory to the classpath for each binary in our case main and test.

Now we’re ready to build our application. Remember that we have to redo the whole cycle again. Do you want a second coffee?

After one more (I don’t remember how many times I did this process) build cycle, we can run our application with our api tests and it looks that everything works! Our application started in half a second, that’s a great success.

Started RealworldApplication in 0.448 seconds (process running for 0.456)

But that’s not finished, to really confirm that our application works properly as a native image we need to run our unit tests in the native environment. To do that we just need to execute this simple command.

./gradlew nativeTest

And it looks like our tests fail because we’re using mockito, do you remember the known limitations?

After we removed the mockito and some hamcrest matchers, we can run our native tests again, and now everything works. I promise!

That was a pretty long journey…

Final thoughts

As you can see, it is possible to run real world spring boot applications as native images, but it is not an easy task, and it takes a lot of time to finally prepare something working. We should also integrate this whole flow into our CI pipeline, and it can be a challenge too.

Let me show you a small comparison of our applications running as a native image and as a regular Java application.

|                     | startup time | occupied memory (rss) |
|---------------------|--------------|-----------------------|
| regular application | ~3 seconds   | 500 Mb                |
| native application  | ~0.5 second  | 200 Mb                |

The difference in startup time and memory usage will be even bigger on larger applications.

These numbers look pretty amazing, but where is the catch? These are the main drawback of switching to native image:

  • the Build time of a native image is much slower compared to a regular build, it takes only ~20 seconds to build a regular application using temurin distribution of Java 17, so 5 minutes compared to 20 seconds is a huge difference,
  • not all libraries work in a native environment yet (known limitations),
  • no JVMTI, Java Agents, JMX, JFR support. You’re left with kernel features to inspect what happens inside your application. That means also, that your metrics about the jvm heap collected through MXBeans (for example, by micrometer) are not available. Generally, all the metrics which are collected from the MXBeans are not available,
  • serialGC is the only available garbage collector in the community edition of GraalVM. You can use G1 GC but only on the enterprise edition of GraalVM available under the Oracle Java SE Subscription,
  • no JIT, which means that the throughput of your application will be lower.

That’s all folks, I hope that this article can help you with the migration to native image, but remember to “choose your fighter wisely”

Now I can close my opened tabs… Great feeling right?
opened tabs 

Additional resources worth reading if you want to go deeper.

  1. Everything You Never Wanted to Know About Spring Boot 3 AOT 
  2. Native image tool documentation
  3. GraalVM medium 

Reviewed by: Dariusz Broda and Sebastian Rabiej

Blog Comments powered by Disqus.