Images Docker#

Si un conteneur est un processus en cours d’exécution, une image Docker en est le plan de construction — le modèle immuable à partir duquel on instancie autant de conteneurs qu’on veut. Comprendre la structure d’une image est essentiel pour écrire de bons Dockerfiles, optimiser les temps de build et réduire les tailles des images en 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 hashlib
import json

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

Anatomie d’une image#

Pensez à une image Docker comme à un millefeuille : une succession de couches fines, chacune ne contenant que les différences par rapport à la couche inférieure. Ces couches sont stockées sous forme de tarballs compressés, chacune identifiée par un hash SHA256 de son contenu — son digest.

Le manifest#

Chaque image possède un manifest : un fichier JSON qui répertorie ses couches dans l’ordre et référence la configuration de l’image.

# Exemple de manifest OCI (simplifié) — tel que Docker le stocke sur le registre
manifest_exemple = {
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7682,
        "digest": "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 29125632,
            "digest": "sha256:2408cc74d12b6cd092bb8b516ba7d5e290f485d3eb9672efc00f0583730179e8"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 45678901,
            "digest": "sha256:a1f58c7e2b3d4a5f6c7e8b9d0a1f2c3e4d5a6f7c8e9b0a1f2c3d4e5f6a7b8c9"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 3456789,
            "digest": "sha256:b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3"
        }
    ]
}

print("Manifest de l'image (format OCI v2) :")
print("-" * 50)
print(f"  Nombre de couches : {len(manifest_exemple['layers'])}")
total_bytes = sum(l["size"] for l in manifest_exemple["layers"])
print(f"  Taille totale compressée : {total_bytes / 1024 / 1024:.1f} MB")
print()
for i, layer in enumerate(manifest_exemple["layers"]):
    digest_court = layer["digest"][:19] + "..."
    taille_mb = layer["size"] / 1024 / 1024
    print(f"  Couche {i} : {digest_court}  ({taille_mb:.1f} MB)")
Manifest de l'image (format OCI v2) :
--------------------------------------------------
  Nombre de couches : 3
  Taille totale compressée : 74.6 MB

  Couche 0 : sha256:2408cc74d12b...  (27.8 MB)
  Couche 1 : sha256:a1f58c7e2b3d...  (43.6 MB)
  Couche 2 : sha256:b2c3d4e5f6a7...  (3.3 MB)

La configuration JSON#

En plus du manifest, chaque image possède une configuration JSON qui décrit tout ce qui doit se passer au lancement du conteneur.

config_exemple = {
    "architecture": "amd64",
    "os": "linux",
    "config": {
        "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
                "LANG=C.UTF-8",
                "PYTHONUNBUFFERED=1"],
        "Cmd": ["python3", "app/main.py"],
        "WorkingDir": "/app",
        "ExposedPorts": {"8000/tcp": {}},
        "User": "appuser",
    },
    "history": [
        {"created_by": "FROM ubuntu:22.04",         "empty_layer": False},
        {"created_by": "RUN apt-get install python3", "empty_layer": False},
        {"created_by": "WORKDIR /app",               "empty_layer": True},
        {"created_by": "COPY requirements.txt .",    "empty_layer": False},
        {"created_by": "RUN pip install -r requirements.txt", "empty_layer": False},
        {"created_by": "COPY . .",                   "empty_layer": False},
        {"created_by": "EXPOSE 8000",                "empty_layer": True},
        {"created_by": "CMD [\"python3\", \"app/main.py\"]", "empty_layer": True},
    ]
}

print("Configuration de l'image :")
print(f"  Plateforme : {config_exemple['os']}/{config_exemple['architecture']}")
print(f"  Répertoire de travail : {config_exemple['config']['WorkingDir']}")
print(f"  Commande par défaut : {config_exemple['config']['Cmd']}")
print(f"  Utilisateur : {config_exemple['config']['User']}")
print(f"  Ports exposés : {list(config_exemple['config']['ExposedPorts'].keys())}")
print()
print("Historique des couches :")
couches_reelles = [h for h in config_exemple["history"] if not h["empty_layer"]]
couches_vides = [h for h in config_exemple["history"] if h["empty_layer"]]
print(f"  Couches avec données : {len(couches_reelles)}")
print(f"  Instructions sans couche (metadata) : {len(couches_vides)}")
for h in config_exemple["history"]:
    signe = "  +" if not h["empty_layer"] else "  ·"
    print(f"{signe} {h['created_by']}")
