Site icon JVM Advent

A Hitchhiker’s Guide to Containerizing Java Apps

Containerizing an application based on a “scripting” language is straightforward. Add the sources, download the dependencies, and you’re good to go. One could say they are WYSIWYR.

FROM python:3

ADD requirements.txt .          # 1
RUN pip install                 # 2

ADD script.py .                 # 3

CMD ["python", "./script.py"]   # 4
  1. Copy the description of the dependencies
  2. Download the dependencies
  3. Copy the main script
  4. Run the script

With compiled languages in general and Java in particular, things are a bit different. In this post, I’d like to list some alternatives to achieve that.

A sample app

To describe those alternatives, we need a sample application. We will use a Spring Boot one, that offers a REST endpoint and store data in Hazelcast. It’s built using Maven, with an existing wrapper.

The REST endpoint works like this:

curl -X PUT http://localhost:8080/John
{"who":"John","when":64244336297226}
curl http://localhost:8080/
[{"who":"John","when":64244336297226}]

Compared to scripting languages, Java applications have two main differences:

  1. They require an extra compilation step, which transforms Java source code to _bytecode_
  2. The deployment unit is generally a self-executable JAR

The naive approach

As a first step, one could build the application outside of Docker, then add the JAR to the image.

./mvnw clean package -DskipTests
# docker build -t spring-in-docker:0.5 .
FROM adoptopenjdk/openjdk11:alpine-jre

COPY target/spring-in-docker-0.5.jar spring-in-docker.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "spring-in-docker.jar"]

The next logical step is to build the application inside of the Dockerfile:

# docker build -t spring-in-docker:1.0 .
FROM adoptopenjdk/openjdk11:alpine-slim

COPY .mvn .mvn
COPY mvnw .
COPY pom.xml .
COPY src src
RUN ./mvnw package -DskipTests

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "target/spring-in-docker-1.0.jar"]

This way has several downsides:

The dive executable highlights the last point:

Layers ├─────────────────────────────────────────────────────────────────────────────
Size Command
5.6 MB FROM 31609b718dd2bed
14 MB apk add --no-cache tzdata --virtual .build-deps curl binutils zstd && GL
17 kB #(nop) COPY multi:5542ba69976bc682acd7b679c22d8a0277609ba9f5b611fd518f87f209
235 MB set -eux; apk add --no-cache --virtual .fetch-deps curl; ARCH="$(apk
56 kB #(nop) COPY dir:20c328136da94aa01b2b6fd62c88ef506a15b545aefeb1a1e13473572aee
10 kB #(nop) COPY file:08c603013feae81d794c29c4c1f489cc58a32bd593154cc5e40c6afa522
1.8 kB #(nop) COPY file:1bb01c4e5b60aae391d2efc563ead23a959701863adcf408540f33b7e40
54 kB #(nop) COPY dir:d2bd6e2521e5d990b16efb81ae8823e23ed2de826e833b331718b2211d6a
108 MB ./mvnw package -DskipTests

│ ● Current Layer Contents ├─────────────────────────────────────────────────────────
Permission UID:GID Size Filetree
drwx------ 0:0 80 MB ├─⊕ root
drwxr-xr-x 0:0 28 MB └── target
drwxr-xr-x 0:0 5.8 kB ├─⊕ classes
drwxr-xr-x 0:0 0 B ├─⊕ generated-sources
drwxr-xr-x 0:0 64 B ├─⊕ maven-archiver
drwxr-xr-x 0:0 364 B ├─⊕ maven-status
-rw-r--r-- 0:0 28 MB ├── spring-in-docker-1.0.jar
-rw-r--r-- 0:0 5.4 kB └── spring-in-docker-1.0.jar.original

The single layer doesn’t look light a big issue at first, but it has a huge consequence. Every change in the source code requires the replacement of the whole OCI layer.

Multi-stage builds to the rescue

Docker multistage builds allow chaining several build steps, with steps later in the chain reusing artifacts created in earlier steps. This way, we can use a JDK for compilation, and a JRE for execution:

# docker build -t spring-in-docker:1.1 .
FROM adoptopenjdk/openjdk11:alpine-slim as build # 1

