Creating optimised Docker Images using Multi-Stage Builds
Have you ever wondered how you can leverage building docker containers in your project to take your experience to the next level? If so then have a look at this step by step guide that hopefully will help you achieve that goal using multi-stage builds.
If you would like to preview the code used in this article, just clone this repository.
Why use Multi-Stage builds?
In a nutshell multi-stage builds feature is a recent improvement to the docker build system
- final image sizes
- image build orchestration
The refinement is achieved via allowing to consecutively use different images (for whatever purpose is needed) and pipe data subsequently through them within a single Dockerfile with assumption that the final image is built based on the last FROM statement.
Standard approach
If you already worked with a docker, you certainly faced the problem of large built images or had to do custom shell scripts to orchestrate build. Each instruction in the dockerfile adds another layer to the image which increases its size. Moreover, creating more complex images may require the use of additional shell scripts and multiple dockerfiles which may be cumbersome to manage.
To illustrate the above issues, I’ve created a simple java web application that I am going to build and run in a docker container. By default, without using multistage builds, the dockerfile would look like this:
# Dockerfile.singlestage
FROM registry.access.redhat.com/ubi8/openjdk-11:1.3
WORKDIR /deployments
COPY . /deployments
RUN mvn package
EXPOSE 8080
CMD ["java", "-jar", "target/demo-webapp-0.0.1-SNAPSHOT.jar"]
In the above file we perform following steps:
- Create a new image based on the redhat’s universal base image that already contains openjdk-11.
- Create the /deployments working directory and then copy the content of our project to it.
- Build java archive file with maven
- Run java application from command line
Since we already have the Dockerfile prepared, we can start building the image with the following command:
$ docker build -t demo-webapp:singlestage -f Dockerfile.singlestage .
Afterwards we can check the result:
$ docker images
## _REPOSITORY TAG IMAGE ID CREATED SIZE
demo-webapp singlestage 491de71ef224 24 seconds ago 549MB
Build an image using builder pattern & shell script
The newly created image that runs our application is quite large, so let’s optimize it using the builder pattern. For this purpose, we need to create 2 separate dockerfiles - one responsible for building the application and the other for launching it. Thanks to this approach, the final runner container may contain only elements that are important to us.
# Dockerfile.builder
FROM registry.access.redhat.com/ubi8/openjdk-11:1.3
WORKDIR /deployments
COPY . /deployments
RUN mvn package
# Dockerfile.runner
FROM gcr.io/distroless/java:11
COPY ./deployments/target/demo-webapp-0.0.1-SNAPSHOT.jar .
EXPOSE 8080
CMD ["demo-webapp-0.0.1-SNAPSHOT.jar"]
To automate the process of building images defined in separate dockerfiles, we create a shell script that carries out the steps necessary to run our application.
#!/bin/bash
echo "Building demo-webapp builder image..."
docker build -t demo-webapp:singlestage-builder -f Dockerfile.builder .
echo "Extracting builder content..."
docker container create --name extract-builder demo-webapp:singlestage-builder
docker container cp extract-builder:/deployments .
docker container rm -f extract-builder
echo "Building demo-webapp runner image..."
docker build -t demo-webapp:singlestage-runner -f Dockerfile.runner .
After executing the build.sh script, we can notice that 2 images were created and the final (runner) image is much smaller than before. It is a very big advantage, because you only need to transfer the runner image during application release in the future.
## _REPOSITORY TAG IMAGE ID CREATED SIZE
demo-webapp singlestage-runner 5fd438ae9faa 9 seconds ago 217MB
demo-webapp singlestage-builder f6e90caab67b 10 seconds ago 532MB
Multi-stage build
Along with the implementation of version 17.05, docker provided the multi-stage build functionality which allows you to divide the creation of the container into several phases and build it as several separate images that have access to each other. To take advantage of multistage builds, we need to create a new dockerfile.
# Dockerfile.multistage
FROM registry.access.redhat.com/ubi8/openjdk-11:1.3 AS builder
WORKDIR /deployments
COPY . /deployments
RUN mvn package
FROM gcr.io/distroless/java:11 AS runner
COPY --from=builder /deployments/target/demo-webapp-0.0.1-SNAPSHOT.jar .
EXPOSE 8080
CMD ["demo-webapp-0.0.1-SNAPSHOT.jar"]
As of now dockerfile contains two FROM clauses and it will build 2 separate images consecutively - the first builder and the second, runner.
When building the builder image we execute almost exactly the same commands as in the case of executing the build using the single-stage build. The difference is the application startup command, which will be invoked by the runner image. So let’s try to run it.
$ docker build -t demo-webapp:multistage -f Dockerfile.multistage .
After calling the docker images command, we can see that also this time two images were created. However, we didn’t need to write additional shell scripts for this.
## _REPOSITORY TAG IMAGE ID CREATED SIZE
demo-webapp multistage 44af0173ce49 8 seconds ago 217MB
<none> <none> 4969b156508a 8 seconds ago 549MB
Conclusions
As you can see, the multi-stage approach has many advantages:
- Easier management of the image creation process (thanks to the possibility of including instructions for several related images in a single dockerfile)
- No need to create shell scripts to automate the entire process.
- Saving disk space (By using the builder container as a common base for subsequent containers)
- Reducing the size of the final runtime container
That’s it! We hope you found “Creating optimised Docker Images using Multi-Stage Builds” helpful in your devops activities.
If this topic interests you, visit use multi-stage builds for more details or even better talk to our team today.