Configuration de l'image :
  Plateforme : linux/amd64
  Répertoire de travail : /app
  Commande par défaut : ['python3', 'app/main.py']
  Utilisateur : appuser
  Ports exposés : ['8000/tcp']

Historique des couches :
  Couches avec données : 5
  Instructions sans couche (metadata) : 3
  + FROM ubuntu:22.04
  + RUN apt-get install python3
  · WORKDIR /app
  + COPY requirements.txt .
  + RUN pip install -r requirements.txt
  + COPY . .
  · EXPOSE 8000
  · CMD ["python3", "app/main.py"]

Le Dockerfile instruction par instruction#

Un Dockerfile est un fichier texte qui décrit, étape par étape, comment construire une image. Chaque instruction (sauf quelques exceptions) crée une nouvelle couche dans l’image finale.

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 11))
ax.set_xlim(0, 14)
ax.set_ylim(0, 13)
ax.axis("off")
ax.set_title("Instructions Dockerfile — référence visuelle", fontsize=14, fontweight="bold", pad=12)

instructions = [
    # (instruction, couleur, crée couche, description, exemple)
    ("FROM",        "#1565c0", True,  "Image de base — toujours la première instruction",
     "FROM python:3.12-slim"),
    ("RUN",         "#2e7d32", True,  "Exécute une commande shell pendant le build",
     "RUN apt-get update && apt-get install -y curl"),
    ("COPY",        "#6a1b9a", True,  "Copie des fichiers depuis le contexte de build",
     "COPY requirements.txt /app/"),
    ("ADD",         "#4527a0", True,  "Comme COPY + extraction tar + URLs (préférer COPY)",
     "ADD app.tar.gz /app/"),
    ("WORKDIR",     "#00838f", False, "Définit le répertoire de travail courant",
     "WORKDIR /app"),
    ("ENV",         "#ef6c00", False, "Définit une variable d'environnement (persistante)",
     "ENV PYTHONUNBUFFERED=1"),
    ("ARG",         "#e65100", False, "Variable de build uniquement (non persistée)",
     "ARG VERSION=1.0"),
    ("EXPOSE",      "#558b2f", False, "Documente le port écouté (ne publie pas !)",
     "EXPOSE 8000"),
    ("VOLUME",      "#37474f", False, "Déclare un point de montage de volume",
     "VOLUME /data"),
    ("USER",        "#c62828", False, "Change l'utilisateur courant (sécurité !)",
     "USER appuser"),
    ("HEALTHCHECK", "#0277bd", False, "Vérifie périodiquement la santé du conteneur",
     "HEALTHCHECK CMD curl -f http://localhost:8000/health"),
    ("CMD",         "#6d4c41", False, "Commande par défaut (surchargeable au run)",
     'CMD ["python3", "-m", "uvicorn", "main:app"]'),
    ("ENTRYPOINT",  "#4e342e", False, "Point d'entrée fixe (CMD devient les arguments)",
     'ENTRYPOINT ["python3", "-m", "gunicorn"]'),
]

cols = 2
col_w = 6.8
row_h = 0.85
margin_x = 0.2
margin_y = 0.3

for idx, (instr, color, new_layer, desc, ex) in enumerate(instructions):
    row = idx // cols
    col = idx % cols
    x = margin_x + col * col_w
    y = 12.5 - row * row_h - margin_y

    # Fond
    bg = FancyBboxPatch((x, y - row_h + 0.05), col_w - 0.2, row_h - 0.1,
                        boxstyle="round,pad=0.07", facecolor=color,
                        edgecolor="white", linewidth=1.5, alpha=0.88)
    ax.add_patch(bg)

    # Badge "nouvelle couche"
    badge_color = "#ffeb3b" if new_layer else "#90a4ae"
    badge_text  = "couche" if new_layer else "métadonnée"
    badge = FancyBboxPatch((x + col_w - 1.5, y - row_h * 0.6), 1.25, 0.32,
                           boxstyle="round,pad=0.04",
                           facecolor=badge_color, edgecolor="gray", linewidth=1)
    ax.add_patch(badge)
    ax.text(x + col_w - 0.875, y - row_h * 0.44,
            badge_text, ha="center", va="center", fontsize=6.5,
            fontweight="bold", color="#212121")

    ax.text(x + 0.15, y - 0.12, instr, ha="left", va="center",
            color="white", fontsize=10, fontweight="bold",
            fontfamily="monospace")
    ax.text(x + 0.15, y - 0.42, desc, ha="left", va="center",
            color="white", fontsize=7.5)
    ax.text(x + 0.15, y - 0.68, ex, ha="left", va="center",
            color="#ffe082", fontsize=7, fontfamily="monospace")

