Monosoul's Dev Blog A blog to write down dev-related stuff I face
reduce Java docker image size

How to reduce Java docker image size

If you’ve been using Java for a while, you might have noticed that starting with Java 11, Java Runtime Environment (JRE) doesn’t have a separate distribution anymore, it is only distributed as a part of Java Development Kit (JDK). As a result of this change, many official Docker images don’t offer a JRE-only image, e.g.: official openjdk images, Amazon corretto images. In my case using such an image was resulting in an app image of 414MB, where the app itself was only taking around 60MB. What a waste of space!

But fear not, as I’m going to show you how to reduce Java docker image size dramatically.

The problem

With Java 9 there was introduced Platform Module Subsystem (JPMS). In short: it lets you create your very own JRE image optimized for your needs. For example: if your app doesn’t use network stack anyhow or doesn’t interact with desktop environment, you can omit java.net and java.desktop packages from your image saving a few megabytes of space.

And starting with Java 11, JRE doesn’t have its own separate distribution, there’s no way to install it without installing JDK.

The reason for that is the modularity introduced in Java 9. There’s no need to try to distribute one JRE that will fit all, instead everyone can create a JRE image suitable for their own needs.

That’s the philosophy that many docker image maintainers have adopted, omitting exclusive JRE images and only shipping images with JDK.

Unfortunately if you’re using such images as is, then you’re wasting the space of your Docker image registry, your local machine and network bandwidth downloading and uploading them. JDK comes with tools, sources and documentation that you don’t need to run your app.

Let’s use this repository as an example. It has a small Java app there, that runs a web server on port 8080 and replies with “Hello, world!” to a get request.

Here’s how the Dockerfile would look like for a typical JDK-based image:

FROM amazoncorretto:17.0.3-alpine

# Add app user
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Configure working directory
RUN mkdir /app && \
    chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "java", "-jar", "/app/app.jar" ]
Code language: Dockerfile (dockerfile)

It uses Amazon corretto JDK image as a base, creates a non-root user to run the app, and then copies the jar file into the image.

Let’s build the image and check it’s size:

docker build -t jvm-in-docker:jre -f jre.dockerfile .
docker image ls | grep -e "jvm-in-docker.*jdk"Code language: Bash (bash)

In my case this is how the output looks like:

jvm-in-docker jdk 4126e7e5ce37 51 minutes ago 341MB

I.e. the image size is 341MB. Pretty huge image for a 7MB jar file, right? Here’s what we can do about it.

The solution

Along with modularity Java 9 has introduced a new tool called jlink. The purpose of this tool is to build a custom JRE image optimized for your use case. It provides a few options to tune the JRE image and modules to use, but there’s also a way to make it pretty generic (include all modules). First let’s have a look at the generic example:

# base image to build a JRE
FROM amazoncorretto:17.0.3-alpine as corretto-jdk

# required for strip-debug to work
RUN apk add --no-cache binutils

# Build small JRE image
RUN $JAVA_HOME/bin/jlink \
         --verbose \
         --add-modules ALL-MODULE-PATH \
         --strip-debug \
         --no-man-pages \
         --no-header-files \
         --compress=2 \
         --output /customjre

# main app image
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# copy JRE from the base image
COPY --from=corretto-jdk /customjre $JAVA_HOME

# Add app user
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Configure working directory
RUN mkdir /app && \
    chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "/jre/bin/java", "-jar", "/app/app.jar" ]
Code language: Dockerfile (dockerfile)

Let’s walk through this file:

  1. Here we use a staged build of 2 stages.
  2. In the first stage we’re using the same Amazon corretto image.
  3. We install binutils package (it is required for jlink to work), and then run jlink. You can have a look at the options’ description in the Oracle documentation, but the most important part for us here – is this line: --add-modules ALL-MODULE-PATH . It commands jlink to include all available modules into the image.
  4. At the second stage of the build we’re copying our custom JRE image from the first stage and do the same configuration as we did here.

Now, let’s build that image and check it’s size:

docker build -t jvm-in-docker:jre -f jre.dockerfile .
docker image ls | grep -e "jvm-in-docker.*jre "Code language: Bash (bash)

In my case here’s how the output looks like:

jvm-in-docker jre 15522f93ea6c 51 minutes ago 103MB

I.e. the image size is 103MB. 3 times smaller than it was before! And that’s with all modules included! Maybe we can improve that result? Let’s see!

When size matters

In the previous step we have included all Java modules into the image. Let’s see how much smaller we can make it if we exclude the modules we don’t use.

To do that we’re going to use jdeps. Jdeps has first been introduced with Java 8 and can be used to analyze dependencies of our app. But what we’re most interested in is Java module dependencies we have. The tricky part here, is that not all of the dependencies are required by the app itself, some of them are required by the libraries we use. Luckily for us, jdeps can detect such dependencies as well.

In our case we have a so-called fat jar (aka uber-jar). Unfortunately, jdeps can’t analyze dependencies of jars inside jars, so we have to unpack our app.jar first. Here’s how to do that:

