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

# Conteneurs et virtualisation

Avant d'écrire une seule commande Docker, prenons le temps de comprendre *pourquoi* les conteneurs existent. Le problème qu'ils résolvent est vieux comme l'informatique : comment garantir qu'un programme qui fonctionne sur la machine du développeur fonctionnera aussi sur le serveur de production, sur la machine d'un collègue, ou dans six mois sur une nouvelle infrastructure ?

```{code-cell} python
:tags: [hide-input]
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.patches as patches
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
import numpy as np
import pandas as pd
import seaborn as sns

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

## L'analogie du conteneur maritime

En 1956, un transporteur américain nommé Malcolm McLean a révolutionné le commerce mondial avec une idée simple : standardiser les boîtes dans lesquelles on transporte les marchandises. Avant lui, chaque navire avait ses propres caisses, ses propres palettes, ses propres systèmes d'arrimage. Charger un bateau prenait des jours ; les dommages et pertes étaient fréquents.

Avec le conteneur maritime normalisé (20 pieds, 40 pieds), tout change. Une boîte de café du Brésil peut voyager par camion jusqu'au port de Santos, être chargée sans manipulation sur un porte-conteneurs, traverser l'Atlantique, être déchargée au Havre par une grue identique, repartir par train vers Paris — sans que personne n'ouvre la boîte, sans reconditionner quoi que ce soit.

```{note}
Le conteneur maritime repose sur trois propriétés fondamentales :
- **Standardisation** : toutes les boîtes ont les mêmes dimensions et coins de fixation.
- **Portabilité** : la même boîte fonctionne sur tous les camions, trains et navires compatibles.
- **Isolation** : le contenu de la boîte n'interagit pas avec celui des boîtes voisines.
```

Le conteneur logiciel reprend exactement ces trois propriétés. Un conteneur Docker empaquette une application et *toutes* ses dépendances (bibliothèques, fichiers de configuration, variables d'environnement) dans une unité standardisée. Cette unité tourne identiquement sur votre laptop Linux, sur un Mac avec Docker Desktop, sur un serveur cloud, sur un Raspberry Pi. Le *runtime* Docker (équivalent de la grue portuaire) sait comment démarrer n'importe quel conteneur sans connaître son contenu.

## Le problème de l'environnement

Imaginez cette situation classique : vous développez une application Python qui utilise la bibliothèque `cryptography` version 41. Sur votre machine, tout fonctionne. Vous envoyez le code à un collègue — il a `cryptography` version 38 installée globalement et obtient des erreurs d'incompatibilité. Vous déployez sur le serveur de production — le serveur tourne sous Ubuntu 20.04 avec Python 3.8 alors que vous avez Python 3.12. L'application plante pour des raisons qui n'ont rien à voir avec votre code.

Ce problème porte un nom dans le jargon : le syndrome du *"ça marche sur ma machine"* (*works on my machine*). Les conteneurs l'éliminent en empaquetant non seulement votre code, mais aussi Python 3.12, `cryptography` 41, et chaque fichier système dont l'application a besoin.

## Machines virtuelles : la première solution

Avant Docker, la solution standard à ce problème était la **machine virtuelle** (VM). Une VM émule un ordinateur complet : processeur, mémoire, disque, carte réseau. Un logiciel appelé **hyperviseur** (VMware, VirtualBox, KVM, Hyper-V) s'exécute sur la machine physique (*hôte*) et crée des machines virtuelles (*invitées*).

Chaque VM contient :
- Un **noyau (kernel)** complet de système d'exploitation (Linux, Windows...)
- Des **pilotes (drivers)** pour le matériel virtualisé
- Tous les **services système** (systemd, sshd, cron...)
- Votre application et ses dépendances

```{code-cell} python
:tags: [hide-input]
fig, axes = plt.subplots(1, 2, figsize=(14, 8))

# ── Diagramme VM ──────────────────────────────────────────────────────────────
ax = axes[0]
ax.set_xlim(0, 10)
ax.set_ylim(0, 12)
ax.axis("off")
ax.set_title("Machines Virtuelles (VM)", fontsize=14, fontweight="bold", pad=12)

# Matériel physique
hw = FancyBboxPatch((0.2, 0.1), 9.6, 1.2, boxstyle="round,pad=0.1",
                    facecolor="#37474f", edgecolor="#263238", linewidth=2)
ax.add_patch(hw)
ax.text(5, 0.7, "Matériel physique (CPU, RAM, Disque)", ha="center", va="center",
        color="white", fontsize=10, fontweight="bold")

# Hyperviseur
hyp = FancyBboxPatch((0.2, 1.5), 9.6, 1.0, boxstyle="round,pad=0.1",
                     facecolor="#f57f17", edgecolor="#e65100", linewidth=2)
ax.add_patch(hyp)
ax.text(5, 2.0, "Hyperviseur (KVM / VMware / VirtualBox)", ha="center", va="center",
        color="white", fontsize=10, fontweight="bold")

