In this post I want to talk about Spring Boot AOT with GraalVM; I won’t go into the details of GraalVM and how it works but I aim to write something that acts as a reference for building native executables with Dockerfiles.

All the examples you will see were tested on:

  • Mac Ventura 13.2.1 M2 pro 16gb RAM
  • Podman VM running with 4 cores and 8gb RAM
  • Podman VM initialized with the “stable” OS image.
  • Podman commands executed with the “–platform linux/amd64” flag (static and mostly static native compilation on aarch64 is still not supported)

Also I needed to set the env JAVA_TOOL_OPTIONS="-Djdk.lang.Process.launchMechanism=vfork" to prevent build failures. It’s possible that on your platform you won’t need this.

With GraalVM Native Image we can package our Spring Boot applications into a native executable; in order to do that we just need to include in our project the Native Build Tools that provide Maven and Gradle support for building native executables.

A native executable with GraalVM can be packaged in 3 ways:

  • Native Executable
  • Mostly Static Native Executable: statically link against every libraries except LIBC (only GLIBC supported)
  • Static Native Executable: statically linked executable linked against MUSL-LIBC.

native_executables.png

Native Executable

With GraalVM we can produce a native executable; but what does it mean? Well, the executable will be our application that, besides our app classes includes the dependencies, lib classes, and code linked from the JDK. We won’t need a JDK to run the executable since it will include all the necessary components from the Substrate VM (the runtime environment embeeded in our artifact). This means the application has a faster startup time and low memory usage compared to the traditional way of packaging Spring Boot applications (JIT vs AOT).

To do so we need the Native Image Builder that is a utility tool that gets all the java classes and libraries, analyzes them, determines what classes and methods are reachable during the execution and then it compiles the code ahead-of-time. Keep in mind that in this way the executable will be tied to a specific OS and architecture.

The final artifact can be placed in a runtime like Ubuntu/Debian/Oracle Linux etc.

To produce a native executable of a Spring Boot application we need to include the native-build-tools plugin in our 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.6</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>it.justinpolidori</groupId>
	<artifactId>my-app</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>my-app</name>
	<description>My APP</description>
	<properties>
		<java.version>17</java.version>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-actuator</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>


		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<profiles>
    <!-- other profiles here... -->
		<profile>
			<id>native</id>
			<build>
                <plugins>
					<plugin>
						<groupId>org.springframework.boot</groupId>
						<artifactId>spring-boot-maven-plugin</artifactId>
					</plugin>
					<plugin>
						<groupId>org.graalvm.buildtools</groupId>
						<artifactId>native-maven-plugin</artifactId>
					</plugin>
				</plugins>
			</build>
		</profile>
	</profiles>
</project>

Then we can build the project and containerize it with the following Dockerfile:

ARG BUILD_IMAGE=ghcr.io/graalvm/native-image:ol9-java17-22.3.2
ARG RUNTIME_IMAGE=debian:bookworm-20230411-slim

FROM ${BUILD_IMAGE} as builder

ARG MAVEN_VERSION=3.9.1

ARG USER_HOME_DIR="/root"

ARG MAVEN_BASE_URL=https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries

RUN mkdir -p /usr/share/maven /usr/share/maven/ref \
 && curl -fsSL -o /tmp/apache-maven.tar.gz ${MAVEN_BASE_URL}/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
 && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \
 && rm -f /tmp/apache-maven.tar.gz \
 && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn

ENV MAVEN_HOME /usr/share/maven

COPY pom.xml /app/pom.xml
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.repo.local=/root/.m2 -e dependency:resolve

COPY src /app/src
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.repo.local=/root/.m2 -f /app -Pnative native:compile

FROM ${RUNTIME_IMAGE} as final
WORKDIR /application
COPY --from=builder "/app/target/my-executable" ./app
USER 1000
ENTRYPOINT ["./app", "-Dserver.port=8080", "-Dspring.config.additional-location=optional:/config/" ]

Mostly Static Native Executable

Mostly static native images provide a balance between static and dynamic linking. In a mostly static native image, the application’s code is compiled ahead of time, resulting in a standalone executable that includes the application’s code and dependencies (like the Native Executable above) but uses dynamic linking for certain components; in fact the executable references shared libraries (libc) that contain the required dependencies.

The C library, or libc, is a core library in the C programming language that provides essential functions and system calls for the execution of programs. Instead of including the entire libc within the native image, mostly static native images rely on the libc provided by the operating system. This means that the executable references the libc dynamically, allowing it to leverage the existing libc implementation on the target system. This approach reduces the size of the executable and ensures compatibility with the target environment.

To build a mostly static native image we need to modify our pom.xml and pass the following flag -H:+StaticExecutableWithDynamicLibC:

<profile>
    <id>native-mostly-static-image</id>
    <build>
        <plugins>
            <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>
            </plugin>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
                <extensions>true</extensions>
                <configuration>
                    <metadataRepository>
                        <enabled>true</enabled>
                    </metadataRepository>
                    <buildArgs>
                        <buildArg>-H:+StaticExecutableWithDynamicLibC</buildArg>
                        <buildArg>-H:+ReportExceptionStackTraces</buildArg>
                    </buildArgs>
                    <mainClass>it.justinpolidori.myapp.Myapp</mainClass>
                </configuration>
                <executions>
                    <execution>
                        <id>add-reachability-metadata</id>
                        <goals>
                            <goal>add-reachability-metadata</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>

