Optimizing the size of Docker images has several benefits. One of these is faster deployment times, which is very important if your application needs to scale out quickly to respond to an unexpected traffic burst. In this post, I’ll show you an interesting approach for optimizing Docker images for Java applications, which also helps to improve startup times. The examples used are based on another post that I published several months ago, Reactive Microservices Architecture on AWS.
How does the Java application work?
The Java application is implemented using Java 11, with Vert.x 3.6 as the main framework. Vert.x is an event-driven, reactive, non-blocking, polyglot framework to implement microservices. It runs on the Java virtual machine (JVM) by using the low-level I/O library Netty. The application consists of five different verticles covering different aspects of the business logic.
To build the application, I used Maven with different profiles. The first profile (which is the default profile) uses a “standard” build to create an Uber JAR – a self-contained application with all dependencies. The second profile uses GraalVM to compile a native image. The standard build uses jlink to build a custom Java runtime with a limited set of modules. (A command line tool, jlink allows you to link sets of modules and their transitive dependencies to create a runtime image.)
Build a custom JDK distribution using jlink
An interesting feature of JDK 9 is the Java Platform Module Feature (JPMS), also known as Project Jigsaw, which was developed to build modular Java runtimes that include only the necessary dependencies. For this application, you need only a limited set of modules, which can be specified during a build process. To prepare for your build, download Amazon Corretto 11, unpack it, and delete any unnecessary files such as the src.zip-file which is shipped with the JDK. In the following sections, to improve understanding, a multi-stage build is used, and the different parts of the build are covered separately.
Step 1: Build a custom runtime module
In the first step of the build process, build a custom runtime with just a few modules necessary to run your application, and then write the result to /opt/minimal:
Step 2: Copy the custom runtime to the target image
Next, copy the freshly-created custom runtime from the build image to the actual target image. In this step, you again use debian:9-slim as the base image. After you copy the minimal runtime, copy your Java application to /opt, add Docker health checks, and start the Java process:
Compile Java to native using GraalVM
GraalVM is an open source, high-performance polyglot virtual machine from Oracle. Use it to compile native images ahead of time to improve startup performance, and reduce the memory consumption and file size of JVM-based applications. The framework that allows ahead-of-time-compilation is called SubstrateVM.
In the following section, you can see the relevant snippet of the pom.xml-file. Create an additional Maven profile called native-image-fargate that uses the native-image-maven plugin to compile the source code to a native image during the phase “package“:
Docker multi-stage build
Your goal is to define a reproducible build environment that needs as few dependencies as possible. To achieve that, create a self-contained build process that uses a Docker multi-stage build.
An interesting aspect of multi-stage builds is that you can use multiple FROM statements in your Dockerfile. Each FROM instruction can use a different base image, and begins a new stage of the build. You can pick the necessary files and copy them from one stage to another, which is great because that allows you to limit the number of files you have to copy. Use this feature to build your application in one stage and copy your compiled artifact and additional files to your target image.
In the following section, you can see the two different stages of the build. Your Dockerfile (which is called Dockerfile-native) is split into two parts: the builder image and the target image.
The first code example shows the builder image, which is based on graalvm-ce. During your build, you must install Maven, set some environment variables, and copy the necessary files into the Docker image. For the build, you need the source code and the pom.xml-file. After successfully copying the files into the Docker image, the build of the application to an executable binary is started by using the profile native-image-fargate. Of course, it would be also possible to use the Maven base image and install GraalVM (the entire build process would be a bit different).
Now the second part of the multi-stage build process begins: creating the actual target image. This image is based on debian:9-slim and sets two environment variables to TLS-specific settings, because the application uses TLS to communicate with Amazon Kinesis Data Streams.
Building your target image is easy. Run the following command:
To build a standard Docker image with an Uber JAR, run the following command:
After you successfully finish both builds, running the command docker images shows the following result:
Here you have the different base images used for your build (oracle/graalvm-ce:1.0.0-rc16 and debian:9-slim), the temporary images you used during your build (without a proper name), and your target images smoell/reactive-vertx and smoell/reactive-vertx-native.
Conclusion
In this post, I described how Java applications can be compiled to a native image using GraalVM using a self-contained application based on a Docker multi-stage build. I also showed how a custom JDK distribution can be created using jlink for smaller target images. I hope I’ve given you some ideas on how you can optimize your existing Java application to reduce startup time and memory consumption.