Building Small JREs and Docker Images with Recent Versions of OpenJDK – Part 2

1 Sep

This is part two of my article about small JREs. You find the first part in a separate blog post. The original post was published in German in my employers blog.

What about Docker?

In the first part, we build a small JRE. Now we want to derive a Docker image from that JRE. Let’s start with a naive approach and write a simple Dockerfile:

FROM ubuntu:latest

COPY target/jlink1b /app
CMD /app/bin/app

The image is very easy to build and to start. With 101 Megabytes it is relatively small.

$ docker build -t test .
Sending build context to Docker daemon  131.9MB
Step 1/3 : FROM ubuntu:latest
 ---> cf0f3ca922e0
Step 2/3 : COPY target/jlink1 /app
 ---> 1105c7ca04cb
Step 3/3 : CMD /app/bin/app
 ---> Running in 517a84b22477
Removing intermediate container 517a84b22477
 ---> 1bd40086a3b9
Successfully built 1bd40086a3b9
Successfully tagged test:latest
$ docker run --rm -it test
Running in module path? yes
The website 'https://www.jowisoftware.de' has 85 lines of HTML code

It is a bit unpleasant that the image has 60 Megabytes overhead compared to the JRE itself. The reason for this is the large Ubuntu base image. I claim that (at least in the rigid interpretation of containers) a normal docker container does not need apt-get, bash and wget during the normal operations. In this article, I’d like to carefully give a proposal on how to build even smaller and more secure containers.

A minimal Docker Image

The idea is simple: the JRE already contains everything we need to start the software. But the JRE itself also has dependencies against libc and zlib. But these are the only requirements – nothing else is needed! Can’t we build an image with only these files? By the way, Google’s Distroless-Project also follows exactly this idea.

The previously used Ubuntu image already contains the libc and zlib. Thankfully, the system is able to tell us which files belong to these libraries – the installer (dpkg) holds this information in text file to be able to uninstall the package. What will happen, if we clone these files in an empty Docker image?

Let’s try with another Dockerfile:

FROM ubuntu:latest AS builder

# copy files from libc/zlib, skip directories and docs
RUN mkdir /target; \
  for lib in /var/lib/dpkg/info/{libc6,zlib1g}:amd64.list; \
  do \
    while IFS='' read -r file; do \
      if [ -f "$file" ]; then \
        dir="/target/$(dirname $file)"; \
        mkdir -p "$dir"; \
        cp -d --preserve=all "$file" "$dir"; \
      fi; \
    done < "$lib"; \
  done; \
  rm -rf /target/usr/share/doc /target/usr/share/lintian



# start with an empty image
FROM scratch

# copy libc and zlib
COPY --from=builder /target /
# copy our app
COPY target/jlink1b /app

USER 65534
CMD "/app/bin/app"

The image builds, but starting a container fails:

$ docker run --rm -it test
docker: Error response from daemon: OCI runtime create failed:
container_linux.go:346: starting container process caused
"exec: \"/bin/sh\": stat /bin/sh: no such file or directory": unknown.

We’re hitting two problems here: First, Docker tries to execute the CMD-directive with /bin/sh. We did not include sh in our image! We can work around this problem if we provide an argument array instead of a string. Docker will then take the first argument as binary and uses the rest of the array as arguments.

The second problem is not immediately visible: the file target/bin/app itself is also a shell script! If we take a look at it’s content, we notice that don’t really need the script at all and we can invoke java directly. We change the second half of our Dockerfile:

FROM scratch

COPY --from=builder /target /
COPY target/jlink1b /app

USER 65534
ENTRYPOINT [ "/app/bin/java" ]
CMD [ "-m", "jowisoftware.jre/de.jowisoftware.learning.jre.Main" ]

This approach works! The image is 48 Megabytes large.

$ docker run --rm -it test2
Running in module path? yes
The website 'https://www.jowisoftware.de' has 85 lines of HTML code

We could also use the normal classpath here. In this case, we have to adapt ENTRYPOINT and CMD. We’ll do a full example in the next section of this post.

If an intruder gains access to the container, he can’t simply install tcpdump or execute arbitrary shell scripts. In addition, we only have to recreate the image if glibc, zlib or java gets updated because there are no other components that could receive updates.

On the other side, debugging gets harder. Patterns like sidecar containers can help here. Another approach would be to temporarily include a shell and additional software. Tools like Spring Boot Actuator or JMX work without any additional software in the container. As always, you have to decide what is most valuable for your case.

A Full Example

Let’s summarize what we learned so far with a complete example:

FROM ubuntu:latest AS jre

RUN apt-get -y update && \
  apt-get -y upgrade && \
  apt-get -y install curl binutils

ENV RELEASE jdk-14.0.2+12
ENV CHECKSUM 7d5ee7e06909b8a99c0d029f512f67b092597aa5b0e78c109bd59405bbfa74fe