# Trois VMs
vm_colors = ["#1565c0", "#2e7d32", "#6a1b9a"]
vm_labels = ["VM 1", "VM 2", "VM 3"]
x_starts = [0.2, 3.47, 6.74]

for i, (x, col, lbl) in enumerate(zip(x_starts, vm_colors, vm_labels)):
    # OS invité
    os_box = FancyBboxPatch((x, 2.7), 3.07, 0.9, boxstyle="round,pad=0.05",
                            facecolor=col, edgecolor="black", linewidth=1.5, alpha=0.85)
    ax.add_patch(os_box)
    ax.text(x + 1.535, 3.15, "OS invité complet\n(kernel + drivers)", ha="center",
            va="center", color="white", fontsize=7.5)

    # Libs
    lib_box = FancyBboxPatch((x, 3.75), 3.07, 0.7, boxstyle="round,pad=0.05",
                             facecolor=col, edgecolor="black", linewidth=1.5, alpha=0.65)
    ax.add_patch(lib_box)
    ax.text(x + 1.535, 4.1, "Bibliothèques système", ha="center", va="center",
            color="white", fontsize=7.5)

    # App
    app_box = FancyBboxPatch((x, 4.6), 3.07, 0.7, boxstyle="round,pad=0.05",
                             facecolor=col, edgecolor="black", linewidth=1.5, alpha=0.5)
    ax.add_patch(app_box)
    ax.text(x + 1.535, 4.95, f"Application {i+1}", ha="center", va="center",
            color="white", fontsize=8, fontweight="bold")

    ax.text(x + 1.535, 5.5, lbl, ha="center", va="center",
            fontsize=9, fontweight="bold", color=col)

# Légende overhead
ax.annotate("", xy=(5, 3.5), xytext=(5, 2.6),
            arrowprops=dict(arrowstyle="<->", color="#e53935", lw=2.0))
ax.text(5.15, 3.05, "~500 MB–\n2 GB\npar VM", fontsize=7.5, color="#e53935",
        va="center")

ax.text(5, 7.2,
        "Chaque VM : kernel complet, drivers,\n"
        "démarrage 30–60 s, overhead mémoire élevé",
        ha="center", va="center", fontsize=9,
        bbox=dict(boxstyle="round,pad=0.4", facecolor="#fff9c4", edgecolor="#f9a825"))

# ── Diagramme Conteneurs ──────────────────────────────────────────────────────
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 12)
ax2.axis("off")
ax2.set_title("Conteneurs Docker", fontsize=14, fontweight="bold", pad=12)

# Matériel
hw2 = FancyBboxPatch((0.2, 0.1), 9.6, 1.2, boxstyle="round,pad=0.1",
                     facecolor="#37474f", edgecolor="#263238", linewidth=2)
ax2.add_patch(hw2)
ax2.text(5, 0.7, "Matériel physique (CPU, RAM, Disque)", ha="center", va="center",
         color="white", fontsize=10, fontweight="bold")

# OS hôte (kernel partagé)
os_host = FancyBboxPatch((0.2, 1.5), 9.6, 1.0, boxstyle="round,pad=0.1",
                          facecolor="#00695c", edgecolor="#004d40", linewidth=2)
ax2.add_patch(os_host)
ax2.text(5, 2.0, "OS hôte — Kernel Linux (partagé par tous les conteneurs)",
         ha="center", va="center", color="white", fontsize=10, fontweight="bold")

# Docker Engine
eng = FancyBboxPatch((0.2, 2.65), 9.6, 0.75, boxstyle="round,pad=0.1",
                      facecolor="#0288d1", edgecolor="#01579b", linewidth=2)
ax2.add_patch(eng)
ax2.text(5, 3.025, "Docker Engine (containerd + runc)", ha="center", va="center",
         color="white", fontsize=10, fontweight="bold")

# Trois conteneurs
ct_colors = ["#d84315", "#558b2f", "#4527a0"]
ct_labels = ["Conteneur 1", "Conteneur 2", "Conteneur 3"]
x_starts2 = [0.2, 3.47, 6.74]

for i, (x, col, lbl) in enumerate(zip(x_starts2, ct_colors, ct_labels)):
    # Libs (pas de kernel !)
    lib_box = FancyBboxPatch((x, 3.55), 3.07, 0.75, boxstyle="round,pad=0.05",
                             facecolor=col, edgecolor="black", linewidth=1.5, alpha=0.75)
    ax2.add_patch(lib_box)
    ax2.text(x + 1.535, 3.925, "Libs applicatives\nseulement", ha="center",
             va="center", color="white", fontsize=7.5)

    # App
    app_box = FancyBboxPatch((x, 4.45), 3.07, 0.75, boxstyle="round,pad=0.05",
                             facecolor=col, edgecolor="black", linewidth=1.5, alpha=0.55)
    ax2.add_patch(app_box)
    ax2.text(x + 1.535, 4.825, f"Application {i+1}", ha="center", va="center",
             color="white", fontsize=8, fontweight="bold")

    ax2.text(x + 1.535, 5.45, lbl, ha="center", va="center",
             fontsize=9, fontweight="bold", color=col)

