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:
FROM debian:9-slim AS builder
LABEL maintainer="Sascha Möllering <smoell@amazon.de>"
# First step: build java runtime module
RUN set -ex && \
apt-get update && apt-get install -y wget unzip && \
wget https://d3pxv6yz143wms.cloudfront.net/11.0.3.7.1/amazon-corretto-11.0.3.7.1-linux-x64.tar.gz -nv && \
mkdir -p /opt/jdk && \
tar zxvf amazon-corretto-11.0.3.7.1-linux-x64.tar.gz -C /opt/jdk --strip-components=1 && \
rm amazon-corretto-11.0.3.7.1-linux-x64.tar.gz && \
rm /opt/jdk/lib/src.zip
RUN /opt/jdk/bin/jlink \
--module-path /opt/jdk/jmods \
--verbose \
--add-modules java.base,java.logging,java.naming,java.net.http,java.se,java.security.jgss,java.security.sasl,jdk.aot,jdk.attach,jdk.compiler,jdk.crypto.cryptoki,jdk.crypto.ec,jdk.internal.ed,jdk.internal.le,jdk.internal.opt,jdk.naming.dns,jdk.net,jdk.security.auth,jdk.security.jgss,jdk.unsupported,jdk.zipfs \
--output /opt/jdk-minimal \
--compress 2 \
--no-header-files
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:
FROM debian:9-slim
LABEL maintainer="Sascha Möllering <smoell@amazon.de>"
COPY --from=builder /opt/jdk-minimal /opt/jdk-minimal
ENV JAVA_HOME=/opt/jdk-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"
RUN mkdir /opt/app && apt-get update && apt-get install curl -y
COPY target/reactive-vertx-1.5-fat.jar /opt/app
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health/check || exit 1
EXPOSE 8080
CMD ["java", "-server", "-XX:+DoEscapeAnalysis", "-XX:+UseStringDeduplication", \
"-XX:+UseCompressedOops", "-XX:+UseG1GC", \
"-jar", "opt/app/reactive-vertx-1.5-fat.jar"]
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“:
<profile>
<id>native-image-fargate</id>
<build>
<plugins>
<plugin>
<groupId>com.oracle.substratevm</groupId>
<artifactId>native-image-maven-plugin</artifactId>
<version>${graal.version}</version>
<executions>
<execution>
<goals>
<goal>native-image</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<imageName>${project.artifactId}</imageName>
<mainClass>${vertx.verticle}</mainClass>
<buildArgs>--enable-all-security-services -H:+ReportUnsupportedElementsAtRuntime --allow-incomplete-classpath</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
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).
FROM oracle/graalvm-ce:1.0.0-rc16 AS build-aot
RUN yum update -y
RUN yum install wget -y
RUN wget https://www-eu.apache.org/dist/maven/maven-3/3.6.1/binaries/apache-maven-3.6.1-bin.tar.gz -P /tmp
RUN tar xf /tmp/apache-maven-3.6.1-bin.tar.gz -C /opt
RUN ln -s /opt/apache-maven-3.6.1 /opt/maven
RUN ln -s /opt/graalvm-ce-1.0.0-rc16 /opt/graalvm
ENV JAVA_HOME=/opt/graalvm
ENV M2_HOME=/opt/maven
ENV MAVEN_HOME=/opt/maven
ENV PATH=${M2_HOME}/bin:${PATH}
ENV PATH=${JAVA_HOME}/bin:${PATH}
COPY ./pom.xml ./pom.xml
COPY src ./src/
ENV MAVEN_OPTS='-Xmx6g'
RUN mvn -Dmaven.test.skip=true -Pnative-image-fargate clean package
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.
FROM debian:9-slim
LABEL maintainer="Sascha Möllering <smoell@amazon.de>"
ENV javax.net.ssl.trustStore /cacerts
ENV javax.net.ssl.trustAnchors /cacerts
RUN apt-get update && apt-get install -y curl
COPY --from=build-aot target/reactive-vertx /usr/bin/reactive-vertx
COPY --from=build-aot /opt/graalvm/jre/lib/amd64/libsunec.so /libsunec.so
COPY --from=build-aot /opt/graalvm/jre/lib/security/cacerts /cacerts
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health/check || exit 1
EXPOSE 8080
CMD [ "/usr/bin/reactive-vertx" ]
Building your target image is easy. Run the following command:
docker build . -t <your_docker_repo>/reactive-vertx-native -f Dockerfile-native
To build a standard Docker image with an Uber JAR, run the following command:
docker build . -t <your_docker_repo>/reactive-vertx -f Dockerfile
After you successfully finish both builds, running the command docker images shows the following result:
REPOSITORY TAG IMAGE ID CREATED SIZE
smoell/reactive-vertx latest 391f944bb553 19 minutes ago 181MB
<none> <none> 389ee5ec6a8c 19 minutes ago 411MB
smoell/reactive-vertx-native latest ecd72b58a3d2 25 minutes ago 133MB
<none> <none> d93993d1d5ab 26 minutes ago 2.89GB
debian 9-slim 92d2f0789514 4 days ago 55.3MB
oracle/graalvm-ce 1.0.0-rc16 131b80926177 2 weeks ago 1.72G
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.