Changer le thème

Build multi-étapes Docker en pratique : passer d'une image de prod de 1 Go à 10 Mo

Easton editorial illustration: tradeoff balance table

« Le push de l’image a échoué, timeout. »

C’était un vendredi après-midi l’année dernière, et le pipeline CI/CD était tout rouge. Je fixais une image Go de 980 Mo et j’ai senti le coup au ventre. Un collègue ops s’est approché en soupirant : « Ton image est plus grosse que le film que j’ai téléchargé à midi. »

Ensuite j’ai adopté le build multi-étapes.

10 Mo. Même application, mêmes fonctionnalités : de 980 Mo à 10 Mo. 99 % du volume disparu ; le push CI/CD est passé de 3 minutes à 3 secondes.

Dans cet article, je partage des techniques concrètes de build multi-étapes : modèles Dockerfile complets pour Go, Node.js et Python, plus 5 erreurs fréquentes que j’ai apprises à mes dépens. Si vous voulez passer d’une image de prod « obèse » à « maigre », continuez la lecture.

Pourquoi votre image est-elle si volumineuse ?

Franchement, la plupart des images Docker gonflées ont les mêmes causes.

J’avais écrit un Dockerfile comme celui-ci :

FROM ubuntu:20.04
RUN apt-get update && apt-get install -y golang
COPY . /app
WORKDIR /app
RUN go build -o myapp
CMD ["./myapp"]

Ça a l’air correct, non ? Puis docker images — 980 Mo.

Le problème en quatre mots : on garde trop, on jette pas assez.

Concrètement :

  1. Image de base trop lourde : ubuntu:20.04 fait déjà 77 Mo ; avec la chaîne d’outils Go, on dépasse 900 Mo
  2. Outils de compilation qui restent : gcc, make, git — inutiles en production
  3. Cache non nettoyé : les caches apt/apk restent dans les couches
  4. Dépendances en trop : devDependencies et frameworks de test embarqués aussi

Image : vous partez en voyage avec valise, sac de couchage, tente et réchaud… pour dormir à l’hôtel. Le build multi-étapes, c’est n’emporter que l’essentiel — vêtements et trousse de toilette — le reste reste à la maison.

Selon la documentation Docker, une app Go typique non optimisée fait environ 800 Mo–1 Go ; optimisée, 10–20 Mo. L’écart est brutal.

Principe du build multi-étapes

L’idée est simple : séparer l’environnement de build et celui d’exécution.

Un Dockerfile classique mélange compilation, packaging et run dans une seule image. Le multi-étapes autorise plusieurs instructions FROM, chacune ouvrant une nouvelle phase.

Exemple minimal :

# Phase 1 : build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Phase 2 : exécution
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Deux syntaxes clés :

  • FROM ... AS builder : nommer la phase
  • COPY --from=builder : copier depuis cette phase

Docker exécute chaque phase dans l’ordre, mais l’image finale ne contient que la dernière. Outils de compilation, caches de dépendances — tout est abandonné.

Selon le tutoriel iximiuz Labs (2026), le multi-étapes exploite le mécanisme de couches : chaque FROM démarre un contexte indépendant ; vous copiez d’une phase à l’autre, sans que le superflu n’entre dans l’image finale.

Comme rénover une maison : phase 1, l’équipe avec perceuse et scie ; phase 2, vous emménagez avec meubles et électroménager. L’équipe part, les outils aussi — il ne reste que ce dont vous avez besoin.

Cas pratiques : modèles pour trois langages

Go : de 980 Mo à 10 Mo

Go est idéal pour le multi-étapes : binaire statique possible.

Dockerfile complet :

# Phase de build
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Copier d'abord go.mod et go.sum pour le cache
COPY go.mod go.sum ./
RUN go mod download

# Puis le code source et la compilation
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o myapp .

# Phase d'exécution
FROM scratch

COPY --from=builder /app/myapp /myapp

# Certificats CA si appels HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/myapp"]

Astuces :

  1. FROM scratch : image vide, seulement votre binaire
  2. CGO_ENABLED=0 : binaire statique pur
  3. Certificats CA : obligatoires pour HTTPS
  4. Cache des deps : copier go.mod/go.sum avant go mod download pour ne pas retélécharger à chaque changement de code

Résultat : environ 10 Mo. Contre 980 Mo, −99 %.

Si scratch est trop strict (pas de shell), utilisez alpine :

FROM alpine:3.18
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]

Environ 15 Mo, avec un shell pour docker exec.

