Docker – or more general containerization – can be applied in several areas of software development to improve many aspects of our work. The most common are:
- In development to easily setup an environment for running and developing the software without installing every manually on the target OS.
- For deployment either in docker-stacks (e.g. managed by portainer) or kubernetes only requiring hosts able to run containers without specialized setups.
- For building software artifacts on your continuous integration (CI) infrastructure with the same benefits as the former two applications.
In essence you trade snowflaking all the involved machines (dev, CI nodes, servers) for some additional complexity around Dockerfiles, images, containers and their orchestration.
In this post I want to focus on the CI part:
Using Docker in CI
Administrators and infrastructure people usually will cheer if they just need to provide docker-capable nodes for developers to build their projects on. No need to provide certain runtimes, libraries, tools and configurations anymore. No need to negotiate with developers about the environment all the time and keeping it updated, appropriate and secure.
Developers on the other hand need to work with another tool encapsulating their build process. While this gives them a lot of freedom in choosing and shaping the environment for their builds (the “inside” of the containers) it adds pitfalls and complexity on the outside.
If your artifact is not (only) another docker image but things like test- and coverage reports, binaries or other files you have to move stuff from inside the containers to the outside aka host. In CI use cases you have essentially the following alternatives, each with different pros and cons:
Bind mounts for a container run
In this approach you build your environment using a normal docker build command and follow it with a docker run, e.g.:
docker build -t build-project .
docker run --rm -u `id -u` -v `pwd`:/build build-project
Pros:
- This approach feels natural and does automatic container cleanup by using the
--rmoption ondocker run. - It also enables dependency caches etc. on the host which can reduce build times, even across projects.
- Sometimes you do not even need a dockerfile but can use plain docker images without building one yourself, e.g.
eclipse-temurin:21-jdk.
Cons:
- It actually runs a container and has limited file system access to the host.
- It may leave build artifacts and temporary files on the host.
- It may create file ownership issues, hence the
-u `id -u`arguments todocker runin the example.
Image build and container to copy from
This approach is similar to above in that is consists of several steps but it does not need mounts and does most of the stuff during docker build:
set -eu
docker build -t build-project .
container_id=$(docker create build-project)
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker start $container_id
docker cp $container_id:/buildresults/ ./artifacts/
Pros:
- Most stuff happens inside
docker build. - You can use layer caching to improve build times.
- Cleanup is done using shell mechanismns (
trap), can also be performed using different means. - What leaves the container is exactly and explicitly controllable
Cons:
- You need to run a container
- You need to take care of container cleanup
- Full benefit of layer caching may require thought/engineering to improve build times
Multi-stage build with scratch-image output
In this approach we never actually run a container and let docker build copy the specified artifacts to the host using a multi-stage build. Most of the interesting bits are in the Dockerfile while the build call looks like:
docker build --output artifacts/ .
The Dockerfile gets a bit more complex:
FROM python:3.14 AS build
WORKDIR /build
COPY . .
RUN pip3 install --no-cache-dir -r requirements.txt
RUN python3 -m build
FROM scratch AS package
COPY --from=build /build/dist/*.whl .
Pros:
- No need to explicitly cleanup containers or dependency caches
- Layer caching possible
- No need to run a container explicitly, building the image is enough
- Only one simple call in the build pipeline
Cons:
- More complexity inside the Dockerfile
Conclusion
Using docker for building software has many advantages with the price of an additional tools and its own complexities. Several, easily adaptable approaches exist to facilitate containerization in CI environments. Use the one that fits your requirements and environment best.
Which approach do you like best? What are you using in your build and delivery pipelines?
I would be glad to hear your thoughts and comments.