ax2.text(5, 7.2,
         "Pas de kernel dupliqué — partage du kernel hôte\n"
         "Démarrage en millisecondes, overhead minimal\n"
         "Taille typique : 5 MB à 200 MB",
         ha="center", va="center", fontsize=9,
         bbox=dict(boxstyle="round,pad=0.4", facecolor="#e8f5e9", edgecolor="#388e3c"))

fig.suptitle("VM vs Conteneurs : architecture comparée", fontsize=15, fontweight="bold", y=0.97)
plt.tight_layout()
plt.savefig("_static/01_vm_vs_conteneurs.png", dpi=130, bbox_inches="tight")
plt.show()
```

La différence fondamentale est dans la **couche de partage**. Les VMs dupliquent intégralement le système d'exploitation : chaque VM embarque son propre kernel Linux (ou Windows) complet, ses propres pilotes, ses propres processus système. C'est robuste et offre une isolation forte, mais c'est lourd :

- Une VM Ubuntu minimale pèse **800 MB à 2 GB**
- Elle prend **30 à 60 secondes** pour démarrer
- Elle consomme de la RAM même lorsqu'elle est inactive (le kernel et les services système tournent en permanence)

Les conteneurs partagent le kernel du système hôte. Il n'y a qu'un seul kernel Linux actif sur la machine ; les conteneurs utilisent ses fonctionnalités via des appels système normaux. En contrepartie, un conteneur Docker ne peut tourner que sur un hôte Linux (ou sur Windows avec un kernel Linux fourni par Docker Desktop, ou sur macOS via une VM Linux légère cachée derrière Docker Desktop).

## Technologies Linux sous-jacentes

Docker n'a pas inventé la magie — il a assemblé de façon élégante des primitives Linux qui existaient déjà. Ces primitives sont **les namespaces** et **les cgroups**.

### Namespaces : l'isolation

Un namespace est un mécanisme kernel qui crée une *vue partielle et isolée* d'une ressource système. Quand un processus vit dans un namespace, il ne voit qu'une portion du système, distincte de ce que voient les autres processus.

Linux propose six namespaces utilisés par Docker :

```{code-cell} python
:tags: [hide-input]
fig, ax = plt.subplots(figsize=(13, 7))
ax.set_xlim(0, 14)
ax.set_ylim(0, 8)
ax.axis("off")
ax.set_title("Les 6 namespaces Linux utilisés par Docker", fontsize=14, fontweight="bold", pad=14)

namespaces = [
    ("PID", "Arbre des processus", "#e53935",
     "PID 1 dans le conteneur\n≠ PID réel sur l'hôte\nIsolation complète des\nprocessus"),
    ("NET", "Réseau", "#1e88e5",
     "Interface réseau virtuelle\npropre (eth0, lo)\nTable de routage isolée\nPorts indépendants"),
    ("MNT", "Système de fichiers", "#43a047",
     "Arbre de fichiers racine\nisolé (OverlayFS)\nMontages indépendants\nChroot évolué"),
    ("UTS", "Hostname / domaine", "#fb8c00",
     "Hostname propre au\nconteneur\n(ex: web-app-1)\nSans affecter l'hôte"),
    ("IPC", "IPC (mémoire partagée)", "#8e24aa",
     "Files de messages\nSémaphores POSIX\nMémoire partagée isolée"),
    ("USER", "Utilisateurs / UID", "#00897b",
     "UID 0 (root) dans le\nconteneur = UID 1000\nsur l'hôte\n(user namespaces)"),
]

cols = 3
rows = 2
w, h = 4.0, 3.2
margin_x, margin_y = 1.0, 0.5

for idx, (name, title, color, desc) in enumerate(namespaces):
    row = idx // cols
    col = idx % cols
    x = margin_x + col * (w + 0.3)
    y = margin_y + (rows - 1 - row) * (h + 0.3)

    box = FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.15",
                         facecolor=color, edgecolor="white", linewidth=2, alpha=0.88)
    ax.add_patch(box)

    ax.text(x + w / 2, y + h - 0.35, f"namespace {name}", ha="center", va="center",
            color="white", fontsize=11, fontweight="bold")
    ax.text(x + w / 2, y + h - 0.75, title, ha="center", va="center",
            color="white", fontsize=9, style="italic")

    ax.text(x + w / 2, y + h / 2 - 0.2, desc, ha="center", va="center",
            color="white", fontsize=8.2, linespacing=1.5)