plt.tight_layout()
plt.savefig("_static/02_dockerfile_instructions.png", dpi=130, bbox_inches="tight")
plt.show()
_images/53a2765604b12e4af5d0b4fae0bc2a39e1cd592ec40f37ea2261e569b3cac827.png

CMD vs ENTRYPOINT : la distinction cruciale#

C’est l’une des sources de confusion les plus fréquentes chez les débutants. Voici la règle simple :

  • ENTRYPOINT définit l”exécutable du conteneur. Il est difficile à remplacer (il faut --entrypoint).

  • CMD définit les arguments par défaut. Il est facile à remplacer (on les passe directement après docker run <image>).

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 6.5))
ax.set_xlim(0, 13)
ax.set_ylim(0, 7)
ax.axis("off")
ax.set_title("CMD vs ENTRYPOINT — comportement selon la configuration", fontsize=13, fontweight="bold", pad=12)

cas = [
    # (titre, dockerfile, commande_run, commande_executee, couleur)
    ("Seulement CMD",
     'CMD ["python3", "app.py"]',
     "docker run monimage",
     "→ python3 app.py",
     "#1565c0"),
    ("Seulement CMD\n(avec override)",
     'CMD ["python3", "app.py"]',
     "docker run monimage bash",
     "→ bash   (CMD ignoré)",
     "#1565c0"),
    ("Seulement ENTRYPOINT",
     'ENTRYPOINT ["python3"]',
     "docker run monimage app.py",
     "→ python3 app.py",
     "#2e7d32"),
    ("ENTRYPOINT + CMD\n(usage recommandé)",
     'ENTRYPOINT ["python3"]\nCMD ["app.py"]',
     "docker run monimage",
     "→ python3 app.py",
     "#6a1b9a"),
    ("ENTRYPOINT + CMD\n(override CMD seul)",
     'ENTRYPOINT ["python3"]\nCMD ["app.py"]',
     "docker run monimage autre.py",
     "→ python3 autre.py",
     "#6a1b9a"),
]

row_h = 1.1
for i, (titre, dockerfile, cmd_run, result, color) in enumerate(cas):
    y = 6.5 - i * row_h
    # Titre
    ax.text(0.1, y, titre, ha="left", va="top", fontsize=8.5,
            fontweight="bold", color=color)
    # Dockerfile
    df_box = FancyBboxPatch((2.2, y - row_h + 0.1), 3.5, row_h - 0.15,
                            boxstyle="round,pad=0.07", facecolor=color,
                            edgecolor="white", linewidth=1.5, alpha=0.85)
    ax.add_patch(df_box)
    ax.text(3.95, y - row_h * 0.5, dockerfile, ha="center", va="center",
            color="white", fontsize=8, fontfamily="monospace")

    # Commande run
    run_box = FancyBboxPatch((6.0, y - row_h + 0.1), 4.0, row_h - 0.15,
                             boxstyle="round,pad=0.07", facecolor="#37474f",
                             edgecolor="white", linewidth=1.5, alpha=0.9)
    ax.add_patch(run_box)
    ax.text(8.0, y - row_h * 0.5, cmd_run, ha="center", va="center",
            color="#ffe082", fontsize=7.5, fontfamily="monospace")

    # Résultat
    ax.text(10.2, y - row_h * 0.5, result, ha="left", va="center",
            color="#212121", fontsize=8.5, fontweight="bold")

# En-têtes
for x, label in [(1.2, "Cas"), (3.95, "Dockerfile"), (8.0, "docker run"), (11.0, "Exécuté")]:
    ax.text(x, 6.8, label, ha="center", va="center", fontsize=9,
            fontweight="bold", color="#546e7a")

ax.axhline(y=6.65, color="#90a4ae", linewidth=1, xmin=0.0, xmax=1.0)

plt.tight_layout()
plt.savefig("_static/02_cmd_entrypoint.png", dpi=130, bbox_inches="tight")
plt.show()
_images/fde42358f579392bd397a06ff2bcadc5d3d007a38a5928e7fda7ad33e06a681e.png

Règle pratique pour CMD et ENTRYPOINT

Utilisez la combinaison ENTRYPOINT + CMD pour les images « outils » où l’exécutable est fixe mais les arguments varient. Par exemple, une image backup-tool avec ENTRYPOINT ["backup"] et CMD ["--help"] : par défaut elle affiche l’aide, mais docker run backup-tool --target /data --s3 bucket/path passe des arguments réels.