mkdir app
cd ./app
unzip ../app.jar
cd ..
jdeps --print-module-deps --ignore-missing-deps --recursive --multi-release 17 --class-path="./app/BOOT-INF/lib/*" --module-path="./app/BOOT-INF/lib/*" ./app.jar
rm -Rf ./appCode language: Bash (bash)

As you can see, first we unpack the jar here and then run jdeps with a few arguments. You can read more about the arguments in the Oracle documentation, but what will happen here: is jdeps will print a list of module dependencies. It should look like that:

java.base,java.management,java.naming,java.net.http,java.security.jgss,java.security.sasl,java.sql,jdk.httpserver,jdk.unsupported

❗NOTE: there seems to be a bug in jdeps version 17.x.x causing a com.sun.tools.jdeps.MultiReleaseException. If you’re getting this exception, try installing jdeps from JDK 18.

Now we need to take that list and replace ALL-MODULE-PATH with it in the docker file from the previous step. Like this:

# base image to build a JRE
FROM amazoncorretto:17.0.3-alpine as corretto-jdk

# required for strip-debug to work
RUN apk add --no-cache binutils

# Build small JRE image
RUN $JAVA_HOME/bin/jlink \
    --verbose \
    --add-modules java.base,java.management,java.naming,java.net.http,java.security.jgss,java.security.sasl,java.sql,jdk.httpserver,jdk.unsupported \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /customjre

# main app image
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# copy JRE from the base image
COPY --from=corretto-jdk /customjre $JAVA_HOME

# Add app user
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Configure working directory
RUN mkdir /app && \
    chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "/jre/bin/java", "-jar", "/app/app.jar" ]
Code language: Dockerfile (dockerfile)

Let’s build that image and check it’s size:

docker build -t jvm-in-docker:jre-slim -f jre-slim.dockerfile .
docker image ls | grep -e "jvm-in-docker.*jre-slim"Code language: Bash (bash)

Here’s what i got:

jvm-in-docker jre-slim c8513c84b324 58 minutes ago 55.1MB

I.e. the image is only 55MB. It’s 6 times smaller than the original image! Pretty impressive result!

But there’s a catch. If your app is under active development, it could be that at some point you will add a dependency on a library that depends on a Java module that’s not included in the image. In that case you’d have to analyze the dependencies again to build a working image. Ideally, that could even be automated, but it’s up to you to decide if it’s worth the hassle.

But in case you’re interested, here’s an example of how it could be automated:

jre-slim-auto.dockerfile
# Used to analyze dependencies bacause jdeps from JDK 17 has a bug
FROM amazoncorretto:18-alpine as deps

# Identify dependencies
COPY ./app.jar /app/app.jar
RUN mkdir /app/unpacked && \
    cd /app/unpacked && \
    unzip ../app.jar && \
    cd .. && \
    $JAVA_HOME/bin/jdeps \
    --ignore-missing-deps \
    --print-module-deps \
    -q \
    --recursive \
    --multi-release 17 \
    --class-path="./unpacked/BOOT-INF/lib/*" \
    --module-path="./unpacked/BOOT-INF/lib/*" \
    ./app.jar > /deps.info

# base image to build a JRE
FROM amazoncorretto:17.0.3-alpine as corretto-jdk

# required for strip-debug to work
RUN apk add --no-cache binutils

# copy module dependencies info
COPY --from=deps /deps.info /deps.info

# Build small JRE image
RUN $JAVA_HOME/bin/jlink \
    --verbose \
    --add-modules $(cat /deps.info) \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /customjre

# main app image
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# copy JRE from the base image
COPY --from=corretto-jdk /customjre $JAVA_HOME

# Add app user
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Configure working directory
RUN mkdir /app && \
    chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "/jre/bin/java", "-jar", "/app/app.jar" ]
Code language: Dockerfile (dockerfile)

Summary

As you can see with just a little effort we can shrink the image size by at least 3 times.

You have 2 options:

  • build a generic JRE image that includes all modules and can be used universally by any app;
  • build a use case specific JRE image that will take less space, but will be less universal.

It’s up to you to decide which path suits you best, but any option will be a win in comparison to the default JDK image.

How to reduce java docker image size, size comparison
image size comparison

It is also worth mentioning that ideally, thanks to Docker images being organized in layers, multiple images based off of the default JDK image shouldn’t take too much space, as all app images will be reusing the same base. But even in that case having smaller images might save you some bandwidth.

That’s it. Now you know how to reduce Java docker image size.

Docker files from the examples are available here: monosoul/jvm-in-docker.

Bonus

If you want to have a look into your Docker image layers to have a better understanding of what’s inside, I totally recommend you to have a look at the tool called dive: wagoodman/dive.

It’s usage is pretty straightforward, once you install it just run: dive jvm-in-docker:jre-slim.

How to reduce Java docker image size, dive's interface
dive’s interface

Sources

  1. https://blog.adoptium.net/2021/10/jlink-to-produce-own-runtime/
  2. https://blog.adoptium.net/2021/08/using-jlink-in-dockerfiles/
Like it? Share it!

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One thought on “How to reduce Java docker image size”