Node.js : de 900 Mo à 120 Mo

Un peu plus délicat à cause de node_modules.

Dockerfile complet :

# Phase de build
FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .

RUN npm run build

# Phase de production
FROM node:18-alpine

WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["node", "dist/index.js"]

Points :

  1. npm ci --only=production : seulement dependencies, pas devDependencies
  2. npm cache clean --force : sinon le cache reste dans une couche
  3. Build séparé : compilation TypeScript en builder, JS seul en prod

Données Oak Oliver Engineering : Express typique ~900 Mo → ~120 Mo (−87 %).

Python : de 300 Mo à 100 Mo

Pas d’étape de compilation, mais des paquets lourds (numpy, pandas…).

Dockerfile complet :

# Phase de build
FROM python:3.9-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Phase de production
FROM python:3.9-alpine

WORKDIR /app

COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH

COPY . .

EXPOSE 8000
CMD ["python", "app.py"]

pip install --user installe dans /root/.local, puis on copie tout le dossier.

Astuces :

  1. --no-cache-dir : évite le cache pip dans l’image
  2. slim vs alpine : slim en build (compatibilité), alpine en prod (taille)
  3. venv : pour des deps complexes, venv peut valoir mieux que --user

Projet FastAPI + SQLAlchemy testé : ~300 Mo → ~100 Mo.

Choix de l’image de base : Alpine vs Distroless vs Slim

La phase d’exécution demande un compromis.

CaractéristiqueAlpineDistrolessSlim
Taille de base3-5 Mo20-65 Mo50-100 Mo
SécuritéMoyenneTrès élevéeMoyenne
DébogageFacile (shell)Difficile (pas de shell)Facile (shell)
CompatibilitéPièges (glibc)BonneBonne
Cas d’usageBinaire Go statiqueExigences sécurité élevéesNode.js/Python

Alpine : le plus petit, attention à glibc

Alpine utilise musl libc, pas glibc. OK pour Go statique ; parfois problématique pour certaines deps Python/Node.

J’ai eu ce cas : numpy qui plante sur Alpine avec ImportError: cannot import name 'random'. Cause : incompatibilité musl/glibc.

Solutions :

  • installer libc6-compat : apk add libc6-compat
  • ou passer à slim au lieu d’alpine

Distroless : référence sécurité, débogage difficile

Série Google : pas de shell, pas de gestionnaire de paquets, le strict minimum.

Selon danieldemmel.me, Distroless réduit beaucoup de CVE critiques — pas de shell pour exécuter des commandes.

Contrepartie : pas de docker exec pour inspecter. Il faut s’appuyer sur logs et monitoring.

Pour une sécurité maximale :

FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /
ENTRYPOINT ["/myapp"]

Slim : compromis

Images officielles -slim (node:18-slim, python:3.9-slim) : entre Alpine et l’image complète.

Un peu plus lourdes qu’Alpine, meilleure compatibilité, shell pour déboguer. Si vous ne voulez pas vous battre avec musl/glibc, slim est le choix tranquille.

Mes recommandations :

  • Go : scratch ou alpine en priorité
  • Node.js/Python : slim d’abord, puis alpine si tout passe
  • Haute sécurité : distroless, avec plan de logs/monitoring

Pièges : 5 erreurs fréquentes et solutions

Après tant de Dockerfiles, voici les 5 erreurs les plus courantes.

Erreur 1 : COPY —from=0 en copie totale

Les débutants copient toute la phase précédente :

# Mauvais exemple
FROM builder
COPY --from=0 /app /app

Tout part : chaîne Go, cache npm, fichiers temporaires — l’image regonfle.

Bonne pratique : copier uniquement le nécessaire.

# Bon exemple
COPY --from=builder /app/myapp /myapp
COPY --from=builder /app/dist /dist

Erreur 2 : cache non nettoyé

Le cache apt/apk reste dans la couche même après suppression.

# Mauvais (le cache reste dans la couche précédente)
RUN apt-get update && apt-get install -y curl
RUN apt-get clean

Nettoyage dans la même couche :

# Bon
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*

Ou avec --no-cache :

RUN apk add --no-cache curl

Erreur 3 : compatibilité glibc sur Alpine

musl vs glibc — certaines deps Python/Node cassent.

Erreur typique :

ImportError: cannot import name 'random' from 'numpy.random'

Solution : libc6-compat ou image slim.

Erreur 4 : pas d’utilisateur non-root

Par défaut le conteneur tourne en root — risque accru.

