🐳 Docker · Intermédiaire

Optimiser ses images Docker en production

⏱ 25 minutes 📦 Docker 24+ 🎯 Production-ready

Une image Docker mal optimisée peut peser 1 Go, exposer des secrets et démarrer en 30 secondes. Ce tutoriel montre comment descendre à moins de 100 Mo, sécuriser les secrets et accélérer les builds avec les multi-stage builds.

Le problème : images trop lourdes

Par défaut, une image Python classique peut peser entre 800 Mo et 1,2 Go. En production, ça coûte cher en stockage, en transfert réseau et en temps de déploiement.

📖 Terme : Multi-stage build

Définition : Technique Docker utilisant plusieurs étapes (stages) dans un même Dockerfile, où seul le résultat final est inclus dans l'image de production.

But : Réduire drastiquement la taille de l'image en excluant tous les outils de compilation et dépendances de développement non essentiels à la production.

Pourquoi ici : La compilation de certains packages Python (avec extensions C) nécessite des outils lourds. Multi-stage permet de compiler dans une image "builder" lourde, puis de copier seulement les résultats compilés dans une image finale légère.

Dockerfile naïf (à éviter)
# ❌ PROBLÈME 1 : Image de base trop lourde (~1.2 Go)
FROM python:3.11

WORKDIR /app
# ❌ PROBLÈME 2 : Code copié avant requirements
#    → chaque modif du code invalide le cache des dépendances
COPY . .
RUN pip install -r requirements.txt

# ❌ PROBLÈME 3 : Pas de .dockerignore
#    → copie tout (.git, venv/, __pycache__) = +200 Mo

# ❌ PROBLÈME 4 : pip cache dans l'image finale
#    → ajoute ~50 Mo inutiles, jamais utilisé en prod

CMD ["python", "main.py"]

# RÉSULTAT : Image finale ~1.2 Go 🚫
Ce Dockerfile combine tous les antipatterns : image lourde, pas d'optimisation du cache, pas de gestion des fichiers inutiles. Le résultat : une image massive et des builds ralentis. Les sections suivantes corrigent chacun de ces problèmes.

1. Choisir la bonne image de base

Comparatif tailles d'images de base
# Avant (slim) = Après (alpine) : 2.7x plus léger
# python:3.11         → ~1.2 Go  (Debian complet, tout inclus)
# python:3.11-slim    → ~145 Mo  (Debian minimal, outils essentiels)
# python:3.11-alpine  → ~55 Mo   (Alpine Linux, ultra-léger)

# RECOMMANDATION : Pour les APIs FastAPI/Flask :
FROM python:3.11-slim    # ✅ Bon compromis : léger (~145 Mo) + compatible

# Alpine pour cas spéciaux (si pas de dépendances C) :
FROM python:3.11-alpine  # ⚠️  Risqué : scipy, numpy, psycopg2 peuvent être complexes à compiler
slim economise 1 Go en gardant les outils essentiels (gcc, curl). Alpine economise 90 Mo supplémentaires mais manque des headers C nécessaires pour certains packages. Pour 90% des APIs, slim est le sweet spot : léger, compatible, et un seul choix à faire.
Pourquoi pas l'image complète ? python:3.11 inclut les compilateurs C, apt (gestiionnaire de paquets), et beaucoup d'utilitaires non essentiels pour la production. Ces outils doublent la taille sans fournir de valeur au runtime. slim les supprime mais garde ce qui est vraiment nécessaire.

2. Multi-stage builds — la technique clé

L'idée : utiliser une image "builder" lourde pour compiler, puis copier seulement le résultat dans une image finale légère.

Dockerfile multi-stage (production)
# ════════════════════════════════════════════
# Stage 1 : BUILDER — installe les dépendances
# ════════════════════════════════════════════
# Utiliser une image complète pour compiler (outils de build inclus)
FROM python:3.11-slim AS builder

WORKDIR /build

# Optimiser pip : pas de cache, pas de vérification de version
ENV PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Copier requirements et installer les dépendances dans /install
# Cet arborescence sera copié en entier dans le stage final
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

# ════════════════════════════════════════════
# Stage 2 : RUNNER — image finale minimale
# ════════════════════════════════════════════
# Nouvelle image vierge, sans rien du stage 1 (sauf ce qu'on copie)
FROM python:3.11-slim AS runner

