For a while now, I have been bothered by not being able to create multiple images from the same Dockerfile. I have a situation where I want to create an image for running unit tests, another "open" image for test and staging environments and a final "closed" image for production.
My quick and dirty solution has been to have a set of Dockerfiles, one for each specific target. This had some drawbacks, such as maintaining multiple files and not utilising the docker cache to full extent, which in turn slowed the whole build process down.
When I finally had some spare time, I took the opportunity to try and find a solution to my docker struggles. What I landed in was the use of the --target
option for docker build. --target
it allows you to stop the build process after a specific stage in a multistage build. This solves both of my issues with bad caching and multiple files.
tl;dr, name each stage in the build and then use --target
to decide what stage to build.
The rest of this post will describe how to structure your Dockerfile and use --target
to build specific stages.
Dockerfile
The first thing is to divide your file in to separate stages. In the example Dockerfile below (for a swift project) there are four different stages: builder, unittest, staging and production,
FROM swift:latest as builder
RUN apt-get -y update && \
apt-get -y upgrade
RUN apt-get -y install openssl libssl-dev && \
apt-get -y install libcurl4-openssl-dev
WORKDIR /build
COPY Package.* .
RUN swift package resolve
#
# Build for unittest
#
FROM builder as unittest
WORKDIR /build
COPY Sources Sources
COPY Tests Tests
RUN swift test
#
# Build for staging
#
FROM builder as staging
WORKDIR /build
COPY Sources Sources
COPY Tests Tests
WORKDIR /staging
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Application" ./
#
# Build for production
#
FROM swift:5.2-focal-slim as production
WORKDIR /app
COPY --from=staging /staging .
ENTRYPOINT ["./Application"]
Builder
The first stage is builder. It installs essential libraries and resolves any swift dependencies. This stage should be fairly stable and not change that much. The only time it actually has to be updated is when we add a new dependency.
Since docker keeps a cached copy of builder, it will serve as the base image for all other stages.
Unittest
Next step in the Dockerfile is the unittest stage. This stage makes use of the cached builder image as a starting point. The unittest stage is more volatile than builder, and has to be rebuilt for each code change. However, since we use a cached copy of builder as our initial image, we will only have to rebuild a small part of the final image, the part that handles our code.
The unittest stage will only build and run the unit tests. Therefore, this image can be discarded after a successful build.
Staging
Staging also extends the builder image building up on it. In contrast to unittest, staging builds a working binary from the source code and places it in the /staging
directory.
Staging results in, what I call an "open" image. It is an open image, since we do not specify any ENTRYPOINT
to this image. It will thus be open to the end user to decide how the binary in this image should be started.
Production
The final stage is production. The production stage built from a minimal version of Ubuntu Focal, and all it does is copying the binary file from the staging, resulting in a very fast build for production.
This is a "closed" image, meaning that it can't be tampered with. A closed image should behave as binary image by starting an executable as soon as the docker image is loaded. This is achieved by using the ENTRYPOINT
command.
Building
Without specifying any target with the --target
option, docker will produce a production image, since it is the last stage in the Dockerfile.
docker build -t my-project .
However, with the --target
option, we can tell docker to stop the build process after a stage has finished.
So, to build the unittest stage we can use --target unittest
. This will first build the builder stage, if not present in the cache, and then the unittest stage.
docker build --target unittest -t unittest .
Likewise, we can build for staging or production and use the previously cached images to enhance build time.
docker build --target staging -t staging .
docker build --target production -t production .
Conclusion
With clever structuring of named multistage builds, you can utilise both the power of docker caching and the ability produce different images from the same Dockerfile.
This will reduce your build time and allow for more a versatile testing and development.