plt.tight_layout()
plt.savefig("_static/01_namespaces.png", dpi=130, bbox_inches="tight")
plt.show()
```

**Namespace PID.** Chaque conteneur possède son propre espace de numérotation des processus. À l'intérieur d'un conteneur, le premier processus lancé a toujours le PID 1 — comme si c'était l'init du système. Depuis l'hôte, ce même processus a un PID différent (par exemple 4237). L'isolation est complète : un processus dans le conteneur ne peut pas voir les processus des autres conteneurs ou de l'hôte.

**Namespace NET.** Chaque conteneur dispose d'une pile réseau entièrement isolée : sa propre interface `eth0`, sa propre adresse IP, sa propre table de routage, ses propres règles iptables. Deux conteneurs peuvent chacun écouter sur le port 80 sans conflit car ils ont des interfaces réseau distinctes.

**Namespace MNT.** Chaque conteneur a son propre arbre de fichiers. Ce que le conteneur voit comme `/` n'est pas le `/` de l'hôte, mais un système de fichiers construit à partir des couches de l'image Docker (nous verrons cela en détail au chapitre suivant).

**Namespace UTS.** Chaque conteneur peut avoir son propre hostname. Si vous faites `hostname` dans un conteneur nommé `web-server`, vous obtiendrez le nom du conteneur, pas celui de la machine hôte.

**Namespace IPC.** Isole les mécanismes de communication inter-processus : files de messages POSIX, sémaphores, mémoire partagée. Les processus dans un conteneur ne peuvent pas accéder à la mémoire partagée des processus d'un autre conteneur.

**Namespace USER.** Permet de mapper les UIDs/GIDs du conteneur vers des UIDs différents sur l'hôte. Avec les user namespaces, le processus qui se croit `root` (UID 0) dans le conteneur peut en réalité être un utilisateur sans privilèges (UID 65534) sur l'hôte — renforçant considérablement la sécurité.

### cgroups : le contrôle des ressources

Si les namespaces s'occupent de l'**isolation** (qui voit quoi), les **cgroups** (control groups) s'occupent du **rationnement** (qui consomme combien). Les cgroups permettent de limiter, mesurer et prioriser l'utilisation des ressources système par des groupes de processus.

```{code-cell} python
:tags: [hide-input]
fig, axes = plt.subplots(1, 3, figsize=(14, 5.5))

# ── CPU ──────────────────────────────────────────────────────────────────────
ax = axes[0]
conteneurs = ["Conteneur A\n(limite 0.5 CPU)", "Conteneur B\n(limite 1 CPU)",
              "Conteneur C\n(limite 2 CPU)", "Système hôte\n(sans limite)"]
cpu_limits = [0.5, 1.0, 2.0, 4.0]
colors_cpu = ["#ef9a9a", "#ffcc80", "#a5d6a7", "#90caf9"]
bars = ax.barh(conteneurs, cpu_limits, color=colors_cpu, edgecolor="white", linewidth=1.5)
ax.set_xlabel("CPUs alloués")
ax.set_title("cgroups : Limite CPU", fontweight="bold")
ax.set_xlim(0, 5)
for bar, val in zip(bars, cpu_limits):
    ax.text(val + 0.1, bar.get_y() + bar.get_height() / 2,
            f"{val} CPU", va="center", fontsize=9, fontweight="bold")
ax.axvline(x=4, color="#e53935", linestyle="--", linewidth=1.5, label="Total CPU physiques")
ax.legend(fontsize=8)

# ── Mémoire ──────────────────────────────────────────────────────────────────
ax2 = axes[1]
conteneurs_m = ["Conteneur A\n(limite 256 MB)", "Conteneur B\n(limite 512 MB)",
                "Conteneur C\n(limite 1 GB)", "RAM totale\nhôte"]
mem_limits = [256, 512, 1024, 4096]
colors_mem = ["#ce93d8", "#80cbc4", "#ffab91", "#b0bec5"]
bars2 = ax2.barh(conteneurs_m, mem_limits, color=colors_mem, edgecolor="white", linewidth=1.5)
ax2.set_xlabel("Mémoire (MB)")
ax2.set_title("cgroups : Limite Mémoire", fontweight="bold")
for bar, val in zip(bars2, mem_limits):
    label = f"{val} MB" if val < 1024 else f"{val//1024} GB"
    ax2.text(val + 30, bar.get_y() + bar.get_height() / 2,
             label, va="center", fontsize=9, fontweight="bold")

# ── I/O ──────────────────────────────────────────────────────────────────────
ax3 = axes[2]
categs = ["Sans\ncgroups", "Avec\ncgroups"]
app_io = [100, 30]
db_io = [100, 70]
x = np.arange(len(categs))
width = 0.35
b1 = ax3.bar(x - width/2, app_io, width, label="App web (moins prioritaire)",
             color="#ef9a9a", edgecolor="white", linewidth=1.5)
b2 = ax3.bar(x + width/2, db_io, width, label="Base de données (prioritaire)",
             color="#a5d6a7", edgecolor="white", linewidth=1.5)
ax3.set_ylabel("Bande passante I/O (%)")
ax3.set_title("cgroups : Priorité I/O disque", fontweight="bold")
ax3.set_xticks(x)
ax3.set_xticklabels(categs)
ax3.legend(fontsize=8)
ax3.set_ylim(0, 120)
for bars_group in [b1, b2]:
    for bar in bars_group:
        ax3.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 2,
                 f"{bar.get_height()}%", ha="center", va="bottom", fontsize=9, fontweight="bold")

