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 ».

Hide code cell source

import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
import numpy as np
import pandas as pd
import seaborn as sns
import re
import fnmatch

sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams.update({
    "figure.dpi": 120,
    "font.family": "DejaVu Sans",
    "axes.titlesize": 13,
    "axes.labelsize": 11,
})

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…

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(14, 7))

# --- Graphique 1 : comparaison des tailles ---
ax1 = axes[0]

scenarios = [
    ("python:3.12\n(base officielle)",  1020, "#ef9a9a"),
    ("Sans multi-stage\n(+ gcc, libs)", 1180, "#e57373"),
    ("python:3.12-slim\n(base)",         150, "#a5d6a7"),
    ("Multi-stage\n(final)",              95, "#66bb6a"),
    ("Alpine-based\n(final)",             45, "#43a047"),
    ("Distroless\n(final)",               35, "#2e7d32"),
]

labels = [s[0] for s in scenarios]
tailles = [s[1] for s in scenarios]
colors  = [s[2] for s in scenarios]

bars = ax1.barh(labels, tailles, color=colors, edgecolor="white", height=0.6)
ax1.set_xlabel("Taille de l'image (MB)")
ax1.set_title("Comparaison des tailles d'images\n(application Python typique)", fontweight="bold")
ax1.set_xlim(0, 1400)

for bar, taille in zip(bars, tailles):
    ax1.text(bar.get_width() + 15, bar.get_y() + bar.get_height()/2,
             f"{taille} MB", va="center", fontsize=9, fontweight="bold")

# Zone "acceptable"
ax1.axvline(x=200, color="#1565c0", linestyle="--", linewidth=1.5, alpha=0.7)
ax1.text(205, 5.5, "200 MB\n(seuil raisonnable)", fontsize=8, color="#1565c0", va="top")

# Zone "à éviter"
ax1.axvspan(500, 1400, alpha=0.07, color="#f44336")
ax1.text(850, 5.5, "À éviter\nen production", fontsize=8, color="#c62828",
         va="top", ha="center",
         bbox=dict(boxstyle="round,pad=0.2", facecolor="#ffebee", edgecolor="#f44336"))

sns.despine(ax=ax1)

# --- Graphique 2 : diagramme du flux multi-stage ---
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 10)
ax2.axis("off")
ax2.set_title("Flux de construction Multi-Stage", fontweight="bold")

# Stage 1 : builder
builder_box = FancyBboxPatch((0.3, 5.5), 4.2, 3.8, boxstyle="round,pad=0.2",
                              facecolor="#fff3e0", edgecolor="#e65100", linewidth=2)
ax2.add_patch(builder_box)
ax2.text(2.4, 9.0, "Stage 1 — builder", ha="center", va="center",
         fontsize=10, fontweight="bold", color="#bf360c")

elements_builder = [
    (2.4, 8.2, "FROM python:3.12-slim", "#fff8e1"),
    (2.4, 7.5, "RUN apt-get install gcc...", "#ffccbc"),
    (2.4, 6.8, "COPY requirements.txt", "#fff8e1"),
    (2.4, 6.1, "RUN pip install --user", "#ffccbc"),
]
for x, y, text, bg in elements_builder:
    b = FancyBboxPatch((x - 1.8, y - 0.28), 3.6, 0.56,
                        boxstyle="round,pad=0.05", facecolor=bg,
                        edgecolor="#e65100", linewidth=1)
    ax2.add_patch(b)
    ax2.text(x, y, text, ha="center", va="center", fontsize=8, color="#bf360c")

# Stage 2 : runner
runner_box = FancyBboxPatch((5.5, 5.5), 4.2, 3.8, boxstyle="round,pad=0.2",
                             facecolor="#e8f5e9", edgecolor="#2e7d32", linewidth=2)
ax2.add_patch(runner_box)
ax2.text(7.6, 9.0, "Stage 2 — runner (final)", ha="center", va="center",
         fontsize=10, fontweight="bold", color="#1b5e20")

