In this article, I want to talk about the practical experience of native compilation of a production application written in Kotlin with Spring Boot and Gradle using GraalVM. I’ll start right away with the pros and cons of the native compilation feature itself and where it can be useful, and then I’ll move directly to the build process for MacOS and Windows.
At the end of the article, in the afterword block, I will talk in more detail about the project and why such a need arose, given quite a few limitations and pitfalls of supporting native compilation both from Spring Boot and from GraalVM.
1. Disadvantages and where it would not make sense to use
Requires large resources and quite a lot of compilation time
It is required to tell GraalVM about all proxy classes and, in general, all reflections in the project so that the compiler does not accidentally throw out any class or method because it could not reach it
It follows from the previous paragraph that integration with CI requires a very large test coverage in order not to miss any code (it is unlikely that anyone will want to manually describe everything in the GraalVM config files)
I didn’t see much point in continuous delivery, because rather large resources are required for assembly, which greatly affects the compilation time, which can reach 15-30 minutes for not very large applications, while at least 8-10 GB of RAM and more CPU cores may be required. Of the pluses, only a very fast start time - my average application starts in 0.5 seconds, and with the JVM - about 5-10 seconds. This can allow you to deploy a cluster very quickly, for example, in k8s (well, if a pod has fallen, it will restart very quickly).
Perhaps in some cases, it will not make sense to use it due to previous restrictions and requirements
2. Where can it be very useful?
I found it very useful for the desktop and where some kind of code protection is needed, because after compilation we will get a completely different binary code (I did not decompile it in detail, but from various discussions, this is no longer simple JAVA code)
The advantages for the desktop application are quite obvious in comparison with the JVM:
The small size of the source binary is important when distributing the application to clients - in my case, the standard *.jar along with the JDK was approximately 300-400MB in size and the native binary was 190MB (MacOS)
No need for JDK and adding all sorts of tricks like downloading the JDK itself in the background during installation to show clients the small size of the original *.jar file
The code is copy-protected - at least much more difficult to reproduce compared to a regular *.jar file, even after running it through ProGuard
Very fast start, almost like low-level languages (C++ and similar)
The last point plays a particularly important role because the user launches the application often and can't wait for 10-20 seconds.
I just needed all these points right away, so I decided to understand the technology and adapt my project for native compilation. At the end, I will tell you how it generally works.
3. Preface
In order not to complicate the reading of the article, I want to concentrate on all the practical steps from creating an application to its native compilation. Therefore, I will skip the points from the technical documentation and limit myself to links, because there is quite a lot of basic information on the Internet.
The technology is used in my real average desktop application for clients, the code of which I, unfortunately, cannot show, but I created a very simplified skeleton for demonstration with the most important dependencies from it.
The sources of the test application are in the repository.
What is included in the skeleton application:
Websockets
Jackson (Object Mapper)
Caffeine Cache
SQLite
Mapstruct
Flyway
Kotlin Coroutines
Logstash Logback (for logging in JSON for log collection and logging storage systems)
All sorts of Junit 5, Kotest, and other test libraries (although I did not add the tests themselves)
4. Some subtleties of assembling a native application
I would like to say a little about what is required for successful compilation and where to save the configs for GraalVM.
So, in order for GraalVM to successfully compile the application and not throw out some parts of the code along the way, it is necessary to describe special config files with meta information about all proxy classes, and indeed about almost all classes/methods/parameters. You can do this manually, but this approach is not suitable for serious use. For this, a native agent was created, which is launched along with the application collects all information from the runtime, and generates the necessary configs.
The configs themselves are saved in the directory: /resources/META-INF/native-image.
You can start the agent with the following command:
<graalvm-jdk-17-home-path>/java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image -jar kotlin-spring-boot-native-skeleton-1.0.0.jar
The design is important here:
-agentlib:native-image-agent=config-merge-dir
In our case, we need exactly the mode: config-merge-dir, which adds new metainformation to existing configs, rather than overwriting existing ones. This is especially important for tests, where the same mode is used. Accordingly, this mode allows you to store configs in Git and if the code has not changed much, then you can rebuild the application without the steps of launching the native agent. For example, it can speed up the CI/CD process if you generate configs on the developer's machine.
5. Toolkit - installation and configuration
I am compiling an application for both MacOS and Windows, and I will describe how to do it, especially since there are nuances for Windows.
Version JDK 17 and GraalVM are also for this version. (I haven't tried version 20)
Next, you need to add dependencies for building native to Gradle (I will give only the necessary dependencies in the example, the entire file can be viewed in the example on Github):
General build.gradle.kts settings for all platforms:
build.gradle.kts
val nativeImageConfigPath = "$projectDir/src/main/resources/META-INF/native-image"
val nativeImageAccessFilterConfigPath = "./src/test/resources/native/access-filter.json"
plugins {
val buildToolsNativeVersion = "0.9.21"
id("org.graalvm.buildtools.native") version buildToolsNativeVersion
}
repositories {
mavenCentral()
maven { url = uri("https://repo.spring.io/milestone") }
mavenLocal()
}
graalvmNative {
binaries {
named("main") {
buildArgs(
"-H:+ReportExceptionStackTraces",
"-H:EnableURLProtocols=http,https",
)
}
}
}
buildscript {
repositories {
maven {
setUrl("https://plugins.gradle.org/m2/")
}
}
}
tasks.withType<Test> {
jvmArgs = listOf(
"-agentlib:native-image-agent=access-filter-file=$nativeImageAccessFilterConfigPath,config-merge-dir=$nativeImageConfigPath"
)
}
Additionally, you need to update the URLs of the pluginManagement repositories in the settings.gradle.kts file:
settings.gradle.kts
pluginManagement {
repositories {
maven { url = uri("https://repo.spring.io/milestone") }
maven { url = uri("https://repo.spring.io/snapshot") }
gradlePluginPortal()
}
}
In the directory with the tests: /test/resources/native/access-filter.json, add a filter file for unnecessary classes from the metainformation file after running the tests.
/test/resources/native/access-filter.json
{ "rules": [
{"excludeClasses": "com.gradle.**"},
{"excludeClasses": "sun.instrument.**"},
{"excludeClasses": "com.sun.tools.**"},
{"excludeClasses": "worker.org.gradle.**"},
{"excludeClasses": "org.gradle.**"},
{"excludeClasses": "com.ninjasquad.**"},
{"excludeClasses": "org.springframework.test.**"},
{"excludeClasses": "org.springframework.boot.test.**"},
{"excludeClasses": "org.junit.**"},
{"excludeClasses": "org.mockito.**"},
{"excludeClasses": "org.opentest4j.**"},
{"excludeClasses": "io.kotest.**"},
{"excludeClasses": "io.mockk.**"},
{"excludeClasses": "net.bytebuddy.**"},
{"excludeClasses": "jdk.internal.**"},
],
"regexRules": [
]
}
Especially when launching the agent from tests, the package gave me problems when compiling: jdk.internal.**. After adding it to the filter, the problems disappeared. If necessary, you can add your own packages/classes here that need to be excluded from compilation. More details about this can be found in the GraalVM documentation.
5.1 MacOS
First, download the archive from GraalVM for MacOS and version Java 17 from the official website: GraalVM Downloads.
Unpack it to a convenient place and set the env variable:
export JAVA_HOME=<path-to-graalvm-jdk-17/Contents/Home>
source ~/.zshrc
Now that the setup is complete, you can select GraalVM in the project settings in IntelliJ Idea and start the build.
5.2 Windows
I made the assembly on a VMware virtual machine with Windows 10.
5.2.1 First about problems and limitations
Here everything turned out to be a little more complicated. Difficulties arose due to Windows restrictions on the length of the file path for maven dependencies (issue). The solution turned out to be to move the directory with dependencies to the shortest path, for me to C:\m2.
5.2.2 Tools
First, download the archive with GraalVM for Windows and Java 17 version from the official website: GraalVM Downloads
In general, the GraalVM website has a detailed tutorial for installation on Windows: Install GraalVM on Windows. I recommend following it because it also requires the installation of Visual Studio Build Tools and Windows SDK.
Install Gradle for Windows according to the tutorial from the official website: Gradle on Windows under Installing manually. I won't repeat myself here.
As a result, env variables with paths to gradle and java must be correctly specified (you can check the versions via the terminal) and Visual Studio Build Tools must be installed correctly.
It is important to build in the correct terminal: x64 Native Tools Command Prompt for VS 20**!
6 Building a test application
The application sources can be downloaded from the repository or cloned with the command:
git clone https://github.com/devslm/kotlin-spring-boot-native-skeleton.git
For the purity of the experiment, I recommend deleting all files from the directory: /resources/META-INF/native-image in order to observe the entire assembly process yourself. If something does not work out, then you can use the files I have already collected and check that the assembly is successful.
Next, you need to run the native agent to collect metadata for the GraalVM compiler. In general, according to my observations, it is best to run the application at least once with an agent for the initial collection of metadata, and only then, if the coverage is sufficient with tests, then simply run the tests to update the configs.
6.1 MacOS
# Build application
gradle clean build
# Start native agent
<path-to-graalvm-jdk-17>/java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image -jar build/libs/kotlin-spring-boot-native-skeleton-1.0.0.jar
After launching the application, you should wait a while for everything to initialize and for the various schedulers to start. It’s even better if it’s possible to call endpoints, etc.
Next, we simply stop the application and see either new config files in the /resources/META-INF/native-image directory if this is the first launch or changes to existing ones.
Now all that remains is to run the compilation directly:
gradle nativeCompile
After a successful build, in the directory: build/native/nativeCompile we will see the application binary, which we can launch from the console.
To optimize this routine, I wrote a simple Python build script in the directory: ci/build.py. When you launch it, it will go through all the stages. Using the --aot-wait flag, you can set the desired waiting time in minutes for the application to run. Only for it to work you need to change the path to GraalVM Java in the GRAALM_HOME_PATH variable to your path.
Running a script from the application directory with an application running time of 1 minute in AOT mode:
python3 ci/build.py --aot-wait 1
The script can also be run under Windows, but I usually just copy the assembly result on Windows and immediately start native compilation, so in my case, there is no need for it under Windows.
6.2 Windows
It is necessary to create a directory for storing Maven dependencies with the shortest possible path. In my case, it was: C:\m2.
Because to build on Windows, I usually just copy the directory of the compiled MacOS application in which the *.jar has already been compiled and the configs have been collected by the native agent, then all that remains is to run the compilation command:
gradle "-Dmaven.repo.local=C:\m2" --gradle-user-home=C:\m2 nativeCompile
If the project was cloned directly onto a Windows machine from the repository, then you need to follow all the steps as for MacOS, only this time we add paths to our custom .m2 directory:
# Build application
gradle "-Dmaven.repo.local=C:\m2" --gradle-user-home=C:\m2 clean build
# Start native agent
java "-Dspring.aot.enabled=true" -agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image -jar build/libs/kotlin-spring-boot-native-skeleton-1.0.0.jar
Now all that remains is to run the compilation directly:
gradle "-Dmaven.repo.local=C:\m2" --gradle-user-home=C:\m2 nativeCompile
After a successful build, in the directory: build/native/nativeCompile we will see the application binary, which we can launch simply by double-clicking.
7. Measurements of the start time, binary size, and resource consumption
As you can see in the screenshots, the native application starts almost 14 times faster than a regular *.jar. As the functionality grows, the launch time for a regular application will increase by seconds, but for a native one, it will never be more than 1 second!
After launch, a native application takes up 100Mb of RAM, and a regular application occupies 350Mb (measurements only at the time of launch).
The size of the *.jar file usually turns out to be 98.2Mb (considering that it is empty without logic) + the size of the archive of a regular JDK 17 is ~220Mb, for a total application size of ~320Mb.
The size of the native application is ~149Mb - that's all. Moreover, this starting size will always be approximately the same, because A native application is not exactly native code; it contains an additional environment for all this to work (as far as I remember, Substrate VM). But as the code base grows, the size will not grow much; the size of my application is ~190Mb.
8. Afterword
As promised, I’ll tell you a little about the history of the creation of the application and what it ended up consisting of.
Initially, I developed it entirely in JavaFX and Spring Boot, but I quickly realized the main disadvantages - large file size, long start-up time, high resource consumption, and a huge amount of time to maintain an acceptable design and UI for 2023. But it was the long start that most likely became decisive, because it may have to be launched quite often. Copy protection and reverse engineering were also important.
Next, I came up with the idea of compiling the application natively in order to get rid of the main problem of a long startup; besides, I had been following the Spring Boot Native project from the first days of its mention (I immediately saw great opportunities). After trying to compile the JavaFX + Spring Boot combination, nothing worked for me. Most likely due to the fact that JavaFX itself needs to be compiled separately and there is a special toolkit.
And then suddenly a great idea came to me - Electron + Backend on Kotlin and Spring Boot. There are a bunch of ready-made beautiful templates for the web, and the backend will become a simple REST service and Electron itself will conveniently assemble all this into an executable application for each OS. The web part is written in React. After creating the first prototype with native compilation, the result exceeded all expectations - everything is beautiful, now it’s easy to develop a UI to suit any imagination, and the Backend starts quickly, is protected from changes and copying, and takes up little space and resources. The final size of the entire assembled application became 212 Mb under MacOS (we are talking about the final build with Electron), and even less under Windows. The entire application starts in less than a second, almost indistinguishable from other native applications.
I often see opinions that native compilation is almost a revolution in the world of spring for containerization, but after quite a lot of assembly experience, I honestly don’t really think so. Yes, this is a huge step for Spring and Java/Kotlin in recent years, new opportunities are opening up that were previously only dreamed of (all sorts of crutches were created). But on the other hand, the assembly is very labor- and resource-intensive, all this is not so easy to automate via CI and takes a lot of assembly time, a lot. It seems to me that this is just a solution in terms of containerization for some specific tasks (each for himself. If, for example, you have 3-4 pods running in k8s, then there is no point, 15 minutes of compilation will not give a big boost to the relatively quick start of the pod But, perhaps, if you have hundreds or thousands of pods, then the build time will be justified relative to the start time of the entire cluster. In general, you need to carefully assess the degree of need for native compilation, otherwise over time there may be more problems than profits.
I'll try to answer any questions in the comments.
Thank you for your attention!