Pour les services (serveur web, base de données), CMD seul suffit souvent.

Le cache de build#

Docker est intelligent : il ne reexécute pas les instructions dont le résultat n’a pas changé depuis le dernier build. Ce mécanisme de cache peut réduire un build de plusieurs minutes à quelques secondes.

La règle du cache est simple mais importante : dès qu’une couche est invalidée, toutes les couches suivantes le sont aussi.

Hide code cell source

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

# ── Dockerfile mal ordonné ────────────────────────────────────────────────────
ax = axes[0]
ax.set_xlim(0, 6)
ax.set_ylim(0, 10)
ax.axis("off")
ax.set_title("Ordre sous-optimal\n(cache invalidé souvent)", fontsize=11, fontweight="bold")

etapes_mauvais = [
    ("FROM python:3.12-slim",          "#37474f", "cache ✓",  "#a5d6a7"),
    ("COPY . /app",                    "#d32f2f", "cache ✗",  "#ef9a9a"),
    ("(le code change souvent !)",     "#d32f2f", "",          "none"),
    ("RUN pip install -r requirements", "#d32f2f", "cache ✗",  "#ef9a9a"),
    ("(réinstallé à chaque COPY !)",   "#d32f2f", "",          "none"),
    ("CMD [\"python3\", \"app.py\"]",  "#d32f2f", "cache ✗",  "#ef9a9a"),
]

for i, (label, color, cache_status, cache_color) in enumerate(etapes_mauvais):
    if label.startswith("("):
        ax.text(0.3, 9.2 - i * 1.3, label, ha="left", va="center",
                fontsize=8, color=color, style="italic")
        continue
    y = 9.2 - i * 1.3
    box = FancyBboxPatch((0.2, y - 0.4), 3.8, 0.8, boxstyle="round,pad=0.07",
                         facecolor=color, edgecolor="white", linewidth=1.5, alpha=0.9)
    ax.add_patch(box)
    ax.text(2.1, y, label, ha="center", va="center", color="white",
            fontsize=8, fontfamily="monospace")
    if cache_status:
        badge = FancyBboxPatch((4.1, y - 0.25), 1.5, 0.5, boxstyle="round,pad=0.05",
                               facecolor=cache_color, edgecolor="gray", linewidth=1)
        ax.add_patch(badge)
        ax.text(4.85, y, cache_status, ha="center", va="center", fontsize=8.5, fontweight="bold")

ax.text(3.0, 0.8, "pip install : toujours relancé\n→ build lent à chaque changement de code",
        ha="center", va="center", fontsize=8.5,
        bbox=dict(boxstyle="round,pad=0.4", facecolor="#ffebee", edgecolor="#d32f2f"))

# ── Dockerfile bien ordonné ───────────────────────────────────────────────────
ax2 = axes[1]
ax2.set_xlim(0, 6)
ax2.set_ylim(0, 10)
ax2.axis("off")
ax2.set_title("Ordre optimal\n(cache préservé au maximum)", fontsize=11, fontweight="bold")

etapes_bon = [
    ("FROM python:3.12-slim",           "#37474f", "cache ✓", "#a5d6a7"),
    ("COPY requirements.txt /app/",      "#2e7d32", "cache ✓", "#a5d6a7"),
    ("(ne change que rarement)",         "#2e7d32", "",         "none"),
    ("RUN pip install -r requirements",  "#2e7d32", "cache ✓", "#a5d6a7"),
    ("(pip servi depuis le cache !)",    "#2e7d32", "",         "none"),
    ("COPY . /app",                      "#d32f2f", "cache ✗", "#ef9a9a"),
    ("(code change → couche invalidée)", "#d32f2f", "",         "none"),
    ("CMD [\"python3\", \"app.py\"]",    "#d32f2f", "cache ✗", "#ef9a9a"),
]

