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
- Copy the description of the dependencies
- Download the dependencies
- Copy the main script
- 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:
- They require an extra compilation step, which transforms Java source code to bytecode
- 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"]
- Build step uses a JDK
- Run step uses a JRE
- 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"]
- Copy all required files to download the dependencies
- Download the dependencies - they will be part of a dedicated layer
- 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
- 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>
- 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:
- Dependencies
- Snapshot dependencies
- Resources
- 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>
- 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:
- Dependencies
- Snapshot dependencies
- Spring Boot runtime
- 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"]
- Create a standard self-executable JAR
- Extract the folder structure
- 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
- 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 |
|
To go further:
- Use multi-stage builds
- Getting Started with Cloud Native Buildpacks
- Jib Quick start
- Packagin OCI images with Spring Boot
Originally published at A Java Geek on October 11th, 2020