(0b)101 ways to containerize your Spring Boot Application

Matthias Seifert
Sep 10 · 12 min read

At some point in the life of a developer there comes a moment when the good and easy times are over and the serious side of life begins. That heartbreaking moment when our well-tested high-quality code is big enough to leave the repository and be deployed in production.

Back in the days™ we used to compile Java code into handy JAR-archives which were then given to Ops and deployed. In a world with build-tools like Maven, creating a JAR is an easy and mostly straightforward job with only a few settings to tweak.

Nowadays we create less JARs to send them to Ops. Instead, we create container-images packed with all the things our code needs to thrive in its hopefully successful life. And in many cases a lot more. Instead of creating a JAR with just about our compiled code and some dependencies we now provide a full environment. But with great environment comes great responsibility. The focus shifts from providing a runnable JAR with all code-dependencies to providing a container image containing not only a JRE but also dependencies like libssl, curl, and maybe even cups.

In this blog post we will look at (0b)101 ways to create a container image from the code of a Spring Boot application — and conclude why one of these in my opinion still is the best.

Photo by Hadija Saidi on Unsplash

When it comes to optimizing a container image there are several points to pay attention to. Among those are the total size of the created image, the size of the single layers the image consists of, and the recyclability of each of these layers.

A container image consists of several layers stacked on top of each other where each command in the Dockerfile adds a new layer to the image. When we rebuild the image with different parameters only the layer that has changed and all layers above it are rebuilt and have to be distributed. So in order to minimize the build-time, the amount of data to be transferred, and therefore startup time it is desirable to keep the amount of altered layers to a minimum.

To not only give a theoretical view on these aspects we will use a very simple yet useful Spring Boot Application and containerize it in different ways. This application is a renderer providing a REST endpoint at which a PDF file and a page number can be provided. The application will then read the PDF file and render the requested page into a PNG file.

The base application and all examples shown in this article are also available on gitlab. Each way of containerization is represented in a separate branch. Within each branch there is a dockerbuild.sh file so you can easily check out and run the code samples without the need for any manual code changes.

#0b000— The ol’ reliable

The classic way to containerize a Java application is basically the reproduction of the good old days. Except that we copy the JAR into a container image instead of sending it to the Ops.

At first we package our application into a JAR, then that JAR is copied into a container image and run on startup.

The corresponding Dockerfile looks like this:

FROM openjdk:11
COPY target/app.jar /app.jar
EXPOSE 8000
ENTRYPOINT ["java","-jar","/app.jar"]

The following two commands then create the jar and eventually the image:

mvn clean package
docker build . -t "spring-containerization:jar-dockerfile"

The corresponding branch is feature/jar-dockerfile.

A quick look at the resulting image using dive shows us a few layers with one layer at the end containing the JAR. This means that every time there is even a minor change in the JAR — even a single changed byte — we have to redistribute this top layer.

A screenshot of a part of the output from the dive-command. The screenshot shows that there are eight layers in the container image with each layer having a size between 27 bytes and 342 megabytes. The top layer containing the sample application has a size of 25 megabytes.
A screenshot of a part of the output from the dive-command. The screenshot shows that there are eight layers in the container image with each layer having a size between 27 bytes and 342 megabytes. The top layer containing the sample application has a size of 25 megabytes.
The layer structure of the image created using the Dockerfile as shown in dive

For this application with only a few dozen lines of code redistributing the whole application with each update is not a big thing. But think of an application with many tens of thousands lines of code and JARs reaching into the gigabytes. For a single changed character in a configuration the entire JAR has to be redistributed and redownloaded. Apart from that the JAR — being a Java ARchive — still has to be unpacked on every start of the container. While this does not concern the layer size it still costs precious time on startup. To make matters worse, we have to maintain a Dockerfile and a docker installation on our build-server besides the Maven build itself. Some room for improvement, eh?

Apparently we want to create smaller layers. Ideally arranged in a way to minimize the amount of layers recreated and redistributed during a release. And preferably we don’t need a docker installation on our build server either.

