This workshop is for developers looking to understand better how to use GraalVM Native Image via a Maven plugin and build size-optimized cloud native Java applications. You are going to discover ways to minimize application footprint by taking advantage of different Native Image linking options and packaging into various base container images. Finally, you will learn how to streamline your development process by automating builds with CI/CD pipelines. [to do]
For the demo part, you will run a Spring Boot web server application, hosting the GraalVM website. This application is enhanced with the GraalVM Native Image Maven plugin. GraalVM Native Image can significantly boost the performance and reduce footprint of a Spring Boot application.
In this workshop you will:
- See how to use the GraalVM Native Build tools, Maven Plugin in particular.
- Learn how to compile a CLI application ahead-of-time into a native executable and optimize for file size.
- Create native executables within a Docker container.
- Shrink a Docker container size taking advantage of different Native Image containerisation and linking options.
- Use GitHub Actions to automate the build of native executables as part of a CI/CD pipeline. [to do]
- Compare the deployed container images sizes
Note that the website pages add 44M to the container size.
- x86 Linux
musl
toolchain- Container runtime such as Rancher Desktop or Docker installed and running
- GraalVM for JDK 22
- GraalVM for JDK 23 Early Access Build
Below see the summary of base images that will/can be used in this workshop:
Image | Purpose | Size |
---|---|---|
debian:12-slim | For JVM-based applications. Full JDK with required libraries | 785 MB |
docker.io/paketo-buildpacks/java-native-image | For JVM-based applications. Full JDK with required libraries | |
gcr.io/distroless/java21-debian12 | For JVM-based applications. Full JDK with required libraries | 192 MB |
gcr.io/distroless/java-base-debian12 | For JVM-based applications. No JDK. Just required libraries | 128 MB |
gcr.io/distroless/base-debian12 | For mostly statically linked applications. Has libc | 48.3 MB |
gcr.io/distroless/static-debian12 | For statically linked applications. No libc | |
scratch | For statically linked applications. No libc | 14.5 MB |
Distroless container images contain only your application and its runtime dependencies. They do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution. Learn more in "Distroless" Container Images.
Clone this repository with Git and enter the application directory:
git clone https://github.com/olyagpl/webserver.git
cd webserver
You are going to compile and run the application from a JAR in a Docker container.
It requires a container image with a full JDK and runtime libraries.
The Dockerfile, provided for this step, Dockerfile.distroless-base.uber-jar, uses a Debian Slim Linux image and installs Oracle GraalVM for JDK 23 in it.
The entrypoint of this image is equivalent to java -jar
, so just specify a path to a JAR file in CMD
.
-
Run the build-jar.sh script:
./build-jar.sh
-
Once the script finishes, a Docker image webserver:debian-slim.jar should be available. Start the application using
docker run
:docker run --rm -p8080:8080 webserver:debian-slim.jar
-
Open a browser and go to http://<SERVER_IP>:8080/, where the
<SERVER_IP>
is the public API address of the host. If you are running the example locally, not on a remote host, just open http://localhost:8080. You see the GraalVM documentation pages served. -
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
Let's check the container and runnable JAR file size:
[to do]
The container started in hundreds of milliseconds ().
Jlink, or jlink
, is a tool that generates a custom Java runtime image that contains only the platform modules that are required for your application.
This is one of the approaches to create cloud native applications introduced in Java 11.
Your application does not have to be modular, but you need to figure out which modules you application depends on.
-
First, run this command to get the classpath:
./mvnw dependency:build-classpath -Dmdep.outputFile=cp.txt
This will generate a cp.txt file containing the classpath with all the dependencies.
-
Then run
jdeps
with the classpath to check required modules for this Spring Boot application:jdeps --ignore-missing-deps -q --recursive --multi-release 21 --print-module-deps --class-path $(cat cp.txt) target/webserver-0.0.1-SNAPSHOT.jar
-
Once you have the module names, create a custom runtime using
jlink
for this application as follows:jlink \ --module-path ${JAVA_HOME}/jmods \ --add-modules java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.sql,jdk.jfr,jdk.unsupported,org.graalvm.nativeimage \ --verbose \ --strip-debug \ --compress zip-9 \ --no-header-files \ --no-man-pages \ --strip-java-debug-attributes \ --output jlink-jre
-
Lastly, run the application using the custom runtime:
./jlink-jre/bin/java -jar target/webserver-0.0.1-SNAPSHOT.jar
However, we prepared the script build-jlink-runner.sh that runs docker build
using the Dockerfile.distroless-java-base.jlink.
The Dockerfile contains a multistage build: first it generates a Jlink custom runtime on a full JDK; then copies the runtime image folder along with static website pages into a Java base container image, and sets the entrypoint:
FROM container-registry.oracle.com/graalvm/jdk:22 AS build
COPY . /webserver
WORKDIR /webserver
RUN ./mvnw clean package
RUN ./mvnw dependency:build-classpath -Dmdep.outputFile=cp.txt
RUN jdeps --ignore-missing-deps -q --recursive --multi-release 21 --print-module-deps --class-path $(cat cp.txt) target/webserver-0.0.1-SNAPSHOT.jar
RUN jlink \
--module-path ${JAVA_HOME}/jmods \
--add-modules java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.sql,jdk.jfr,jdk.unsupported,org.graalvm.nativeimage \
--verbose \
--strip-debug \
--compress zip-9 \
--no-header-files \
--no-man-pages \
--strip-java-debug-attributes \
--output jlink-jre
FROM gcr.io/distroless/java-base-debian12
COPY --from=build /webserver/target/webserver-0.0.1-SNAPSHOT.jar webserver-0.0.1-SNAPSHOT.jar
COPY --from=build /webserver/jlink-jre jlink-jre
EXPOSE 8080
ENTRYPOINT ["jlink-jre/bin/java", "-jar", "webserver-0.0.1-SNAPSHOT.jar"]
-
Run the script:
./build-jlink.sh
-
Run the container image, mapping the ports:
docker run --rm -p8080:8080 webserver:distroless-java-base.jlink
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
Now let's compare file size of build artifacts and container images, and the startup times at this point.
docker images webserver
[to do]
Requires GraalVM for JDK 22.
Spring Boot supports building a native image in a container using the Paketo Buildpack for Oracle which provides GraalVM Native Image.
The mechanism is that the Paketo builder pulls the Jammy Tiny Stack image (Ubuntu Jammy Jellyfish build distroless-like image) which contains no buildpacks. Then you point the "builder" image to the "creator" image (see the Paketo reference documentation). In our case, we would like to point to the Paketo Buildpack for Oracle explicitly requesting the Native Image tool.
Note that if you do not specify Oracle's buildpack, it will pull the default buildpack, which can result in reduced performance.
-
Open the pom.xml file, and find the
spring-boot-maven-plugin
declaration:<configuration> <image> <builder>paketobuildpacks/builder-jammy-buildpackless-tiny</builder> <buildpacks> <buildpack>paketobuildpacks/oracle</buildpack> <buildpack>paketobuildpacks/java-native-image</buildpack> </buildpacks> </image> </configuration>
When
java-native-image
is requested, the buildpack downloads Oracle GraalVM, which includes Native Image. -
Build a native executable for this Spring application using the Paketo buildpack:
./mvnw -Pnative spring-boot:build-image
-
Once the build completes, a container image should be available. Run the container image, mapping the ports:
docker run --rm -p8080:8080 docker.io/library/webserver:0.0.1-SNAPSHOT
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
The server running from the native image started inside a container! The container started in just milliseconds!
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
The Paketo documentation provides several examples that show you how to build applications with Native Image using buildpacks.
Let's check the size of this container image:
docker images webserver
This works for those who want to create a native image on a host machine, and only run inside a container.
Spring Boot 3 has integrated support for GraalVM Native Image, making it easier to set up and configure your project. Native Build Tools project, maintained by the GraalVM team, provide Maven and Gradle plugins for building native images. The project configuration already contains all necessary plugins, including Native Image Maven plugin:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
You can build this web server ahead of time into a native executable, on your host machine, just like this:
./mvnw -Pnative native:compile
The command will compile the application and create a fully dynamically linked native image, webserver
, in the target/ directory.
However, we prepared a script build-dynamic-image.sh, for your convenience, that does that and packages this native binary in a distroless base container image with just enough to run the application. No Java Runtime Environment (JRE) is required!
-
Run the script:
./build-dynamic-image.sh
-
Run the container image, mapping the ports:
docker run --rm -p8080:8080 webserver:distroless-java-base.dynamic
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
Let's check the size of this container image:
docker images webserver
[to do]
This is where the fun begins.
Requires GraalVM for JDK 23 Early Access Build. Run:
wget -q https://github.com/graalvm/oracle-graalvm-ea-builds/releases/download/jdk-23.0.0-ea.23/graalvm-jdk-23.0.0-ea.23_linux-x64_bin.tar.gz && tar -xf graalvm-jdk-23.0.0-ea.23_linux-x64_bin.tar.gz && rm -f graalvm-jdk-23.0.0-ea.23_linux-x64_bin.tar.gz
export JAVA_HOME=/home/opc/graalvm-jdk-23+36.1
export PATH=/home/opc/graalvm-jdk-23+36.1/bin:$PATH
Next we are going to build a fully dynamically linked native image with the file size optimization on, giving it a different name. For that, we provide a separate Maven profile to differentiate this run from the default build.
<profile>
<id>dynamic-size-optimized</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<imageName>webserver.dynamic</imageName>
<buildArgs>
<buildArg>-Os</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
The -Os
option optimizes the resulting native binary for file size.
-Os
enables -O2
optimizations except those that can increase code or executable size significantly. Learn more in the Native Image documentation.
We will keep the
-Os
optimization for all the subsequent builds.
The script build-dynamic-image.sh, available in this repository for your convenience, creates a native image with fully dynamically linked shared libraries, optimized for size, and then packages it in a distroless base container image with just enough to run the application. No Java Runtime Environment (JRE) is required. The Dockerfile.distroless-java-base.dynamic-optimized Dockerfile copies this native image along with static website pages into a container image, and sets the entrypoint.
-
Run the script:
./build-dynamic-image-optimized.sh
-
Run the container image, mapping the ports:
docker run --rm -p8080:8080 webserver:distroless-java-base.dynamic-optimized
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
Let's check the size of this container image:
docker images webserver
[to do]
Requires GraalVM for JDK 23 Early Access Build. (See Step 5.)
A mostly-static native image links all the shared libraries on which it relies (zlib
, JDK-shared static libraries) except the standard C library, libc
.
This type of native image is useful for deployment on a distroless base container image.
So now build a mostly statically linked image, by passing the --static-nolibc
option, and package it into a container image that provides glibc
.
A separate Maven profile exists for this build:
<profile>
<id>mostly-static</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<imageName>webserver.mostly-static</imageName>
<buildArgs>
<buildArg>--static-nolibc</buildArg>
<buildArg>-Os</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
(The file size optimization is on.)
-
Run the script:
./build-mostly-static-image.sh
-
Run the container image, mapping the ports:
docker run --rm -p8080:8080 webserver:distroless-base.mostly-static
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
Let's check the size of this container image:
docker images webserver
[to do]
Requires GraalVM for JDK 23 Early Access Build. (See Step 5.)
Requires the
musl
toolchain withzlib
. Run the following script to download and configure themusl
toolchain, and installzlib
into the toolchain:
./setup-musl.sh
A fully static native image is a statically linked binary that you can use without any additional library dependencies.
It is easy to deploy on a slim or distroless container, even a scratch container.
You can create a static native image by statically linking it against musl-libc
, a lightweight, fast and simple libc
implementation.
So now build a fully static executable, by passing the --static --libc=musl
options, and package it into a scratch container.
A scratch container is a Docker official image, useful for building super minimal images.
A separate Maven profile exists for this build:
<profile>
<id>fully-static</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<imageName>webserver.static</imageName>
<buildArgs>
<buildArg>--static --libc=musl</buildArg>
<buildArg>-Os</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
(The file size optimization is on.)
-
Run the script:
./build-static-image.sh
-
Run the container image, mapping the ports:
docker run --rm -p8080:8080 webserver:scratch.static
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
As a result you get the tiny container image with a fully functional and deployable server application. Note that the website static pages added 44M to the container images size!
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
To summarize this step, the native image that was just created is indeed fully self-contained which can be confirmed by examining it with ldd
:
lld target/webserver.static
This should result in:
not a dynamic executable
Which means that the image does not rely on any libraries in the operating system environment and can be packaged in the tiniest container!
Let's check the size of this container image:
docker images webserver
[to do]
What can you do next to reduce the size even more?
You can compress your native image with UPX - an advanced executable file compressor. Then package it into a scratch container.
-
Download and install UPX:
./setup-upx.sh
-
Compress the fully static executable, created at the previous step, and package it into a scratch container.
./build-static-upx-image.sh
-
Run the container image, mapping the ports:
docker run --rm -p8080:8080 webserver:scratch.static-upx
Open a browser and navigate to http://<SERVER_IP>:8080/ or to localhost:8080/ to see the GraalVM website running.
-
Stop the running container. Find out the container image ID and stop it:
docker ps
docker stop <image id>
The application and container image's size were "shrinked" to the minimum.
Let's check the sizes of all deployed containers to see the overall picture:
docker images webserver
[add a table]
Sorted by size, it is clear that the fully static native image, compressed with upx
, and then packaged on the scratch container is the smallest at just MB.
The upx
compressed executable is over xx% smaller from the "uncompressed" one, but note that UPX loads the native executable into the memory, unpackages it, and then compresses.
To clean up all images, run the ./clean.sh
script provided for that purpose.
A fully functional and, at the same time, minimal, Java application was compiled into a native Linux executable and packaged into base, distroless, and scratch-based containers thanks to GraalVM Native Image's support for various linking options. All the versions of this Spring Boot application are functionally equivalent.