← Retour aux tutoriels

Docker dans les pipelines CI : échecs courants de build et de cache, et comment les corriger

dockerci-cddevopsbuildkitcachingpipelinesdockerfileregistry

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 :

En CI, c’est souvent l’inverse :

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

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

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 :

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

Causes fréquentes

  1. Image de base trop vieille (Debian oldstable, Ubuntu EOL).
  2. Miroirs apt instables.
  3. DNS/proxy en CI.
  4. 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

Causes

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

Causes

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 :

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

Causes

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

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

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

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 :

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

Causes

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

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

Causes

Corrections

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 :

  1. 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 .
  2. Forcer un build sans cache pour isoler un cache corrompu

    docker build --no-cache --progress=plain -t debug:nocache .
  3. Inspecter les étapes lentes
    Identifiez l’instruction qui prend du temps (souvent RUN apt-get, npm ci, pip install).

  4. 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"
  5. 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
  6. 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 :

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)


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.