for i, (label, color, cache_status, cache_color) in enumerate(etapes_bon):
    if label.startswith("("):
        ax2.text(0.3, 9.4 - i * 1.1, label, ha="left", va="center",
                 fontsize=8, color=color, style="italic")
        continue
    y = 9.4 - i * 1.1
    box = FancyBboxPatch((0.2, y - 0.4), 3.8, 0.8, boxstyle="round,pad=0.07",
                         facecolor=color, edgecolor="white", linewidth=1.5, alpha=0.9)
    ax2.add_patch(box)
    ax2.text(2.1, y, label, ha="center", va="center", color="white",
             fontsize=8, fontfamily="monospace")
    if cache_status:
        badge = FancyBboxPatch((4.1, y - 0.25), 1.5, 0.5, boxstyle="round,pad=0.05",
                               facecolor=cache_color, edgecolor="gray", linewidth=1)
        ax2.add_patch(badge)
        ax2.text(4.85, y, cache_status, ha="center", va="center", fontsize=8.5, fontweight="bold")

ax2.text(3.0, 0.8, "pip install : servi depuis le cache\n→ seules les couches code sont rebuiltées",
         ha="center", va="center", fontsize=8.5,
         bbox=dict(boxstyle="round,pad=0.4", facecolor="#e8f5e9", edgecolor="#2e7d32"))

fig.suptitle("Cache de build Docker : l'ordre des instructions compte !", fontsize=13, fontweight="bold")
plt.tight_layout()
plt.savefig("_static/02_build_cache.png", dpi=130, bbox_inches="tight")
plt.show()
_images/7eb5dfe8c204dca753e399ffb03bc0cca9a489fd0a1e71fa91a980085df54db6.png

La règle d’or est : placez ce qui change le moins souvent en premier, ce qui change souvent en dernier. Les dépendances (requirements.txt, package.json, go.mod) changent beaucoup moins fréquemment que votre code source — copiez-les séparément avant de copier le reste.

Tags, digests et immutabilité#

Une image est référencée par son tag : ubuntu:22.04, python:3.12-slim, nginx:latest. Le tag est mutable — le propriétaire d’une image peut le faire pointer vers une nouvelle version à tout moment. ubuntu:latest aujourd’hui n’est pas forcément la même image qu”ubuntu:latest demain.

Pour garantir la reproductibilité, Docker offre les digests SHA256 :

# Référencer une image par digest (immuable)
docker pull ubuntu@sha256:a0f1e2b3c4d5e6f7a0f1e2b3c4d5e6f7a0f1e2b3c4d5e6f7a0f1e2b3c4d5e6f7

# Voir le digest d'une image
docker inspect ubuntu:22.04 --format='{{.RepoDigests}}'

Un digest est calculé sur le manifest complet de l’image (toutes ses couches). Si une seule couche change d’un octet, le digest est différent. C’est une garantie cryptographique d’identité.

# Simulation du calcul de digest SHA256 d'un layer
# En réalité Docker calcule le SHA256 du tarball compressé de chaque couche

def simuler_digest_layer(contenu_layer: bytes) -> str:
    """Calcule le digest SHA256 d'un layer Docker (simulé)."""
    h = hashlib.sha256(contenu_layer)
    return f"sha256:{h.hexdigest()}"

def simuler_digest_manifest(layers_digests: list[str], config_digest: str) -> str:
    """Calcule le digest du manifest à partir des digests des couches."""
    manifest_content = json.dumps({
        "config": config_digest,
        "layers": layers_digests
    }, sort_keys=True).encode()
    return simuler_digest_layer(manifest_content)

# Simulons trois layers avec leur contenu binaire fictif
layers_contenus = [
    b"ubuntu:22.04 base filesystem tarball content [29 MB]",
    b"python3.12 binaries and standard library [~45 MB]",
    b"pip packages: fastapi uvicorn pydantic [~12 MB]",
    b"application source code: main.py, config.yml [~50 KB]",
]

print("Calcul des digests des couches :")
print("-" * 65)
layer_digests = []
noms = ["ubuntu:22.04 base", "python3.12", "pip deps", "code source"]
for nom, contenu in zip(noms, layers_contenus):
    digest = simuler_digest_layer(contenu)
    layer_digests.append(digest)
    print(f"  {nom:<20} {digest[:35]}...")

config_digest = simuler_digest_layer(b"config JSON with CMD, ENV, WORKDIR...")
manifest_digest = simuler_digest_manifest(layer_digests, config_digest)

print()
print(f"Digest du manifest (= identifiant de l'image) :")
print(f"  {manifest_digest}")
print()

# Montrons l'immutabilité : modifier 1 octet dans une couche change tout
layers_modifies = layers_contenus.copy()
layers_modifies[3] = b"application source code: main.py, config.yml [~51 KB]"  # 1 byte diff

layer_digests_v2 = [simuler_digest_layer(c) for c in layers_modifies]
manifest_digest_v2 = simuler_digest_manifest(layer_digests_v2, config_digest)