#0b001 buildpacks

Buildpacks are an easy and convenient way to create container images from code straight to a container image without the fuzz of writing and maintaining a Dockerfile. With buildpacks we don’t even have to compile our code ourselves. This is all done through buildpack.

To be able to use build packs to compile our code into a container image the machine this is done on needs to have Docker installed as well as the pack utility. Also, we have to provide a builder which then compiles and containerizes our code at compile time. The builder used for our demo project is the one from the google cloud platform. This is only one a whole lot of builders available for utilization in pack.

The builder provided to pack then evaluates how to compile and pack the code and does this in the most efficient way it knows. The outcome of course depends a lot on the builder so you should check carefully, which builders you are using and if they match your requirements. A good starting point usually is the builder provided by your cloud provider.

If you want to adjust the base image, you can easily extend the base image and tell the builder to utilize the updated image. This is explained properly in the github readme of the matching Google Cloud Platform repository. The basic steps to extend the base image is the same for all builders, though.

In the sample app this example is contained in the feature/buildpack branch. To create a container with the sample application you only have to execute the dockerbuild.sh script. This will then build the image using

pack build fromcodetocloud:buildpacks --builder gcr.io/buildpacks/builder:v1

Once the build is finished and the image is added to the local Docker instance we can take a deeper look into the image using dive:

A screenshot of a part of the output from the dive-command. The screenshot shows that there are ten layers in the container image with each layer having a size between zero bytes and 63 megabytes. The layer containing the sample application has a size of 25 megabytes.
A screenshot of a part of the output from the dive-command. The screenshot shows that there are ten layers in the container image with each layer having a size between zero bytes and 63 megabytes. The layer containing the sample application has a size of 25 megabytes.
The layer structure of the image created using buildpack as shown in dive. The exact comands for each layer are unfortunately not displayed by dive.
A screenshot of the output of the dive command with a list of files added in one layer. The files are all either yaml-files containing configuration or class-files containing compiled Java code
A screenshot of the output of the dive command with a list of files added in one layer. The files are all either yaml-files containing configuration or class-files containing compiled Java code
The files as added in the 25MB application layer

Here we see two major differences to the image created using our own docker file and JAR file.

  • There are plenty of layers
  • There is not a single JAR file in the container but a load of compiled class files

A more detailed look at the layers show that each of the layers only adds up to a few megabyte to the image. If the builder creates these thoughtfully the complexity on an update of the image can reduce.

At the same time adding the compiled class files instead of a JAR file mitigates a problem mentioned earlier. On startup of the container there is no need to unarchive the classes from the fat JAR. Instead, the class files containing our compiled software are readily available immediately. This can have a drastic effect on the startup time of an application. Additional properties like environment variables to be used in the image or tags can be added using commandline parameters .

#0b010 Spring Boot Build Image

Since version 2.3 Spring provides a functionality to create container images at build time using the Spring Boot Maven plugin. Spring Boot then utilizes buildpacks to create a container. Yes, you heard that right. Spring Boot build pack seems to be not more than the build packs we discussed before.

To create the container image all we have to do is call the spring-boot-maven-plugin with the goal build-image:

mvn spring-boot:build-image

In addition to this you will find a property in the branch’s pom.xml which makes the image have a nice tag:

<spring-boot.build-image.imageName>${project.artifactId}:build-image</spring-boot.build-image.imageName>

But the Spring Boot Maven Plugin gives us a little more than just a shiny xml-abstraction for the CLI. In contrast to the “pure” use of buildpacks with the Spring Boot Maven Plugin we don’t need to have pack available at build time. All we need is a Docker installation to create the image. Also, Spring Boot uses a quite sophisticated builder - namely docker.io/paketobuildpacks/builder:base.The big difference between the Spring Boot build-image and build-packs is the sophisticated order in which class files, binaries, and configuration files are added to the container image. Using dive to examine the image, the first thing we notice is that the image has even more layers than the one created using the google builder.