fig.suptitle("cgroups : contrôle des ressources par conteneur", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig("_static/01_cgroups.png", dpi=130, bbox_inches="tight")
plt.show()
```

Sans cgroups, n'importe quel conteneur pourrait consommer toute la RAM ou tout le CPU de la machine et affamer les autres. Avec les cgroups, on peut définir :
- `--memory=512m` : le conteneur ne peut pas utiliser plus de 512 MB de RAM
- `--cpus=0.5` : le conteneur est limité à la moitié d'un cœur CPU
- `--blkio-weight=300` : priorité d'accès disque réduite

## Union filesystems et OverlayFS

La troisième brique technologique fondamentale est l'**union filesystem**. Imaginez que vous disposez de plusieurs disques transparents (des calques) que vous pouvez empiler : le disque du dessus masque ce qui est en dessous, mais si un calque inférieur contient un fichier absent du calque supérieur, ce fichier est visible à travers. C'est exactement le principe d'OverlayFS.

Une image Docker est composée de **couches (layers)** en lecture seule, empilées. Quand vous lancez un conteneur, Docker ajoute au sommet une couche en lecture-écriture (la couche conteneur). Toutes les modifications (nouveaux fichiers, modifications, suppressions) vont dans cette couche supérieure. Les couches inférieures (l'image) restent intactes et peuvent être partagées entre plusieurs conteneurs.

```{code-cell} python
:tags: [hide-input]
fig, ax = plt.subplots(figsize=(11, 8))
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis("off")
ax.set_title("OverlayFS : architecture en couches d'une image Docker", fontsize=13, fontweight="bold", pad=12)

layers = [
    (0.5, 0.3, "#37474f", "white", "Layer 0 — Image de base : ubuntu:22.04\n(~29 MB — /bin, /lib, /usr, /etc...)", "READ ONLY"),
    (0.5, 1.8, "#1565c0", "white", "Layer 1 — RUN apt-get install python3\n(~45 MB — ajout de /usr/bin/python3...)", "READ ONLY"),
    (0.5, 3.3, "#2e7d32", "white", "Layer 2 — COPY requirements.txt + RUN pip install\n(~38 MB — /app/requirements.txt, /usr/lib/python3/...)", "READ ONLY"),
    (0.5, 4.8, "#6a1b9a", "white", "Layer 3 — COPY . /app\n(~2 MB — /app/main.py, /app/config.yml...)", "READ ONLY"),
    (0.5, 6.5, "#c62828", "white", "Couche conteneur (lecture-écriture)\nFichiers créés/modifiés pendant l'exécution\n(logs, cache, fichiers temporaires...)", "READ/WRITE"),
]

for x, y, color, tc, label, rw in layers:
    rw_color = "#ef9a9a" if rw == "READ/WRITE" else "#b0bec5"
    box = FancyBboxPatch((x, y), 8.5, 1.2, boxstyle="round,pad=0.1",
                         facecolor=color, edgecolor="white", linewidth=2, alpha=0.9)
    ax.add_patch(box)
    ax.text(x + 4.0, y + 0.6, label, ha="center", va="center",
            color=tc, fontsize=8.5, linespacing=1.4)
    badge = FancyBboxPatch((x + 7.0, y + 0.35), 1.3, 0.5, boxstyle="round,pad=0.08",
                           facecolor=rw_color, edgecolor="gray", linewidth=1)
    ax.add_patch(badge)
    ax.text(x + 7.65, y + 0.6, rw, ha="center", va="center", fontsize=6.5, fontweight="bold",
            color="#212121" if rw == "READ/WRITE" else "#424242")

# Flèche "vue unifiée"
ax.annotate("", xy=(9.3, 5.5), xytext=(9.3, 0.9),
            arrowprops=dict(arrowstyle="<->", color="#f57f17", lw=2.0))
ax.text(9.55, 3.2, "Vue\nunifiée\n(merge)", ha="left", va="center", fontsize=8,
        color="#f57f17", fontweight="bold")

# Note partage
ax.text(5, 8.8,
        "Partage des couches : deux conteneurs basés sur la même image\n"
        "partagent les layers READ ONLY — seule la couche R/W est dupliquée.",
        ha="center", va="center", fontsize=9,
        bbox=dict(boxstyle="round,pad=0.4", facecolor="#fff9c4", edgecolor="#f9a825"))

plt.tight_layout()
plt.savefig("_static/01_overlayfs.png", dpi=130, bbox_inches="tight")
plt.show()
```

Ce mécanisme apporte plusieurs avantages :
- **Partage de couches** : cent conteneurs basés sur `ubuntu:22.04` ne stockent pas cent fois l'image de base — elle est présente une seule fois sur le disque.
- **Rapidité** : créer un nouveau conteneur revient à ajouter une couche vide vierge, ce qui est quasi-instantané.
- **Immuabilité** : les modifications dans un conteneur n'affectent jamais l'image sous-jacente.

## Histoire : de chroot à containerd

La conteneurisation n'est pas apparue du jour au lendemain. C'est le résultat de quarante ans d'évolution progressive des mécanismes d'isolation.

```{code-cell} python
:tags: [hide-input]
fig, ax = plt.subplots(figsize=(14, 5))
ax.set_xlim(1978, 2028)
ax.set_ylim(-1, 4)
ax.axis("off")
ax.set_title("Histoire de la conteneurisation : 1979 → aujourd'hui", fontsize=14, fontweight="bold", pad=12)

events = [
    (1979, 0.5, "#455a64", "chroot (1979)\nIsolation du système\nde fichiers (Unix V7)"),
    (1992, 0.5, "#455a64", "FreeBSD jails\n(2000) — isolation\nprocessus complète"),
    (2002, 2.0, "#1565c0", "Linux namespaces\n(2002–2013)\nPID, NET, MNT..."),
    (2006, 0.5, "#2e7d32", "cgroups (2006)\nGoogle → Linux kernel\ncontrôle ressources"),
    (2008, 2.0, "#6a1b9a", "LXC (2008)\n1er conteneur Linux\ncomplet (libvirt)"),
    (2013, 3.2, "#d84315", "Docker (2013)\nRévolution UX\ndot Cloud → Docker Inc"),
    (2016, 0.5, "#0288d1", "containerd (2016)\nruntime standard\ndonné à la CNCF"),
    (2017, 2.0, "#558b2f", "OCI (2017)\nOpen Container Initiative\nspécs image + runtime"),
    (2019, 0.5, "#455a64", "Podman (2019)\nAlternative rootless\nRed Hat / libpod"),
    (2024, 2.0, "#1b5e20", "Aujourd'hui\ncontainerd/runc\nomniprésents"),
]

# Ligne temporelle
ax.axhline(y=1.0, color="#90a4ae", linewidth=2.5, zorder=0)

for year, y_text, color, label in events:
    ax.plot(year, 1.0, "o", color=color, markersize=10, zorder=5)
    ax.annotate("", xy=(year, 1.0), xytext=(year, y_text + (0.3 if y_text > 1 else -0.3)),
                arrowprops=dict(arrowstyle="-", color=color, lw=1.5))
    ax.text(year, y_text + (0.35 if y_text >= 1 else -0.45), label,
            ha="center", va="bottom" if y_text >= 1 else "top",
            fontsize=7.5, color=color, fontweight="bold",
            bbox=dict(boxstyle="round,pad=0.2", facecolor="white", edgecolor=color, alpha=0.9))

# Années sur la frise
for yr in range(1980, 2026, 5):
    ax.text(yr, 0.82, str(yr), ha="center", va="top", fontsize=7.5, color="#546e7a")

plt.tight_layout()
plt.savefig("_static/01_historique.png", dpi=130, bbox_inches="tight")
plt.show()
```

**1979 — chroot.** La primitive d'isolation la plus ancienne est `chroot` (*change root*), introduite dans Unix Version 7. Elle permet de changer le répertoire racine apparent d'un processus : le programme croit que `/` est en réalité `/jails/monapp/`. L'isolation est partielle (pas de namespaces réseau ou PID), mais c'est le concept fondateur.

**2000 — FreeBSD Jails.** FreeBSD introduit les "jails" : une isolation bien plus complète incluant le réseau, les processus et le système de fichiers. C'est le premier vrai système de conteneurisation, mais limité à FreeBSD.

**2002–2013 — Linux namespaces.** Le kernel Linux intègre progressivement les namespaces : mount (2002), UTS et IPC (2006), PID et réseau (2008), user (2013). Les briques sont en place.

**2006 — cgroups.** Des ingénieurs de Google (Paul Menage et Rohit Seth) développent les *process containers*, renommés *cgroups* et intégrés au kernel Linux 2.6.24 en 2008.

**2008 — LXC.** Linux Containers (LXC) combine namespaces et cgroups pour offrir le premier système de conteneurs complet sur Linux standard. C'est robuste mais complexe à utiliser.

**2013 — Docker.** Solomon Hykes présente Docker lors de la PyCon 2013. La révolution n'est pas technique — les briques existaient — mais ergonomique : un Dockerfile simple, une commande `docker run`, un registre d'images public (Docker Hub). Docker démocratise la conteneurisation.

**2016–2017 — Standardisation.** Docker fait don de `containerd` (son runtime interne) à la CNCF. L'**OCI** (Open Container Initiative) est fondée pour standardiser le format des images et le comportement des runtimes.

**2019 — Podman.** Red Hat lance Podman, une alternative à Docker sans daemon central et sans nécessité d'être root sur la machine hôte (*rootless*). Podman est compatible avec les commandes Docker mais architecturalement différent.

## OCI : le standard ouvert

L'OCI (Open Container Initiative), fondée en 2015 sous l'égide de la Linux Foundation, a défini deux spécifications cruciales :

**Image Spec.** Définit le format d'une image de conteneur : une liste de layers (tarballs), un manifest JSON décrivant les couches et leurs digests SHA256, et une configuration JSON spécifiant la commande à lancer, les variables d'environnement, etc. N'importe quel outil (Docker, Podman, Buildah, Kaniko) qui produit une image conforme peut être exécuté par n'importe quel runtime conforme.

**Runtime Spec.** Définit le comportement d'un runtime : comment créer un conteneur à partir d'un bundle OCI (une image extraite), quels namespaces et cgroups configurer, quels hooks exécuter. `runc` est l'implémentation de référence, écrite en Go.

```{admonition} Podman : l'alternative rootless
:class: tip
Podman (Pod Manager) remplace Docker commande par commande (`alias docker=podman` fonctionne), mais sans daemon central. Chaque `podman run` lance directement un processus `conmon` qui gère le conteneur. L'avantage majeur est le mode **rootless** : un utilisateur normal peut créer et gérer des conteneurs sans jamais toucher à root, ce qui est un gain de sécurité significatif sur les serveurs partagés. Podman est particulièrement populaire dans les environnements Red Hat/Fedora/CentOS.
```

## Simulation Python : namespaces et isolation

Pour rendre concrets ces mécanismes, voici une simulation Python qui illustre comment les namespaces isolent les processus — sans avoir besoin de Docker.

```{code-cell} python
import os
import json

# Simulation du concept de namespace PID
# En réalité, les vrais namespaces nécessitent des appels système privilégiés
# Ici on simule la VISION qu'ont les processus de leur environnement

class SimNamespacePID:
    """Simule l'isolation des PIDs dans un namespace conteneur."""

    def __init__(self, nom: str):
        self.nom = nom
        self._processus: list[dict] = []
        self._prochain_pid_interne = 1  # Le conteneur commence à PID 1

    def ajouter_processus(self, nom_proc: str, pid_hote: int):
        """Enregistre un processus avec son PID hôte et son PID interne."""
        pid_interne = self._prochain_pid_interne
        self._prochain_pid_interne += 1
        self._processus.append({
            "nom": nom_proc,
            "pid_hote": pid_hote,
            "pid_interne": pid_interne,
        })

    def vue_interne(self) -> list[dict]:
        """Ce que le conteneur voit (PID internes)."""
        return [{"PID": p["pid_interne"], "processus": p["nom"]}
                for p in self._processus]

    def vue_hote(self) -> list[dict]:
        """Ce que l'hôte voit (PID réels)."""
        return [{"PID hôte": p["pid_hote"], "PID conteneur": p["pid_interne"],
                 "conteneur": self.nom, "processus": p["nom"]}
                for p in self._processus]


# Créons deux conteneurs simulés
c1 = SimNamespacePID("web-app")
c1.ajouter_processus("nginx (PID 1)", pid_hote=4213)
c1.ajouter_processus("nginx worker", pid_hote=4214)
c1.ajouter_processus("nginx worker", pid_hote=4215)

c2 = SimNamespacePID("database")
c2.ajouter_processus("postgres (PID 1)", pid_hote=4320)
c2.ajouter_processus("postgres worker", pid_hote=4321)

print("=" * 55)
print("VUE depuis l'intérieur du conteneur 'web-app'")
print("=" * 55)
for proc in c1.vue_interne():
    print(f"  PID {proc['PID']:3d}  {proc['processus']}")

print()
print("=" * 55)
print("VUE depuis l'intérieur du conteneur 'database'")
print("=" * 55)
for proc in c2.vue_interne():
    print(f"  PID {proc['PID']:3d}  {proc['processus']}")

print()
print("=" * 55)
print("VUE depuis l'hôte (tous les conteneurs)")
print("=" * 55)
tous = c1.vue_hote() + c2.vue_hote()
for proc in tous:
    print(f"  PID hôte {proc['PID hôte']}  "
          f"[{proc['conteneur']}:PID {proc['PID conteneur']}]  "
          f"{proc['processus']}")
```

```{code-cell} python
# Simulation des cgroups : répartition des ressources
class SimCgroup:
    """Simule un cgroup avec limites CPU et mémoire."""

    def __init__(self, nom: str, cpu_limit: float, mem_limit_mb: int):
        self.nom = nom
        self.cpu_limit = cpu_limit      # en nombre de CPUs
        self.mem_limit_mb = mem_limit_mb
        self.cpu_actuel = 0.0
        self.mem_actuelle_mb = 0

    def utiliser(self, cpu: float, mem_mb: int) -> dict:
        """Tente d'allouer des ressources. Retourne le résultat."""
        cpu_ok = cpu <= self.cpu_limit
        mem_ok = mem_mb <= self.mem_limit_mb
        if cpu_ok:
            self.cpu_actuel = cpu
        if mem_ok:
            self.mem_actuelle_mb = mem_mb
        return {
            "conteneur": self.nom,
            "cpu_demandé": cpu,
            "cpu_accordé": min(cpu, self.cpu_limit),
            "cpu_throttled": not cpu_ok,
            "mem_demandée_mb": mem_mb,
            "mem_accordée_mb": min(mem_mb, self.mem_limit_mb),
            "mem_oom_killed": not mem_ok,
        }


conteneurs_cg = [
    SimCgroup("web-api",   cpu_limit=0.5, mem_limit_mb=256),
    SimCgroup("worker",    cpu_limit=1.0, mem_limit_mb=512),
    SimCgroup("database",  cpu_limit=2.0, mem_limit_mb=2048),
]

# Scénario : une charge normale puis une surcharge
scenarios = [
    (0.3, 200, "Charge normale"),
    (0.8, 300, "Pic de charge (CPU throttlé)"),
    (0.4, 600, "Fuite mémoire (OOM Kill simulé)"),
]

print(f"{'Conteneur':<12} {'Scénario':<30} {'CPU':>8} {'Mem':>10} {'Throttle':>10} {'OOM':>6}")
print("-" * 82)

for sg_cpu, sg_mem, sg_label in scenarios:
    for cg in conteneurs_cg:
        r = cg.utiliser(sg_cpu, sg_mem)
        throttle = "OUI ⚠" if r["cpu_throttled"] else "non"
        oom = "OUI ⚠" if r["mem_oom_killed"] else "non"
        print(f"{cg.nom:<12} {sg_label:<30} "
              f"{r['cpu_accordé']:>6.1f}/{cg.cpu_limit:.1f}"
              f"  {r['mem_accordée_mb']:>4}MB/{cg.mem_limit_mb}MB"
              f"  {throttle:>8}  {oom:>4}")
    print()
```

## Bilan : pourquoi les conteneurs ont gagné

Les conteneurs ont conquis l'industrie pour des raisons très concrètes.

```{code-cell} python
:tags: [hide-input]
fig, ax = plt.subplots(figsize=(12, 5.5))

categories = ["Démarrage\n(secondes)", "Taille\n(MB)", "RAM overhead\n(MB)",
               "Densité\n(conteneurs/VM)", "Isolation\n(score /10)"]

vm_values     = [45, 1500, 400, 1, 9]
docker_values = [1,  100,  20,  30, 7]

x = np.arange(len(categories))
width = 0.35

bars1 = ax.bar(x - width/2, vm_values, width, label="Machine Virtuelle",
               color="#ef9a9a", edgecolor="white", linewidth=1.5)
bars2 = ax.bar(x + width/2, docker_values, width, label="Conteneur Docker",
               color="#a5d6a7", edgecolor="white", linewidth=1.5)

ax.set_ylabel("Valeur (échelle logarithmique approximative)")
ax.set_title("Comparaison VM vs Conteneurs — métriques clés", fontsize=13, fontweight="bold")
ax.set_xticks(x)
ax.set_xticklabels(categories)
ax.legend(fontsize=10)
ax.set_yscale("symlog", linthresh=1)

for bar in bars1:
    h = bar.get_height()
    ax.text(bar.get_x() + bar.get_width() / 2, h * 1.15,
            str(h), ha="center", va="bottom", fontsize=8.5, fontweight="bold", color="#c62828")
for bar in bars2:
    h = bar.get_height()
    ax.text(bar.get_x() + bar.get_width() / 2, h * 1.15,
            str(h), ha="center", va="bottom", fontsize=8.5, fontweight="bold", color="#2e7d32")

ax.text(0.5, -0.18,
        "Note : les VMs offrent une meilleure isolation (kernel séparé) mais au prix d'un overhead "
        "très supérieur.\nLes conteneurs combinent vitesse, légèreté et isolation suffisante pour "
        "la grande majorité des cas d'usage.",
        transform=ax.transAxes, ha="center", fontsize=8.5, color="#546e7a", style="italic")

plt.tight_layout()
plt.savefig("_static/01_comparaison_finale.png", dpi=130, bbox_inches="tight")
plt.show()
```

Les conteneurs ne remplacent pas toujours les VMs. Pour des charges de travail nécessitant une isolation totale du kernel (environnements multi-tenants hostiles, machines Windows sur hôte Linux), les VMs restent la référence. En pratique, les deux coexistent : on fait tourner des VMs dans le cloud (une VM par client), et des conteneurs à l'intérieur de ces VMs (plusieurs services par VM).

## Résumé

- Un **conteneur** est un processus Linux isolé grâce aux **namespaces** (isolation) et aux **cgroups** (limitation des ressources), avec son propre système de fichiers construit par **OverlayFS**.
- Les **VMs** offrent une isolation plus forte (kernel séparé) mais sont plus lourdes (secondes de démarrage, centaines de MB de RAM).
- **Docker** n'a pas inventé les conteneurs mais les a rendus accessibles grâce à une UX révolutionnaire et Docker Hub.
- Le standard **OCI** garantit l'interopérabilité entre outils (Docker, Podman, Buildah) et runtimes (containerd, crun, youki).
- **Podman** est une alternative rootless populaire, compatible avec l'écosystème Docker.

Dans le prochain chapitre, nous entrons dans le concret : les **images Docker**, leur structure en couches, le Dockerfile et le processus de build.