print("Après modification d'un seul octet dans la couche 'code source' :")
print(f"  Digest v1 : {manifest_digest[:50]}...")
print(f"  Digest v2 : {manifest_digest_v2[:50]}...")
print(f"  Identiques ? {manifest_digest == manifest_digest_v2}")
Calcul des digests des couches :
-----------------------------------------------------------------
  ubuntu:22.04 base    sha256:963e40ec582faea391af0bb4bfa8...
  python3.12           sha256:3b5ee607ce75ab71a6adf40c9413...
  pip deps             sha256:68441ffbbdab91d2cf22866f3f90...
  code source          sha256:f670b217570d6343d10860545383...

Digest du manifest (= identifiant de l'image) :
  sha256:6334c5b0ec38d226ee1c5f10c4d37e6328a11185abccfb5c4d8001c284ae110a

Après modification d'un seul octet dans la couche 'code source' :
  Digest v1 : sha256:6334c5b0ec38d226ee1c5f10c4d37e6328a11185abc...
  Digest v2 : sha256:f0666fb317b6ef0c1cb22fa17b339b882eeec94565f...
  Identiques ? False

Images de base : choisir la bonne fondation#

Le choix de l’image de base est l’une des décisions les plus impactantes sur la taille finale et la surface d’attaque de votre image.

Hide code cell source

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

# ── Tailles comparées ─────────────────────────────────────────────────────────
ax = axes[0]
images = {
    "scratch": 0,
    "alpine:3.19": 7,
    "distroless/base": 20,
    "debian:bookworm-slim": 74,
    "ubuntu:22.04": 77,
    "python:3.12-alpine": 52,
    "python:3.12-slim": 130,
    "python:3.12": 1020,
    "ubuntu:22.04 + python3": 130,
}
noms = list(images.keys())
tailles = list(images.values())

colors_img = []
for t in tailles:
    if t == 0:
        colors_img.append("#90a4ae")
    elif t < 30:
        colors_img.append("#a5d6a7")
    elif t < 200:
        colors_img.append("#fff176")
    else:
        colors_img.append("#ef9a9a")

bars = ax.barh(noms, tailles, color=colors_img, edgecolor="white", linewidth=1.5)
ax.set_xlabel("Taille (MB)")
ax.set_title("Tailles des images de base courantes", fontweight="bold")
ax.set_xscale("symlog", linthresh=1)

for bar, val in zip(bars, tailles):
    label = f"{val} MB" if val > 0 else "0 B"
    ax.text(max(val + 1, 1.5), bar.get_y() + bar.get_height() / 2,
            label, va="center", fontsize=8.5, fontweight="bold")

# Légende couleurs
legend_elements = [
    mpatches.Patch(color="#a5d6a7", label="< 30 MB (minimal)"),
    mpatches.Patch(color="#fff176", label="30–200 MB (raisonnable)"),
    mpatches.Patch(color="#ef9a9a", label="> 200 MB (lourd)"),
]
ax.legend(handles=legend_elements, loc="lower right", fontsize=8)

# ── Arbre de dépendances ──────────────────────────────────────────────────────
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 8)
ax2.axis("off")
ax2.set_title("Arbre de dépendances entre images", fontweight="bold")

deps = [
    # (x, y, label, couleur, parent_xy)
    (5.0, 7.2, "scratch\n(vide)", "#546e7a", None),
    (2.5, 5.8, "alpine:3.19\n(musl libc)", "#2e7d32", (5.0, 7.2)),
    (7.5, 5.8, "debian:bookworm-slim\n(glibc)", "#1565c0", (5.0, 7.2)),
    (1.2, 4.2, "python:3.12-alpine\n(52 MB)", "#558b2f", (2.5, 5.8)),
    (6.5, 4.2, "python:3.12-slim\n(130 MB)", "#1976d2", (7.5, 5.8)),
    (9.0, 4.2, "python:3.12\n(1020 MB)", "#c62828", (7.5, 5.8)),
    (1.2, 2.5, "votre-app-alpine\n(~60 MB)", "#43a047", (1.2, 4.2)),
    (6.5, 2.5, "votre-app-slim\n(~150 MB)", "#1e88e5", (6.5, 4.2)),
]

