---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

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

```{code-cell} python
:tags: [hide-input]
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.

```{code-cell} python
# 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)")
```

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

```{code-cell} python
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']}")
```

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

```{code-cell} python
:tags: [hide-input]
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()
```

### 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>`).

```{code-cell} python
:tags: [hide-input]
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()
```

```{admonition} Règle pratique pour CMD et ENTRYPOINT
:class: tip
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**.

```{code-cell} python
:tags: [hide-input]
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()
```

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 :

```bash
# 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é.

```{code-cell} python
# 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}")
```

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

```{code-cell} python
:tags: [hide-input]
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()
```

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

```{admonition} Dockerfile illustratif
:class: note
Le Dockerfile ci-dessous est un exemple de référence. Il sera optimisé (multi-stage, cache mount) au chapitre 5.
```

```bash
# ── 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)

```{code-cell} python
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)
```

```{code-cell} python
:tags: [hide-input]
# 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()
```

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