elements_runner = [
    (7.6, 8.2, "FROM python:3.12-slim", "#f1f8e9"),
    (7.6, 7.5, "RUN useradd appuser", "#c8e6c9"),
    (7.6, 6.8, "COPY --from=builder ...", "#a5d6a7"),
    (7.6, 6.1, "COPY code source", "#f1f8e9"),
]
for x, y, text, bg in elements_runner:
    b = FancyBboxPatch((x - 1.8, y - 0.28), 3.6, 0.56,
                        boxstyle="round,pad=0.05", facecolor=bg,
                        edgecolor="#2e7d32", linewidth=1)
    ax2.add_patch(b)
    ax2.text(x, y, text, ha="center", va="center", fontsize=8, color="#1b5e20")

# Flèche COPY --from=builder
ax2.annotate("", xy=(5.5, 6.8), xytext=(4.5, 6.8),
             arrowprops=dict(arrowstyle="->", color="#1565c0", lw=2.5))
ax2.text(5.0, 7.1, "COPY\n--from=", ha="center", va="center",
         fontsize=8, color="#1565c0", fontweight="bold")

# Image finale (en bas)
final_box = FancyBboxPatch((3.5, 0.5), 3, 2.5, boxstyle="round,pad=0.2",
                            facecolor="#e8f5e9", edgecolor="#1b5e20", linewidth=2.5)
ax2.add_patch(final_box)
ax2.text(5.0, 2.65, "Image finale", ha="center", va="center",
         fontsize=11, fontweight="bold", color="#1b5e20")
ax2.text(5.0, 2.05, "python:3.12-slim\n+ dépendances\n+ votre code", ha="center", va="center",
         fontsize=9, color="#2e7d32", linespacing=1.5)
ax2.text(5.0, 0.85, "~95 MB", ha="center", va="center",
         fontsize=12, fontweight="bold", color="#1b5e20")

# Flèche vers image finale
ax2.annotate("", xy=(5.0, 3.0), xytext=(7.6, 5.5),
             arrowprops=dict(arrowstyle="->", color="#2e7d32", lw=2))

# Symbole "poubelle" pour ce qui est jeté
ax2.text(1.5, 3.5, "GCC, make,\nbibliothèques de\ncompilation\n→ SUPPRIMÉS",
         ha="center", va="center", fontsize=8.5, color="#c62828",
         bbox=dict(boxstyle="round,pad=0.3", facecolor="#ffebee", edgecolor="#f44336"))
ax2.annotate("", xy=(2.4, 5.5), xytext=(2.0, 4.3),
             arrowprops=dict(arrowstyle="->", color="#f44336", lw=1.5, linestyle="dashed"))

plt.tight_layout()
plt.savefig("_static/05_multistage.png", dpi=130, bbox_inches="tight")
plt.show()
_images/58b5273b25b7671c08f56587885eefaf0f2784bdff2840f8ca148f82a77e888a.png

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.

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(14, 8))

def dessiner_layers(ax, titre, layers, cache_invalide_a=None):
    ax.set_xlim(0, 10)
    ax.set_ylim(0, len(layers) + 1.5)
    ax.axis("off")
    ax.set_title(titre, fontweight="bold", fontsize=11)

    layer_height = 0.75
    gap = 0.12
    invalide = False

    for i, (label, duree, couleur) in enumerate(reversed(layers)):
        idx = len(layers) - 1 - i
        y = i * (layer_height + gap) + 0.3

        if cache_invalide_a is not None and idx >= cache_invalide_a:
            invalide = True

        fc = "#ffcdd2" if invalide else couleur
        ec = "#c62828" if invalide else "#37474f"
        lw = 2 if invalide else 1

        rect = FancyBboxPatch((0.5, y), 9, layer_height,
                               boxstyle="round,pad=0.05",
                               facecolor=fc, edgecolor=ec, linewidth=lw)
        ax.add_patch(rect)

        # Icône cache
        if not invalide:
            ax.text(0.9, y + layer_height/2, "✓", fontsize=11, color="#2e7d32",
                    va="center", ha="center", fontweight="bold")
        else:
            ax.text(0.9, y + layer_height/2, "✗", fontsize=11, color="#c62828",
                    va="center", ha="center", fontweight="bold")

        ax.text(1.5, y + layer_height/2 + 0.1, label, fontsize=8.5,
                va="center", color="#212121", fontweight="bold" if invalide else "normal")
        ax.text(1.5, y + layer_height/2 - 0.15, f"~{duree}s", fontsize=7.5,
                va="center", color="#666666")

        # Barre durée
        barre_max = 8.5
        barre_len = min(duree / 120 * barre_max, barre_max)
        barre_color = "#f44336" if invalide else "#43a047"
        ax.add_patch(plt.Rectangle((8.9 - barre_len, y + 0.2), barre_len, 0.35,
                                    facecolor=barre_color, alpha=0.6))

