Accueil

Docker Avancé : Multi-stage builds, Healthchecks et Sécurité

Devops
Difficulte: Advanced
22 min de lecture

Guide avancé Docker pour la production : multi-stage builds pour des images 10x plus légères, healthchecks fiables, sécurité des images et du runtime, Docker Compose production-ready, et pipeline CI/CD multi-arch.

Retour aux tutoriels

L'essentiel

  • Un multi-stage build sépare compilation et exécution, avec des gains mesurés par langage : Go passe de 850 Mo à 8-15 Mo (binaire statique CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath copié sur scratch), Node.js de 1,1 Go à 150-250 Mo, Python de 900 Mo à 180-250 Mo, Java de 700 Mo à 80-120 Mo avec jlink.
  • Le HEALTHCHECK se règle avec quatre paramètres — --interval (30s par défaut), --timeout (30s), --start-period (0s) et --retries (3) — pour trois états possibles : starting, healthy, unhealthy. Le piège est le start-period laissé à 0 : il doit dépasser le temps de démarrage réel, sinon le conteneur bascule en unhealthy alors qu'il s'initialise encore. Contrôle : docker inspect --format='{{.State.Health.Status}}' mon-conteneur.
  • Docker accorde une quinzaine de capabilities par défaut : appliquez le moindre privilège avec --cap-drop ALL, puis --cap-add NET_BIND_SERVICE uniquement si le service écoute sous le port 1024, complété par --security-opt no-new-privileges:true, un filesystem en lecture seule (--read-only --tmpfs /tmp:size=100M,noexec) et un profil seccomp en defaultAction: SCMP_ACT_ERRNO.
  • Déclarez l'utilisateur en UID numérique (USER 1001:1001) pour ne pas dépendre de /etc/passwd. Pour les secrets, ARG fuit dans docker history et ENV dans les métadonnées de l'image : au build, RUN --mount=type=secret,id=github_token lit /run/secrets/github_token ; au runtime, la section secrets: de Compose monte des fichiers dans /run/secrets/ plutôt que des variables d'environnement.
  • En production, séparez les fichiers Compose : docker-compose.yml + docker-compose.override.yml (chargé automatiquement en dev) et docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d en prod, la fusion se vérifiant avec docker compose config. Côté CI/CD : build multi-arch docker buildx --platform linux/amd64,linux/arm64, signature cosign, et cache type=gha qui ramène un build de 5-10 minutes à 1-2 minutes.
Prérequis
Ce tutoriel suppose une connaissance de base de Docker (images, conteneurs, Dockerfile, docker compose). Si vous débutez, consultez d'abord le guide d'installation et configuration de Docker et le guide d'installation Docker.

Pourquoi Docker "basique" ne suffit pas en production

La grande majorité des équipes qui adoptent Docker commencent par un Dockerfile simple : une image officielle, quelques RUN, un CMD, et c'est en production. Ça marche. Jusqu'au jour où ça ne marche plus.

Les problèmes typiques des Dockerfiles naïfs en production sont toujours les mêmes. Une image de 2 Go qui met 8 minutes à builder en CI. Un conteneur qui tourne en root et qui expose des binaires système inutiles. Un service qui déclare être "up" alors qu'il ne répond plus aux requêtes. Des secrets passés en ARG visibles dans docker history. Un docker compose up qui démarre l'application avant que la base de données soit prête.

Ces problèmes ont des solutions éprouvées et documentées. Ce guide les couvre de manière exhaustive :

  • Multi-stage builds : images 5 à 100x plus légères selon le langage
  • Optimisation des layers : builds rapides grâce au cache
  • Healthchecks production-ready : ordonnancement fiable des services
  • Sécurité des images : utilisateur non-root, images minimales, scan de vulnérabilités
  • Sécurité du runtime : capabilities, seccomp, isolation réseau
  • Docker Compose production : override files, secrets, limites de ressources
  • Registry et CI/CD : multi-arch, signing, pipeline GitHub Actions

1. Multi-stage builds

Le principe des multi-stage builds est simple : utiliser plusieurs images successives dans un seul Dockerfile pour séparer la phase de compilation de la phase d'exécution. L'image finale ne contient que ce qui est strictement nécessaire pour faire tourner l'application.

Exemple complet : application Go

