How to compile a Spring Boot application in a native image using GraalVm and deploy it using Docker
Contents
Prehistory
Until recently, my experience with back-end programs was limited to creating a program based on Spring Boot of various versions using relational databases, liquidbase, message brokers, etc. The programs were mostly lightweight, quick to launch, and didn’t require a lot of resources. Until at work, my team and I encountered a project that not only took a very long time to run, but also worked with a large number of services, constantly sending and then processing various data. All this, of course, led to slow work in production, frequent hangs or a general breakdown of the service.
This became one of the reasons for the interest in GraalVm – a virtual machine written in Java that helps make programs faster with the help of a JIT compiler. GraalVm helps to compile java code called native image. It is an executable program file that starts instantly without starting the JVM.
This article is a tutorial on how to make friends with Spring Boot, GraalVm, Liquibase and Docker, pitfalls that can occur and how to work around them. Let’s start!
Program configuration
From creating a Spring Boot program, of course. Maven will be used as the program compiler. To quickly create an application, you can use https://start.spring.io/. For my project I used the following versions: Spring Boot version 3.0.8, GraalVm version 23.0 and Java 17. If you have never worked with Graal (like me, until recently) I recommend using the same versions as me, and after as you get things going, experiment with versioning to see if it affects the result.
After we downloaded the program archive, it’s time to unzip it and open it.
To build a native image, make sure spring-boot-starter-parent is included in your pom.xml file. The parent section should look like this:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.8</version>
</parent>
We will also need the following dependencies:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
</dependencies>
If you build the project with spring starter, all this can be selected there at the stage of connecting additional dependencies, and they will be automatically generated for you in pom.xml
For convenient work with the database on our work projects, we use liquibase, therefore, for the sake of purity of the experiment, it was decided to connect it as a dependency to the test project, as well as to take the same database used in the work project, namely PostgreSql. What nuances should be considered here, I will tell below.
Next, we connect the plugins. After many hours spent studying different project setup guides for native image, I came to the conclusion that there are no specific settings required in standard projects for plugins. The main thing is not to forget to specify the path to the property file and the main changelog file for Liquibase.
So, the build section ended up looking like this:
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</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>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<configuration>
<changeLogFile>src/main/resources/db/master-changelog.xml</changeLogFile>
<propertyFile>src/main/resources/liquibase.properties</propertyFile>
</configuration>
</plugin>
</plugins>
</build>
More about settings native-maven-plugin
and spring-boot-maven-plugin
can be read here and here respectively.
Full pom.xml file:
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.test</groupId>
<artifactId>graalvm-project</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>graalvm-project</name>
<description>Test project for testing GraalVm</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</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>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<configuration>
<changeLogFile>src/main/resources/db/master-changelog.xml</changeLogFile>
<propertyFile>src/main/resources/liquibase.properties</propertyFile>
</configuration>
</plugin>
</plugins>
</build>
</project>
We write the code
I will not dwell here in detail, since this is a test application and all the classes below are quite trivial.
The main code of the program class:
@SpringBootApplication
public class GraalvmProjectApplication {
public static void main(String[] args) {
SpringApplication.run(GraalvmProjectApplication.class, args);
}
}
Now let’s create a test controller, an entity class and a repository.
TestEntity.java
@AllArgsConstructor
@Entity
@Table(name = "test_table")
public class TestEntity {
@Id
@Column(name = "test_id")
private Long id;
@Column(name = "test_column")
private String testColumn;
}
TestRepository.java
public interface TestRepository extends JpaRepository<TestEntity, Long> {
}
TestController.java
@RestController
@RequestMapping("/api/tests")
@RequiredArgsConstructor
public class TestController {
private final TestRepository testRepository;
@GetMapping
public List<TestEntity> getAllTests(){
return testRepository.findAll();
}
}
Liquibase and reflection
In order not to create tables by hand, if we use Liquibase, we create a changeset with our test table, as well as a main changelog file.
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet logicalFilePath="/changelog/2023-07-25--01-test-table-changeset.xml"
id="1" author="Liquibase">
<createTable tableName="test_table">
<column name="test_id" type="int">
<constraints primaryKey="true"/>
</column>
<column name="test_column" type="varchar"/>
</createTable>
</changeSet>
</databaseChangeLog>
We put the new changeset in the resources folder on the /db/changelog path, and in order for Liquibase to see it, our main file should look like this:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="db/changelog/2023-07-25--01-test-table-changeset.xml"/>
</databaseChangeLog>
But, of course, simply specifying all the paths is not enough.
As the Spring Boot developers write, the GraalVm native image supports configuration through static files that are automatically detected when located in META-INF/native-image. These can be native-image.properties, Reflect-config.json, proxy-config.json, or resource-config.json files. Spring Native generates the following configuration files (which will sit alongside any user-provided files) using the Spring AOT build plugin. However, there are situations when you need to specify additional native configuration.
That is, in order for our Liquibase changeset files to be in the final executable file, we need to write a special class, HintsRegistrar, that will implement RuntimeHintsRegistrar:
public class HintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("db/master-changelog.xml");
hints.resources().registerPattern("db/changelog/*.xml");
}
}
In order for our configuration to work, it must be connected using the manual @ImportRuntimeHints(HintsRegistrar.class)
.
Now our main class looks like this:
@SpringBootApplication
@ImportRuntimeHints(HintsRegistrar.class)
public class GraalvmProjectApplication {
public static void main(String[] args) {
SpringApplication.run(GraalvmProjectApplication.class, args);
}
}
You can read more about it here.
Database, Docker and docker-compose
Let’s start with the database service. Let’s create a composite file and put it in the environments folder. The structure of our program now looks something like this:
graalvm-project
|
+-environments
| +-docker-compose.yml
+-src
| +-main
| +-java
| +-com.test.graalvmproject
| +-<application classes>
| +-resources
| +-db
| +-master-changelog.xml
| +-application.yml
| +-liquibase.properties
+-pom.xml
Before populating the docker-compose file, I create a file with the extension .sql where I do the basic setup of my future database:
CREATE DATABASE testdb ENCODING 'UTF8';
CREATE USER test_user WITH PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE testdb TO test_user;
And docker-compose:
version: '3'
services:
graalvm-project:
image: graalvm-project:latest
container_name: graalvm-project-container
build:
context: ../
dockerfile: ./environments/Dockerfile
ports:
- '8080:8080'
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/testdb?useSSL=false
- SPRING_DATASOURCE_USERNAME=test_user
- SPRING_DATASOURCE_PASSWORD=password
depends_on:
db:
condition: service_healthy
db:
image: postgres:14.1-alpine
container_name: db
restart: on-failure
environment:
- POSTGRES_USER=test_user
- POSTGRES_PASSWORD=password
ports:
- '5432:5432'
volumes:
- db:/var/lib/postgresql/data
- ./database.sql:/docker-entrypoint-initdb.d/database.sql
healthcheck:
test: pg_isready -Utest_user
interval: 1s
timeout: 1s
retries: 5
volumes:
db: { }
In order for our program to be able to connect to the database from the docker container, we specify the name of the container as the host in SPRING_DATASOURCE_URL. And additionally, in the database service, we add a health check – so that our program waits for the database itself to start before starting and knocking on it.
For the application build, we use the following Dockerfile:
FROM vegardit/graalvm-maven:latest-java17 as builder
WORKDIR /app
COPY pom.xml /app/
COPY src /app/src/
RUN mvn -Pnative native:compile
FROM mirekphd/jenkins-jdk17-on-ubuntu2204:latest
COPY --from=builder /app/target/graalvm-project /app/
CMD ["/app/graalvm-project"]
To call the team mvn -Pnative native:compile
an environment that includes grail and maven is needed, so it is used vegardit/graalvm-maven:latest-java17
image.
Team mvn -Pnative native:compile
will start the native image compilation, after which the native image executable file can be found in the target folder of our project. As you can see from the Dockerfile syntax, we copy the executable to the docker working folder and run our application.
The following command can also be used to create a native image using maven:
mvn -Pnative spring-boot:build-image
With this approach, a containerized version of the program is assembled. You can run it, for example, like this:
docker run --rm -p 8080:8080 graalvm-project:0.0.1-SNAPSHOT
And the conclusions?
Now let’s compare the application with GraalVm and Jvm-Hotspot in terms of memory and performance. To do this, we will make a native image and JAR for the same program and look at the indicators:
-
Of course, the time of compiling a native image requires much more time and system resources. It took me about 12 minutes for a simple app.
-
You can feel a huge difference when you start it up.
When starting the program in the usual way, we get the following result:
c.t.g.GraalvmProjectApplication: Started GraalvmProjectApplication in 3.508 seconds (process running for 3.874)
With native image we get the following result:
c.t.g.GraalvmProjectApplication: Started GraalvmProjectApplication in 0.788 seconds (process running for 0.793)
Of course, on the test project there is not much difference, less than a second or 3 the program is loaded. But if we compare the application more, for example, our working one, there the difference will already be pleasant – 0.356 seconds of native image versus 7.152 of jar launch. The difference is almost 20 times! It turns out a really instant launch.
-
Since the JAR requires the JRE to run, and the native image already contains everything necessary, it is quite difficult to compare the file sizes. Approximately, it turns out that the native image is 7% smaller than the JAR.
-
Also, with a native image, RAM consumption is approximately 7% less.
-
When comparing endpoint performance, JAR consistently performs better.
It is also worth highlighting some more differences between GraalVm and Jvm-Hotspot:
-
An important point is that the native image has certain limitations related to dynamic loading of classes and the use of reflection. You’ll have to spend time setting up things like serialization, resource usage, etc., because they require special configuration for native imaging.
-
The advantage of GraalVM is the support for several programming languages. Thus, we can write data processing in Python, and the business logic of the program in Java.
And now let’s move on to the most interesting, where you can use GraalVM. Comparing Jvm Hotspot and GraalVM, we can say that GraalVM is perfect for applications that require fast startup and do not have a lot of complex business logic.
In general, both GraalVM and JVM have their strengths and weaknesses, and the choice between them depends on the specific needs of the project or application.
The source code of the test project used in the text can be found here.