# Mauvais ordre : COPY . . en premier
mauvais_layers = [
    ("FROM python:3.12-slim",    2,  "#e3f2fd"),
    ("RUN apt-get install gcc",  45, "#e8f5e9"),
    ("COPY . .",                  1,  "#fff9c4"),   # ← invalidé à chaque modif de code
    ("RUN pip install -r req.txt",60,"#fff9c4"),
    ("CMD python app.py",         0,  "#fff9c4"),
]

# Bon ordre : COPY requirements.txt d'abord
bon_layers = [
    ("FROM python:3.12-slim",         2,  "#e3f2fd"),
    ("RUN apt-get install gcc",       45, "#e8f5e9"),
    ("COPY requirements.txt .",        1,  "#e8f5e9"),  # stable, rarement modifié
    ("RUN pip install -r req.txt",    60, "#e8f5e9"),  # caché si requirements.txt stable
    ("COPY . .",                        1,  "#fff9c4"),  # invalidé à chaque modif de code
    ("CMD python app.py",              0,  "#c8e6c9"),  # seuls ces 2 layers sont recalculés
]

dessiner_layers(axes[0], "Mauvais ordre — cache invalidé tôt\n(COPY . . avant pip install)",
                mauvais_layers, cache_invalide_a=2)
dessiner_layers(axes[1], "Bon ordre — cache préservé au maximum\n(requirements.txt stable en couche intermédiaire)",
                bon_layers, cache_invalide_a=4)

# Légende
for ax in axes:
    ax.text(0.5, 0.02, "✓ = lu depuis le cache (rapide)   ✗ = recalculé (lent)",
            transform=ax.transAxes, fontsize=8, ha="center", color="#666666")

# Annotations
axes[0].text(5, len(mauvais_layers) * (0.75 + 0.12) + 0.7,
             "Temps total si code modifié : ~108s\n(cache invalidé dès COPY . .)",
             ha="center", va="center", fontsize=9, color="#c62828",
             bbox=dict(boxstyle="round,pad=0.3", facecolor="#ffebee", edgecolor="#f44336"))

axes[1].text(5, len(bon_layers) * (0.75 + 0.12) + 0.7,
             "Temps total si code modifié : ~1s\n(seuls les 2 derniers layers sont recalculés)",
             ha="center", va="center", fontsize=9, color="#2e7d32",
             bbox=dict(boxstyle="round,pad=0.3", facecolor="#e8f5e9", edgecolor="#2e7d32"))

plt.tight_layout()
plt.savefig("_static/05_cache_layers.png", dpi=130, bbox_inches="tight")
plt.show()
_images/5a247410fc8d32ec24ba321cfb1a3660e1d39a6dce74c6093b8e69c23583b057.png

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%

Hide code cell source

fig, ax = plt.subplots(figsize=(12, 5))
ax.set_xlim(0, 12)
ax.set_ylim(0, 6)
ax.axis("off")
ax.set_title("Impact du .dockerignore sur la taille du contexte de build", fontweight="bold")

# Sans .dockerignore
sans_ignore_total = (total_inclus + total_exclus) / 1024
avec_ignore_total = total_inclus / 1024

