Docker dans les pipelines CI : échecs courants de build et de cache, et comment les corriger
Ce tutoriel explique en profondeur pourquoi des builds Docker échouent (ou deviennent lents et imprévisibles) dans des pipelines CI/CD, comment diagnostiquer les causes, et comment corriger durablement les problèmes de cache, de dépendances, de réseau, d’authentification et de reproductibilité. Les exemples utilisent des commandes réelles et des Dockerfiles concrets. L’objectif est de rendre vos builds rapides, déterministes et debuggables.
1) Comprendre le contexte CI : ce qui change par rapport à votre machine
Sur un poste local, vous avez généralement :
- un daemon Docker “chaud” (cache déjà rempli),
- un accès réseau stable,
- des credentials déjà configurés,
- un espace disque confortable,
- une architecture CPU unique (souvent amd64),
- un environnement persistant (les couches Docker restent).
En CI, c’est souvent l’inverse :
- runners éphémères (cache perdu entre jobs),
- quotas disque stricts,
- réseau filtré (proxy, DNS, TLS inspection),
- builds concurrents,
- multi-arch (amd64 + arm64),
- authentification au registre via jetons temporaires.
Conséquence : un Dockerfile “qui marche chez moi” peut échouer en CI, ou être 10x plus lent, ou produire des images différentes d’un run à l’autre.
2) Pré-requis : activer BuildKit et les logs utiles
BuildKit est le moteur moderne de build Docker. Il gère mieux le cache, les secrets, les mounts et les builds parallèles.
Activer BuildKit
Dans la plupart des environnements :
export DOCKER_BUILDKIT=1
export BUILDKIT_PROGRESS=plain
BUILDKIT_PROGRESS=plainévite une sortie “TUI” difficile à lire dans les logs CI.- Si vous utilisez
docker buildx, BuildKit est implicite.
Vérifier la version
docker version
docker buildx version || true
Obtenir des logs plus explicites
Pour un build standard :
docker build --progress=plain -t monapp:ci .
Pour buildx :
docker buildx build --progress=plain -t monapp:ci .
3) Échec courant : “works on my machine” à cause du contexte de build
Symptôme
- Le build échoue sur
COPYouADD: fichier introuvable. - Ou au contraire, le build est très lent car le contexte envoyé au daemon est énorme.
Cause
Le contexte de build est le répertoire envoyé à Docker au moment du build. En CI, vous n’êtes pas toujours au même chemin, et vous pouvez inclure involontairement :
node_modules/,target/,.git/, artefacts, caches…- des secrets (mauvais).
Diagnostic
Affichez la taille du contexte :
du -sh .
Regardez ce qui part au build (avec BuildKit, c’est moins visible, mais la lenteur est un indicateur). Vérifiez le .dockerignore.
Correction : écrire un .dockerignore strict
Exemple générique :
.git
.gitignore
**/.DS_Store
node_modules
dist
build
target
coverage
*.log
.env
.env.*
*.pem
*.key
Bon pattern : copier d’abord les manifests, puis installer
Pour Node.js :
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
Puis seulement ensuite :
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
Ce pattern maximise le cache : tant que package-lock.json ne change pas, npm ci reste en cache.
4) Échec courant : cache “cassé” par l’ordre des instructions Dockerfile
Symptôme
Le build réinstalle tout à chaque fois, même si vous n’avez touché qu’un fichier applicatif.
Cause
Le cache Docker est instructionnel : une couche est réutilisée si l’instruction et ses entrées n’ont pas changé. Si vous faites :
COPY . .
RUN npm ci
alors tout changement dans le repo invalide la couche COPY . ., donc invalide RUN npm ci.
Correction : réordonner le Dockerfile
Node.js (rappel) : copier package*.json avant le reste.
Python (pip) :
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
Puis :
FROM python:3.12-slim
WORKDIR /app
COPY --from=deps /usr/local /usr/local
COPY . .
CMD ["python", "-m", "monapp"]
Java/Maven :
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -q -e -DskipTests dependency:go-offline
COPY . .
RUN mvn -q -DskipTests package
Ici, dependency:go-offline est cachable tant que pom.xml ne change pas.
5) Échec courant : “apt-get update” instable, 404, dépôts expirés
Symptômes
E: Failed to fetch ... 404 Not FoundRelease file is expiredTemporary failure resolving ...- builds qui passent puis échouent sans changement de code
Causes fréquentes
- Image de base trop vieille (Debian oldstable, Ubuntu EOL).
- Miroirs apt instables.
- DNS/proxy en CI.
- Cache apt incohérent.
Corrections essentielles
a) Utiliser une base maintenue et pinner les tags
Évitez ubuntu:latest (non déterministe) et évitez une version EOL. Préférez une version explicite :
FROM debian:bookworm-slim
b) Toujours combiner apt-get update et apt-get install dans la même couche
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Pourquoi ? Parce que si vous séparez :
RUN apt-get update
RUN apt-get install -y curl
la seconde couche peut réutiliser un cache avec un index apt périmé.
c) Diagnostiquer le DNS en CI
Dans un job CI, testez :
getent hosts deb.debian.org || true
curl -I https://deb.debian.org/ || true
Si vous êtes derrière un proxy, configurez HTTP_PROXY/HTTPS_PROXY au build (voir section 10).
6) Échec courant : limites de disque et “no space left on device”
Symptôme
no space left on device- erreurs pendant
npm ci,pip install,apt-get, ou lors de l’export de l’image.
Causes
- Runners avec peu d’espace.
- Images multi-stage qui laissent beaucoup de couches.
- Cache BuildKit qui grossit.
- Contexte de build énorme.
Corrections
a) Nettoyer ce qui n’est pas nécessaire dans l’image finale
Multi-stage : ne copiez que le nécessaire.
Exemple Node.js minimal :
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
L’image finale ne contient pas node_modules ni le toolchain.
b) Nettoyer sur le runner (si autorisé)
Sur certains runners (self-hosted), vous pouvez faire :
docker system df
docker builder prune -af
docker image prune -af
Attention : sur un runner partagé, cela peut impacter d’autres jobs. À réserver aux runners dédiés.
c) Réduire le contexte
Un .dockerignore strict est souvent la meilleure “optimisation disque”.
7) Échec courant : dépendances privées et authentification au registre
Symptômes
pull access deniedunauthorized: authentication requireddenied: requested access to the resource is denied
Causes
docker loginabsent dans le job.- Token expiré.
- Mauvais registre (Docker Hub vs registry interne).
- Pull d’une image de base privée.
Diagnostic
Testez explicitement :
docker pull registry.exemple.com/mon-projet/base:1.2.3
Correction : login explicite (sans exposer le secret)
Utilisez --password-stdin :
echo "$REGISTRY_PASSWORD" | docker login registry.exemple.com -u "$REGISTRY_USER" --password-stdin
Puis :
docker pull registry.exemple.com/mon-projet/base:1.2.3
Cas BuildKit + secrets (pour éviter de “baker” le secret)
Si vous devez accéder à un dépôt privé pendant le build (ex: npm, pip, git), évitez de COPY un fichier .npmrc contenant un token dans l’image.
Avec BuildKit, vous pouvez monter un secret au moment du build :
docker build \
--secret id=npmrc,src=$HOME/.npmrc \
--progress=plain \
-t monapp:ci .
Et dans le Dockerfile :
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --no-audit --no-fund
Le secret n’est pas conservé dans les couches finales.
8) Échec courant : cache absent entre jobs CI (builds lents)
Symptôme
Chaque pipeline reconstruit tout depuis zéro.
Cause
Les runners sont éphémères. Le cache Docker local n’existe pas d’un run à l’autre.
Solution moderne : cache BuildKit via registre (cache export/import)
Le principe : pousser un cache dans un registre (souvent le même que vos images), puis le réutiliser au build suivant.
Avec buildx, vous pouvez faire :
docker buildx create --use --name ci-builder || docker buildx use ci-builder
docker buildx inspect --bootstrap
Puis build avec cache :
IMAGE=registry.exemple.com/mon-projet/monapp:sha-$(git rev-parse --short HEAD)
CACHE=registry.exemple.com/mon-projet/monapp:buildcache
docker buildx build \
--progress=plain \
--cache-from=type=registry,ref=$CACHE \
--cache-to=type=registry,ref=$CACHE,mode=max \
-t $IMAGE \
--push \
.
Points importants :
--cache-to ... mode=maxstocke plus d’informations (cache plus efficace, mais plus volumineux).--pushest requis pour exporter vers le registre dans beaucoup de configurations (sinon l’image reste locale au builder).- Vous devez être authentifié au registre avant.
Variante : cache local (si votre CI le persiste)
Si votre CI propose un mécanisme de cache de répertoires, vous pouvez exporter le cache BuildKit localement :
mkdir -p .buildkit-cache
docker buildx build \
--cache-from=type=local,src=.buildkit-cache \
--cache-to=type=local,dest=.buildkit-cache-new,mode=max \
-t monapp:ci \
.
rm -rf .buildkit-cache
mv .buildkit-cache-new .buildkit-cache
Cela suppose que .buildkit-cache/ est sauvegardé/restauré entre runs via le système de cache CI.
9) Échec courant : builds non déterministes (tags flottants, dépôts “latest”, dépendances non pinées)
Symptômes
- Le même commit produit des images différentes.
- Un build casse “sans raison” un matin.
Causes
FROM node:latestapt-get installsans versionpip installsans locknpm installau lieu denpm ci- dépendances téléchargées depuis des URL non versionnées
Corrections
a) Pinner les images de base
Mieux :
FROM node:20.12.2-alpine3.19
Encore mieux (immuable) : pinner par digest :
FROM node:20.12.2-alpine3.19@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Le digest garantit que l’image de base ne change pas.
b) Utiliser des locks de dépendances
- Node :
npm ci+package-lock.json - Python :
pip-tools(requirements.txtgénéré) oupoetry.lock - Rust :
Cargo.lock - Java : verrouillage plus complexe, mais vous pouvez au minimum pinner les versions dans
pom.xmlet utiliser un repo manager.
c) Éviter curl | bash non versionné
Si vous devez télécharger un binaire, versionnez l’URL et vérifiez un checksum :
RUN curl -fsSL -o outil.tar.gz https://exemple.com/outil-1.2.3-linux-amd64.tar.gz \
&& echo "0123456789abcdef... outil.tar.gz" | sha256sum -c - \
&& tar -xzf outil.tar.gz -C /usr/local/bin \
&& rm outil.tar.gz
10) Échec courant : proxy, MITM TLS, certificats internes
Symptômes
x509: certificate signed by unknown authoritycurl: (60) SSL certificate problemnpm ERR! unable to verify the first certificatepipougitéchouent en HTTPS
Cause
En entreprise, le trafic sortant peut passer par un proxy avec inspection TLS. Votre conteneur ne connaît pas le certificat racine interne.
Corrections
a) Passer les variables proxy au build
Au moment du build :
docker build \
--build-arg HTTP_PROXY="$HTTP_PROXY" \
--build-arg HTTPS_PROXY="$HTTPS_PROXY" \
--build-arg NO_PROXY="$NO_PROXY" \
-t monapp:ci .
Dans le Dockerfile :
ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG NO_PROXY
ENV HTTP_PROXY=$HTTP_PROXY HTTPS_PROXY=$HTTPS_PROXY NO_PROXY=$NO_PROXY
b) Installer le certificat racine interne
Copiez le certificat (ex: corp-ca.crt) et mettez-le dans le trust store.
Debian/Ubuntu :
COPY corp-ca.crt /usr/local/share/ca-certificates/corp-ca.crt
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Alpine :
COPY corp-ca.crt /usr/local/share/ca-certificates/corp-ca.crt
RUN apk add --no-cache ca-certificates \
&& update-ca-certificates
11) Échec courant : multi-arch (amd64/arm64) et binaires incompatibles
Symptômes
exec format error- Tests qui passent sur amd64 mais échouent sur arm64
- Téléchargement d’un binaire “linux-amd64” dans une image arm64
Diagnostic
Dans le build, vérifiez l’arch :
RUN uname -m && cat /etc/os-release
Correction : utiliser buildx et des variables de plateforme
Avec BuildKit, vous avez :
TARGETPLATFORM(ex:linux/arm64)TARGETARCH(ex:arm64)TARGETOS
Exemple de téléchargement conditionné :
# syntax=docker/dockerfile:1.6
FROM alpine:3.20
ARG TARGETARCH
RUN apk add --no-cache curl
RUN case "$TARGETARCH" in \
amd64) url="https://exemple.com/outil-1.2.3-linux-amd64" ;; \
arm64) url="https://exemple.com/outil-1.2.3-linux-arm64" ;; \
*) echo "arch non supportée: $TARGETARCH" && exit 1 ;; \
esac \
&& curl -fsSL -o /usr/local/bin/outil "$url" \
&& chmod +x /usr/local/bin/outil
Build multi-arch :
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.exemple.com/mon-projet/monapp:1.0.0 \
--push \
.
12) Échec courant : “permission denied” (utilisateur non root, fichiers non accessibles)
Symptômes
EACCES: permission deniedpermission deniedlors d’unCOPY, d’unRUN, ou au démarrage du conteneur- problèmes avec des volumes montés
Causes
- Vous passez à
USER node/USER apptrop tôt. - Les fichiers copiés appartiennent à root et ne sont pas lisibles/écrits.
- Dans Kubernetes/OpenShift, l’UID runtime peut être arbitraire.
Corrections
a) Changer le propriétaire au COPY (BuildKit)
COPY --chown=10001:10001 . /app
b) Créer un utilisateur et ajuster les droits
RUN addgroup -g 10001 app && adduser -D -u 10001 -G app app
WORKDIR /app
COPY --chown=app:app . .
USER app
c) Éviter d’écrire dans des répertoires non autorisés
Écrivez dans /tmp ou un répertoire applicatif possédé par l’utilisateur.
13) Échec courant : tests intégrés au build (RUN tests) qui rendent le cache inutile
Problème
Si vous faites :
RUN npm test
dans le Dockerfile, alors chaque changement de code invalide le cache et relance les tests, ce qui peut être voulu… mais peut aussi rendre le build très lent et difficile à paralléliser.
Approche recommandée
- Construire l’image (ou une image “builder”).
- Exécuter les tests dans un conteneur séparé, en CI, avec des commandes
docker run.
Exemple :
docker build -t monapp:test --target test .
docker run --rm monapp:test
Avec un Dockerfile multi-stage :
FROM node:20-alpine AS test
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
CMD ["npm", "test"]
Ainsi, vous contrôlez mieux quand les tests s’exécutent, et vous pouvez séparer les jobs.
14) Échec courant : cache inefficace à cause de fichiers “bruyants” (timestamps, versions, git metadata)
Symptômes
- Le cache saute alors que le code n’a pas changé “fonctionnellement”.
- Un fichier généré (ex:
version.txt) change à chaque build.
Causes
COPY . .inclut des fichiers générés par la CI (numéro de build).git rev-parseécrit un fichier dans le contexte.- Artefacts de compilation présents avant le build.
Corrections
- Exclure ces fichiers via
.dockerignore. - Générer les métadonnées au runtime (labels) plutôt que dans le contexte.
Exemple de labels au build :
docker build \
--label org.opencontainers.image.revision="$(git rev-parse HEAD)" \
--label org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t monapp:ci .
Cela évite de modifier des fichiers dans le repo juste pour injecter une version.
15) Stratégie de diagnostic : une checklist “anti-perte de temps”
Quand un build CI échoue, appliquez cette méthode :
-
Reproduire localement avec les mêmes paramètres
Utilisez la même commande que la CI (tags, build args, platform).DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=plain docker build -t debug:local . -
Forcer un build sans cache pour isoler un cache corrompu
docker build --no-cache --progress=plain -t debug:nocache . -
Inspecter les étapes lentes
Identifiez l’instruction qui prend du temps (souventRUN apt-get,npm ci,pip install). -
Tester la connectivité réseau depuis une image proche
docker run --rm -it debian:bookworm-slim bash -lc "apt-get update && apt-get install -y curl && curl -I https://example.com" -
Vérifier l’auth registre
docker logout registry.exemple.com || true echo "$REGISTRY_PASSWORD" | docker login registry.exemple.com -u "$REGISTRY_USER" --password-stdin docker pull registry.exemple.com/mon-projet/base:1.2.3 -
Vérifier l’espace disque
df -h docker system df
16) Exemple complet : Dockerfile “CI-friendly” (Node.js) + build avec cache registre
Dockerfile
# syntax=docker/dockerfile:1.6
FROM node:20.12.2-alpine3.19 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
FROM node:20.12.2-alpine3.19 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
Points clés :
--mount=type=cache,target=/root/.npmaccélèrenpm cidans un même runner (et peut aider aussi avec cache BuildKit exporté).- Multi-stage : image finale minimale.
- Versions pinées.
Commandes CI (exemple générique)
set -euo pipefail
echo "$REGISTRY_PASSWORD" | docker login registry.exemple.com -u "$REGISTRY_USER" --password-stdin
docker buildx create --use --name ci-builder || docker buildx use ci-builder
docker buildx inspect --bootstrap
IMAGE="registry.exemple.com/mon-projet/monapp:sha-$(git rev-parse --short HEAD)"
CACHE="registry.exemple.com/mon-projet/monapp:buildcache"
docker buildx build \
--progress=plain \
--cache-from=type=registry,ref=$CACHE \
--cache-to=type=registry,ref=$CACHE,mode=max \
-t "$IMAGE" \
--push \
.
17) Bonnes pratiques récapitulatives (à appliquer systématiquement)
- Activer BuildKit et utiliser
--progress=plainen CI. - Pinner les images de base (idéalement par digest) et les dépendances.
- Optimiser l’ordre des instructions (manifests → install deps → code).
- Éviter les secrets dans les couches : préférer
--secretBuildKit. - Réduire le contexte avec un
.dockerignorestrict. - Gérer le cache inter-runs via
buildx --cache-to/--cache-from(registre ou cache CI). - Combiner
apt-get updateetapt-get installdans une seule couche, nettoyerapt lists. - Prévoir le multi-arch si nécessaire (variables
TARGETARCH,--platform). - Surveiller l’espace disque et utiliser des images finales minimales.
18) Annexes : commandes utiles au quotidien
Voir l’historique des couches (taille, commandes)
docker history --no-trunc monapp:ci
Inspecter une image (labels, env, entrypoint)
docker inspect monapp:ci | less
Lister les builders buildx
docker buildx ls
docker buildx inspect --bootstrap
Nettoyer le cache BuildKit (à utiliser avec prudence)
docker builder prune -af
Vérifier la taille réelle des images/caches
docker system df -v
En appliquant ces corrections (ordre des couches, .dockerignore, BuildKit + cache registre, pinning, secrets BuildKit, gestion réseau/proxy), vous éliminez la majorité des échecs “mystérieux” en CI et vous obtenez des builds plus rapides, reproductibles et faciles à diagnostiquer. Si vous me donnez votre Dockerfile actuel et un extrait de logs CI, je peux proposer une version corrigée et une stratégie de cache adaptée à votre plateforme.