Building images in CI/CD pipeline can be quite different from builds on local machine. One major difference is availability of cache. In the local environment you most likely have all the resources, dependencies and image layers cached from previous builds and therefore your builds might take just a few seconds. In the CI pipeline on the other hand, there's no local cache, which can cause the builds to take several minutes. There's solution to this though, and in this article we will look at how we can solve it both with and without Docker and for any CI/CD platform you might be using.
The Generic Solution
The idea for the generic solution that would work in any environment is pretty simple - we need to somehow create or bring the cache to the pipeline. We have 2 options here - either we point the builder tool (e.g. Docker) to the repository of our image from which it can retrieve image layers and use them as cache, or alternatively, we store the layers on a filesystem which we make available to the pipeline and grab the layers from there. Either way, we need to create the cache by pushing the image to repository or to filesystem, then - in the subsequent builds - we try to use it and if that doesn't work because of cache-miss, we update it with new layers.
Now let's see how we can do that in practice with various tools...
Docker
The simplest solution to this problem is to use Docker with BuildKit. BuildKit is a set of enhancements for docker build
which improves performance, storage management and adds couple extra features, including better caching functionality. To build container image with BuildKit, all we need to do is prepend DOCKER_BUILDKIT=1
to each command:
# Warm up cache
~ $ DOCKER_BUILDKIT=1 docker build -t martinheinz/docker-cached --build-arg BUILDKIT_INLINE_CACHE=1 .
...
=> => writing image sha256:09f473587beb1a1f240a776760655637ca00894a2a31b730019ecfee48d43848 0.0s
=> => naming to docker.io/martinheinz/docker-cached 0.0s
=> exporting cache 0.0s
=> => preparing build cache for export 0.0s
~ $ docker push martinheinz/docker-cached
# Build using cache repo
~ $ DOCKER_BUILDKIT=1 docker build --cache-from martinheinz/docker-cached .
=> [internal] load metadata for docker.io/library/ubuntu:latest 0.5s
=> importing cache manifest from martinheinz/docker-cached 0.0s
=> CACHED [1/1] FROM docker.io/library/ubuntu@sha256:44ab2c3b26363823dcb965498ab06abf...50743df0d4172d 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:09f473587beb1a1f240a776760655637ca00894a2a31b730019ecfee48d43848 0.0s
This example should be self-explanatory to anyone who ever built an image with Docker. Only real difference between this and basic Docker usage is the addition of BUILDKIT_INLINE_CACHE=1
which tells BuildKit to enable inline cache exporter. This makes sure that Docker writes metadata needed for caching into the image. This metadata will be then used in subsequent builds to find out which layers can be cached. The only other difference in the above snippet is the command output - during the first build we can see that Docker exports the cache to the repository, while during the second one it imports cache manifests and also uses one cached layer.
The use of BuildKit as part of Docker is convenient, but it hides some features and options. So, in case you want more control over the build and caching, then you can directly use the upstream BuildKit project. To do so, you will need to download binaries from GitHub release page, unpack it and move it into your path (e.g. /usr/local/bin/
). Finally, you need to start the BuildKit daemon and then you're ready to build:
sudo cp ~/Downloads/buildkit-v0.9.0.linux-amd64/bin/buildctl /usr/local/bin/
sudo cp ~/Downloads/buildkit-v0.9.0.linux-amd64/bin/buildkitd /usr/local/bin/
sudo cp ~/Downloads/buildkit-v0.9.0.linux-amd64/bin/buildkit-runc /usr/local/bin/
sudo buildkitd
If we want to perform the same cached build with upstream BuildKit as we did with the Docker integration, we will need to craft a bit more complicated command:
sudo buildctl build martinheinz/docker-cached \
--output type=image,name=docker.io/martinheinz/docker-cached,push=true \
--export-cache type=inline \
--import-cache type=registry,ref=docker.io/martinheinz/docker-cached \
--frontend=dockerfile.v0 --local context=. --local dockerfile=.
...
=> => pushing layers 0.6s
=> => pushing manifest for docker.io/martinheinz/docker-cached:latest@sha256:d5e200aa86c...18e234cc92 0.4s
...
=> exporting cache 0.0s
=> => preparing build cache for export 0.0s
As you can see here, there's a lot of flags and arguments that we had to specify, which can be annoying, but allows for great customizability. One advantage of this approach is that we don't need to run docker push
, instead we include push=true
in one of the arguments and buildctl
takes care of pushing the image.
Another advantage of using BuildKit in this way is ability to push the image and the cached layers into separate repositories or tags. In this example we will store the image itself in docker-cached:latest
, while the cache will live in docker-cached:buildcache
:
sudo buildctl build martinheinz/docker-cached \
--output type=image,name=docker.io/martinheinz/docker-cached,push=true \
--export-cache type=registry,ref=docker.io/martinheinz/docker-cached:buildcache \
--import-cache type=registry,ref=docker.io/martinheinz/docker-cached:buildcache \
--frontend=dockerfile.v0 --local context=. --local dockerfile=.
# During first build - `=> ERROR importing cache manifest from docker.io/martinheinz/docker-cached:buildcache`
# During second build - `=> importing cache manifest from docker.io/martinheinz/docker-cached:buildcache`
For completeness, I will also mention that it's also possible to leverage the above mentioned advanced features of BuildKit without installing it separately. For that you will need buildx
which is a Docker CLI plugin for extended build capabilities. buildx
however, has different arguments than buildctl
, so you will need to adjust your build commands based on the docs here.
With that said, we're doing all these shenanigans to improve CI/CD build performance, so running these commands locally is nice for testing, but we need to somehow perform this in the environment of some CI/CD platform, and the environment of choice for me is Kubernetes.
To make this work in Kubernetes, we will need to bring a couple of additional things - namely credentials for pushing the image and volume used as a workspace:
apiVersion: batch/v1
kind: Job
metadata:
name: buildkit
spec:
template:
spec:
restartPolicy: Never
initContainers:
- name: prepare
image: alpine:3.10
command:
- sh
- -c
- 'echo -e "FROM ubuntu\nENTRYPOINT ["/bin/bash", "-c", "echo hello"]\n" > /workspace/Dockerfile'
volumeMounts:
- name: workspace
mountPath: /workspace
containers:
- name: buildkit
image: moby/buildkit:master
command:
- buildctl-daemonless.sh
args: [ "build", "--frontend", "dockerfile.v0", "--local", "context=/workspace", "--local", "dockerfile=/workspace",
"--output", "type=image,name=docker.io/martinheinz/docker-cached,push=true", "--import-cache",
"type=registry,ref=docker.io/martinheinz/docker-cached","--export-cache", "type=inline"]
securityContext:
privileged: true
env:
- name: DOCKER_CONFIG
value: /docker/.docker
volumeMounts:
- name: docker-config
mountPath: /docker/.docker
- name: workspace
readOnly: true
mountPath: /workspace
volumes:
- name: docker-config
secret:
secretName: buildkit-docker-config
items:
- key: config.json
path: config.json
- name: workspace
persistentVolumeClaim:
claimName: buildkit-workspace
The above is a single Job, which first creates a Dockerfile
inside the workspace provided by PersistentVolumeClaim using an init container. The actual job then performs the build as shown earlier. It also mounts repository credentials from Secret named buildkit-docker-config
, which is needed so that BuildKit can push both the cached layers and the image itself to the repository.
For clarity, I omitted the manifests of the PersistentVolumeClaim and Secret used above, but if you want test it out yourself, then you can find those here.
Docker-less
Docker is not however, the only tool for building images that can help us leverage cache during CI/CD builds. One of the alternatives to Docker is Google's Kaniko. Its advantage is that it's meant to be run as container image, which makes it suitable for environments like Kubernetes.
Considering that this tool is meant for CI/CD pipelines, we need to simulate the same conditions locally to be able to test it. To do so, we will need a couple of directories and files that will be used as volumes:
mkdir volume && cd volume
echo 'FROM ubuntu' >> Dockerfile
echo 'ENTRYPOINT ["/bin/bash", "-c", "echo hello"]' >> Dockerfile
mkdir cache
mkdir config
cp ~/.docker/config.json config/config.json # or podman login --authfile config/config.json
tree
.
|____Dockerfile -> Sample Dockerfile (will be mounted as workspace)
|____cache -> Cache directory/volume
|____config -> Config directory/volume
|____config.json
Above we created 3 things - a sample Dockerfile
consisting of single layer, which we will use for testing. Next, we created a cache
directory which will be mounted into container and used for storing cached image layers. Finally, we created config
directory, containing registry credentials, which will be mounted read-only.
In previous section we only looked at the caching image layers using image registry/repository, with Kaniko though, we can also use a local directory/volume as a cache source. To do that we first need to "warm-up" the cache aka populate it with image layers:
# Warm up (populate) the cache with base image(s)
~ $ docker run --rm \
-v $(pwd):/workspace \
gcr.io/kaniko-project/warmer:latest \
--cache-dir=/workspace/cache \
--image=ubuntu # --image=more-images
~ $ ls cache/
sha256:3555f4996aea6be945ae1532fa377c88f4b3b9e6d93531f47af5d78a7d5e3761
sha256:3555f4996aea6be945ae1532fa377c88f4b3b9e6d93531f47af5d78a7d5e3761.json
Note: This section is about building images and caching images without docker, however during testing outside of Kubernetes, we still need to run the Kaniko image somehow, and that's using docker
.
Kaniko project provides 2 images - warmer
and executor
, above we used the former, which takes variable number of images and uses them to populate specified cache directory.
With the cache ready, we can move onto building the image. This time we use the executor
image, passing in 2 volumes - one for registry credential (mounted read-only) and one for workspace, which we pre-populated with sample Dockerfile
. Additionally, we specify flags to enable caching as well as destination, where the final image will be pushed:
# Use the cache
~ $ docker run --rm \
-v $(pwd)/config/config.json:/kaniko/.docker/config.json:ro \
-v $(pwd):/workspace \
gcr.io/kaniko-project/executor:latest \
--dockerfile=/workspace/Dockerfile \
--cache \
--cache-dir=/workspace/cache \
--destination martinheinz/kaniko-cached \
--context dir:///workspace/
...
INFO[0002] Returning cached image manifest
INFO[0002] Found sha256:3555f4996aea6be945ae1532fa377c88f4b3b9e6d93531f47af5d78a7d5e3761 in local cache
INFO[0002] Found manifest at /workspace/cache/sha256:3555f4996aea6be945ae1532fa377c88f4b3b9e6d93531f47af5d78a7d5e3761.json
These examples show us how it works in theory, but in practice we will want to run this on Kubernetes. For that we will need similar set of objects as in the example with BuildKit, that is - volume claim for cache directory, volume claim for workspace (Dockerfile), a secret with registry credentials and a Job or Pod that will execute kaniko
:
apiVersion: v1
kind: Pod
metadata:
name: kaniko
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:latest
args: ["--dockerfile=/workspace/Dockerfile",
"--context=dir://workspace",
"--destination=martinheinz/kaniko-cached",
"--cache",
"--cache-dir=/cache"]
volumeMounts:
- name: kaniko-docker-config
mountPath: /kaniko/.docker/
- name: kaniko-cache
mountPath: /cache
- name: kaniko-workspace
mountPath: /workspace
restartPolicy: Never
volumes:
- name: kaniko-docker-config
secret:
secretName: kaniko-docker-config
items:
- key: config.json
path: config.json
- name: kaniko-cache
persistentVolumeClaim:
claimName: kaniko-cache
- name: kaniko-workspace
persistentVolumeClaim:
claimName: kaniko-workspace
Here, assuming that we already have the cache populated using warmer
image, we run kaniko
executor, which retrieves Dockerfile
from /workspace
directory, cached layers from /cache
and credentials from /kaniko/.docker/config.json
. If everything goes well, we should see in logs that the cached layers were found by Kaniko executor
.
Caching layers from local volume can be useful, but most of the time you'll probably want to use remote registry. Kaniko can do that too, and all we need to do is change a couple of arguments:
apiVersion: v1
kind: Pod
metadata:
name: kaniko
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:latest
args: ["--dockerfile=/workspace/Dockerfile",
"--context=dir://workspace",
"--destination=martinheinz/kaniko-cached",
"--cache",
"--cache-copy-layers",
"--cache-repo=martinheinz/kaniko-cached"]
volumeMounts:
- name: kaniko-docker-config
mountPath: /kaniko/.docker
- name: kaniko-workspace
mountPath: /workspace
restartPolicy: Never
volumes:
- name: kaniko-docker-config
secret:
secretName: kaniko-docker-config
items:
- key: config.json
path: config.json
- name: kaniko-workspace
persistentVolumeClaim:
claimName: kaniko-workspace
The important change we made here is that we replaced --cache-dir
flag with --cache-repo
. Additionally, we were also able to omit the volume claim used for cache directory.
Besides Kaniko, there are quite a few other tools that can build a container image. The most notable one is podman
, which leverages buildah
to build images. Using these 2 for caching however, is not an option right now. The --cache-from
option is available in buildah
, it is however NOOP, so even if you specify it, nothing will happen. So, if you want to migrate your CI from Docker to Buildah and the caching is a requirement, then you will need to wait for this issue to be implemented/resolved.
Closing Thoughts
This article described how we can leverage layer caching to improve build performance. If you're experiencing bad performance in image builds, chances are though, that problem doesn't lie in missing caching, but rather in the commands in your Dockerfile
. Therefore, before you in jump into implementing layer caching, I'd suggest you try to optimize structure of your Dockerfiles
first. Additionally, the caching will only work if you have well-structured Dockerfiles
, because after first cache miss, no further cached layers can be used.
Besides caching layers, you might also want to cache dependencies, that way you can save time needed to download libraries from NPM, PyPI, Maven or other artifact repositories. One way to do this would be using BuildKit and its --mount=type=cache
flag described here.