categories_exclues = [
    ("venv/", 95000 / 1024, "#ef9a9a"),
    (".git/", 8500 / 1024, "#ffcc80"),
    ("tests/, docs/", (25 + 80) / 1024, "#fff59d"),
    ("cache, .env, IDE", (35 + 1 + 2 + 15) / 1024, "#ce93d8"),
    ("Code source + config", total_inclus / 1024, "#a5d6a7"),
]

# Barres empilées horizontales
y = 3.5
x_pos = 0.5
for nom, taille_mb, couleur in categories_exclues:
    largeur = taille_mb / sans_ignore_total * 10.5
    ax.add_patch(FancyBboxPatch((x_pos, y - 0.4), largeur, 0.8,
                                 boxstyle="round,pad=0.02", facecolor=couleur, edgecolor="white"))
    if largeur > 0.5:
        ax.text(x_pos + largeur / 2, y, f"{taille_mb:.1f} MB", ha="center", va="center",
                fontsize=7.5, fontweight="bold")
    x_pos += largeur

ax.text(0.5, 4.6, f"Sans .dockerignore : {sans_ignore_total:.0f} MB envoyés au daemon",
        fontsize=10, color="#c62828", fontweight="bold")

# Barre avec .dockerignore
y2 = 1.5
largeur2 = avec_ignore_total / sans_ignore_total * 10.5
ax.add_patch(FancyBboxPatch((0.5, y2 - 0.4), largeur2, 0.8,
                             boxstyle="round,pad=0.02",
                             facecolor="#a5d6a7", edgecolor="#2e7d32", linewidth=2))
ax.text(0.5 + largeur2 / 2, y2, f"{avec_ignore_total:.1f} MB", ha="center", va="center",
        fontsize=9, fontweight="bold", color="#1b5e20")
ax.text(0.5 + largeur2 + 0.2, y2, f"Avec .dockerignore",
        va="center", fontsize=10, color="#2e7d32", fontweight="bold")

# Flèche économie
reduction_pct = (1 - avec_ignore_total / sans_ignore_total) * 100
ax.annotate("", xy=(0.5, 2.0), xytext=(0.5, 3.1),
            arrowprops=dict(arrowstyle="<->", color="#1565c0", lw=2))
ax.text(-0.2, 2.55, f"-{reduction_pct:.0f}%", ha="center", va="center",
        fontsize=11, color="#1565c0", fontweight="bold")

# Légende
legend_items = [(nom, coul) for nom, _, coul in categories_exclues]
legend_patches = [mpatches.Patch(facecolor=c, label=n) for n, c in legend_items]
ax.legend(handles=legend_patches, loc="upper right", fontsize=8, ncol=2)

plt.tight_layout()
plt.savefig("_static/05_dockerignore.png", dpi=130, bbox_inches="tight")
plt.show()
_images/6970db1a530aa3bac363a43be8dcaa81e2956575b63a6b71948aba3bda99db13.png

Checklist Dockerfile de production#

Checklist Dockerfile optimisé

Taille et performance :

  • Utiliser une image de base minimale (slim ou Alpine)

  • Multi-stage build si compilation nécessaire

  • .dockerignore configuré correctement

  • RUN combinés avec && pour réduire le nombre de layers

  • --no-cache pour apt, --no-cache-dir pour pip

  • Cache 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 --secret BuildKit pour les credentials de build

  • Image de base mise à jour régulièrement

Fiabilité :

  • HEALTHCHECK configuré

  • ENTRYPOINT + CMD distincts (entrypoint = exécutable, cmd = arguments par défaut)

  • WORKDIR explicite (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, ARG ou COPY — utiliser les secrets BuildKit (--mount=type=secret)

  • Le .dockerignore réduit drastiquement la taille du contexte de build et évite d’envoyer des données sensibles au daemon Docker

  • HEALTHCHECK est essentiel pour que Docker (et les orchestrateurs) sachent si votre application est réellement opérationnelle

  • BuildKit offre le parallélisme, le cache persistant et les secrets de build — l’activer systématiquement