Bonnes pratiques Docker pour des images saines

10 mai 20269 min de lecture

Une image Docker mal construite, c'est rarement bloquant : ça démarre, ça répond, on passe à autre chose. Sauf qu'un peu plus tard, on découvre une image de 1,2 Go qui met deux minutes à se déployer, qui tourne en root, qui contient le .git complet et un .env avec un token GitHub. Tout ça aurait pu être évité avec quelques règles simples appliquées en amont.

Voici huit pratiques qui couvrent l'essentiel : taille d'image, vitesse de build, surface d'attaque, fiabilité au runtime.

1. Choisir la bonne image de base

L'image de base décide à elle seule de la taille finale et de ce qui sera scanné par les outils de sécurité. Trois familles à connaître pour un langage comme Node, Python ou Go :

  • <lang>:<version> — l'image complète, basée sur Debian. Pratique en dev, trop grosse pour la prod (souvent 800 Mo+).
  • <lang>:<version>-slim — Debian minimal, autour de 100-200 Mo. Bon compromis : compatible avec la plupart des dépendances natives.
  • <lang>:<version>-alpine — basé sur Alpine Linux, autour de 40-80 Mo. Très léger mais utilise musl au lieu de glibc, ce qui casse certaines dépendances natives (Prisma, certaines libs Python compilées).
# Mauvais : image énorme, trop de surface
FROM node:20

# Bon : compromis raisonnable
FROM node:20-slim

# Encore mieux si vos deps sont compatibles
FROM node:20-alpine

Pour aller plus loin : les images distroless de Google (gcr.io/distroless/...) ne contiennent que le runtime et les libs nécessaires — pas de shell, pas d'apt, pas d'utilitaires. Excellente surface d'attaque, mais débogage plus contraignant (pas de docker exec ... sh).

2. Adopter le multi-stage build

C'est probablement la règle qui a le plus d'impact. L'idée : séparer l'étape de build (qui a besoin de compilateurs, dev dependencies, sources) de l'étape runtime (qui n'a besoin que du binaire ou du bundle).

# ── Stage 1 : build ────────────────────────────
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── Stage 2 : runtime ──────────────────────────
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]

L'image finale ne contient que le bundle compilé et les dépendances de production. Le compilateur TypeScript, ESLint, les tests, tout ça reste dans le stage builder qui est jeté.

Le gain est concret : une appli Node typique passe de 1,1 Go à 150 Mo. Pour une appli Go ou Rust, on peut tomber sous les 20 Mo en copiant juste le binaire dans scratch.

3. Optimiser le cache de couches

Docker met en cache chaque instruction du Dockerfile. Si une couche n'a pas changé, elle est réutilisée — mais dès qu'une couche change, toutes les suivantes sont reconstruites.

D'où la règle : mettre ce qui change rarement avant ce qui change souvent.

# ❌ Mauvais : le COPY . copie tout, donc le RUN npm ci se rejoue à chaque
# modification de fichier source
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# ✅ Bon : npm ci se rejoue seulement si package*.json change
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

La différence en pratique : un build local passe de 45 secondes à 3 secondes sur un changement de code typique. En CI, c'est encore plus visible.

4. Ne jamais tourner en root

Par défaut, le processus dans le conteneur tourne en root (UID 0). Si une faille permet une évasion ou si un volume est monté avec des permissions larges, l'attaquant hérite de droits maximum.

La parade : créer un utilisateur dédié et passer dessus avant le CMD.

FROM node:20-alpine
WORKDIR /app

COPY --chown=node:node . .
RUN npm ci --omit=dev

# L'image node fournit déjà un user "node" non-privilégié
USER node

CMD ["node", "server.js"]

Pour les images qui n'ont pas d'utilisateur prédéfini :

RUN addgroup --system app && adduser --system --ingroup app app
USER app

5. Toujours avoir un .dockerignore

Sans .dockerignore, le COPY . . embarque tout : node_modules local, .git, fichiers de config IDE, fichiers de test, et — le pire — vos .env avec les secrets.

Un .dockerignore correct pour un projet Node :

node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
.vscode
.idea
*.md
coverage
dist
.DS_Store

Bénéfice double : moins de données envoyées au daemon Docker (build plus rapide), et zéro risque de fuiter un secret en copiant .env par accident.

6. Ne jamais coder en dur les secrets

Trois anti-patterns à bannir :

# ❌ Le secret est dans l'image, visible par quiconque la pull
ENV DATABASE_URL=postgres://user:pass@host/db

# ❌ Le secret est dans une couche, visible avec `docker history`
RUN curl -H "Authorization: Bearer SECRET_TOKEN" https://api/install.sh

# ❌ ARG passé au build, le secret reste dans l'image
ARG API_KEY
RUN echo $API_KEY > /app/key.txt

Les bonnes options :

  • Au runtime : passer les secrets via docker run -e, --env-file, ou l'orchestrateur (Kubernetes secrets, Docker secrets, AWS Secrets Manager).
  • Au build : utiliser BuildKit avec --secret, qui monte le secret en tmpfs sans l'inscrire dans une couche.
# syntax=docker/dockerfile:1.7
FROM alpine
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install
docker build --secret id=npm_token,src=$HOME/.npmrc .

Le secret n'apparaît dans aucune couche finale.

7. Healthcheck et gestion des signaux

Une image bien faite déclare elle-même comment vérifier qu'elle va bien :

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1

L'orchestrateur (Docker Swarm, Kubernetes via livenessProbe) peut redémarrer un conteneur qui ne répond plus.

L'autre piège classique : votre processus tourne en PID 1, ce qui veut dire qu'il doit gérer lui-même les signaux (SIGTERM, SIGINT) et les processus zombies. La plupart des runtimes (Node, Python) ne le font pas correctement par défaut.

Solution : utiliser --init au runtime ou tini dans l'image.

docker run --init myimage

Ou dans le Dockerfile :

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Sans ça, un docker stop met 10 secondes au lieu d'1 seconde, et les sous-processus s'accumulent en zombies.

8. Scanner et auditer

Avant de pousser une image en prod, lancez au moins un scan de vulnérabilités. Les outils gratuits suffisent largement :

# Trivy : rapide, signal très propre
trivy image myimage:latest

# Docker Scout : intégré au CLI Docker
docker scout cves myimage:latest

Vous découvrirez des CVE sur des libs système que vous n'utilisez même pas. Souvent, mettre à jour l'image de base ou passer en -slim/-alpine règle 80% des problèmes.

Checklist mentale

Avant de merger un Dockerfile, vérifier :

  1. Image de base pinnée (pas :latest)
  2. Multi-stage si build et runtime ont des deps différentes
  3. Ordre des COPY : dépendances avant le code source
  4. .dockerignore présent et complet
  5. USER non-root déclaré
  6. Aucun secret en ENV, ARG ou RUN brut
  7. HEALTHCHECK déclaré (sauf si géré par l'orchestrateur)
  8. --init au runtime ou tini dans l'image
  9. Scan trivy ou équivalent sans CVE critique

C'est neuf points, dix minutes à appliquer la première fois, deux minutes ensuite. Le retour sur investissement est immédiat : images plus petites, builds plus rapides, surface d'attaque réduite.