# Créer un utilisateur non-root pour la sécurité (voir section 4)
RUN groupadd -r appuser && useradd -r -g appuser appuser

WORKDIR /app

# Copier UNIQUEMENT les packages compilés du builder
# Le cache pip du builder est exclu, économisant 50 Mo
COPY --from=builder /install /usr/local

# Copier le code source avec les droits à l'utilisateur non-root
COPY --chown=appuser:appuser . .

# Basculer sur l'utilisateur non-root pour la sécurité
USER appuser

# Variables d'environnement pour Python
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

EXPOSE 8000

# Utiliser JSON array form (exec form) pour bien gérer les signaux SIGTERM
# --workers 2 : utiliser 2 workers uvicorn pour paralléliser les requêtes
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Le multi-stage build fonctionne en deux phases : 1. Stage builder : installe pip et compile les packages. C'est lourd mais temporaire. 2. Stage runner : démarre vierge, copie seulement les packages compilés. Le builder est complètement oublié après la build. La clé est COPY --from=builder : elle établit un "pont" qui ne copie que /install, excluant automatiquement le cache pip et tous les outils de build du builder.
Sans multi-stage, l'image finale contiendrait les compilateurs C, pip cache, et tous les outils du builder (~1 Go). Avec multi-stage, seuls les packages compilés (~95 Mo) sont inclus. C'est 92% d'économie ! Et paradoxalement, les deux images (builder et runner) construisent plus vite en parallèle qu'une seule monolithique.
Image finale ~95 Mo vs. 1.2 Go en traditionnel. Économie : -92% de taille, -3 sec au déploiement, -30% d'utilisation mémoire.

3. Optimiser le cache des layers

Docker construit les images couche par couche. Si une couche change, toutes les suivantes sont reconstruites. L'ordre des instructions est crucial :

📖 Terme : Build cache

Définition : Mécanisme Docker qui réutilise les layers non modifiées lors de builds successifs.

But : Accélérer les builds : au lieu de refaire chaque étape, Docker saute les étapes où rien n'a changé.

Pourquoi ici : Mal comprendre le cache peut multiplier par 10 le temps de build. L'ordre des instructions détermine le taux de "cache hit".

Ordre optimal des instructions
# ✅ BON ORDRE : ce qui change rarement en premier
FROM python:3.11-slim