A screenshot of a part of the output from the dive-command. The screenshot shows that there are twenty-one layers in the container image with each layer having a size between zero bytes and 122 megabytes. The layer containing the sample application has a size of 25 megabytes.
A screenshot of a part of the output from the dive-command. The screenshot shows that there are twenty-one layers in the container image with each layer having a size between zero bytes and 122 megabytes. The layer containing the sample application has a size of 25 megabytes.
The layer structure of the image created using build-image as shown in dive. The exact comands for each layer are unfortunately not displayed by dive.

After a few basic layers containing some binaries and libs the first big layer has about 140MB. This is the JRE itself. A bit further up the stack we see a layer with about 25MB, that is when our dependencies are added. This is useful since these change less frequently than our code. And a bit farther up the stack the compiled (and extracted!) application is added in single class-files creating a layer of only 11kB. So whenever we fix a bug in our application only this layer and the ones above it have to be redistributed. Overall a little more than 2.5MB, only about a tenth of the total size of the application.

Configuration of the created image can be done through the pom.xml, making it fairly easy to re-use Maven-properties, e.g. for the image name. Another benefit of configuration in the pom is that the pom is part of the git repository and therefore part of version control making it easy to track changes.

#0b011 Layered JARs

So, obviously it is a good idea to put some thinking into the formation and the order of the layers a container image consists of.

If we do not want to rely on buildpacks or the Spring Boot Maven Plugin to create container images with a sensible layer-structure we can also create that structure by ourselves. The key to this are layered JARs. Since version 2.3 the Spring Boot Maven Plugin supports the thoughtful creation JAR files so they can easily be used for a layered structure of a container image. From version 2.4 on the layered JAR setting is the default and does not have to be enabled explicitly.

Running mvn clean package then creates a JAR as usual. In the sample project the name of the created JAR file is set to app.jar to make extracting and creating the container a bit easier.

We can then extract the contents of the JAR file using

java -Djarmode=layertools -jar app.jar extract

After extracting we find four folders:

  • dependencies
  • spring-boot-loader
  • snapshot-dependencies
  • application

Each of these folders later corresponds to a layer in our container image. At the bottom of the stack we will place the dependencies, then the spring-boot-loader on top, then snapshot-dependencies (if any) and finally the application.

So to create a container image from this layered JAR we need to perform the following steps:

  • package the application into a JAR
  • extract the JAR
  • create the container image and copy the extracted folders in a useful way

All these steps are performed in dockerbuild.sh- the corresponding branch on the repository is feature/layered-jar .

#Retrieve the artifact id from maven. This is not strictly necessary to create the container image.
artifactId=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.artifactId}' --non-recursive exec:exec)

#Package the application
mvn clean package

#move the created application jar to a tmp folder
mkdir target/tmp
mv target/app.jar target/tmp/

#extract the layers of the application
(cd target/tmp;java -Djarmode=layertools -jar app.jar extract)

#execute docker build
docker build . -t ${artifactId}:layered-jar

The docker build will then use the following Dockerfile:

FROM adoptopenjdk:11
COPY target/tmp/dependencies/ ./
COPY target/tmp/spring-boot-loader/ ./
COPY target/tmp/snapshot-dependencies/ ./
COPY target/tmp/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Once again using dive we can unsurprisingly see that the layer structure is the one defined in our Dockerfile with the different contents added as intended.

A screenshot of a part of the output from the dive-command. The screenshot shows that there are seven layers in the container image with each layer having a size between zero bytes and 321 megabytes. The layer containing the sample application has a size of 25 megabytes.
A screenshot of a part of the output from the dive-command. The screenshot shows that there are seven layers in the container image with each layer having a size between zero bytes and 321 megabytes. The layer containing the sample application has a size of 25 megabytes.
The layer structure of the image created using build-image as shown in dive

The final image itself is quite big but this is owed to the fact that we are using the openjdk11:latest image and can easily be mitigated using a more suitable base image.

#0b100 Jib to the rescue