COPY .mvn .mvn
COPY mvnw .
COPY pom.xml .
COPY src src
RUN ./mvnw package -DskipTests

FROM adoptopenjdk/openjdk11:alpine-jre          # 2

COPY --from=build target/spring-in-docker-1.1.jar spring-in-docker.jar                                      # 3

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "spring-in-docker.jar"]
  1. Build step uses a JDK
  2. Run step uses a JRE
  3. Copy the JAR created in the previous `build` step

Multistage builds create one image per step. All images but the latest are untagged.

To improve the layering, one can decouple the download of the dependencies from the compilation and the packaging.

# docker build -t spring-in-docker:1.2 .
FROM adoptopenjdk/openjdk11:alpine-slim as build

COPY .mvn .mvn                       # 1
COPY mvnw .                          # 1
COPY pom.xml .                       # 1
RUN ./mvnw dependency:go-offline     # 2

COPY src src                         # 3
RUN ./mvnw package -DskipTests

FROM adoptopenjdk/openjdk11:alpine-jre

COPY --from=build target/spring-in-docker-1.2.jar spring-in-docker.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "spring-in-docker.jar"]
  1. Copy all required files to download the dependencies
  2. Download the dependencies – they will be part of a dedicated layer
  3. Now copy the sources; this will add another layer

Let’s dive into the build image:

Layers ├────────────────────────────────────────────────────────────────────────────
Size Command
5.6 MB FROM 31609b718dd2bed
14 MB apk add --no-cache tzdata --virtual .build-deps curl binutils zstd && GL
17 kB #(nop) COPY multi:5542ba69976bc682acd7b679c22d8a0277609ba9f5b611fd518f87f209
235 MB set -eux; apk add --no-cache --virtual .fetch-deps curl; ARCH="$(apk
56 kB #(nop) COPY dir:20c328136da94aa01b2b6fd62c88ef506a15b545aefeb1a1e13473572aee
10 kB #(nop) COPY file:08c603013feae81d794c29c4c1f489cc58a32bd593154cc5e40c6afa522
1.8 kB #(nop) COPY file:f191db2f3a7fe2e434025c321ad8106112373b1aa0fa99f1a76c884bf61
100 MB ./mvnw dependency:go-offline
54 kB #(nop) COPY dir:d2bd6e2521e5d990b16efb81ae8823e23ed2de826e833b331718b2211d6a # 1
28 MB ./mvnw package -DskipTests
  1. Dependencies layer

With the last Dockerfile, we managed to solve two issues: the security one coming from the JDK, and the layering one. Still, we need to set the version manually when we build the image. There’s no synchronization between the POM’s and the image’s.

Moreover, multistage builds are not compatible with skaffold. If you’re used to automatically trigger deployments to a (local) Kubernetes cluster when you change the source code, forget about them.

Jib

Jib is a Maven plugin (also available for Gradle) provided by Google that elegantly solves the above issues.

The concept behind Jib is simple but clever. Java allows running JARs, but also standard Java classes. Outside the world of containers, the JAR makes for a good deployment unit. However, in the container world, a JAR is just an extra wrapper, as the container is the deployment unit.

Jib plugins hook into the build system to compile Java sources, copy the adequate resources in layers and create an image that runs the app in “exploded” (non-JAR) format.

<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>2.5.2</version>
  <configuration>
    <to>
      <image>${project.artifactId}:${project.version}</image>  <!-- 1 -->
    </to>
  </configuration>
</plugin>${project.artifactId}:${project.version}
  1. Automatically sync the version of the image with the POM’s

Jib offers two goals: build to upload the image to a Docker repository, and dockerBuild to upload the image to a Docker repository, and dockerBuild to build to a Docker daemon. Let’s create the image locally.

mvn compile com.google.cloud.tools:jib-maven-plugin:2.6.0

dive outputs the following:

Layers ├────────────────────────────────────────────────────────────────────────────
Size Command
1.8 MB FROM 7cfeac17984f4f4
15 MB bazel build ...
1.9 MB bazel build ...
8.4 MB bazel build ...
170 MB bazel build ...
16 MB jib-maven-plugin:2.5.2
12 MB jib-maven-plugin:2.5.2
1 B jib-maven-plugin:2.5.2
5.8 kB jib-maven-plugin:2.5.2

Jib creates 4 layers, from oldest to most recent:

  1. Dependencies
  2. Snapshot dependencies
  3. Resources
  4. Compiled code

The second layer handles the case of SNAPSHOT dependencies: those are dependencies whose content can change despite having the same version number. This might be the case during development. You shouldn’t deploy snapshot dependencies to production.

The command-line of the Docker container is akin to:

java -cp /app/resources:/app/classes:/app/libs/* ch.frankel.blog.springindocker.SpringInDockerApplication

Besides, changing the parent image of the created image is straightforward:

<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>2.5.2</version>
  <configuration>
    <from>
      <image>adoptopenjdk/openjdk11:alpine-jre</image><!-- 1 -->
    </from>
    <to>
      <image>${project.artifactId}:${project.version}</image>
    </to>
  </configuration>
</plugin>
  1. Change the parent image to Alpine

Jib seems to be the best alternative. But let’s continue to explore other options.

Spring Boot layered JAR

With version 2.3, Spring Boot allows creating a JAR with a dedicated folder structure. You can map those folders to layers in the Dockerfile. By default, those are:

  1. Dependencies
  2. Snapshot dependencies
  3. Spring Boot runtime
  4. Resources and compiled code

It’s possible to customize those folders via a specific layers.xml file.

Here’s a multi-stage build file that shows how to create a Spring Boot application with the default layers:

FROM adoptopenjdk/openjdk11:alpine-slim as builder
COPY .mvn .mvn
COPY mvnw .
COPY pom.xml .
RUN ./mvnw dependency:go-offline

COPY src src
RUN ./mvnw package -DskipTests                               # 1

FROM adoptopenjdk/openjdk11:alpine-jre as layers

COPY --from=builder target/spring-in-docker-3.0.jar spring-in-docker.jar
RUN java -Djarmode=layertools -jar spring-in-docker.jar extract # 2

FROM adoptopenjdk/openjdk11:alpine-jre

COPY --from=layers dependencies/ .                           # 3
COPY --from=layers snapshot-dependencies/ .                  # 3
COPY --from=layers spring-boot-loader/ .                     # 3
COPY --from=layers application/ .                            # 3

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
  1. Create a standard self-executable JAR
  2. Extract the folder structure
  3. Copy each folder in a layer

This approach has all the downsides of the Dockerfile described above: no integration with skaffold, and no synchronization with the POM’s version.

Moreover, you should be careful to create the layers in the correct order.

Cloud-Native Buildpacks

Cloud-Native Buildpacks originate from Heroku’s buildpacks. Heroku is one of the early Cloud hosting platforms. It also offers Git repositories. To deploy on Heroku, you just need to push the source to a remote Heroku repository.

The platform understands how to build an executable from sources. It checks for the presence of some files as hints. For example, if the repo contains a pom.xml file at the root, it activates the Maven buildpack; if it contains a package.json file at the root, it activates the Maven buildpack; if it contains a package.json, it activates the Node.js one; etc.

CNBs are the revamp of Heroku’s buildpacks, targeted at OCI containers. Heroku and VMWare Tanzu – the company behind Spring Boot, are spearheading the project. It’s hosted at the CNCF.

To use a buildpack, just invoke the pack command with a builder reference, and the image tag to build. It will build the application, and inherit from the default parent image. For example, here’s the command-line to build the sample app:

pack build --builder gcr.io/paketo-buildpacks/builder:base-platform-api-0.3 spring-in-docker:4.0

It triggers the buildpacks that apply to the project:

===> DETECTING
[detector] 6 of 17 buildpacks participating
[detector] paketo-buildpacks/bellsoft-liberica 4.0.0
[detector] paketo-buildpacks/maven 3.1.1
[detector] paketo-buildpacks/executable-jar 3.1.1
[detector] paketo-buildpacks/apache-tomcat 2.3.0
[detector] paketo-buildpacks/dist-zip 2.2.0
[detector] paketo-buildpacks/spring-boot 3.2.1
[builder] Paketo BellSoft Liberica Buildpack 4.0.0
[builder] https://github.com/paketo-buildpacks/bellsoft-liberica
[builder] Build Configuration:
[builder] $BP_JVM_VERSION 11.* the Java version # 1
[builder] Launch Configuration:
[builder] $BPL_JVM_HEAD_ROOM 0 the headroom in memory calculation
[builder] $BPL_JVM_LOADED_CLASS_COUNT 35% of classes the number of loaded classes in memory calculation
[builder] $BPL_JVM_THREAD_COUNT 250 the number of threads in memory calculation
[builder] $JAVA_TOOL_OPTIONS the JVM launch flags
  1. Detect the JVM version. The buildpack downloads the correct JDK.

I’ve experienced several shortcomings:

Spring Boot plugin

With Spring Boot, it’s not necessary to invoke an external command. The plugin offers the build-image target that does the same as pack, invoking the relevant buildpack.

Let’s run it:

./mvnw spring-boot:build-image
[INFO] > Running creator
[INFO] [creator] ===> DETECTING
[INFO] [creator] 5 of 17 buildpacks participating
[INFO] [creator] paketo-buildpacks/bellsoft-liberica 4.0.0
[INFO] [creator] paketo-buildpacks/executable-jar 3.1.1
[INFO] [creator] paketo-buildpacks/apache-tomcat 2.3.0
[INFO] [creator] paketo-buildpacks/dist-zip 2.2.0
[INFO] [creator] paketo-buildpacks/spring-boot 3.2.1

This option has a lot of benefits:

Moreover, some buildpacks make it easy to customize the build process. For example, to make the final artifact a native executable is just a matter of adding the necessary environment variable:

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <layers>
      <enabled>true</enabled>
    </layers>
    <image>
      <name>${project.artifactId}:${project.version}</name>
      <env>
        <BP_BOOT_NATIVE_IMAGE>true</BP_BOOT_NATIVE_IMAGE>
      </env>
    </image>
  </configuration>
</plugin>

The biggest issue is that it’s not easy to change the parent image.

Recap

Here are the final images, with their respective size. I also tagged images used in the multistage builds.

REPOSITORY TAG IMAGE ID CREATED SIZE
spring-in-docker 0.5 ca380d4677f9 3 days ago 177MB
spring-in-docker 1.0 f16667a974f4 3 days ago 363MB
spring-in-docker/build 1.1 2f2a59f49486 3 days ago 363MB
spring-in-docker 1.1 45ae57fab5ae 3 days ago 177MB
spring-in-docker/build 1.2 b94b6a80a437 3 days ago 383MB
spring-in-docker 1.2 cbddb2300b1a 3 days ago 177MB
spring-in-docker 2.0 fb7d8501623a 50 years ago 225MB
spring-in-docker 2.1 c3b60a214da2 50 years ago 177MB
spring-in-docker/build 3.0-b 1eea78545af2 2 days ago 206MB
spring-in-docker/build 3.0-a 52c180e9f3d1 2 days ago 383MB
spring-in-docker 3.0 9e2240a4fc00 2 days ago 177MB
spring-in-docker 5.0 4cbda769276f 40 years ago 264MB
spring-in-docker 5.5 4ac2d37253ee 40 years ago 184MB
Build Version synch Layering Comments
Multistage No Manual
Jib Configuration Yes
Buildpack No Yes Buggy
Spring Boot Maven plugin Yes Configuration
  • Less flexible than multistage builds
  • Powerful parameterization depending on the buildpack

To go further:

Author: Nicolas Fränkel

Nicolas Fränkel is a Developer Advocate with 15+ years experience consulting for many different customers, in a wide range of contexts (such as telecoms, banking, insurances, large retail and public sector). Usually working on Java/Java EE and Spring technologies, but with focused interests like Rich Internet Applications, Testing, CI/CD and DevOps. Also double as a teacher in universities and higher education schools, a trainer and triples as a book author.

You can find Nicolas’ weekly post on his blog.

Exit mobile version