# 1. Packages système (change ~jamais) → cache réutilisé 99% du temps
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    && rm -rf /var/lib/apt/lists/*  # --no-install-recommends = -30 Mo

# 2. Dépendances Python (change occasionnellement) → réutilisé 80% du temps
COPY requirements.txt .  # Copier SEUL requirements, pas tout le code
RUN pip install --no-cache-dir -r requirements.txt

# 3. Code source (change souvent) → réutilisé 20% du temps
#    Mis en dernier pour ne pas invalider les étapes précédentes
COPY . .

# ❌ MAUVAIS ORDRE : chaque modif du code invalide le cache
# COPY . .  ← copier avant requirements
# RUN pip install -r requirements.txt  ← recalculé à chaque commit !
# Résultat : 2 min par build au lieu de 30 sec
Docker utilise un hash (SHA256) du contenu + instructions pour décider si un layer peut être réutilisé. Si le hash change, le cache est invalidé. En plaçant les changements fréquents (code source) en dernier, on maximise la réutilisation des layers stables (système, dépendances).
Chaque modification du code recompile toutes les dépendances si on copie le code en premier. C'est un gâchis : pip ne s'exécute que si requirements.txt change. En recopiant les requirements seules d'abord, on peut réutiliser la couche "pip install" entre les commits du code.

4. Sécurité : utilisateur non-root + gestion des secrets

❌ Anti-pattern : secrets en dur
# ❌ JAMAIS FAIRE ÇA !!!
# Les ENV dans le Dockerfile restent dans les layers historiques
# et sont inspectables pour quiconque a accès à l'image
ENV SECRET_KEY=mon-super-secret-en-dur
ENV AWS_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
RUN pip install awscli

# Les secrets resteraient dans l'historique même après suppression :
# docker history mon-image  # affiche tous les ENV historiques
Les ENV codés dans le Dockerfile restent dans les metadata de l'image. "docker history" ou "docker inspect" les révèlent. Pire, les layers Docker sont immuables : même si vous les supprimez plus tard, ils restent accessibles. Jamais de secrets en dur.
✅ Bonne pratique 1 : Secrets au runtime
# Dans le Dockerfile : NE RIEN coder en dur
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Lancer le conteneur avec --env-file (secrets externes)
# Le fichier .env contient SECRET_KEY=xxxx, jamais commité sur Git
docker run --env-file .env mon-api:v1
Les secrets sont passés à l'exécution (runtime) via --env-file ou -e, jamais codés. Cela sépare complètement la configuration (secrète) de l'image (publique). L'image est "vierge" ; chaque déploiement injecte ses propres secrets.
✅ Bonne pratique 2 : Utilisateur non-root
# Créer un utilisateur non-root pour isoler l'application
FROM python:3.11-slim

# Créer le groupe et l'utilisateur app (sans accès à /etc/passwd)
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app

WORKDIR /app
COPY --chown=app:app . .

# Basculer sur l'utilisateur non-root AVANT CMD
USER app

# Si l'app est compromise, l'attaquant n'aura pas accès root
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Par défaut, les conteneurs tournent en tant que root (UID 0), ce qui est dangereux. Si le code contient une vulnérabilité, l'attaquant gagne l'accès root au conteneur. En créant un utilisateur non-root et en utilisant USER app, on limite les dégâts : l'attaquant ne peut plus lire /etc/shadow ou modifier les fichiers système.
✅ Bonne pratique 3 : BuildKit secrets (pour tokens de build)
# Si vous avez besoin d'un token (pip, npm, git) pendant la build :
# (exemple : installer un package privé depuis GitHub)

# Créer un fichier secret temporaire
# echo "ghp_xxxxxxxxxxxx" > /tmp/github-token

# Dans le Dockerfile, utiliser RUN --mount=type=secret
FROM python:3.11-slim

RUN --mount=type=secret,id=github_token \
    TOKEN=$(cat /run/secrets/github_token) && \
    pip install git+https://$TOKEN@github.com/org/private-package.git

# Construire avec DOCKER_BUILDKIT=1
# DOCKER_BUILDKIT=1 docker build --secret id=github_token,src=/tmp/github-token .
# Le token n'entre JAMAIS dans les layers finales
BuildKit secrets montent un fichier secret en /run/secrets/id (accessible seulement pendant le RUN) puis l'efface après. Contrairement aux ENV, le secret n'est pas persistent dans l'image. Utile pour les tokens de build mais pas essentiels pour la plupart des APIs (utilisez les secrets runtime pour les clés).
Séparer les secrets du code et de l'image est un princpe de sécurité fondamental. L'image Docker est un artifact que vous distribuez, committez en repo (parfois public), et partagez. Les secrets varient selon l'environnement (dev vs. production). Les mélanger crée des brèches de sécurité massives.

5. Healthcheck intégré

📖 Terme : HEALTHCHECK

Définition : Instruction Docker vérifiant périodiquement qu'un conteneur est opérationnel en exécutant une commande (ex: curl http://localhost:8000/health).

But : Donner aux orchestrateurs (Kubernetes, Docker Swarm, Cloud Run) l'information pour redémarrer automatiquement un conteneur défaillant.

Pourquoi ici : Un conteneur peut "être lancé" (processus actif) mais non-réactif (base de données non connectée). HEALTHCHECK détecte cette distinction.

Dockerfile avec HEALTHCHECK
FROM python:3.11-slim AS runner

# ... (reste du Dockerfile) ...

# Définir un health check : Docker teste toutes les 30 secondes
# --interval=30s : fréquence des tests
# --timeout=10s : délai max pour que curl réponde
# --start-period=40s : attendre 40s avant les premiers tests (warmup)
# --retries=3 : marquer unhealthy après 3 échecs consécutifs
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD curl -f http://localhost:8000/health || exit 1

# -f : échouer si le serveur retourne un code d'erreur HTTP (!=200)
# || exit 1 : retourner 1 en cas d'échec du curl

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
La commande CMD du HEALTHCHECK s'exécute périodiquement à l'intérieur du conteneur. Si elle retourne 0, le status est "healthy". Si elle échoue 3 fois d'affilée, Docker le marque "unhealthy". Les orchestrateurs voient ce statut et peuvent redémarrer automatiquement. start-period=40s laisse du temps au démarrage de l'API (évite de la marquer unhealthy pendant l'initialisation).
Terminal — vérifier le status
# Voir le statut du conteneur
docker ps

# STATUS: Up 2 minutes (healthy)   ← 🟢 Conteneur réactif
# STATUS: Up 2 minutes (unhealthy) ← 🔴 Problème détecté, redémarrage prévu

# Inspecter les détails du health check
docker inspect mon-api-container | grep -A 10 Health
docker ps affiche le statut du health check. Les orchestrateurs consultent régulièrement ce statut et redémarrent les conteneurs unhealthy. Sans HEALTHCHECK, Docker ne peut que voir "le processus tourne" mais pas "l'API répond aux requêtes".
Un conteneur qui "tourne" mais ne répond pas est un état zombie dangereux. L'utilisateur reçoit des timeout au lieu d'une réponse. HEALTHCHECK permet aux orchestrateurs de détecter et corriger ce problème automatiquement sans intervention manuelle.

6. Dockerfile final de production

Dockerfile production complet (all best practices)
# ══════════════════════════════════════════════════
# Stage 1 : BUILDER
# ══════════════════════════════════════════════════
FROM python:3.11-slim AS builder

WORKDIR /build

# Optimiser pip : pas de cache (sauve 50 Mo), pas de check version (plus rapide)
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1

# Copier SEUL requirements.txt pour profiter du cache lors des rebuilds
COPY requirements.txt .

# Installer dans /install pour faciliter la copie au stage runner
RUN pip install --prefix=/install -r requirements.txt

# ══════════════════════════════════════════════════
# Stage 2 : RUNNER — image finale
# ══════════════════════════════════════════════════
FROM python:3.11-slim AS runner

# Sécurité 1 : créer un utilisateur non-root
# -r : système user (UID < 1000, pas de login shell)
# -s /sbin/nologin : impossible de se logger en tant que app
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app

WORKDIR /app

# Métadonnées pour traçabilité et documentation
LABEL maintainer="alderi@kamtchoua.com" \
      version="1.0" \
      description="API Python production-ready avec multi-stage"

# Copier UNIQUEMENT les packages compilés du builder
# Le cache pip et outils de build restent dans le stage builder
COPY --from=builder /install /usr/local

# Copier le code source avec le bon propriétaire (non-root)
COPY --chown=app:app . .

# Variables d'environnement pour la production
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PYTHONPATH=/app

# Health check : Docker teste si l'API répond
# Utiliser urllib pour éviter une dépendance curl
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

# Basculer sur l'utilisateur non-root AVANT CMD
USER app

# Déclarer le port écouté (documentation, pas de binding réel)
EXPOSE 8000

# Lancer l'application
# --workers 2 : utiliser 2 processus worker pour paralléliser
# Utiliser exec form (JSON array) pour les signaux SIGTERM propres
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Ce Dockerfile combine toutes les best practices : multi-stage (réduit la taille), cache optimisé (ordre des instructions), sécurité (non-root, pas de secrets), santé (HEALTHCHECK), documentation (LABEL). L'image finale ~95 Mo intègre tout ce qui est nécessaire pour la production et rien de plus.
Chaque décision ici a une raison : slim pour le poids, multi-stage pour l'exclusion du builder, USER app pour la sécurité, HEALTHCHECK pour l'observabilité, --workers pour la scalabilité. Ensemble, elles créent une image production-ready : petite, sûre, et observable.

Récapitulatif des gains

Avant vs. Après : les bénéfices concrets
Critère Avant (naïf) Après (optimisé) Gain
Taille image 1.2 Go ~95 Mo -92% 🎉
Push vers registry 4-5 min 10-15 sec 25x plus rapide
Déploiement (pull) 2-3 min 5-10 sec 20x plus rapide
Rebuild (code change) 2 min (pas de cache) 30 sec (cache layers) 4x plus rapide
Mémoire utilisée ~500 Mo ~100 Mo -80%
Sécurité ❌ Root user ✅ Non-root Sécurité +
Observabilité ❌ Pas de health ✅ HEALTHCHECK Auto-healing
Les chiffres ne sont pas juste cosmétiques : une image 12x plus petite signifie 12x moins de temps pour la déployer, moins de charge sur votre registry, moins de bande passante, et moins de mémoire sur chaque serveur. En orchestrant 100+ conteneurs, les économies se multiplient exponentiellement.