for x, y, label, color, parent in deps:
    if parent:
        ax2.annotate("", xy=(x, y + 0.4), xytext=(parent[0], parent[1] - 0.35),
                     arrowprops=dict(arrowstyle="->", color="#90a4ae", lw=1.5))
    box = FancyBboxPatch((x - 1.0, y - 0.3), 2.0, 0.7, boxstyle="round,pad=0.08",
                         facecolor=color, edgecolor="white", linewidth=1.5, alpha=0.88)
    ax2.add_patch(box)
    ax2.text(x, y + 0.05, label, ha="center", va="center", color="white",
             fontsize=7.5, fontweight="bold")

fig.suptitle("Images de base Docker : tailles et hiérarchie", fontsize=13, fontweight="bold")
plt.tight_layout()
plt.savefig("_static/02_images_base.png", dpi=130, bbox_inches="tight")
plt.show()
_images/6f040e1a1592e4e034a389d28edf6035a427cb855058aaf6941f9bfb506d9733.png

scratch est l’image vide absolue — zéro octet. Utile uniquement pour des binaires compilés statiquement (Go, Rust) qui n’ont besoin d’aucune bibliothèque système.

Alpine Linux est basé sur musl libc (au lieu de la glibc standard) et BusyBox. Très léger (7 MB) mais peut causer des incompatibilités avec certaines bibliothèques C. Souvent utilisé pour Python mais avec quelques pièges (notamment pour NumPy qui doit être recompilé).

distroless (Google) contient uniquement les bibliothèques d’exécution nécessaires, sans shell, sans gestionnaire de paquets. Excellente sécurité (pas de shell = moins de surface d’attaque) mais difficile à déboguer.

debian:slim et ubuntu offrent un bon compromis : glibc standard, outils de débogage disponibles, taille raisonnable.

Exemple complet : Dockerfile pour une app Python#

Voici un Dockerfile pédagogique pour une application Python Flask/FastAPI, commenté ligne par ligne.

Dockerfile illustratif

Le Dockerfile ci-dessous est un exemple de référence. Il sera optimisé (multi-stage, cache mount) au chapitre 5.

# ── Dockerfile pour une app FastAPI ──────────────────────────────────────────

# Image de base : python slim = python + debian minimal (sans outils de dev)
FROM python:3.12-slim

# Métadonnées (OCI labels)
LABEL org.opencontainers.image.author="Lôc Cosnier"
LABEL org.opencontainers.image.description="API FastAPI exemple"

# Variables d'environnement
# PYTHONUNBUFFERED : affiche les logs immédiatement (pas de buffering stdout)
# PYTHONDONTWRITEBYTECODE : ne génère pas de fichiers .pyc (inutiles en conteneur)
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1

# Répertoire de travail dans le conteneur
WORKDIR /app

# ÉTAPE CRITIQUE POUR LE CACHE :
# On copie uniquement requirements.txt d'abord,
# puis on installe les dépendances.
# Tant que requirements.txt ne change pas, pip install est servi depuis le cache.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Maintenant on copie le code source (change souvent → couche invalidée souvent)
COPY src/ ./src/

# Utilisateur non-root pour la sécurité
# (UID 1000 = premier utilisateur système standard sur Linux)
RUN adduser --disabled-password --gecos "" --uid 1000 appuser
USER appuser

# Documenter le port (n'ouvre pas vraiment le port — juste de la documentation)
EXPOSE 8000

# Vérification de santé : Docker testera cette commande périodiquement
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

# Commande par défaut
CMD ["python3", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Parsing et analyse d’un manifest (simulation)#

import hashlib
import json
import struct

def analyser_manifest(manifest: dict) -> None:
    """Analyse un manifest Docker et affiche des statistiques."""
    layers = manifest.get("layers", [])
    config = manifest.get("config", {})

    total_bytes = sum(l.get("size", 0) for l in layers)
    total_mb = total_bytes / 1024 / 1024

    print("Analyse du manifest Docker")
    print("=" * 55)
    print(f"  Schéma version : {manifest.get('schemaVersion', '?')}")
    print(f"  Nombre de couches : {len(layers)}")
    print(f"  Taille totale compressée : {total_mb:.1f} MB")
    print(f"  Config digest : {config.get('digest', 'N/A')[:30]}...")
    print()
    print(f"  {'#':<4} {'Taille (MB)':<14} {'Digest (abrégé)'}")
    print("  " + "-" * 48)
    for i, layer in enumerate(layers):
        taille_mb = layer.get("size", 0) / 1024 / 1024
        digest_court = layer.get("digest", "N/A")[:25] + "..."
        barre = "█" * int(taille_mb / 3)
        print(f"  {i:<4} {taille_mb:>8.1f} MB   {digest_court}  {barre}")

    # Calcul de la taille décompressée estimée (facteur ~3)
    decompresse_estime = total_mb * 3
    print()
    print(f"  Taille décompressée estimée (×3) : ~{decompresse_estime:.0f} MB")


# Manifest simulé d'une image python:3.12-slim
manifest_python_slim = {
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 8512,
        "digest": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
    },
    "layers": [
        {"size": int(29.1 * 1024 * 1024),
         "digest": "sha256:2408cc74d12b6cd092bb8b516ba7d5e290f485d3eb9672efc00f0583730179e8"},
        {"size": int(15.4 * 1024 * 1024),
         "digest": "sha256:a1f58c7e2b3d4a5f6c7e8b9d0a1f2c3e4d5a6f7c8e9b0a1f2c3d4e5f6a7b8c9"},
        {"size": int(42.7 * 1024 * 1024),
         "digest": "sha256:b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4"},
        {"size": int(7.3 * 1024 * 1024),
         "digest": "sha256:c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"},
    ]
}

