Dockerfile optimisé#
Écrire un Dockerfile qui fonctionne est une chose. Écrire un Dockerfile qui produit des images légères, sécurisées, et qui se construisent rapidement grâce au cache est un art. Ce chapitre vous enseigne les techniques professionnelles pour passer de « ça tourne » à « c’est prêt pour la production ».
Multi-stage builds : l’outil le plus puissant#
L’idée est simple mais révolutionnaire : utiliser plusieurs instructions FROM dans un seul Dockerfile. Chaque FROM démarre un nouveau stage de construction. Le résultat final ne contient que ce que vous copiez explicitement du stage précédent.
Sans multi-stage : l’image monolithique#
# Dockerfile SANS multi-stage — à NE PAS faire en production
FROM python:3.12
# Outils de build installés dans l'image finale
RUN apt-get update && apt-get install -y gcc g++ make libpq-dev
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# L'image contient : Python + GCC + toutes les dépendances de dev + votre code
# Taille typique : 800 MB à 1.2 GB
CMD ["python", "app.py"]
Avec multi-stage : builder → runner#
# Dockerfile AVEC multi-stage — la bonne pratique
# ============================================================
# Stage 1 : BUILDER (contient tous les outils de compilation)
# ============================================================
FROM python:3.12-slim AS builder
# Outils de build (resteront dans le stage builder UNIQUEMENT)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Installer les dépendances dans un répertoire isolé
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# ============================================================
# Stage 2 : RUNNER (image finale légère)
# ============================================================
FROM python:3.12-slim AS runner
# Créer un utilisateur non-root (sécurité)
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /app
# Copier UNIQUEMENT les dépendances compilées depuis le builder
COPY --from=builder /root/.local /home/appuser/.local
# Copier le code source
COPY --chown=appuser:appuser . .
# Basculer sur l'utilisateur non-root
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["python", "app.py"]
# Taille finale : ~80-120 MB (sans GCC, sans cache pip)
Ce qui reste dans l’image finale
Avec le multi-stage, l’image runner ne contient que :
Python (interpréteur minimal)
Les packages Python installés (sans leurs compilateurs)
Votre code source
PAS de
gcc,g++,make,libpq-dev, cache pip, fichiers temporaires…
Images minimales : Alpine, distroless, scratch#
Alpine Linux#
Alpine est une distribution Linux minimaliste (~5 MB) très utilisée comme base d’image Docker. Elle utilise musl libc et apk comme gestionnaire de paquets.
FROM python:3.12-alpine
# apk au lieu de apt-get
RUN apk add --no-cache gcc musl-dev libpq-dev
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Alpine : avantages et pièges
Avantages : image très légère (~45 MB pour Python), surface d’attaque réduite.
Pièges : Alpine utilise musl libc au lieu de glibc. Certains packages Python avec des extensions C (numpy, pandas…) peuvent être plus lents à compiler voire incompatibles. Les wheels binaires PyPI sont souvent compilés pour glibc. En pratique, python:3.12-slim (basé Debian slim) est souvent un meilleur compromis.
Distroless#
Les images distroless de Google contiennent uniquement l’exécutable et ses dépendances runtime — pas de shell, pas d’outils système, pas de gestionnaire de paquets.
# Multi-stage avec image distroless
FROM python:3.12-slim AS builder
# ... (compilation, installation des dépendances) ...
FROM gcr.io/distroless/python3
COPY --from=builder /app /app
COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12
WORKDIR /app
CMD ["app.py"]
# Avantage : aucun shell disponible → exploitation plus difficile
# En cas d'intrusion, l'attaquant ne peut pas exécuter bash/sh
Scratch (image vide)#
# Pour les binaires Go ou Rust : compilation statique + image vide
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o serveur .
FROM scratch
COPY --from=builder /app/serveur /serveur
# L'image finale ne contient QUE le binaire compilé statiquement
# Taille : ~10-15 MB
EXPOSE 8080
CMD ["/serveur"]
L’ordre des layers : optimiser le cache#
Le cache de build Docker fonctionne couche par couche. Si une instruction change, toutes les couches suivantes sont invalidées. L’ordre des instructions est donc crucial.
Sécurité dans le Dockerfile#
Utilisateur non-root#
Par défaut, Docker exécute le processus principal en tant que root dans le conteneur. C’est une mauvaise pratique de sécurité : si l’application est compromise et qu’une faille d’évasion de conteneur existe, l’attaquant obtient des droits root sur l’hôte.
# Créer un utilisateur dédié
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Changer le propriétaire des fichiers
COPY --chown=appuser:appgroup . /app
# Basculer sur cet utilisateur pour toutes les instructions suivantes
USER appuser
# Les RUN, CMD, ENTRYPOINT suivants s'exécutent en tant qu'appuser
CMD ["python", "app.py"]
Système de fichiers en lecture seule#
# Dans le Dockerfile, on peut signaler l'intention de rendre le FS read-only
# mais c'est surtout appliqué au lancement avec docker run
# Au lancement : système de fichiers read-only
# L'application ne peut plus écrire dans son propre filesystem
docker run --read-only \
--tmpfs /tmp \ # Exception : /tmp reste en écriture (en RAM)
--tmpfs /var/run \ # Exception : sockets, PID files
mon-image
Réduction des capabilities Linux#
# Retirer TOUTES les capabilities et n'en ajouter que les strictement nécessaires
docker run --cap-drop ALL \
--cap-add NET_BIND_SERVICE \ # Pour écouter sur les ports < 1024
mon-image
Secrets au build : BuildKit#
Ne jamais mettre de secrets dans un Dockerfile
Les secrets dans les variables d’environnement ENV, dans les ARG ou copiés dans une couche sont permanents dans l’image. Même supprimés dans une couche ultérieure, ils restent dans les métadonnées de l’image.
# ❌ À NE JAMAIS FAIRE
ARG API_KEY
ENV API_KEY=${API_KEY} # Visible dans docker history !
RUN curl -H "Authorization: ${API_KEY}" https://api.exemple.com
# ✓ Utiliser les secrets BuildKit
RUN --mount=type=secret,id=api_key \
API_KEY=$(cat /run/secrets/api_key) \
curl -H "Authorization: $API_KEY" https://api.exemple.com
# Le secret n'apparaît PAS dans l'image finale
# Fournir le secret au build sans l'inclure dans l'image
DOCKER_BUILDKIT=1 docker build \
--secret id=api_key,src=./secrets/api_key.txt \
-t mon-image .
HEALTHCHECK#
Le HEALTHCHECK indique à Docker comment vérifier si votre application est réellement fonctionnelle (pas seulement si le processus tourne).
# Syntaxe complète
HEALTHCHECK \
--interval=30s \ # Vérifier toutes les 30 secondes
--timeout=5s \ # Timeout si la commande prend plus de 5s
--start-period=10s \ # Grace period au démarrage
--retries=3 \ # 3 échecs consécutifs → unhealthy
CMD curl -f http://localhost:8000/health || exit 1
# Pour une application Python sans curl
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "
import urllib.request, sys
try:
urllib.request.urlopen('http://localhost:8000/health')
sys.exit(0)
except:
sys.exit(1)
"
# Voir l'état de santé d'un conteneur
docker ps
# STATUS: Up 2 minutes (healthy)
# STATUS: Up 30 seconds (starting)
# STATUS: Up 5 minutes (unhealthy)
docker inspect --format='{{json .State.Health}}' mon-conteneur
.dockerignore : contrôler le contexte de build#
Quand vous lancez docker build ., Docker envoie le contexte de build (le répertoire courant) au daemon Docker. Sans .dockerignore, tout votre projet est envoyé — y compris node_modules (des centaines de MB), .git, les credentials…
# .dockerignore — fichier à placer à la racine du projet
# Contrôle de version
.git
.gitignore
.github/
# Dépendances (seront réinstallées dans l'image)
node_modules/
__pycache__/
*.pyc
*.pyo
.venv/
venv/
env/
# Fichiers de développement
.env
.env.*
*.env
secrets/
*.key
*.pem
# Documentation et tests (pas nécessaires en production)
docs/
tests/
*.md
*.test.js
*.spec.py
# IDE et OS
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Artifacts de build
dist/
build/
*.egg-info/
BuildKit : le moteur de build moderne#
BuildKit est le moteur de build nouvelle génération de Docker, activé par défaut depuis Docker 23.0.
# Activer BuildKit (versions antérieures à Docker 23)
export DOCKER_BUILDKIT=1
docker build .
# Ou en permanence dans /etc/docker/daemon.json :
# { "features": { "buildkit": true } }
Fonctionnalités clés de BuildKit :
# Cache persistant pour pip (ne re-télécharge pas si requirements.txt n'a pas changé)
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Cache pour apt
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update && apt-get install -y gcc
# Build parallèle : deux stages FROM peuvent se construire en parallèle
# BuildKit détecte les dépendances et parallélise automatiquement
Simulation Python : analyse des layers et du .dockerignore#
import fnmatch
import os
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class LayerSimulateur:
"""Simule le calcul de la taille d'une image Docker par accumulation de layers."""
nom: str
layers: List[dict] = field(default_factory=list)
def ajouter_layer(self, instruction: str, taille_mb: float, description: str = ""):
self.layers.append({
"instruction": instruction,
"taille": taille_mb,
"cumul": sum(l["taille"] for l in self.layers) + taille_mb,
"description": description,
})
return self
def taille_totale(self) -> float:
return sum(l["taille"] for l in self.layers)
def rapport(self):
print(f"\n{'='*60}")
print(f"Image : {self.nom}")
print(f"{'='*60}")
print(f"{'Instruction':<35} {'Couche':>8} {'Cumulé':>8}")
print("-" * 55)
for l in self.layers:
print(f"{l['instruction']:<35} {l['taille']:>6.1f}MB {l['cumul']:>6.1f}MB"
+ (f" ← {l['description']}" if l['description'] else ""))
print("-" * 55)
print(f"{'TOTAL':<35} {self.taille_totale():>6.1f} MB")
# Image non optimisée
sans_multi = LayerSimulateur("python-app:sans-optimisation")
sans_multi.ajouter_layer("FROM python:3.12", 350, "image de base")
sans_multi.ajouter_layer("RUN apt-get install gcc g++ make", 280, "compilateurs (inutiles en prod)")
sans_multi.ajouter_layer("COPY . .", 45, "tout le projet (sans .dockerignore !)")
sans_multi.ajouter_layer("RUN pip install -r requirements.txt", 380, "dépendances + cache pip")
sans_multi.ajouter_layer("CMD python app.py", 0, "configuration")
sans_multi.rapport()
# Image optimisée
avec_multi = LayerSimulateur("python-app:optimise")
avec_multi.ajouter_layer("FROM python:3.12-slim (runner)", 95, "image de base slim")
avec_multi.ajouter_layer("COPY --from=builder .local", 85, "seulement les packages installés")
avec_multi.ajouter_layer("COPY . . (avec .dockerignore)", 3, "code source uniquement")
avec_multi.ajouter_layer("USER appuser + CMD", 0, "config sécurité")
avec_multi.rapport()
reduction = sans_multi.taille_totale() - avec_multi.taille_totale()
pct = (1 - avec_multi.taille_totale() / sans_multi.taille_totale()) * 100
print(f"\nRéduction : {reduction:.0f} MB ({pct:.0f}% plus légère)")
============================================================
Image : python-app:sans-optimisation
============================================================
Instruction Couche Cumulé
-------------------------------------------------------
FROM python:3.12 350.0MB 350.0MB ← image de base
RUN apt-get install gcc g++ make 280.0MB 630.0MB ← compilateurs (inutiles en prod)
COPY . . 45.0MB 675.0MB ← tout le projet (sans .dockerignore !)
RUN pip install -r requirements.txt 380.0MB 1055.0MB ← dépendances + cache pip
CMD python app.py 0.0MB 1055.0MB ← configuration
-------------------------------------------------------
TOTAL 1055.0 MB
============================================================
Image : python-app:optimise
============================================================
Instruction Couche Cumulé
-------------------------------------------------------
FROM python:3.12-slim (runner) 95.0MB 95.0MB ← image de base slim
COPY --from=builder .local 85.0MB 180.0MB ← seulement les packages installés
COPY . . (avec .dockerignore) 3.0MB 183.0MB ← code source uniquement
USER appuser + CMD 0.0MB 183.0MB ← config sécurité
-------------------------------------------------------
TOTAL 183.0 MB
Réduction : 872 MB (83% plus légère)
class DockerIgnoreSimulateur:
"""Simule le filtrage des fichiers par .dockerignore."""
def __init__(self, patterns: List[str]):
self.patterns = [p.strip() for p in patterns if p.strip() and not p.strip().startswith("#")]
def est_ignore(self, chemin: str) -> bool:
"""Retourne True si le chemin est ignoré par .dockerignore."""
for pattern in self.patterns:
# Correspondance directe ou par glob
if fnmatch.fnmatch(chemin, pattern):
return True
if fnmatch.fnmatch(os.path.basename(chemin), pattern):
return True
# Répertoires
if chemin.startswith(pattern.rstrip("/") + "/"):
return True
return False
def analyser_projet(self, fichiers: List[tuple]) -> dict:
"""Analyse un projet et retourne les fichiers inclus/exclus avec leurs tailles."""
inclus = []
exclus = []
for chemin, taille_kb in fichiers:
if self.est_ignore(chemin):
exclus.append((chemin, taille_kb))
else:
inclus.append((chemin, taille_kb))
return {
"inclus": inclus,
"exclus": exclus,
"taille_inclus_kb": sum(t for _, t in inclus),
"taille_exclus_kb": sum(t for _, t in exclus),
}
# Simulation d'un projet Python typique
patterns_dockerignore = [
".git", ".gitignore", ".github/", "node_modules/",
"__pycache__/", "*.pyc", ".env", ".env.*",
"venv/", ".venv/", "tests/", "docs/", "*.md",
".vscode/", ".idea/", ".DS_Store",
"dist/", "build/", "*.egg-info/",
]
fichiers_projet = [
("app.py", 12),
("requirements.txt", 2),
("Dockerfile", 1),
("README.md", 15), # sera ignoré
(".env", 1), # sera ignoré
(".git/objects/...", 8500), # sera ignoré
("venv/lib/python3.12/", 95000), # sera ignoré
("__pycache__/app.cpython-312.pyc", 35), # sera ignoré
("tests/test_app.py", 25), # sera ignoré
("docs/api.md", 80), # sera ignoré
("static/styles.css", 15),
("templates/index.html", 8),
("config/prod.yaml", 3),
(".vscode/settings.json", 2), # sera ignoré
("src/models.py", 45),
("src/utils.py", 20),
]
sim = DockerIgnoreSimulateur(patterns_dockerignore)
resultat = sim.analyser_projet(fichiers_projet)
print("Analyse du contexte de build Docker")
print("=" * 50)
print(f"\nFichiers INCLUS dans le contexte ({len(resultat['inclus'])} fichiers) :")
for chemin, taille in resultat["inclus"]:
print(f" ✓ {chemin:<45} {taille:>8} KB")
print(f"\nFichiers EXCLUS par .dockerignore ({len(resultat['exclus'])} fichiers) :")
for chemin, taille in resultat["exclus"]:
print(f" ✗ {chemin:<45} {taille:>8} KB")
total_inclus = resultat["taille_inclus_kb"]
total_exclus = resultat["taille_exclus_kb"]
total = total_inclus + total_exclus
print(f"\nRésumé :")
print(f" Contexte envoyé au daemon : {total_inclus / 1024:.1f} MB")
print(f" Économie grâce au .dockerignore : {total_exclus / 1024:.1f} MB")
print(f" Réduction du contexte : {total_exclus / total * 100:.0f}%")
Analyse du contexte de build Docker
==================================================
Fichiers INCLUS dans le contexte (8 fichiers) :
✓ app.py 12 KB
✓ requirements.txt 2 KB
✓ Dockerfile 1 KB
✓ static/styles.css 15 KB
✓ templates/index.html 8 KB
✓ config/prod.yaml 3 KB
✓ src/models.py 45 KB
✓ src/utils.py 20 KB
Fichiers EXCLUS par .dockerignore (8 fichiers) :
✗ README.md 15 KB
✗ .env 1 KB
✗ .git/objects/... 8500 KB
✗ venv/lib/python3.12/ 95000 KB
✗ __pycache__/app.cpython-312.pyc 35 KB
✗ tests/test_app.py 25 KB
✗ docs/api.md 80 KB
✗ .vscode/settings.json 2 KB
Résumé :
Contexte envoyé au daemon : 0.1 MB
Économie grâce au .dockerignore : 101.2 MB
Réduction du contexte : 100%
Checklist Dockerfile de production#
Checklist Dockerfile optimisé
Taille et performance :
Utiliser une image de base minimale (
slimou Alpine)Multi-stage build si compilation nécessaire
.dockerignoreconfiguré correctementRUNcombinés avec&&pour réduire le nombre de layers--no-cachepour apt,--no-cache-dirpour pipCache mount BuildKit pour pip/apt si builds fréquents
Sécurité :
Utilisateur non-root (
useradd+USER)Pas de secrets dans les variables d’environnement
Utiliser
--secretBuildKit pour les credentials de buildImage de base mise à jour régulièrement
Fiabilité :
HEALTHCHECKconfiguréENTRYPOINT+CMDdistincts (entrypoint = exécutable, cmd = arguments par défaut)WORKDIRexplicite (pas de chemins relatifs flottants)
Points clés à retenir#
Le multi-stage build est la technique la plus efficace pour réduire la taille des images : l’image finale ne contient que le runtime, pas les outils de compilation
L’ordre des instructions dans le Dockerfile détermine l’efficacité du cache : les fichiers stables (
requirements.txt) avant les fichiers qui changent souvent (code source)Ne jamais mettre de secrets dans les
ENV,ARGouCOPY— utiliser les secrets BuildKit (--mount=type=secret)Le
.dockerignoreréduit drastiquement la taille du contexte de build et évite d’envoyer des données sensibles au daemon DockerHEALTHCHECKest essentiel pour que Docker (et les orchestrateurs) sachent si votre application est réellement opérationnelleBuildKit offre le parallélisme, le cache persistant et les secrets de build — l’activer systématiquement