As we have seen, creating a container image for a Spring Boot application can be easy and it can be very efficient in terms of the optimization of layer reusability.

But all the ways we have looked at so far require us to have software apart from Maven installed on our build machine and to use some shell magic and more or less thinking to create a useful container image.

At this point another little helper from the google container tools comes to our rescue: With Jib we can create container images without having Docker installed and without having to write and maintain Dockerfiles. We don’t even have to select a base image ourselves. Jib is available in many flavours, also as a plugin for Maven and Gradle. This means that we can create a container image at build time without explicitly calling another tool like Docker or pack. Furthermore, we don’t even need to have Docker or pack installed on the build machine.

All we have to do in order to build a container image using Jib is to add the Jib Maven plugin to our pom.xml and then build the Maven project with mvn compile jib:build. All configuration can be done in the pom.

As with the previous examples this one also has its own branch in the example repo. You can check out the branch feature/jib to explore the configuration and build yourselves.

As mentioned before the configuration of the Jib plugin is done in the pom.xml. Taking a closer look we see that we no longer use the Spring Boot Maven Plugin. That is because Jib does all the compilation and building and containerization by itself.

<build>
<plugins>
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.1.3</version>
<!-- since jib does not use the local docker daemon in this example we need a registry to deploy the image to. -->
<configuration>
<to>
<image>localhost:5000/${project.artifactId}</image>
<tags>
<tag>jib</tag>
</tags>
</to>
<!-- we have to allow insecure registries since there is no valid certificate available for the local registry>
<allowInsecureRegistries>true</allowInsecureRegistries>
</configuration>
</plugin>
</plugins>
</build>

Inside the pom within the plugin configuration there are several other properties that define how our container image is to be built. Jib offers a lot more ways for configuration and build. All configuration options are listed on the respective documentation page at https://github.com/GoogleContainerTools/jib/tree/master/jib-maven-plugin#quickstart.

Now that we have the configuration and execution covered let’s take a look at the image created using Jib.

Dive shows us a layer structure consisting of some base contents followed by a JRE, dependencies, configuration, and the extracted application.

A animated screenshot of a part of the output from the dive-command. The animation cycles through the top four of the seven image layers showing the contents being added in the respective layer. It shows that the fourth layer has a size of 23 MB and that it adds the app/libs folder. The fifth layer has a size of 82 Bytes and adds the app/resources folder. The sixth layer has a size of 6.5 kB, adding the app/classes folder. The seventh layer has a size of one 1.7 kB adding two jib-specific files.
A animated screenshot of a part of the output from the dive-command. The animation cycles through the top four of the seven image layers showing the contents being added in the respective layer. It shows that the fourth layer has a size of 23 MB and that it adds the app/libs folder. The fifth layer has a size of 82 Bytes and adds the app/resources folder. The sixth layer has a size of 6.5 kB, adding the app/classes folder. The seventh layer has a size of one 1.7 kB adding two jib-specific files.
The layer structure of the image created and the contents added in these layers

The top four layers add the libs, the resources, the compiled class files and Jib-information on where to find libs, classes, resources, and the main class in the container.

Conclusion

So, what did we just learn? There are many ways to build a container image from a Spring Boot Application. Every way is correct in that it creates a working container image. But depending on the use of the image we have to consider which way to use. Using a Dockerfile may be suitable for long living containers, Spring Boot build image is adopted fast and only needs an additional goal and two more programs on the build machine.

My personal favourite is Jib. It combines the ease of use with the flexibility of extensive configurations and creates nicely layered container images and does not require software apart from Maven and Java.

Thank you for reading this rather long article on a topic that seems straightforward at first. Of course these are only 0b101 ways and there are plenty more.

What is your favourite way to create a container image from your Spring Boot Application? Do you share my enthusiasm on Jib or do you prefer a different approach?

If you have any comments, suggestions, or questions please leave a comment, drop me a message on medium, or DM me on twitter.

Digital Frontiers — Das Blog

Dies ist das Blog der Digital Frontiers GmbH & Co.