analyser_manifest(manifest_python_slim)
Analyse du manifest Docker
=======================================================
  Schéma version : 2
  Nombre de couches : 4
  Taille totale compressée : 94.5 MB
  Config digest : sha256:a1b2c3d4e5f6a7b8c9d0e1f...

  #    Taille (MB)    Digest (abrégé)
  ------------------------------------------------
  0        29.1 MB   sha256:2408cc74d12b6cd092...  █████████
  1        15.4 MB   sha256:a1f58c7e2b3d4a5f6c...  █████
  2        42.7 MB   sha256:b3c4d5e6f7a8b9c0d1...  ██████████████
  3         7.3 MB   sha256:c4d5e6f7a8b9c0d1e2...  ██

  Taille décompressée estimée (×3) : ~283 MB

Hide code cell source

# Visualisation de la composition de l'image
fig, ax = plt.subplots(figsize=(10, 5))

layers_info = [
    ("debian:bookworm-slim base", 29.1, "#37474f"),
    ("Python 3.12 runtime", 15.4, "#1565c0"),
    ("pip + setuptools + wheel", 42.7, "#2e7d32"),
    ("metadata + entrypoints", 7.3, "#6a1b9a"),
]

noms_l = [l[0] for l in layers_info]
tailles_l = [l[1] for l in layers_info]
colors_l = [l[2] for l in layers_info]

bars = ax.barh(noms_l, tailles_l, color=colors_l, edgecolor="white", linewidth=2, height=0.6)
ax.set_xlabel("Taille compressée (MB)")
ax.set_title("Composition de l'image python:3.12-slim", fontweight="bold", fontsize=12)

for bar, val in zip(bars, tailles_l):
    ax.text(val + 0.5, bar.get_y() + bar.get_height() / 2,
            f"{val} MB", va="center", fontsize=10, fontweight="bold")

total = sum(tailles_l)
ax.axvline(x=total, color="#e53935", linestyle="--", linewidth=2, label=f"Total : {total:.1f} MB")
ax.legend(fontsize=10)
ax.set_xlim(0, total * 1.25)

plt.tight_layout()
plt.savefig("_static/02_composition_image.png", dpi=130, bbox_inches="tight")
plt.show()
_images/6ca99cff9037b776e98740536090eabe9cd9d386d0f5d97c2de95b3f17659fa7.png

Résumé#

  • Une image Docker est une pile de couches immuables (OverlayFS), chacune identifiée par un digest SHA256.

  • Le manifest liste les couches ; la configuration JSON décrit la commande, les variables d’environnement, le répertoire de travail.

  • Chaque instruction Dockerfile qui modifie le filesystem (FROM, RUN, COPY, ADD) crée une nouvelle couche. Les autres (ENV, EXPOSE, CMD…) ajoutent uniquement des métadonnées.

  • CMD = arguments par défaut (surchargeable) ; ENTRYPOINT = exécutable fixe. La combinaison des deux est le pattern le plus flexible.

  • Le cache de build est invalidé dès qu’une couche change. Pour en profiter au maximum : dépendances d’abord, code source ensuite.

  • Les tags sont mutables ; les digests sont immuables. En production, référencez vos images par digest.

  • Choisissez l’image de base selon vos contraintes : alpine pour la légèreté, slim pour la compatibilité, distroless pour la sécurité maximale.

Le prochain chapitre passe à la pratique : comment lancer, inspecter, déboguer et gérer des conteneurs en conditions réelles.