Honey, I shrank the Java image!
Smaller Docker images with multi-stage builds and Jlink.
Have you ever wondered why deploying your application take so long? Or why your disk is always full? Or why Amazon data transfer bills are so high?
There are plenty of reasons for this to happen and while some are outside of your control, the size of your docker images is not!
Using Jlink to create a minimal custom runtime, we managed to reduce the size of the docker image for a Spring Boot application by an impressive 250MB.
daniel$ docker images | grep example-spring-bootexample-spring-boot-1.1 ea662709c6dd 10 hours ago 99MBexample-spring-boot-1.0 521d3a82a93a 23 hours ago 348MB
How did we do this?
To explain how we did this we’ll start by creating a simple Java application. A sort of a Hello World but with a dependency to a 3rd party library (in this case, Google’s Guava).
import com.google.common.cache.CacheBuilder;class JlinkTest { public static void main(String[] args) { var cache = CacheBuilder.newBuilder().build(); cache.put("foo", "bar"); System.out.println(cache.getIfPresent("foo")); }}
After using Maven to package this into a jar, we run jdeps and we get the following output:
daniel$ jdeps -s hello.jarhello.jar -> java.basehello.jar -> not found
Jdeps is a dependency analyzer for Java. It processes .class files (or jars) and does a static analysis of the dependencies between them. Because this tool is aware of the Java module system we can use it to list the modules that our application will need to run.
In this example Jdeps is telling us that our application depends on the java.base module and also on a “not found” module.
This happens because we didn’t tell Jdeps where to look for the dependencies (remember we added Guava?).
If we try again, but this time we tell Jdeps where our dependencies are, and if we ask it to recursively go through all of them, we get this instead:
daniel$ jdeps -cp 'lib/*' -recursive -s hello.jarerror_prone_annotations-2.1.3.jar -> java.baseguava-25.1-jre.jar -> lib/error_prone_annotations-2.1.3.jarguava-25.1-jre.jar -> java.baseguava-25.1-jre.jar -> java.logginghello.jar -> lib/guava-25.1-jre.jarhello.jar -> java.base
Things make a bit more sense now.
Our application depends on the java.base module, but also on java.logging (via the transitive dependency to Guava).
You can remove the summary flag (-s) to get a more detailed view of which modules are used by each of the imports.
Let’s get to work
Using Jlink
Now that we know what modules we actually need we can use Jlink to create a smaller Docker image.
Jlink was made available with Java 9 and is a command line tool that can assemble a set of modules into a custom runtime image.
To be able to compare the complexity and extra work introduced by this tool I’m going to first create a Docker image with a normal (full size) Java runtime.
FROM debian:9.5-slim
# Install wget to pull java binariesRUN apt-get update && apt-get install -y --no-install-recommends wget rm -rf /var/lib/apt/lists/*
# Download java and unpack itARG JAVA_DOWNLOAD_CHECKSUM=3784cfc4670f0d4c5482604c7c513beb1a92b005f569df9bf100e8bef6610f2eRUN cd /opt; wget --no-check-certificate https://download.java.net/java/ga/jdk11/openjdk-11_linux-x64_bin.tar.gz && echo "${JAVA_DOWNLOAD_CHECKSUM} openjdk-11_linux-x64_bin.tar.gz" | sha256sum -c && tar zxf openjdk-11_linux-x64_bin.tar.gz && rm -f openjdk-11_linux-x64_bin.tar.gz
ENV JAVA_HOME=/opt/jdk-11ENV PATH="$PATH:$JAVA_HOME/bin"ENV DIRPATH /opt/hello
# Create some dirs and copy hello jar and libsRUN mkdir -p $DIRPATHCOPY target/hello.jar $DIRPATH/COPY target/lib $DIRPATH/libRUN chmod 755 $DIRPATH/hello.jarWORKDIR $DIRPATH
CMD exec $JAVA_HOME/bin/java $JAVA_JVM_ARGS -jar hello.jar
TL;DR; we start with Debian as our base image, we then grab Oracle’s OpenJDK, install it, and finally we copy our jars.
And unsurprisingly, our Docker image is massive.
daniel$ docker images | grep hellohello latest 946a41d508bd 8 minutes ago 375MB
Using Docker multi stage builds
Some time back Docker introduced multi stage builds as a way to keep images size down.
In your Dockerfile you can have multiple FROM instructions, each using a different base image, and each will begin a new stage of the build. What this means is that you can have temporary stages using fat images with the entire JDK, and even Maven or other utilities to build your project, and then selectively copy just what you need to the next build stages.
Let’s look at the following example:
# Image used to create the minimal java distributionFROM debian:9.5-slim AS build
# Install wget to pull java binariesRUN apt-get update && apt-get install -y --no-install-recommends wget && rm -rf /var/lib/apt/lists/*
# Download java and unpack itARG JAVA_DOWNLOAD_CHECKSUM=3784cfc4670f0d4c5482604c7c513beb1a92b005f569df9bf100e8bef6610f2eRUN cd /opt; wget --no-check-certificate https://download.java.net/java/ga/jdk11/openjdk-11_linux-x64_bin.tar.gz && echo "${JAVA_DOWNLOAD_CHECKSUM} openjdk-11_linux-x64_bin.tar.gz" | sha256sum -c && tar zxf openjdk-11_linux-x64_bin.tar.gz && rm -f openjdk-11_linux-x64_bin.tar.gz
# Set java home and run jlink to create a minimal java distribution with modules required for Spring BootENV JAVA_HOME=/opt/jdk-11ENV PATH="$PATH:$JAVA_HOME/bin"RUN jlink \--module-path /opt/java/jmods \--compress=2 \--add-modules java.base,java.logging \--no-header-files \--no-man-pages \--output /opt/jdk-mini
# Start a new image and copy just the minimal java distribution from the previous oneFROM debian:9.5-slimCOPY --from=build /opt/jdk-mini /opt/jdk-mini
# Set our java home and other useful envsENV JAVA_HOME=/opt/jdk-miniENV PATH="$PATH:$JAVA_HOME/bin"ENV DIRPATH /opt/hello
# Create some dirs and copy hello jar and libsRUN mkdir -p $DIRPATHCOPY target/hello.jar $DIRPATH/COPY target/lib $DIRPATH/libRUN chmod 755 $DIRPATH/hello.jarWORKDIR $DIRPATH
CMD exec $JAVA_HOME/bin/java $JAVA_JVM_ARGS -jar hello.jar
The first few lines are similar to the previous Dockerfile. We start with Debian again and install (a full) Java.
However, this time we run Jlink to create a minimal runtime with just the two modules that we will need.
We then start a new stage with an empty Debian image and copy the minimal runtime instead of installing the full runtime.
Let’s see the difference:
daniel$ docker images | grep hellohello latest 946a41d508bd 8 minutes ago 95MB
This small change represents a massive saving of 280MB for this simple Hello World application .
Conclusion
Be careful when using this though as you may run into issues at runtime. If you miss a module you will get a not very cool ClassNotFoundException. The number of things that can go wrong is proportional to the size and complexity of your application, however some (or most?) of these steps can be automated by just adding a few extra lines to the Dockerfile or with some existing maven plugins (check links below). And the best part is that you don’t even need to be using the new modules systems (ie, module descriptors) in your project to get these benefits.