/ docker

Building tiny docker containers with JDK9

With JDK9 hitting GA, we finally have a way to build small docker containers with java microservices, all as a result of work done on java modularization. Below is a quick demonstration on how to build small docker containers with customized jdk9. For impatient, code is available on my GitHub

Bit of background first

Before JDK9, one could build java containers based on alpine linux, adding on top jdk/jre and finally the java application itself, or use one of the pre-built openjdk/openjre alpine flavour images as a base, but resulting container would still be large. In the examples, below, I am using intentionally jdk itself. We could use alpine flavour of jre8, and that would still result in 80MB space taken, but with JDK9 we can do even better, with JDK.

Starting from scratch with alpine base

We can start from scratch, using only alpine linux as base image, add jdk distribution and our app. Let's see how this works in terms of size and numbers:

➜  jdk-9-docker git:(master) ✗ docker images
REPOSITORY             TAG            IMAGE ID       CREATED           SIZE
alpine                 3.6            37eec16f1872   11 hours ago      3.97MB
github-twitter-search  1.0.1-SNAPSHOT 844cae17273b   10 hours ago      10.1MB

So, we have alpine as base, jdk/jre, and our application and on top of this. Let's assume we add in JDK8 compressed archive, which is around 165MB. Inside container we would decompress it, so the size doubles and we end up with ~300MB of JDK8, 3.97MB of alpine docker container base, and 10MB of our actuall application. Things get even worse, if you are using any of the JEE application servers, which would typically add another ~400MB to the container size, but that's a different story all together.

Using jdk/jre-alpine prebuilt containers

As an alternative to starting from the scratch, we could start off with one of the openjdk base images, slim or alpine flavour. Since alpine flavour is smaller, we will use that.

Let's see how this would work, again in terms of size and numbers. If we do

docker pull openjdk:8-jdk-alpine
docker pull openjdk:8-jre-alpine

we end up with something like this:

REPOSITORY            TAG            IMAGE ID      CREATED            SIZE
openjdk               8-jdk-alpine   3b1fdb34c52a  10 hours ago       101MB
openjdk               8-jre-alpine   5699ac7295f9  12 hours ago       81.4MB
github-twitter-search 1.0.1-SNAPSHOT 844cae17273b  10 hours ago       10.1MB

If we start with openjdk alpine flavour as our base docker image, we end up again with bit over 100MB in container size, better then starting from the scratch, but still not very good, since our application is only 10MB and jdk/jre is 81.4MB-101MB.

JDK9 to the Rescue

Let's see what we can do with JDK9, even without fully modularizing our application, a naive and simplified approach. In this example, I will be using simple application based on Vertx. All it does, is searches GitHub for projects with given name, crossreferencing that with Twitter and extracting couple tweets mentioning that project. It will be available on port 8080, and response will be pretty formated JSON (for no particular reason, other then being pretty to look at, since a human/me/you will be looking at it; otherwise, machine to machine, well, who cares about pretty formatting).

We will build this application with jdk8, and use jdk9 to run it.

So let's start. Assuming we have our application written and working/tested to high standards of software industry these days, we will focus on packaging it into docker container.

In this example, I will use alpine linux as a base docker image, and we will be using docker multi-stage build to add openjdk and customize it as first stage, followed by using that customized small jdk in second stage of the build. You can find out more about multi-stage builds in docker here

But before that, lets see how JDK9 can help us. With JDK9 we can start with standard JDK9 distro, use jdeps on our application to gain more insight into which JDK modules it needs. Once we know the modules we need, we can then build another JDK9 distribution which contains only those, and save on size significantly.

Let's see how it works and how to do it, on our little test vertx application here:

➜  target git:(master) ✗ jdeps github-twitter-search-1.0.1-SNAPSHOT-fat.jar
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.base
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.compiler
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.desktop
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.logging
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.naming
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.sql
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> java.xml
github-twitter-search-1.0.1-SNAPSHOT-fat.jar -> jdk.unsupported

....output here will be much longer...

This gives us rough idea on what we would need for this application. In our case, we can trim it down even further, since here I know that java.desktop will not be needed, so I will be very agressive with it and remove it as well, when time comes.

Now that we have some idea on which modules we will need, let's build our own JDK9 distro, starting with alpine base image, and leverage docker multi-stage builds, to keep everything in single docker file.

FROM alpine:3.6 as packager
ADD jdk-9-ea+181_linux-x64-musl_bin.tar /opt/
# Build our own jdk9 distro with only these modules, as first stage
RUN /opt/jdk-9/bin/jlink \
    --module-path /opt/jdk-9/jmods \
    --verbose \
    --add-modules java.base,java.logging,java.xml,jdk.unsupported \
    --compress 2 \
    --no-header-files \
    --output /opt/jdk-9-minimal

# Second stage, add only our custom jdk9 distro and our app
FROM alpine:3.6
COPY --from=packager /opt/jdk-9-minimal /opt/jdk-9-minimal
COPY github-twitter-search-1.0.1-SNAPSHOT-fat.jar /opt/

ENV JAVA_HOME=/opt/jdk-9-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"

EXPOSE 8080
CMD java -jar /opt/github-twitter-search-1.0.1-SNAPSHOT-fat.jar

Note that, in order to use docker multi-stage builds, you need later(est) version of docker, at the time of writing this, it was 17.10-ce.
Once this build is executed we get our new docker container, built from alpine linux, containing only the parts of jdk9 we actually need, and our application. And the container size? Well, see below:

➜  jdk-9-docker git:(master) ✗ docker images
REPOSITORY             TAG    IMAGE ID       CREATED       SIZE
github-twitter-search  1.0.1  844cae17273b   10 hours ago  53.5MB

And all of this, without even going deeper into details, of fully modularizing our application. Finally, something much closer to what you could get with statically linked go binaries in containers - not there all the way, but a huge leap for java.

On a side note, this can be automated nicely, as we can create our own jdk9:alpine base image, from alpine by just adding openjdk9 for alpine linux into it, and using that as a base container for first stage of multistage build, as shown here:

FROM dekstroza/openjdk9-alpine as packager

RUN /opt/jdk-9/bin/jlink \
    --module-path /opt/jdk-9/jmods \
    --verbose \
    --add-modules java.base,java.logging,java.xml,jdk.unsupported \
    --compress 2 \
    --no-header-files \
    --output /opt/jdk-9-minimal

FROM alpine:3.6
COPY --from=packager /opt/jdk-9-minimal /opt/jdk-9-minimal
COPY github-twitter-search-1.0.1-SNAPSHOT-fat.jar /opt/

ENV JAVA_HOME=/opt/jdk-9-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"

EXPOSE 8080
CMD java -jar /opt/github-twitter-search-1.0.1-SNAPSHOT-fat.jar

For above then, we only need to add our application into container in second stage of the build.

Building your own image for the first stage

Dockerfile used below can be found on my GitHub
To create your own base image for the first stage:

  1. Download openjdk9 for alpine linux from here: http://jdk.java.net/9/ea
  2. Create Dockerfile like this:
FROM alpine:3.6
ADD jdk-9-ea+181_linux-x64-musl_bin.tar /opt/
  1. Place the downloaded file next to above Dockerfile
  2. Build the docker container and push somewhere