A guide to Docker multi-stage builds for Spring Boot
[
](https://medium.com/@cat.edelveis?source=post_page-----08e3a64c9812--------------------------------)
·
There are several tried-and-true methods of reducing the size of Docker container images. But your container image may still bloat with time if you frequently introduce updates.
So how to keep your containers as neat as a pin at all times? The answer is: multi-stage builds. Plus, if you work with Spring Boot, the framework offers a mighty feature, layered jars, which complements multi-stage builds and will help you organize the layers in your final image nicely.
Before multi-stage builds: layer upon layer upon layer
Each command in a Dockerfile creates a new layer, which is then added to the final container image. Developers have to navigate between Scylla and Charybdis and choose between the lower number of layers and more pull-efficient layers.
One feasible solution to the issue is to write separate Dockerfiles, but it makes the process of building the image more complex: you have to maintain several Dockerfiles and plus use specific scripts to tie them all together.
But starting with Docker 17.05, we can use one Dockerfile to control all images!
How multi-stage builds change the game
So how exactly do multi-stage Docker builds work?
You write one Dockerfile that contains several FROM
statements (the number is not limited). Each FROM
statement uses its own base (you can use the same base image or different ones depending on your purposes) and starts a new build phase. Each phase can use the image built in the previous stage, and you can copy only artifacts from the previous stage that you need.
The final image won’t contain the layers of the previous images, tools required for building the application, or the files that weren’t explicitly copied. As a result, it will contain only the components your application needs to run yielding significantly reduced memory footprint.
How to write a Dockerfile to use multi-stage builds
I will use Spring Petclinic as a demo project for this tutorial, but you can take any other Spring Boot application.
Also, I will use Liberica Runtime Container as a base image as it includes Liberica JDK recommended by Spring and lightweight Alpaquita Linux optimized for Java.
We will need the following Dockerfile to implement the multi-stage build approach. The Dockerfile contains two sections, one for building the application, and another for placing the resulting jar file into a clean image:
FROM bellsoft/liberica-runtime-container:jdk-21-stream-musl as builderWORKDIR /home/appADD spring-petclinic-main /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package
FROM bellsoft/liberica-runtime-container:jre-21-muslWORKDIR /home/appEXPOSE 8080
ENTRYPOINT ["java", "-jar", "/petclinic.jar"]
COPY --from=builder /home/app/spring-petclinic-main/target/*.jar petclinic.jar
Let’s break the file down line by line:
- Take the first base image with JDK and define it as a builder.
- Create a work directory (WORKDIR) within the image.
- Place the directory with the application into the WORKDIR.
- Go to the application directory and build the jar file with Maven. I added
-Dmaven.test.skip=true
simply to accelerate the build. - For the second stage, take a JRE image because the application doesn’t need a full-blown JDK to run. We don’t give the name to the stage because it is the final one and won’t be referred to further on.
- Create a work directory within the final image.
- Copy the artifact we built previously into the new image. The
--from=builder
indicates that we use the file from the previous stage named builder. By the way, you don’t have to give names to the stages and can refer to them by their indices (--from=0
in this case). But naming the stages is recommended so as not to break the build if the stages are added, changed, or removed. - As Spring Petclinic is a web application, define the port it will listen to.
- Specify the command that will be executed upon running the image.
This Dockerfile is quite simple, but imagine you have to perform more complex operations on the image. All these additional layers won’t get into the final image, which is what we aim for.
All we have to do now is run docker build:
docker build -t petclinic-image .
Check the images with:
docker imagesREPOSITORY TAG IMAGE ID CREATED SIZE
petclinic-image latest 511ee6f42373 2 minutes ago 199MB
Combine multi-stage builds with Spring Boot layered jars
Thanks to using a small base image, we received quite a small container image with the Spring Boot application. You’d think that we could stop here. However, there’s one more issue left to solve.
In the previous section, we used a fat jar to containerize the app. Fat jars include the application classes and all its dependencies, which means that everytime we change the application layer, we have to build a new artifact and drag all the dependencies there. Besides, pulling the fat jar with application dependencies or without them makes a difference, especially in case of an enterprise application and/or slow internet connection.
Spring Boot makes it possible to build layered jars, where application classes and dependencies are stored in different layers. The most frequently updated application layer is placed on top, and when you introduce changes, you can reuse the dependency layers. Plus the dependencies can be stored in a local repository, so you don’t have to push the containerized workloads with all the dependencies across development, test, and production environments.
Layering a Docker image
To list or extract layers, we need the [-Djarmode=layertools](https://docs.spring.io/spring-boot/docs/current/reference/html/container-images.html#container-images.dockerfiles)
system property. For instance, you can examine the existing jar by running:
java -Djarmode=layertools -jar yourapp.jar list
IMPORTANT NOTE: Starting with Spring Boot 3.3, -Djarmode=layertools extract
is deprecated in favor of -Djarmode=tools extract
, and -Djarmode=layertools list
is deprecated in favor of -Djarmode=tools list-layers
By default, Spring Boot provides the following layers:
- dependencies for third-party dependencies;
- spring-boot-loader for Spring Boot tools for running jars;
- snapshot-dependencies for snapshot versions of third-party dependencies;
- application for application code.
Take note of these modules as we will need to place them into the final image.
Let’s reuse our Dockerfile from above and only make several changes:
FROM bellsoft/liberica-runtime-container:jdk-21-stream-musl as builder
WORKDIR /home/app
ADD spring-petclinic-main /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package
FROM bellsoft/liberica-runtime-container:jdk-21-stream-musl as optimizer
WORKDIR /home/app
COPY --from=builder /home/app/spring-petclinic-main/target
What do we have here?
As you can see, we create two intermediate images, one for packaging the app into a jar file, and another for extracting the layers.
For Spring Boot 3.3+, layer extraction instruction in the file above should be substituted with
RUN java -Djarmode=tools -jar petclinic.jar extract
We then add these layers to the final image. The order matters: dependencies go first as this layer is rarely updated, and the application layer goes on top as it is the most frequently changed part. As a result, docker pull
will be faster because you pull only the changed part of the image.
In the last line, we call Spring Boot’s JarLauncher, which is a subclass of a special bootstrap Launcher
class that knows how to work with our “decomposed” jar.
The command for containerizing the app doesn’t change. So run:
docker build -t petclinic-image-layered .
Our cool layered image of a Spring Boot application is ready! Check the images:
docker imagesREPOSITORY TAG IMAGE ID CREATED SIZE
petclinic-image-layered latest 93e89b16c634 27 seconds ago 200MB
Note that despite the fact that we have more instructions in the Dockerfile (which should have created more layers and increased memory footprint), the final image is still pretty small thanks to the multi-stage approach.
As you can see, Spring Boot eliminates the tradeoff between the simplicity of build and pull time. With layered jars, you can get a custom image with rapid pull times, and a multi-stage approach takes away the complexity of maintaining separate Dockerfiles or layer optimization stunts.