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 ?

Hide code cell source

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

Hide code cell source

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()
_images/95df3e01ecbf7e63ba7d49893d63bb9f87f219ea935cc3109bf338de231500ac.png

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 :

Hide code cell source

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()
_images/f3c22ba94d06e3b32dae90d8add88f4fa186ccf80a2104660f3fd10c0587a5a4.png

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.

Hide code cell source

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()
_images/1be867c4dc550a81cac6ced2930d9b6f1079cab84e0e042ced041054c24e8785.png

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.

Hide code cell source

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()
_images/11c35ac9c03abd9e185c683c2899c8bca006681414dcd6ecba0595a18d44389f.png

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.

Hide code cell source

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()
_images/9db6480da6a4afd736de3b086bbe932a924c2e75c210139d63720dc44ba919e8.png

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.

Podman : l’alternative rootless

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.

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']}")
=======================================================
VUE depuis l'intérieur du conteneur 'web-app'
=======================================================
  PID   1  nginx (PID 1)
  PID   2  nginx worker
  PID   3  nginx worker

=======================================================
VUE depuis l'intérieur du conteneur 'database'
=======================================================
  PID   1  postgres (PID 1)
  PID   2  postgres worker

=======================================================
VUE depuis l'hôte (tous les conteneurs)
=======================================================
  PID hôte 4213  [web-app:PID 1]  nginx (PID 1)
  PID hôte 4214  [web-app:PID 2]  nginx worker
  PID hôte 4215  [web-app:PID 3]  nginx worker
  PID hôte 4320  [database:PID 1]  postgres (PID 1)
  PID hôte 4321  [database:PID 2]  postgres worker
# 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()
Conteneur    Scénario                            CPU        Mem   Throttle    OOM
----------------------------------------------------------------------------------
web-api      Charge normale                    0.3/0.5   200MB/256MB       non   non
worker       Charge normale                    0.3/1.0   200MB/512MB       non   non
database     Charge normale                    0.3/2.0   200MB/2048MB       non   non

web-api      Pic de charge (CPU throttlé)      0.5/0.5   256MB/256MB     OUI ⚠  OUI ⚠
worker       Pic de charge (CPU throttlé)      0.8/1.0   300MB/512MB       non   non
database     Pic de charge (CPU throttlé)      0.8/2.0   300MB/2048MB       non   non

web-api      Fuite mémoire (OOM Kill simulé)    0.4/0.5   256MB/256MB       non  OUI ⚠
worker       Fuite mémoire (OOM Kill simulé)    0.4/1.0   512MB/512MB       non  OUI ⚠
database     Fuite mémoire (OOM Kill simulé)    0.4/2.0   600MB/2048MB       non   non

Bilan : pourquoi les conteneurs ont gagné#

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

Hide code cell source

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()
_images/36120472e66f99f3941390dcd90daf039bbfc4f3fd99d2f8beccf3b54a1cc456.png

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.