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.
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.
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.
# ❌ 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 🚫
# 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
L'idée : utiliser une image "builder" lourde pour compiler, puis copier seulement le résultat dans une image finale légère.
# ════════════════════════════════════════════
# 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"]
Docker construit les images couche par couche. Si une couche change, toutes les suivantes sont reconstruites. L'ordre des instructions est crucial :
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".
# ✅ 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
# ❌ 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
# 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
# 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"]
# 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
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.
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"]
# 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
# ══════════════════════════════════════════════════
# 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"]
| 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 |