Process of migrating an application to Native Image using Spring Boot 3, analyzing the problems, possible solutions, and results
Since the announcement of the Spring Native Beta, I’ve encountered various criticisms about the project from my colleagues. Some have expressed concerns about the potential time-consuming nature of compiling applications and whether users would receive it well. Others have doubted the significance of code optimization improvements, while some even questioned whether Spring Native would make it out of the beta stage.
However, at the end of 2022, Spring 6 and Spring Boot 3 were released, with an important addition — support for Native Image build. Our team has decided to update the framework and, concurrently, explore GraalVM to address a crucial question: will it truly benefit our project?
Small remark: in this article, we will only talk about GraalVM CE.
First Service for Upgrade
It was decided to choose a lightly-load service that exports data from the database to Kafka (implementing the Transactional Outbox pattern) with pre-processing of unloaded events as the research object.
Before migration, the application used Spring Boot 2.7.6 and the following base dependencies:
- spring-boot-starter-aop
- spring-boot-starter-web
- spring-boot-starter-jdbc
- spring-boot-starter-cache
- spring-boot-starter-actuator
- spring-data-jdbc
- spring-kafka: 2.9.3
- micrometer-registry-prometheus: 1.10.2
- com.oracle.database.jdbc: ojdbc8: 21.7.0.0 (Oracle database)
Looking ahead, most of these dependencies were not a problem, but some were not trivial problems that had to be dealt with.
The Migration Process
First, we started by migrating the application to Spring Boot 3 (you can read the migration guide and description of the innovations here). The migration itself wasn’t too hard, and we even converted all our entities from classes to records.
Next, we started diving into GraalVM and exploring the features we were given in Spring Boot 3. If you have never encountered Native Image, I recommend you get an introduction to the technology here. The integration of GraalVM with Spring Boot 3 is well explained in Josh Long’s video and in this part of the Spring Boot documentation.
Of course, you will also need to install GraalVM to make it work.
After studying the above materials, we know that:
- GraalVM is not directly aware of dynamic elements of your code and must be told about reflection, resources, serialization, and dynamic proxies
- The application classpath is fixed at build time and cannot change
- There is no lazy class loading. Everything shipped in the executables will be loaded in memory on startup
On top of that, we have to understand that Native Image gives us both advantages and disadvantages. The most significant for us is the reduction of memory consumption and the shortening of the application’s startup time. On the negative side, the increased CPU consumption is because the native compilation can not provide you with the same level of code optimization as the JVM.
Now, back to converting our service to a native build.
We had no problem with point 2, so I can’t make any recommendations on that. The main problems we had with point 1, which I will talk about later. As for point 3, in our case, we used the ConcurrentInitializer from the Apache Common Lang library. Of course, it’s not quite the functionality we had in Spring, but we could take care of it with the help of this class. However, it’s unlikely to help you in complex cases, and you must look for workarounds.
Dynamic Elements From Our Code
Let’s go back to point 1 and further analyze how we should live under the current conditions.
As you might have seen in the Spring Boot 3 documentation, to support dynamics such as reflections, resources, serialization, and dynamic proxies, we need to prepare special metadata for GraalVM called hints. So for each class that uses reflection, you have to make a description of that class in a common reflect-config.json file, and that’s a really hard task. This is where the new Spring Boot 3 API comes in.
Let’s look at the reflections and resources required for a Spring app to work. It’s not too difficult to do. I will show you an example with the Gradle project configuration, but finding a configuration for Maven should be no problem for you.
Okay, first, we need to add the following two plugins to our project:
plugins {
id 'org.springframework.boot' version '3.0.6'
id 'org.graalvm.buildtools.native' version '0.9.20'
}
Since version 3 of the Spring Boot plugin has added tasks for preprocessing data for AOT compilation. Spring Boot plugin intercepts some of the GraalVM plugin’s tasks and generates all the necessary metadata for its application launch, not including metadata for external libraries.
Now you can execute nativeCompile task, and it might be enough if you have a fairly simple application that does not use resources or reflections. But we live in the real world, so most likely, your application won’t compile, or, worst of all, you’ll just get runtime errors.
But let’s go in order.
First, let’s execute processAotResources task. After successful execution, you will be able to find the methods that were generated by the plugins.
As you can see, we got the required metadata files, but by default, these files will not contain metadata of classes and resources from your application.
Adding Resources
Let’s start by figuring out how to configure the generation of the resources we need. We have two ways. The first is to configure the GraalVM plugin so that it automatically registers all the resources it manages to find:
graalvmNative {
binaries {
all {
resources.autodetect()
}
}
}
The second way is to use the APIs provided by Spring Boot:
@Configuration
@ImportRuntimeHints(AppConfiguration.AppRuntimeHintsRegistrar.class)
public class AppConfiguration {
public static class AppRuntimeHintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources()
.registerResource(new ClassPathResource("custom-resource.properties"));
}
}
}
To create a special class that can register your resources, reflections, etc. You must implement the RuntimeHintsRegistrar interface and declare everything you need in the registerHints method via API. Don’t forget to specify your registrar in @ImportRuntimeHints annotation.
Adding Classes for Reflection
Registering classes for which you want to use reflection is just as easy. You either use @RegisterReflectionForBinding annotation, or you can do the same through the RuntimeHintsRegistrar with more flexible configuration:
@Configuration
@RegisterReflectionForBinding({CustomMessage.class, CustomMessage.Status.class})
@ImportRuntimeHints(AppConfiguration.AppRuntimeHintsRegistrar.class)
public class AppConfiguration {
public static class AppRuntimeHintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection()
.registerType(
CustomMessage.class,
PUBLIC_FIELDS, INVOKE_PUBLIC_METHODS, INVOKE_PUBLIC_CONSTRUCTORS
).registerType(
CustomMessage.Status.class,
PUBLIC_FIELDS, INVOKE_PUBLIC_METHODS, INVOKE_PUBLIC_CONSTRUCTORS
);
}
}
}
Moreover, you can use the @RegisterReflectionForBinding annotation anywhere (services, methods, etc.), but we decided to use only the RuntimeHintsRegistrar to contain all the AOT configurations in one place and not search through the code.
If you don’t know how to structure your AOT configurations properly, you can look at how the Spring team implements this:
What About External Libraries?
Setting up metadata for libraries is a headache since not all projects have AOT compilation support out of the box.
The GraalVM plugin can pull some of them from the special graalvm-reachability-metadata repository. If possible, you can add metadata there to make registration of dynamic components from the libraries automatic.
But what if your library is outside this repository? You may not like the answer, but you must do everything by hand.
The easiest option is when you know which classes to register for and use the toolkit described above. But in most cases, we cannot know which classes from the external library will need to be configured. To do this, GraalVM has a special agent that allows you to collect all the required metadata (you can see more about this functionality in Josh Long’s report, which I have attached above). Analyzed it, you can find the classes or packages you need to register.
You need to run your jar like that:
java -agentlib:native-image-agent=config-output-dir=<output-directory-path> -jar app.jar
When you run your application, you must run the functionality that uses the library you are interested in. This is not always easy to do in a real project, so you can build a minimal slice of your application and execute the functionality we are interested in manually.
Another way is to use the Black Box testing approach I described in my previous article and prepare the Docker image, which will not only help to collect all the metadata on the real project but also catch any errors that may occur in runtime.
Example of Dockerile which you can use in your Black Box tests for collecting metadata:
FROM ghcr.io/graalvm/graalvm-ce:ol8-java17-22.3.1
WORKDIR /app
COPY <path-of-jar> app.jar
RUN gu install native-image
CMD ["java", "-agentlib:native-image-agent=config-output-dir=generated-native-metadata", "-jar", "app.jar"]
Don’t forget also to configure the volume for your container of the application:
withFileSystemBind(
System.getProperty("user.dir") + "/build/generated-native-metadata",
"/app/generated-native-metadata"
)
Unobvious Compilation Errors
Unfortunately, when working with the Native Image, you may run into problems that are not obvious and hard to understand.
In our service example, we used a logback along with ojdbc8, which caused a serialization conflict. I spent quite a lot of time trying to find and solve this problem. However, as in most problems, this one was solved before me.
https://github.com/spring-projects/spring-boot/issues/34819
If you do not encounter an obvious error, try to look for it in issues of the Spring Boot project or your library. This problem may already be solved by a newer library version, as in our case.
Migration Results
After launching the application in the production stage, we were pleasantly surprised by the result. Below are some charts that show that memory consumption has dropped from 870 mb to 300 mb at peak and only 100–110 mb during the idle time for two pods:
However, as we said before, GraalVM can’t provide the level of optimization that JVM provides, so our application’s CPU consumption increased from 4.4 millicores to 6.86:
Conclusion
In our opinion, despite some difficulties in migration, working with Native Image is another tool in your bag that can be used in certain situations.
For example, when your application must quickly auto-scale under load and its startup time is critical for you, or as in our case, when the application works, it usually works with idle and consumes resources.
Migrating an Application to Native Image With Spring Boot 3 was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.