# download openjdk
RUN mkdir -p /tmp/jdk/ && \
  cd /tmp/jdk && \
  url="https://api.adoptopenjdk.net/v3/binary/version/$RELEASE/linux/x64/jdk/hotspot/normal/adoptopenjdk?project=jdk" && \
  curl -s -L -o jdk.tgz "$url" && \
  echo "$CHECKSUM jdk.tgz" > SHA256SUMS && \
  sha256sum --status --strict -c SHA256SUMS

# copy libc and zlib
RUN for lib in libc6 zlib1g; do \
    while IFS='' read -r file; do \
      if [ -f "$file" ]; then \
        dir="/target/$(dirname $file)"; \
        mkdir -p "$dir"; \
        cp -d --preserve=all "$file" "$dir"; \
      fi; \
    done < "/var/lib/dpkg/info/$lib:amd64.list"; \
  done; \
  rm -rf /target/usr/share/doc /target/usr/share/lintian; \
  mkdir -p /target/tmp /target/etc; \
  chmod 777 /target/tmp && chmod +t /target/tmp

# create users root and nobody
RUN echo "root:x:0:" > /target/etc/group; \
    echo "nogroup:x:65534:" >> /target/etc/group; \
    echo "root:x:0:0:root:/root:/bin/bash" > /target/etc/passwd; \
    echo "nobody:x:65534:65534:nobody:/tmp:/usr/sbin/nologin" >> /target/etc/passwd

# create jre (with all java.se modules)
RUN tar -C /tmp/jdk --strip-components=1 -xzf /tmp/jdk/jdk.tgz && \
	/tmp/jdk/bin/jlink \
	--add-modules java.se \
	--output /target/jre \
	--no-header-files \
	--no-man-pages \
	--strip-debug \
	--compress=2



FROM maven:3-openjdk-14-slim AS build

WORKDIR /app

# cache dependencies as long as pom.xml does not change
COPY pom.xml ./
RUN echo "Pre-caching mvn artifacts..." && \
 mvn de.qaware.maven:go-offline-maven-plugin:1.2.5:resolve-dependencies || true

# build project offline
COPY . .
RUN mvn -o package
RUN mkdir -p /target && \
    mv target/app.jar /target



FROM scratch

# copy jre & app
COPY --from=jre /target /
COPY --from=build /target /app

# run java as "nobody"
USER 65534
ENTRYPOINT [ "/jre/bin/java" ]
CMD [ "-jar", "/app/app.jar" ]

We start with a Docker container that has the sole purpose to building our JRE. First (lines 3-5) we install some dependencies, then (lines 7-16) we Download our required Version of the OpenJDK. We use AdoptOpenJDK here.

The lines 19-28 copy our dependencies into /target.

We also create a /tmp directory because our software might expect one to exist… In addition, we create a user „root“ and a user „nobody“ (note that the login shells are invalid because none of the binaries exist). These steps might not be necessary and depend on your use case.

In the last step we create our tailored JRE (lines 39-46).

The next Container uses Maven to build the application. We cache the Maven artifacts (lines 55-57). At least as long as the pom.xml does not change, this saves us much time and bandwidth. This step is also fully optional. Then we copy the whole project into the container and build the app using Maven (lines 60-63).

Now we finally create our minimal image: we copy the JRE with its dependencies and the built artifacts into an empty container, switch to a non-root user as default and set the correct entrypoint.

$ docker run --rm -it test
Running in module path? no
The website 'https://www.jowisoftware.de' has 85 lines of HTML code

The example is 68 Megabytes in size! It runs as non-root and contains only our JRE. It uses the classic classpath and all modules from java.se.

You can use this Dockerfile as a starting point for your own experiments.

My Own Docker Image

Instead of creating the JRE on every build it is also possible to use such a minimal Docker image as parent image in the last FROM directive (line 67).

I’m doing this for my personal projects. I built a parent Docker image using the scripts in my github repository. The Images are published on Docker hub. In my JRE I included java.se and jdk.unsupported. This is fine for my personal projects. YMMV. Again: You’re welcome to use this project as a template to build your own JRE.

Summary

I hope that I was able to show that it’s not that hard to build tailored JREs from recent JDKs. With full java modules it is really simple. Using the old classpath, we have to walk the extra mile and collect the required modules.

I’d like to invite you to reflect, if such a way might be useful for your projects. Creating minimal docker images has many advantages, but debugging is more difficult. Projects like Spring Boot Actuator can help (use a separate port and do not expose it into the public world!). Nevertheless, we have to re-think some of our old concepts. You still have to think about if this is a sensible way for your development environment.

I had fun with these experiments and I hope that I was able to share some of my insights 🙂

One Reply to “Building Small JREs and Docker Images with Recent Versions of OpenJDK – Part 2”

  1. Pingback: Building Small JREs and Docker Images with Recent Versions of OpenJDK – Part 1 – JoWiSoftware

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert