The Many Ways to Containerize a Spring Boot application
As a Spring Boot Java developer, have you ever wondered about how your application gets containerized in order to be able to run on a Kubernetes cluster. As you would expect, there are numerous ways that this can be done. In this blog post, we will examine the different ways to achieve this.
The following table shows a summary of the different approaches that we examined. We identify several aspects of each approach/method such as:
- the number of layers in the OCI image
- size of the image in MB
- if a Dockerfile is needed to create the image
You can clone the example git repo and try out each of the different approaches:
https://github.com/gkovan/spring-boot-cloud-native
Buildpacks
The easiest way to containerize a Spring Boot app is to use buildpacks. Since Spring Boot 2.3.0.M1, buildpack support is built directly into the framework. You do not need to create a Dockerfile. Make sure you have the docker daemon running locally.
Simply run the command:
./mvnw spring-boot:build-image
A container image gets created where the image name will be the application name from the pom.xml file and the image version will be the version from the pom.xml file.
Here is the snippet from the pom.xml file:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>rest-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rest-service</name>
<description>Demo project for Spring Boot</description>
We used spring-boot-starter-parent version 2.6.0 which is greater than 2.3.0 so Buildpack support is included.
To build the Docker image run the command:
./mvnw spring-boot:build-image
Command to run the image:
docker run -it -p8080:8080 rest-service:0.0.1-SNAPSHOT
The image created has 15 layers and its size is 261 MB.
Jib
Jib builds optimized Docker OCI images for Java applications. It is an open source project developed by Google. Similar to builldpacks, no Dockerfile needs to be created.
To use Jib, you need to add a maven plugin into your pom.xml file as follows:
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>2.6.0</version>
</plugin>
To build the Docker image run the command:
./mvnw compile jib:dockerBuild -Dimage=rest-server-jib:0.0.1
Command to run the image:
docker run -it -p8080:8080 rest-server-jib:0.0.1
Dockerfile — single stage jdk with fat jar
This is the traditional approach where a fat uber jar containing the java artifacts (spring boot libraries, dependency libraries, class files) are packaged into a single jar file using the mvn package
command. The developer needs to create the Dockerfile. Here is how this type of Dockerfile looks:
FROM adoptopenjdk/openjdk11
EXPOSE 8080
ARG JAR_FILE=target/rest-service-0.0.1-SNAPSHOT.jar
ADD ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
The developer must first run the mvn package
command to create the reference jar file target/rest-service-0.0.1-SNAPSHOT.jar
that is the uber jar fille containing everything to run the microservice.
To build the Docker image run the command:
docker build -t rest-server-dockerfile:0.0.1 .
Command to run the image:
docker run -it -p8080:8080 rest-server-dockerfile:0.0.1
This approach is not the most efficient. The image size is relatively big at 455 MB. The reason why it is big is because it uses the adoptopenjdk/openjdk11
base image. A jre image as opposed to a jdk image will result in a smaller container image size. Another reason why this approach is not efficient is because the resulting Docker image has only 4 layers, where one of the layers consists of the uber jar. The line in the Dockerfile that adds the uber jar file layer to the Docker image is ADD ${JAR_FILE} app.jar. Therefore, when a single java file changes, the java application needs to be rebuilt (all spring boot libraries, dependencies, app source files), a new fat jar needs to be created and then a new image needs to be created with the new fat jar layer.
Dockerfile — single stage jre with fat jar
This approach improves the efficiency relative to the previous approach by using a JRE base image as opposed to a jdk base image. The Dockerfile now looks like this:
FROM adoptopenjdk:11-jre-hotspot
EXPOSE 8080
ARG JAR_FILE=target/rest-service-0.0.1-SNAPSHOT.jar
ADD ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
The first line in the file specified the base image and we are now using the adoptopenjdk:11-jre-hotspot base image. The jre base image only includes the Java runtime components as opposed to all the utility programs included in the jdk which results in a smaller overall image size.
The developer must first run the mvn package
command to create the reference jar file target/rest-service-0.0.1-SNAPSHOT.jar
that is the uber jar fille containing everything to run the microservice.
To build the Docker image run the command:
docker build -f Dockerfile.jre -t rest-server-dockerfile-jre:0.0.1 .
Command to run the image:
docker run -it -p8080:8080 rest-server-dockerfile-jre:0.0.1
The image size is now 262 MB (193 MB smalller than the previous example).
This approach still uses the fat jar file so there is additional room to improve efficiency.
Dockerfile — multistaged jre layered
This approach introduces several new techniques. The first is a two staged Dockerfile, where the first stage is responsible for building the java artifact and creating the layers of the java appllication such as dependencies, spring-boot dependencies and application code. The second stage is responsible for creating the actual Docker image.
The Dockerfile for this approach looks like this:
# STAGE 1FROM adoptopenjdk/maven-openjdk11 as builder
WORKDIR applicationCOPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY src srcRUN ./mvnw install -DskipTests
RUN java -Djarmode=layertools -jar target/rest-service-0.0.1-SNAPSHOT.jar extract# STAGE 2FROM adoptopenjdk:11-jre-hotspot
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
To build the Docker image, run the command:
docker build -f Dockerfile.multistage-layered -t rest-server-dockerfile-multistage-layered:0.0.1 .
Command to run the image:
docker run -it -p8080:8080 rest-server-dockerfile-multistage-layered:0.0.1
This approach uses a maven image in Stage 1. Therefore, you do not need to have maven installed on your machine to run the maven build commands.
This approach uses a jre base image in Stage 2. Furthermore it uses layers as opposed to a fat jar.
There are four layers consisting of the following:
- java dependencies
- Spring Boot loader
- snapshot dependencies
- application code
These four dependencies are organized in order of least likely to change. Naturally, the application code is ordered last since it changes the most often. Therefore, when application code changes, only the java files that have changed need to be compiled and added to the new image during a build. The docker build engine can use the cached layers created from previous build to reuse the other layers. The total number of layers for the resulting Docker image is 8 compared to 4 for the other approaches that used a fat jar approach. This speeds up build and image creation time. Since the base image is a jre image, the resulting image is still small at 262 MB.
Summary
In this blog post, we examined several ways to build a docker container image for a Spring Boot application. In a future blog posts, I will discuss the pros and cons of each approach and discuss when to use each approach. I will also compare build times for each approach. In yet another future blog post, I will discuss the many ways you can deploy and run a Spring Boot container image in a Kubernetes cluster.
The git repo for this example is: https://github.com/gkovan/spring-boot-cloud-native