Then the dockerfile:

ARG BUILD_IMAGE=ghcr.io/graalvm/native-image:ol9-java17-22.3.2
ARG RUNTIME_IMAGE=cgr.dev/chainguard/graalvm-native

FROM ${BUILD_IMAGE} as builder

ARG MAVEN_VERSION=3.9.1

ARG USER_HOME_DIR="/root"

ARG MAVEN_BASE_URL=https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries

RUN mkdir -p /usr/share/maven /usr/share/maven/ref \
 && curl -fsSL -o /tmp/apache-maven.tar.gz ${MAVEN_BASE_URL}/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
 && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \
 && rm -f /tmp/apache-maven.tar.gz \
 && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn

ENV MAVEN_HOME=/usr/share/maven
ENV JAVA_TOOL_OPTIONS="-Djdk.lang.Process.launchMechanism=vfork"

COPY pom.xml /app/pom.xml
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.repo.local=/root/.m2 -e dependency:resolve

COPY src /app/src
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.repo.local=/root/.m2 -f /app -Pnative-mostly-static-image native:compile

FROM ${RUNTIME_IMAGE} as final
WORKDIR /home/nonroot/application
COPY --from=builder "/app/target/myapp" ./app
USER nonroot
ENTRYPOINT ["./app", "-Dserver.port=8080", "-Dspring.config.additional-location=optional:/config/" ]

As you can see the Dockerfile is the same as the one used for the Native Executable but we use a different runtime image: cgr.dev/chainguard/graalvm-native that contains a bare-minimum OS and the glibc lib.

We could also have used the gcr.io/distroless/base but at the time of writing the BUILD_IMAGE uses GLIBC 3.24 so the executable, once placed in the RUNTIME_IMAGE doesn’t work since the distroless image is based on Debian and contains GLIBC 3.21.

Static Native Executable

Static native executables built with GraalVM use the MUSL-LIBC implementation and are fully self-contained executables that include both the application code and all necessary dependencies, including the libc library. Unlike the mostly static native images that rely on the system’s libc dynamically, these executables are statically linked with a lightweight alternative libc implementation called MUSL-LIBC.

Musl-libc is a C standard library designed for static linking and optimized for size, performance, and security. It aims to provide a minimalistic yet fully functional libc implementation suitable for building static executables. When building static native executables with GraalVM and musl-libc, all necessary libc functions are included within the executable, ensuring complete independence from the system’s libc.

Building static native executables with GraalVM and musl-libc can be particularly useful in scenarios where complete independence, portability, and small footprint are desired. We can even run these executables on the SCRATCH (non) image but we will then face (more than) a couple issues such as /tmp folders, tzdata, user management missing etc.

For this reason we can opt in for the gcr.io/distroless/static image since it just a Debian based image stripped to the minimum but with the files above.

To build a static native executable we can pass the following arguments: --static --libc=musl

<profile>
    <id>native-static-image</id>
    <build>
        <plugins>
            <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>
            </plugin>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
                <configuration>
                    <metadataRepository>
                        <enabled>true</enabled>
                    </metadataRepository>
                    <buildArgs>
                        <buildArg>--static --libc=musl</buildArg>
                        <buildArg>-H:+ReportExceptionStackTraces</buildArg>
                    </buildArgs>
                    <mainClass>it.justinpolidori.myapp.MyApp</mainClass>
                </configuration>
                <executions>
                    <execution>
                        <id>add-reachability-metadata</id>
                        <goals>
                            <goal>add-reachability-metadata</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</profile>

And the Dockerfile

ARG BUILD_IMAGE=ghcr.io/graalvm/native-image:muslib-ol9-java17-22.3.2
ARG RUNTIME_IMAGE=gcr.io/distroless/static

FROM ${BUILD_IMAGE} as builder

ARG MAVEN_VERSION=3.9.1

ARG USER_HOME_DIR="/root"

ARG MAVEN_BASE_URL=https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries

RUN mkdir -p /usr/share/maven /usr/share/maven/ref \
 && curl -fsSL -o /tmp/apache-maven.tar.gz ${MAVEN_BASE_URL}/apache-maven-${MAVEN_VERSION}-bin.tar.gz \
 && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \
 && rm -f /tmp/apache-maven.tar.gz \
 && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn

ENV MAVEN_HOME=/usr/share/maven
ENV JAVA_TOOL_OPTIONS="-Djdk.lang.Process.launchMechanism=vfork"

COPY pom.xml /app/pom.xml
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.repo.local=/root/.m2 -e dependency:resolve

COPY src /app/src
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.repo.local=/root/.m2 -f /app -Pnative-static-image native:compile

FROM ${RUNTIME_IMAGE} as final
WORKDIR /home/nonroot/application
COPY --from=builder "/app/target/myapp" ./app
USER nonroot
ENTRYPOINT ["./app", "-Dserver.port=8080", "-Dspring.config.additional-location=optional:/config/" ]