Go est l'exemple parfait pour illustrer les gains : le SDK Go pèse environ 850 Mo. Un binaire compilé en statique pèse quelques mégaoctets.

# syntax=docker/dockerfile:1

# ── Stage 1 : Build ────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Copier les fichiers de dépendances en premier pour le cache
COPY go.mod go.sum ./
RUN go mod download

# Copier le code source et compiler un binaire statique
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -trimpath -o /app/server ./cmd/server

# ── Stage 2 : Runtime ──────────────────────────────────────────────────
FROM scratch

# Copier les certificats TLS (requis pour les appels HTTPS)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copier uniquement le binaire compilé
COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

Résultat : l'image finale pèse entre 8 et 15 Mo au lieu de 850 Mo. Les options -s -w de ldflags suppriment les symboles de debug et les informations DWARF. -trimpath retire les chemins locaux du binaire pour la reproductibilité.

Exemple complet : application Node.js

Pour Node.js, l'enjeu est de séparer les devDependencies (outils de build, TypeScript, etc.) des dependencies de runtime.

# syntax=docker/dockerfile:1

# ── Stage 1 : Installation de toutes les dépendances ──────────────────
FROM node:20-alpine AS deps

WORKDIR /app
COPY package*.json ./

# Installer TOUTES les dépendances (dev + prod) pour le build
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# ── Stage 2 : Build TypeScript ─────────────────────────────────────────
FROM deps AS builder

COPY . .
RUN npm run build

# ── Stage 3 : Runtime minimal ──────────────────────────────────────────
FROM node:20-alpine AS runner

RUN addgroup -S nodejs && adduser -S nextjs -G nodejs

WORKDIR /app

# Copier uniquement les artefacts de build et les dépendances de production
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./

USER nextjs

EXPOSE 3000
ENV NODE_ENV=production

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

Pattern avec cible spécifique --target

Un Dockerfile peut servir plusieurs environnements grâce aux targets. Chaque stage hérite du précédent et y ajoute sa couche spécifique.

# syntax=docker/dockerfile:1

FROM python:3.12-slim AS base
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-compile -r requirements.txt

# ── Target dev : outils de debug ───────────────────────────────────────
FROM base AS dev
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install debugpy pytest ipdb watchfiles
COPY . .
CMD ["python", "-m", "uvicorn", "main:app", "--reload", "--host", "0.0.0.0"]

# ── Target test : exécution des tests ──────────────────────────────────
FROM base AS test
COPY requirements-test.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements-test.txt
COPY . .
CMD ["pytest", "-v", "--cov=app", "--cov-report=xml"]

# ── Target prod : image minimale sécurisée ─────────────────────────────
FROM python:3.12-slim AS prod
COPY --from=base /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .
RUN groupadd -r appuser && useradd -r -g appuser -s /sbin/nologin appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
# Builder une target spécifique
docker buildx build --target dev -t monapp:dev .
docker buildx build --target test -t monapp:test .
docker buildx build --target prod -t monapp:prod .

# En CI/CD : builder uniquement la target prod
docker buildx build --target prod --push -t registry.example.com/monapp:1.2.3 .
Gains typiques par langage
Go : de 850 Mo à 8-15 Mo (binaire statique sur scratch). Node.js : de 1,1 Go à 150-250 Mo. Python : de 900 Mo à 180-250 Mo. Java : de 700 Mo à 80-120 Mo avec jlink. Les gains sont proportionnellement plus importants pour les langages compilés.

Contenu Premium

Ce tutoriel avancé est réservé aux membres premium.

9,90€ / mois
  • Tous les tutoriels avancés
  • Nouveaux contenus chaque semaine
  • Suivi de progression
  • Annulation à tout moment
MR

Écrit par

Morgann Riu

Expert en cybersécurité et administration Linux. Je partage mes connaissances à travers des tutoriels gratuits et des formations pour aider les administrateurs systèmes et développeurs à sécuriser leurs infrastructures.

Questions fréquentes

