Docker in CI-pipelines: veelvoorkomende build- en cachingfouten (en hoe je ze oplost)
Docker-builds in CI (GitHub Actions, GitLab CI, Jenkins, Azure DevOps, Buildkite, …) voelen vaak “magisch” aan: lokaal bouwt je image snel en voorspelbaar, maar in CI is het traag, faalt het op willekeurige momenten, of lijkt caching nooit te werken. In deze tutorial krijg je een diepgaande, praktische uitleg van de meest voorkomende build- en cachingproblemen, waarom ze gebeuren, en hoe je ze oplost met echte commando’s.
We focussen op moderne Docker-builds met BuildKit en buildx, omdat die in CI het meeste voordeel bieden (parallelisme, betere caching, cache export/import, secrets/ssh mounts).
Inhoud
- Basis: wat gebeurt er in CI bij een Docker build?
- BuildKit en buildx: de standaard in CI
- Veelvoorkomende cachingfouten (en fixes)
- 1) “Caching werkt niet” door veranderende build context
- 2)
COPY . .breekt je cache - 3)
.dockerignoreontbreekt of is verkeerd - 4)
apt-get updateen package installs: niet deterministisch - 5)
pip/npm/yarninstalls zonder cache mounts - 6) Cache export/import ontbreekt in CI
- 7) Multi-stage builds: cache verdwijnt tussen stages
- 8) Platform mismatch (amd64 vs arm64) en “lege” cache
- Veelvoorkomende buildfouten (en fixes)
- Diagnose: zo zie je waarom caching faalt
- Aanbevolen Dockerfile-patronen voor snelle CI
- Checklist: stabiele, snelle Docker builds in CI
Basis: wat gebeurt er in CI bij een Docker build?
Een Docker build bestaat grofweg uit:
- Build context: de map (meestal je repo) die naar de Docker daemon/BuildKit wordt gestuurd.
- Dockerfile: de instructies (FROM, RUN, COPY, …).
- Layers: elke instructie levert (meestal) een layer op. Caching werkt op basis van:
- de instructie zelf,
- de content die erin gebruikt wordt (bijv. bestanden bij COPY),
- en de state van de vorige layer.
In CI is de omgeving vaak “clean”: elke pipeline-run start met een verse VM/container. Daardoor is er meestal geen lokale Docker layer cache beschikbaar, tenzij je die expliciet opslaat en hergebruikt via een registry of CI-cache.
Belangrijk verschil met lokaal: lokaal heb je vaak al base images gepulled en eerdere layers in cache. In CI heb je dat niet, dus zonder caching voelt elke build als “from scratch”.
BuildKit en buildx: de standaard in CI
BuildKit is de moderne build engine voor Docker. buildx is de CLI plugin die BuildKit features toegankelijk maakt, waaronder:
--cache-from/--cache-to(cache import/export)- multi-platform builds (
--platform linux/amd64,linux/arm64) RUN --mount=type=cache(dependency caches)RUN --mount=type=secreten--mount=type=ssh(veilig secrets/ssh gebruiken)
Controleer of BuildKit aan staat:
docker buildx version
docker buildx ls
Een typische CI build met buildx:
docker buildx create --use --name ci-builder
docker buildx inspect --bootstrap
docker buildx build \
--progress=plain \
-t myorg/myapp:ci \
.
--progress=plain is cruciaal in CI: je ziet dan precies welke stappen cache hits/misses zijn.
Veelvoorkomende cachingfouten (en fixes)
1) “Caching werkt niet” door veranderende build context
Symptoom: elke build doet opnieuw COPY en daarna alle RUN stappen, zelfs als je niets aan code hebt veranderd.
Oorzaak: je build context verandert elke run, bijvoorbeeld door:
- gegenereerde files (build artifacts) in de repo directory,
- timestamps of logfiles,
.gitdirectory die mee wordt gestuurd,- dependency directories (zoals
node_modules/,dist/,.pytest_cache/) die wisselen.
Fix: minimaliseer je context met .dockerignore.
Voorbeeld .dockerignore (Node/Python mix):
.git
.gitignore
.github
.vscode
node_modules
dist
build
coverage
*.log
__pycache__
*.pyc
.pytest_cache
.venv
Dockerfile
docker-compose.yml
Let op: Dockerfile negeren is optioneel; meestal hoeft die niet in context als je -f gebruikt, maar vaak laat je hem gewoon staan. Belangrijker is: negeer alles wat niet nodig is.
Test wat je context bevat:
docker buildx build --no-cache --progress=plain .
En inspecteer de contextgrootte in de output. Als je honderden MB’s verstuurt, is dat bijna altijd een probleem.
2) COPY . . breekt je cache
Symptoom: dependency install stappen (npm/pip) draaien elke keer opnieuw.
Oorzaak: je doet:
COPY . .
RUN npm ci
Als eender welk bestand in je repo verandert (README, tests, config), dan verandert de COPY layer en dus ook de cache key voor RUN npm ci.
Fix: kopieer eerst alleen dependency manifests, installeer dependencies, kopieer daarna de rest.
Node voorbeeld:
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
Python voorbeeld:
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=deps /usr/local /usr/local
COPY . .
CMD ["python", "-m", "myapp"]
Dit patroon zorgt dat dependency layers alleen invaliden wanneer package-lock.json of requirements.txt wijzigt.
3) .dockerignore ontbreekt of is verkeerd
Symptoom: caching is inconsistent; builds zijn traag; soms “random” cache misses.
Oorzaak: zonder .dockerignore stuur je:
.git(kan groot zijn en verandert vaak),- lokale artifacts,
- CI-generated files (bijv. coverage reports).
Fix: voeg .dockerignore toe en controleer dat je niet per ongeluk noodzakelijke files negeert (zoals src/ of configbestanden). Als je build faalt met “file not found” tijdens COPY, check dan of je .dockerignore te agressief is.
Snelle sanity check:
docker buildx build --progress=plain .
Als je ziet: transferring context: 450.23MB, dan is je context vrijwel zeker te groot.
4) apt-get update en package installs: niet deterministisch
Symptoom: builds falen sporadisch met 404’s of hash mismatch, of caching lijkt nutteloos.
Oorzaak: Debian/Ubuntu repositories veranderen continu. apt-get update haalt een momentopname. Als je caching gebruikt, kan een oude layer met apt-get update gecombineerd met een nieuwe apt-get install falen, of andersom.
Fix: combineer update + install in één RUN en ruim apt lists op.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
Extra: pin versies waar mogelijk of gebruik een base image met vaste digest:
FROM debian:bookworm-slim@sha256:...
In CI helpt dit tegen “werkt vandaag, faalt morgen”.
5) pip/npm/yarn installs zonder cache mounts
Symptoom: zelfs met goede layer caching blijft dependency install traag, vooral wanneer cache niet hergebruikt wordt tussen CI runs.
Oorzaak: layer caching is niet hetzelfde als tool caching. BuildKit kan caches mounten die buiten layers vallen en herbruikbaar zijn via --cache-to/--cache-from.
Fix: gebruik BuildKit cache mounts.
Node:
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
Python (pip):
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Belangrijk: deze caches werken optimaal als je in CI ook cache export/import configureert (zie volgende sectie).
6) Cache export/import ontbreekt in CI
Symptoom: lokaal snelle rebuilds, maar CI altijd cold start.
Oorzaak: de runner is ephemeral. Zonder cache export (naar registry of CI cache) is er niets om te hergebruiken.
Fix: gebruik --cache-to en --cache-from. De meest robuuste aanpak is een registry cache (OCI cache) in je container registry.
Voorbeeld:
export IMAGE="registry.example.com/myorg/myapp"
export CACHE_REF="registry.example.com/myorg/myapp:buildcache"
docker login registry.example.com -u "$REG_USER" -p "$REG_PASS"
docker buildx build \
--progress=plain \
--cache-from=type=registry,ref=$CACHE_REF \
--cache-to=type=registry,ref=$CACHE_REF,mode=max \
-t $IMAGE:ci \
--push \
.
Uitleg:
mode=maxbewaart meer cache metadata (grotere cache, maar betere hits).--pushis nodig als je build output naar registry moet én vaak ook om cache export te laten werken in remote builders.
Als je enkel wil testen zonder push:
docker buildx build \
--progress=plain \
--cache-from=type=registry,ref=$CACHE_REF \
--cache-to=type=registry,ref=$CACHE_REF,mode=max \
-t myapp:local \
--load \
.
Let op: --load werkt niet voor multi-platform builds; dan moet je --push gebruiken.
7) Multi-stage builds: cache verdwijnt tussen stages
Symptoom: stage deps cached, maar stage build niet, of omgekeerd.
Oorzaak: kleine wijzigingen in vroege stages invaliden downstream stages. Ook kan het gebeuren dat je in stage 2 opnieuw dingen downloadt die je al in stage 1 had kunnen hergebruiken.
Fixes:
-
Zorg dat je stages logisch gescheiden zijn:
deps: alleen dependency installbuild: compile/bundlesruntime: minimale runtime image
-
Copy alleen wat nodig is van stage naar stage.
Voorbeeld (Go):
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Hier zijn twee caches: module download en build cache. In CI scheelt dit enorm.
8) Platform mismatch (amd64 vs arm64) en “lege” cache
Symptoom: je hebt caching ingesteld, maar bij multi-platform builds lijken cache hits laag.
Oorzaak: cache keys zijn platform-specifiek. Een linux/amd64 layer is niet hetzelfde als linux/arm64. Als je runners wisselen (bijv. ARM runners vs x86 runners), dan “mis” je cache.
Fix:
- Bouw consistent op hetzelfde platform, of
- gebruik multi-platform cache export, en bouw met dezelfde
--platformset.
Voorbeeld:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from=type=registry,ref=$CACHE_REF \
--cache-to=type=registry,ref=$CACHE_REF,mode=max \
-t $IMAGE:latest \
--push \
.
Zorg ook dat je builder multi-platform ondersteunt:
docker buildx inspect --bootstrap
Veelvoorkomende buildfouten (en fixes)
1) “no space left on device”
Symptoom: build faalt tijdens pull, unpack of tijdens RUN stappen.
Oorzaak: CI runners hebben beperkte disk. Docker layers, build cache en artifacts stapelen snel op.
Fixes:
- Ruim op vóór/na build (vooral op self-hosted runners):
docker system df
docker builder prune -af
docker image prune -af
docker container prune -f
-
Verminder image size:
- gebruik
-slimbase images, - multi-stage runtime minimaliseren,
- verwijder package lists (
rm -rf /var/lib/apt/lists/*), - vermijd grote build context (zie
.dockerignore).
- gebruik
-
Gebruik registry cache i.p.v. enorme lokale caches.
2) Rate limits en pull failures
Symptoom: toomanyrequests: You have reached your pull rate limit of timeouts bij FROM.
Oorzaak: Docker Hub rate limiting, vooral in CI met veel parallelle jobs.
Fixes:
- Authenticeer naar Docker Hub:
echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USER" --password-stdin
- Mirror base images naar je eigen registry en gebruik die in
FROM. - Pin op digest om onverwachte wijzigingen te vermijden.
3) DNS/Netwerk issues tijdens build
Symptoom: apt-get/npm/pip faalt met “Temporary failure resolving …”.
Oorzaak: CI netwerk/DNS instabiliteit, of corporate proxies.
Fixes:
- Retries toevoegen in je package manager (waar mogelijk).
- Voor apt: gebruik
Acquire::Retries.
RUN echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries
- Voor npm:
npm config set fetch-retries 5 - Overweeg dependency proxy of interne mirror.
4) “permission denied” bij files of Docker socket
Symptoom:
permission deniedtijdensCOPYofRUN,- of CI job kan Docker daemon niet bereiken.
Oorzaak:
- Bestandsrechten in repo (bijv. scripts niet executable),
- Docker-in-Docker setup vereist privileges,
- rootless builds vs root-required acties.
Fixes:
- Zet execute bit in git:
git update-index --chmod=+x scripts/build.sh
- In Dockerfile: expliciet chmod:
COPY scripts/build.sh /usr/local/bin/build.sh
RUN chmod +x /usr/local/bin/build.sh
- Gebruik buildx met een geschikte driver (
docker-container) in plaats van “random” docker daemon state.
5) Secrets lekken in layers
Symptoom: tokens in image history of in intermediate layers.
Oorzaak: ARG TOKEN=... en dan RUN curl -H "Authorization: Bearer $TOKEN"; dit kan in build logs en layers terechtkomen.
Fix: BuildKit secrets mounts.
Dockerfile:
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
Build commando:
docker buildx build \
--secret id=npmrc,src=$HOME/.npmrc \
-t myapp:ci \
.
Secrets worden niet in layers opgeslagen.
Diagnose: zo zie je waarom caching faalt
- Gebruik plain progress:
docker buildx build --progress=plain -t myapp:debug .
Je ziet regels als:
CACHED(cache hit)- of een stap die opnieuw uitgevoerd wordt
-
Bekijk de uiteindelijke buildkit logs (in CI output).
-
Inspecteer je builder en cache:
docker buildx ls
docker buildx inspect --bootstrap
docker buildx du
- Forceer een vergelijking:
- Run A met
--no-cacheom baseline te zien. - Run B met cache import/export en kijk welke stappen
CACHEDworden.
- Check of je cache ref wel geschreven wordt:
- In registry moet je een tag zoals
:buildcachezien. - Als je geen pushrechten hebt, faalt cache export stil of met een fout. Gebruik
--progress=plainom dit te zien.
Aanbevolen Dockerfile-patronen voor snelle CI
Patroon A: “dependency-first” (Node)
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
FROM node:20-bookworm AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
CMD ["node", "dist/index.js"]
Waarom dit werkt:
npm ciindepsis stabiel zolang lockfile stabiel is.- Build output zit in
build. - Runtime image is kleiner en bevat geen build tooling.
Patroon B: “compile in builder, run minimal” (Rust)
# syntax=docker/dockerfile:1.7
FROM rust:1.78 AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src && echo "fn main(){}" > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release
RUN rm -rf src
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/myapp /usr/local/bin/myapp
ENTRYPOINT ["myapp"]
De “dummy main” truc zorgt dat dependency compilation cached blijft, zelfs als je code vaak wijzigt.
Checklist: stabiele, snelle Docker builds in CI
- Gebruik BuildKit/buildx en zet
--progress=plainaan voor diagnose. - Minimaliseer build context met een goede
.dockerignore. - Vermijd
COPY . .vóór dependency install; kopieer manifests eerst. - Combineer
apt-get update+apt-get installen ruim apt lists op. - Gebruik
RUN --mount=type=cachevoor npm/pip/go/rust caches. - Configureer cache export/import:
--cache-to type=registry,ref=...,mode=max--cache-from type=registry,ref=...
- Wees consistent in platform (
--platform) om cache hits te maximaliseren. - Pin base images (liefst op digest) voor determinisme.
- Gebruik secrets via
--mount=type=secret(niet viaARG). - Ruim Docker resources op als je disk issues hebt (
docker builder prune -af).
Praktisch “recept”: één build commando dat meestal goed zit
Als je één solide uitgangspunt wil (met registry cache):
export IMAGE="registry.example.com/myorg/myapp"
export CACHE_REF="registry.example.com/myorg/myapp:buildcache"
docker login registry.example.com -u "$REG_USER" -p "$REG_PASS"
docker buildx create --use --name ci-builder || docker buildx use ci-builder
docker buildx inspect --bootstrap
docker buildx build \
--progress=plain \
--platform linux/amd64 \
--cache-from=type=registry,ref=$CACHE_REF \
--cache-to=type=registry,ref=$CACHE_REF,mode=max \
-t $IMAGE:${GIT_SHA:-ci} \
--push \
.
Pas daarna je Dockerfile aan volgens de patronen hierboven. In de praktijk komt “caching werkt niet” bijna altijd neer op (a) te grote/veranderlijke context, (b) verkeerde volgorde van COPY/RUN, of (c) geen cache import/export in CI.
Extra: snelle triage bij een trage CI build
-
Context te groot?
Kijk naar “transferring context”. Is het >50–100MB zonder goede reden, fix.dockerignore. -
Dependency install draait steeds?
Controleer Dockerfile-volgorde: manifests eerst, dan install, dan rest kopiëren. -
Cache wordt niet hergebruikt tussen runs?
Voeg--cache-to/--cache-fromtoe met registry ref en controleer pushrechten. -
Multi-platform?
Verwacht lagere cache hits tenzij je consistent dezelfde platform set bouwt. -
Onstabiele downloads?
Pin versies/digests, voeg retries toe, gebruik mirrors/proxies.
Als je wil, kan je je Dockerfile en je huidige CI build-commando (alleen de relevante regels, zonder secrets) plakken; dan wijs ik exact aan welke layers je cache breken en hoe je ze herstructureert voor maximale cache hits.