← Terug naar tutorials

Docker in CI-pipelines: veelvoorkomende build- en cachingfouten (en hoe je ze oplost)

dockerci/cddevopsbuildkitdocker cachingmulti-stage buildsgithub actionsgitlab ci

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

  1. Basis: wat gebeurt er in CI bij een Docker build?
  2. BuildKit en buildx: de standaard in CI
  3. Veelvoorkomende cachingfouten (en fixes)
  4. Veelvoorkomende buildfouten (en fixes)
  5. Diagnose: zo zie je waarom caching faalt
  6. Aanbevolen Dockerfile-patronen voor snelle CI
  7. Checklist: stabiele, snelle Docker builds in CI

Basis: wat gebeurt er in CI bij een Docker build?

Een Docker build bestaat grofweg uit:

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:

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:

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:

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:

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:

  1. Zorg dat je stages logisch gescheiden zijn:

    • deps: alleen dependency install
    • build: compile/bundles
    • runtime: minimale runtime image
  2. 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:

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:

docker system df
docker builder prune -af
docker image prune -af
docker container prune -f

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:

echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USER" --password-stdin

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:

RUN echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries

4) “permission denied” bij files of Docker socket

Symptoom:

Oorzaak:

Fixes:

git update-index --chmod=+x scripts/build.sh
COPY scripts/build.sh /usr/local/bin/build.sh
RUN chmod +x /usr/local/bin/build.sh

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

  1. Gebruik plain progress:
docker buildx build --progress=plain -t myapp:debug .

Je ziet regels als:

  1. Bekijk de uiteindelijke buildkit logs (in CI output).

  2. Inspecteer je builder en cache:

docker buildx ls
docker buildx inspect --bootstrap
docker buildx du
  1. Forceer een vergelijking:
  1. Check of je cache ref wel geschreven wordt:

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:

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


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

  1. Context te groot?
    Kijk naar “transferring context”. Is het >50–100MB zonder goede reden, fix .dockerignore.

  2. Dependency install draait steeds?
    Controleer Dockerfile-volgorde: manifests eerst, dan install, dan rest kopiëren.

  3. Cache wordt niet hergebruikt tussen runs?
    Voeg --cache-to/--cache-from toe met registry ref en controleer pushrechten.

  4. Multi-platform?
    Verwacht lagere cache hits tenzij je consistent dezelfde platform set bouwt.

  5. 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.