Quelle différence entre CMD et ENTRYPOINT dans un Dockerfile ?
ENTRYPOINT définit le processus principal du conteneur, celui qui est lancé au démarrage et dont la fin arrête le conteneur. CMD fournit les arguments par défaut de ce processus. Ensemble, ils se combinent : ENTRYPOINT ["/app/server"] CMD ["--port", "8080"] lance /app/server --port 8080. Si vous remplacez CMD au lancement (docker run monimage --port 9000), seuls les arguments changent. Si vous utilisez uniquement CMD, la commande entière peut être remplacée. En pratique : ENTRYPOINT pour le binaire principal, CMD pour ses arguments par défaut.
Comment réduire la taille d'une image Docker au maximum ?
Plusieurs techniques cumulatives permettent de réduire drastiquement la taille. Utilisez les multi-stage builds pour séparer compilation et exécution : copiez uniquement l'artefact final. Choisissez une image de base minimale : Alpine (5 Mo), Distroless (pas de shell ni de gestionnaire de paquets) ou scratch (0 octet, pour les binaires statiques). Combinez les instructions RUN pour réduire le nombre de layers et supprimez les caches dans la même instruction (rm -rf /var/lib/apt/lists/* après apt-get). Utilisez .dockerignore pour exclure les fichiers inutiles du contexte de build. Analysez avec docker history pour identifier les layers les plus lourdes.
Les multi-stage builds fonctionnent-ils avec tous les langages ?
Oui, les multi-stage builds sont universels et particulièrement efficaces avec les langages compilés. Pour Go et Rust, on passe d'une image de 800 Mo à moins de 10 Mo en copiant le binaire statique vers scratch. Pour Java, on utilise jlink pour créer un JRE minimal et passer de 700 Mo à 100 Mo. Pour Node.js, on sépare les dépendances de build (devDependencies) des dépendances de runtime. Pour Python, on installe les dépendances dans un virtualenv que l'on copie vers une image slim. Les langages interprétés bénéficient moins de la réduction de taille mais profitent de la séparation des outils de build.
Comment gérer les secrets en production avec Docker ?
N'utilisez jamais ARG ou ENV pour les secrets : ces valeurs sont visibles dans docker history et stockées dans les métadonnées de l'image. Pour les builds, utilisez les secret mounts de BuildKit (--secret id=mysecret,src=./secret.txt) qui injectent le secret sans le stocker dans aucune layer. Pour le runtime, préférez les variables d'environnement injectées par le système d'orchestration (Docker Compose secrets:, Kubernetes Secrets, HashiCorp Vault). Avec Docker Compose, définissez les secrets dans une section dédiée et montez-les en tant que fichiers dans /run/secrets/ plutôt que comme variables d'environnement.
Distroless vs Alpine, lequel choisir comme image de base ?
Le choix dépend de l'usage. Alpine est idéale pour le développement et les équipes qui ont besoin de debugger les conteneurs en production : elle dispose d'un shell (sh), d'un gestionnaire de paquets (apk) et de nombreux utilitaires. Sa taille est d'environ 5 Mo. Distroless (Google) est optimale pour la production haute sécurité : pas de shell, pas de gestionnaire de paquets, pas d'interpréteur de commandes, surface d'attaque minimale. Elle existe en variantes pour Python, Java, Node.js et en version debug (avec busybox) pour le troubleshooting. En règle générale : Alpine en développement et staging, Distroless en production pour les applications critiques. Pour les binaires Go ou Rust entièrement statiques, scratch offre la surface d'attaque nulle.
Comment fonctionne le healthcheck Docker et comment le configurer correctement ?
Le HEALTHCHECK dans le Dockerfile définit une commande que Docker exécute périodiquement pour vérifier que le conteneur fonctionne correctement. Quatre paramètres contrôlent son comportement : --interval (fréquence, défaut 30s), --timeout (délai maximal de la commande, défaut 30s), --start-period (période de grâce au démarrage, défaut 0s) et --retries (échecs consécutifs avant de passer en unhealthy, défaut 3). Un conteneur peut être en trois états : starting (pendant start-period), healthy ou unhealthy. Dans Docker Compose, la condition service_healthy dans depends_on attend que le service passe en healthy avant de démarrer le service dépendant. Toujours définir un start-period adapté au temps de démarrage réel de l'application pour éviter les faux positifs.

Partager ce tutoriel

Cet article vous a plu ?

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Commentaires

Recommandé pour vous

Article de fond sur le sujet

Checklist Sécurité Linux

30 points essentiels pour sécuriser un serveur Linux. Recevez aussi les nouveaux tutoriels par email.

Pas de spam. Désabonnement en 1 clic.