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>${project.artifactId}:${project.version}
- 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:
- 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
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
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.