RUN adduser -D appuser
USER appuser

En cas d’intrusion, privilèges limités.

Erreur 5 : oublier .dockerignore

.dockerignore est la liste d’exclusion du Dockerfile. Sans lui, COPY . . embarque .git, node_modules, tests…

Exemple .dockerignore :

.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
*.md
.env

Contexte de build plus léger, build plus rapide.

Conclusion

Le build multi-étapes est la technique la plus utile pour alléger une image Docker.

En une phrase : les outils de build restent en build, l’image de run ne garde que l’app.

Récap des chiffres :

  • Go : 980 Mo → 10 Mo (−99 %)
  • Node.js : 900 Mo → 120 Mo (−87 %)
  • Python : 300 Mo → 100 Mo (−67 %)

Si vous n’avez pas encore testé, prenez un projet, adaptez un modèle ci-dessus, comparez avec docker images.

Vous devriez être agréablement surpris — au minimum, le push CI/CD ne timeoutera plus.

Optimisation d'image Docker par build multi-étapes

Processus complet pour réduire une image Docker au minimum

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Analyser la composition de l'image actuelle

    Utilisez `docker history` pour voir la taille de chaque couche :

    ```bash
    docker history your-image:tag
    ```

    Identifiez les couches les plus volumineuses, en général :
    • l'image de base elle-même
    • les outils de build et dépendances de compilation
    • le cache du gestionnaire de paquets
  2. 2

    Step 2: Rédiger un Dockerfile multi-étapes

    Créez un Dockerfile avec une phase de build et une phase d'exécution :

    ```dockerfile
    # Phase de build
    FROM golang:1.21-alpine AS builder
    WORKDIR /app
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN CGO_ENABLED=0 go build -o myapp .

    # Phase d'exécution
    FROM alpine:3.18
    COPY --from=builder /app/myapp /myapp
    ENTRYPOINT ["/myapp"]
    ```

    Points clés :
    • nommer les phases avec AS
    • COPY --from=builder ne copie que le nécessaire
  3. 3

    Step 3: Construire et comparer la taille des images

    Construisez la nouvelle image et comparez le volume :

    ```bash
    docker build -t myapp:optimized .
    docker images | grep myapp
    ```

    Comparez la différence avant/après optimisation.
  4. 4

    Step 4: Vérifier que l'application fonctionne

    Lancez le conteneur et testez l'application :

    ```bash
    docker run -d -p 8080:8080 myapp:optimized
    curl http://localhost:8080/health
    ```

    Vérifiez que tout fonctionne sans dépendance manquante.
  5. 5

    Step 5: Déployer en production

    Mettez à jour le pipeline CI/CD pour utiliser la nouvelle image :

    • pousser vers le registre d'images
    • mettre à jour le Deployment Kubernetes ou docker-compose.yml
    • valider le déploiement

FAQ

Le build multi-étapes ralentit-il la construction ?
Il augmente le temps de build (deux phases), mais l'image finale est bien plus légère : déploiement et transfert accélérés. Sur un pipeline CI/CD, le temps total est souvent réduit.
Comment choisir entre Alpine et Distroless ?
Pour Go compilé statiquement, privilégiez Alpine ou scratch ; pour Node.js/Python, testez d'abord slim pour la compatibilité, puis Alpine ; pour une sécurité maximale, Distroless, en prévoyant logs et monitoring.
Quels langages en bénéficient le plus ?
Presque tous. Les gains les plus marqués : Go (jusqu'à 10 Mo), Node.js (80 %+), Python (60 %+), Rust, Java et tout langage avec étape de compilation ou gestion lourde des dépendances.
Comment gérer les fichiers de configuration en multi-étapes ?
En production, montez-les plutôt que de les embarquer. Utilisez un volume Docker ou un ConfigMap Kubernetes. Si vous devez les inclure, COPY en phase d'exécution suffit.
De combien peut-on réduire le volume d'image ?
Selon le langage et l'app. Go : souvent 90 %-99 % (1 Go → 10 Mo) ; Node.js : 70 %-90 % ; Python : 50 %-70 %. L'essentiel : ne garder que ce qui est indispensable à l'exécution.
Quelles précautions avec FROM scratch ?
scratch est vide : pas de shell, pas de gestionnaire de paquets, pas de certificats CA. Pour HTTPS, copiez /etc/ssl/certs/ca-certificates.crt depuis le builder. Débogage difficile ; validez d'abord avec alpine.

7 min de lecture · Publié le: 19 avr. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog