loading...
Cover image for A Hitchhiker's Guide to Containerizing (Spring Boot) Java Apps

A Hitchhiker's Guide to Containerizing (Spring Boot) Java Apps

nfrankel profile image Nicolas Frankel Originally published at blog.frankel.ch ・10 min read

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.

Sample application architecture

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 final image embeds a JDK. First, it increases the size of the final image compared to a JRE. More importantly, it allows the image to compile Java code: this can be a severe security hole in production.
  • The version in the Maven's POM needs to be manually synchronized with the image's version.
  • There's a single OCI layer for the JAR.

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>
  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 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, 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:

  • The base-platform-api-0.3 is not immutable. The build worked previously; at the time of this writing, it fails with a Go bug.
  • The download of the JDK happens at every run, despite having been downloaded in previous runs.
  • I didn't find any easy to change the parent image.

Besides, as with Dockerfile, you need to manually tag the version.

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:

  • The image's version is automatically read from the POM's version.
  • Repeated builds with no changes are fast.
  • With a build configuration parameter, the final image has the 4 Spring Boot layers described above.

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:

Originally published at A Java Geek on October 11th, 2020

Discussion

pic
Editor guide