13 — Stockage Kubernetes#

Hide code cell source

import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
import numpy as np
import pandas as pd
import seaborn as sns
import json
sns.set_theme(style="whitegrid", palette="muted")

Le problème du stockage éphémère#

Un Pod Kubernetes est, par nature, éphémère. Quand un Pod est recréé — qu’il s’agisse d’un crash, d’une mise à jour rolling, ou d’un reschedule sur un autre nœud — toutes les données écrites dans son système de fichiers disparaissent.

Analogie : le château de sable

Un conteneur sans volume persistent, c’est comme un château de sable. Magnifique, fonctionnel, mais la prochaine marée (redémarrage) l’efface complètement. Si vous voulez que votre travail survive aux marées, il vous faut construire sur du rocher — c’est le rôle des volumes persistants.

Pour une application stateless (API REST sans état), c’est acceptable. Mais pour une base de données, un système de fichiers partagé, ou tout service qui doit mémoriser des données — il faut une solution de stockage persistant.

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("Stockage éphémère vs persistant dans un Pod", fontsize=13, fontweight='bold')

for ax, (title, is_persistent) in zip(axes, [
    ("Sans volume persistant\n(données perdues au redémarrage)", False),
    ("Avec volume persistant\n(données survivent au cycle de vie du Pod)", True)
]):
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 9)
    ax.axis('off')
    color = "#E74C3C" if not is_persistent else "#27AE60"
    ax.set_title(title, fontsize=10.5, fontweight='bold', color=color, pad=10)

    def boite(ax, x, y, w, h, label, sublabel="", fc="#4A90D9", tc="white", fs=9):
        ax.add_patch(FancyBboxPatch((x-w/2, y-h/2), w, h, boxstyle="round,pad=0.1",
                                     linewidth=1.5, edgecolor="white", facecolor=fc, alpha=0.9))
        ax.text(x, y+(0.15 if sublabel else 0), label, ha='center', va='center',
                color=tc, fontsize=fs, fontweight='bold')
        if sublabel:
            ax.text(x, y-0.3, sublabel, ha='center', va='center', color=tc, fontsize=7.5, alpha=0.85)

    # Pod vivant
    ax.add_patch(FancyBboxPatch((1.5, 5.0), 7, 3.5, boxstyle="round,pad=0.2",
                                 facecolor=color, alpha=0.08, edgecolor=color, lw=2))
    ax.text(5, 8.2, "Pod (v1)", ha='center', fontsize=10, fontweight='bold', color=color)
    boite(ax, 5, 7.0, 4.5, 0.9, "Conteneur", "", "#4A90D9")
    boite(ax, 5, 5.8, 4.5, 0.75, "Filesystem temporaire", "(données écrites ici)", "#7F8C8D", "white", 8)

    ax.annotate("", xy=(5, 4.2), xytext=(5, 4.9),
                arrowprops=dict(arrowstyle="-|>", color="#E74C3C", lw=2.5))
    ax.text(5, 3.9, "CRASH / REDÉMARRAGE", ha='center', fontsize=9.5,
            fontweight='bold', color="#E74C3C")

    if not is_persistent:
        # Pod recréé sans données
        ax.add_patch(FancyBboxPatch((1.5, 1.2), 7, 2.3, boxstyle="round,pad=0.2",
                                     facecolor="#27AE60", alpha=0.08, edgecolor="#27AE60", lw=2))
        ax.text(5, 3.2, "Pod (v2) — nouveau démarrage", ha='center', fontsize=9,
                fontweight='bold', color="#27AE60")
        boite(ax, 5, 2.3, 4.5, 0.85, "Conteneur (réinitialisé)", "", "#4A90D9")
        ax.text(5, 1.6, "⚠ Données perdues !", ha='center', fontsize=10,
                color="#E74C3C", fontweight='bold')
    else:
        # Avec volume
        boite(ax, 5, 5.8, 4.5, 0.75, "Volume mount /data", "(vers PV externe)", "#8E44AD", "white", 8)
        # PV externe
        ax.add_patch(FancyBboxPatch((2, 3.5), 6, 1.2, boxstyle="round,pad=0.15",
                                     facecolor="#8E44AD", alpha=0.85, edgecolor='none'))
        ax.text(5, 4.1, "PersistentVolume (stockage externe)", ha='center', va='center',
                fontsize=9, fontweight='bold', color='white')
        # Pod recréé
        ax.add_patch(FancyBboxPatch((1.5, 0.8), 7, 2.3, boxstyle="round,pad=0.2",
                                     facecolor="#27AE60", alpha=0.08, edgecolor="#27AE60", lw=2))
        ax.text(5, 2.85, "Pod (v2) — réattache le volume", ha='center', fontsize=9,
                fontweight='bold', color="#27AE60")
        boite(ax, 5, 1.8, 4.5, 0.75, "Volume mount /data", "(mêmes données)", "#8E44AD", "white", 8)
        ax.text(5, 1.1, "✓ Données préservées !", ha='center', fontsize=10,
                color="#27AE60", fontweight='bold')

plt.tight_layout()
plt.savefig("13_stockage_ephemere.png", dpi=120, bbox_inches='tight')
plt.show()
_images/8e45d83335322b5824d5a459f70a906c3d09e7e7f29ee1a4a89670b7d06f3c11.png

emptyDir : volume temporaire partagé#

emptyDir est le type de volume le plus simple : un répertoire créé vide au démarrage du Pod, partagé entre tous les conteneurs du Pod. Il est détruit quand le Pod disparaît.

apiVersion: v1
kind: Pod
metadata:
  name: app-avec-sidecar
spec:
  containers:
    - name: app
      image: mon-app:1.0
      volumeMounts:
        - name: shared-data
          mountPath: /app/cache
    - name: sidecar-log-shipper
      image: fluentd:latest
      volumeMounts:
        - name: shared-data
          mountPath: /var/log/app     # Lit les logs produits par 'app'
  volumes:
    - name: shared-data
      emptyDir:
        medium: ""        # Disque du nœud (par défaut)
        # medium: Memory  # Utilise la RAM (tmpfs) : plus rapide, mais limité
        sizeLimit: 500Mi

Cas d’usage de emptyDir

emptyDir sur disque : cache temporaire, fichiers intermédiaires entre conteneurs (sidecar pattern).

emptyDir en mémoire (tmpfs) : données sensibles qui ne doivent pas toucher le disque (tokens, clés temporaires), ou cache haute performance.

hostPath : monter un répertoire du nœud#

hostPath monte un répertoire du nœud hôte dans le Pod. C’est puissant, mais dangereux.

volumes:
  - name: host-data
    hostPath:
      path: /var/log/containers    # Répertoire du nœud hôte
      type: DirectoryOrCreate      # Crée le dossier s'il n'existe pas

Danger de hostPath en production

hostPath donne au Pod un accès direct au système de fichiers du nœud. Un Pod malveillant pourrait monter / et lire tous les fichiers du nœud. En production, hostPath est généralement interdit par les PodSecurityAdmission policies. Réservez-le aux cas très spécifiques (DaemonSets de monitoring comme node-exporter).

PersistentVolume et PersistentVolumeClaim#

Kubernetes sépare la gestion du stockage physique (côté administrateur) de la demande de stockage (côté développeur) grâce à deux ressources complémentaires.

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 8))
ax.set_xlim(0, 14)
ax.set_ylim(0, 9)
ax.axis('off')
ax.set_title("Cycle de vie PersistentVolume / PersistentVolumeClaim", fontsize=14, fontweight='bold')

def boite(ax, x, y, w, h, label, sublabel="", fc="#4A90D9", tc="white", fs=9):
    ax.add_patch(FancyBboxPatch((x-w/2, y-h/2), w, h, boxstyle="round,pad=0.1",
                                 linewidth=1.5, edgecolor="white", facecolor=fc, alpha=0.9))
    ax.text(x, y+(0.18 if sublabel else 0), label, ha='center', va='center',
            color=tc, fontsize=fs, fontweight='bold')
    if sublabel:
        ax.text(x, y-0.32, sublabel, ha='center', va='center', color=tc, fontsize=7.5, alpha=0.88)

def fleche(ax, x1, y1, x2, y2, label="", color="#333", lw=2):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="-|>", color=color, lw=lw))
    if label:
        mx, my = (x1+x2)/2, (y1+y2)/2
        ax.text(mx+0.15, my+0.15, label, fontsize=8, color=color)

# Acteurs
ax.add_patch(FancyBboxPatch((0.2, 7.5), 3, 1.2, boxstyle="round,pad=0.1",
                             facecolor="#7F8C8D", alpha=0.85, edgecolor='none'))
ax.text(1.7, 8.1, "Administrateur\ncluster", ha='center', va='center',
        fontsize=10, fontweight='bold', color='white')

ax.add_patch(FancyBboxPatch((10.5, 7.5), 3, 1.2, boxstyle="round,pad=0.1",
                             facecolor="#6C63FF", alpha=0.85, edgecolor='none'))
ax.text(12.0, 8.1, "Développeur\n/ application", ha='center', va='center',
        fontsize=10, fontweight='bold', color='white')

# PV
boite(ax, 3.0, 5.5, 4.5, 1.4, "PersistentVolume (PV)", "capacité: 50Gi | AccessMode: RWO\nstorageClass: fast-ssd | Reclaim: Retain", "#E67E22", fontsize=8)

# PVC
boite(ax, 11.0, 5.5, 4.5, 1.4, "PersistentVolumeClaim (PVC)", "capacité: ≥20Gi | AccessMode: RWO\nstorageClass: fast-ssd", "#4A90D9", fontsize=8)

# Binding
fleche(ax, 5.25, 5.5, 8.75, 5.5, "", "#27AE60", 2.5)
ax.text(7.0, 6.0, "BINDING", ha='center', fontsize=10, fontweight='bold', color="#27AE60")
ax.text(7.0, 5.75, "(K8s trouve le PV qui correspond\naux critères du PVC)", ha='center',
        fontsize=8, color="#555")

# Stockage physique
ax.add_patch(FancyBboxPatch((0.5, 2.2), 5, 1.8, boxstyle="round,pad=0.2",
                             facecolor="#E74C3C", alpha=0.12, edgecolor="#E74C3C", lw=1.5))
ax.text(3.0, 3.1, "Stockage physique", ha='center', fontsize=10, fontweight='bold', color="#E74C3C")
ax.text(3.0, 2.6, "AWS EBS · GCP PD · NFS\nCeph · iSCSI · local", ha='center',
        fontsize=8.5, color="#333")

fleche(ax, 3.0, 4.82, 3.0, 4.1, "référence", "#E74C3C")

# Pod
boite(ax, 11.0, 2.8, 4, 1.2, "Pod", "volume: pvc-mon-app\nmountPath: /data", "#8E44AD", fontsize=8)

fleche(ax, 1.7, 7.5, 3.0, 6.2, "crée le PV", "#7F8C8D")
fleche(ax, 12.0, 7.5, 11.0, 6.2, "crée le PVC", "#6C63FF")
fleche(ax, 11.0, 4.82, 11.0, 3.42, "utilise le PVC", "#8E44AD")

# États du cycle de vie
states = [
    (3.0, 1.5, "Available → Bound → Released → (Retain/Delete/Recycle)", "#E67E22"),
    (11.0, 1.5, "Pending → Bound → Lost", "#4A90D9"),
]
for x, y, text, color in states:
    ax.text(x, y, text, ha='center', fontsize=8.5, color=color,
            bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor=color))

plt.tight_layout()
plt.savefig("13_pv_pvc_cycle.png", dpi=120, bbox_inches='tight')
plt.show()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[3], line 34
     30 ax.text(12.0, 8.1, "Développeur\n/ application", ha='center', va='center',
     31         fontsize=10, fontweight='bold', color='white')
     33 # PV
---> 34 boite(ax, 3.0, 5.5, 4.5, 1.4, "PersistentVolume (PV)", "capacité: 50Gi | AccessMode: RWO\nstorageClass: fast-ssd | Reclaim: Retain", "#E67E22", fontsize=8)
     36 # PVC
     37 boite(ax, 11.0, 5.5, 4.5, 1.4, "PersistentVolumeClaim (PVC)", "capacité: ≥20Gi | AccessMode: RWO\nstorageClass: fast-ssd", "#4A90D9", fontsize=8)

TypeError: boite() got an unexpected keyword argument 'fontsize'
_images/8b3cd8aef692b499b0e53333494538f55a3152dcf090732121e90b4211e05634.png

Manifestes PV et PVC#

# PersistentVolume (créé par l'admin)
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-postgres-data
spec:
  capacity:
    storage: 50Gi
  accessModes:
    - ReadWriteOnce           # Un seul Pod peut monter en lecture-écriture
  persistentVolumeReclaimPolicy: Retain   # Garde les données après libération
  storageClassName: fast-ssd
  csi:
    driver: pd.csi.storage.gke.io
    volumeHandle: projects/mon-projet/zones/europe-west1-b/disks/postgres-disk

---
# PersistentVolumeClaim (créé par le dev)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-postgres
  namespace: production
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi           # Demande ≤ capacité du PV
  storageClassName: fast-ssd  # Doit correspondre à un PV ou une StorageClass

Les modes d’accès#

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 5))
ax.set_xlim(0, 13)
ax.set_ylim(0, 6)
ax.axis('off')
ax.set_title("Modes d'accès aux PersistentVolumes", fontsize=13, fontweight='bold')

modes = [
    {
        "name": "ReadWriteOnce\n(RWO)",
        "desc": "Lecture-écriture\npar UN SEUL nœud",
        "use": "Base de données,\nstockage mono-instance",
        "color": "#E67E22",
        "nodes": [(3.0, 4.0)],
        "pods": [(3.0, 2.8)],
        "x": 1.5
    },
    {
        "name": "ReadOnlyMany\n(ROX)",
        "desc": "Lecture seule\npar PLUSIEURS nœuds",
        "use": "Assets statiques,\nconfig partagée en lecture",
        "color": "#4A90D9",
        "nodes": [(5.5, 4.0), (7.0, 4.0), (8.5, 4.0)],
        "pods": [(5.5, 2.8), (7.0, 2.8), (8.5, 2.8)],
        "x": 5.5
    },
    {
        "name": "ReadWriteMany\n(RWX)",
        "desc": "Lecture-écriture\npar PLUSIEURS nœuds",
        "use": "NFS, CephFS,\nstockage partagé",
        "color": "#27AE60",
        "nodes": [(10.5, 4.0), (12.0, 4.0)],
        "pods": [(10.5, 2.8), (12.0, 2.8)],
        "x": 10.5
    },
]

# PV central
pv_positions = {
    "ReadWriteOnce\n(RWO)": (3.0, 1.0),
    "ReadOnlyMany\n(ROX)": (7.0, 1.0),
    "ReadWriteMany\n(RWX)": (11.0, 1.0),
}

for mode in modes:
    color = mode["color"]
    x = mode["x"]

    # Étiquette du mode
    ax.add_patch(FancyBboxPatch((x - 1.3, 5.2), 2.8, 0.65, boxstyle="round,pad=0.1",
                                 facecolor=color, alpha=0.9, edgecolor='none'))
    ax.text(x + 0.1, 5.52, mode["name"], ha='center', va='center', fontsize=9.5,
            fontweight='bold', color='white')

    # PV
    pv_x = pv_positions[mode["name"]][0]
    ax.add_patch(FancyBboxPatch((pv_x - 1.2, 0.5), 2.8, 0.85, boxstyle="round,pad=0.1",
                                 facecolor=color, alpha=0.85, edgecolor='none'))
    ax.text(pv_x + 0.2, 0.92, f"PV ({mode['name'].split(chr(10))[1]})", ha='center', va='center',
            fontsize=8.5, fontweight='bold', color='white')

    # Description et usage
    ax.text(x + 0.1, 4.75, mode["desc"], ha='center', fontsize=8.5, color=color,
            multialignment='center')
    ax.text(x + 0.1, 4.25, mode["use"], ha='center', fontsize=8, color="#555",
            multialignment='center', style='italic')

    # Pods et connexions
    for pod_x, pod_y in mode["pods"]:
        ax.add_patch(plt.Circle((pod_x, pod_y), 0.3, color=color, alpha=0.75))
        ax.text(pod_x, pod_y, "P", ha='center', va='center', fontsize=8,
                color='white', fontweight='bold')
        # Nœud englobant
        ax.add_patch(FancyBboxPatch((pod_x - 0.45, 3.8), 0.9, 0.65, boxstyle="round,pad=0.05",
                                     facecolor=color, alpha=0.15, edgecolor=color, lw=1))
        ax.text(pod_x, 4.12, "Node", ha='center', fontsize=7, color=color)
        # Flèche vers PV
        rw = "R/W" if mode["name"].startswith("ReadWrite") else "R"
        ax.annotate("", xy=(pv_x + 0.2, 1.35), xytext=(pod_x, pod_y - 0.3),
                    arrowprops=dict(arrowstyle="-|>", color=color, lw=1.5, alpha=0.6))

plt.tight_layout()
plt.savefig("13_access_modes.png", dpi=120, bbox_inches='tight')
plt.show()

StorageClass : provisionnement dynamique#

Créer manuellement un PV pour chaque PVC devient vite fastidieux. La StorageClass automatise ce provisionnement.

Analogie : le distributeur automatique

Sans StorageClass, créer du stockage, c’est comme aller au sous-sol chercher un disque dur physique à chaque fois. Avec une StorageClass, c’est comme un distributeur automatique : vous déposez votre PVC (commande), et le provisioner crée le disque automatiquement dans le cloud.

# StorageClass : définit un "profil" de stockage
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-ssd
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"  # Utilisée si pas de storageClass spécifiée
provisioner: pd.csi.storage.gke.io   # Driver CSI du cloud provider
parameters:
  type: pd-ssd                        # SSD rapide
  replication-type: regional-pd       # Réplication multi-zone
  zones: europe-west1-b,europe-west1-c
reclaimPolicy: Delete                 # Supprime le disque quand le PVC est supprimé
allowVolumeExpansion: true            # Permet d'agrandir le volume en ligne
volumeBindingMode: WaitForFirstConsumer  # Crée le disque quand le Pod est schedulé
# Lister les StorageClasses disponibles
kubectl get storageclasses
# NAME                PROVISIONER                    RECLAIMPOLICY
# standard (default)  kubernetes.io/gce-pd           Delete
# fast-ssd            pd.csi.storage.gke.io          Delete
# slow-hdd            kubernetes.io/gce-pd           Retain

# Créer un PVC avec la StorageClass (le PV est créé automatiquement !)
kubectl apply -f pvc.yaml
kubectl get pvc
# NAME           STATUS   VOLUME                                     CAPACITY
# pvc-postgres   Bound    pvc-abc-123-def-456                        20Gi

Simulation d’un scheduler de PV#

import json
from typing import Optional

# Simulation du processus de binding PV ↔ PVC par le scheduler Kubernetes

class PersistentVolume:
    def __init__(self, name, capacity_gi, access_modes, storage_class, status="Available"):
        self.name = name
        self.capacity_gi = capacity_gi
        self.access_modes = access_modes
        self.storage_class = storage_class
        self.status = status
        self.bound_to = None

    def __repr__(self):
        return (f"PV({self.name}, {self.capacity_gi}Gi, "
                f"{'+'.join(self.access_modes)}, {self.storage_class}, {self.status})")


class PersistentVolumeClaim:
    def __init__(self, name, namespace, requested_gi, access_modes, storage_class):
        self.name = name
        self.namespace = namespace
        self.requested_gi = requested_gi
        self.access_modes = access_modes
        self.storage_class = storage_class
        self.status = "Pending"
        self.bound_pv = None

    def __repr__(self):
        return (f"PVC({self.name}, {self.requested_gi}Gi, "
                f"{'+'.join(self.access_modes)}, {self.storage_class}, {self.status})")


class PVScheduler:
    """
    Simule l'algorithme de binding PV/PVC du controller-manager Kubernetes.
    Critères de sélection (dans l'ordre) :
    1. storageClass doit correspondre
    2. accessModes : le PV doit supporter TOUS les modes demandés
    3. capacité : le PV doit avoir au moins la capacité demandée
    4. statut : le PV doit être Available
    5. Parmi les candidats : sélectionne le plus petit PV suffisant
    """

    def __init__(self):
        self.pvs = []
        self.pvcs = []
        self.binding_log = []

    def add_pv(self, pv: PersistentVolume):
        self.pvs.append(pv)

    def add_pvc(self, pvc: PersistentVolumeClaim):
        self.pvcs.append(pvc)

    def find_best_pv(self, pvc: PersistentVolumeClaim) -> Optional[PersistentVolume]:
        """Trouve le PV le mieux adapté à un PVC."""
        candidates = []
        for pv in self.pvs:
            if pv.status != "Available":
                continue
            if pv.storage_class != pvc.storage_class:
                continue
            # Vérifie que tous les accessModes du PVC sont dans le PV
            if not all(mode in pv.access_modes for mode in pvc.access_modes):
                continue
            if pv.capacity_gi < pvc.requested_gi:
                continue
            candidates.append(pv)

        if not candidates:
            return None
        # Sélectionne le plus petit PV suffisant (meilleur fit)
        return min(candidates, key=lambda p: p.capacity_gi)

    def bind(self, pvc: PersistentVolumeClaim, pv: PersistentVolume):
        """Lie un PVC à un PV."""
        pv.status = "Bound"
        pv.bound_to = pvc.name
        pvc.status = "Bound"
        pvc.bound_pv = pv.name
        self.binding_log.append(f"  BOUND : {pvc.name} ({pvc.requested_gi}Gi) → {pv.name} ({pv.capacity_gi}Gi)")

    def schedule_all(self):
        """Lance le processus de binding pour tous les PVCs en attente."""
        print("\n=== Scheduler PV/PVC — Résolution des bindings ===\n")
        print(f"PVs disponibles ({len(self.pvs)}) :")
        for pv in self.pvs:
            print(f"  {pv}")

        print(f"\nPVCs en attente ({len(self.pvcs)}) :")
        for pvc in self.pvcs:
            print(f"  {pvc}")

        print("\n--- Résolution ---")
        for pvc in self.pvcs:
            best_pv = self.find_best_pv(pvc)
            if best_pv:
                self.bind(pvc, best_pv)
            else:
                pvc.status = "Pending"
                self.binding_log.append(f"  PENDING : {pvc.name} — aucun PV correspondant trouvé !")

        print("\nRésultats :")
        for log_entry in self.binding_log:
            symbol = "✓" if "BOUND" in log_entry else "⚠"
            print(f"{symbol} {log_entry}")

        print("\nÉtat final des PVs :")
        for pv in self.pvs:
            bound_info = f" → {pv.bound_to}" if pv.bound_to else ""
            print(f"  {pv.name:20s} {pv.status:10s}{bound_info}")


# Création du scheduler et des ressources
scheduler = PVScheduler()

# PVs disponibles
scheduler.add_pv(PersistentVolume("pv-ssd-10gi",  10,  ["ReadWriteOnce"], "fast-ssd"))
scheduler.add_pv(PersistentVolume("pv-ssd-50gi",  50,  ["ReadWriteOnce"], "fast-ssd"))
scheduler.add_pv(PersistentVolume("pv-ssd-100gi", 100, ["ReadWriteOnce", "ReadOnlyMany"], "fast-ssd"))
scheduler.add_pv(PersistentVolume("pv-nfs-200gi", 200, ["ReadWriteMany", "ReadOnlyMany"], "nfs-shared"))
scheduler.add_pv(PersistentVolume("pv-hdd-500gi", 500, ["ReadWriteOnce"], "slow-hdd"))

# PVCs à résoudre
scheduler.add_pvc(PersistentVolumeClaim("pvc-postgres",   "prod",  20,  ["ReadWriteOnce"], "fast-ssd"))
scheduler.add_pvc(PersistentVolumeClaim("pvc-redis",      "prod",  8,   ["ReadWriteOnce"], "fast-ssd"))
scheduler.add_pvc(PersistentVolumeClaim("pvc-assets",     "prod",  50,  ["ReadWriteMany"],  "nfs-shared"))
scheduler.add_pvc(PersistentVolumeClaim("pvc-backup",     "prod",  200, ["ReadWriteOnce"], "slow-hdd"))
scheduler.add_pvc(PersistentVolumeClaim("pvc-impossible", "dev",   150, ["ReadWriteOnce"], "fast-ssd"))

scheduler.schedule_all()

CSI : Container Storage Interface#

Le CSI est le standard qui permet à n’importe quel vendor de stockage de développer un plugin compatible Kubernetes sans modifier le code du noyau K8s.

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 6))
ax.set_xlim(0, 13)
ax.set_ylim(0, 7)
ax.axis('off')
ax.set_title("Architecture CSI (Container Storage Interface)", fontsize=13, fontweight='bold')

def boite(ax, x, y, w, h, label, sublabel="", fc="#4A90D9", tc="white", fs=9):
    ax.add_patch(FancyBboxPatch((x-w/2, y-h/2), w, h, boxstyle="round,pad=0.1",
                                 linewidth=1.5, edgecolor="white", facecolor=fc, alpha=0.9))
    ax.text(x, y+(0.18 if sublabel else 0), label, ha='center', va='center',
            color=tc, fontsize=fs, fontweight='bold')
    if sublabel:
        ax.text(x, y-0.32, sublabel, ha='center', va='center', color=tc, fontsize=7.5, alpha=0.88)

def fleche(ax, x1, y1, x2, y2, label="", color="#555"):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="-|>", color=color, lw=1.8))
    if label:
        mx, my = (x1+x2)/2, (y1+y2)/2
        ax.text(mx+0.1, my+0.15, label, fontsize=7.5, color=color)

# Kubernetes core
boite(ax, 3.5, 6.2, 4.5, 0.85, "kube-controller-manager", "PV Controller, Attach/Detach", "#7F8C8D", fontsize=8)
boite(ax, 3.5, 5.0, 4.5, 0.85, "kubelet", "Mount/Unmount volumes", "#7F8C8D", fontsize=8)

# CSI Interface
ax.add_patch(FancyBboxPatch((0.3, 3.5), 12.5, 0.65, boxstyle="round,pad=0.1",
                             facecolor="#E67E22", alpha=0.85, edgecolor='none'))
ax.text(6.5, 3.82, "Interface CSI  (CreateVolume · DeleteVolume · NodePublishVolume · NodeUnpublishVolume…)",
        ha='center', va='center', fontsize=9, fontweight='bold', color='white')

fleche(ax, 3.5, 4.58, 3.5, 4.18, "gRPC", "#E67E22")
fleche(ax, 3.5, 5.82, 3.5, 5.43, "gRPC", "#E67E22")

# CSI Drivers
drivers = [
    ("AWS EBS CSI", "ebs.csi.aws.com", "#FF9900", 1.5),
    ("GCP PD CSI", "pd.csi.storage.gke.io", "#4285F4", 4.0),
    ("NFS CSI", "nfs.csi.k8s.io", "#27AE60", 6.5),
    ("Ceph RBD CSI", "rbd.csi.ceph.com", "#E74C3C", 9.0),
    ("Longhorn CSI", "driver.longhorn.io", "#8E44AD", 11.5),
]

for name, provisioner, color, x in drivers:
    ax.add_patch(FancyBboxPatch((x-1.2, 1.5), 2.5, 1.7, boxstyle="round,pad=0.1",
                                 facecolor=color, alpha=0.12, edgecolor=color, lw=1.5))
    ax.text(x+0.05, 3.0, name, ha='center', fontsize=8.5, fontweight='bold', color=color)
    ax.text(x+0.05, 2.6, provisioner, ha='center', fontsize=7, color="#333", family='monospace')
    ax.text(x+0.05, 2.15, "Controller\n+ Node plugin", ha='center', fontsize=7.5, color="#555")
    fleche(ax, x+0.05, 3.17, x+0.05, 3.5, "", color)

# Backends
ax.add_patch(FancyBboxPatch((0.3, 0.2), 12.5, 1.0, boxstyle="round,pad=0.1",
                             facecolor="#ECF0F1", edgecolor="#BDC3C7", lw=1))
ax.text(6.5, 0.7, "Backends physiques  (EBS volumes · GCP Persistent Disks · NFS server · Ceph cluster · Longhorn nodes)",
        ha='center', va='center', fontsize=8.5, color="#555")

plt.tight_layout()
plt.savefig("13_csi_architecture.png", dpi=120, bbox_inches='tight')
plt.show()

StatefulSet avec stockage#

Les StatefulSets sont conçus pour les applications avec état (bases de données, message queues). Ils garantissent une identité stable à chaque Pod et gèrent automatiquement les PVCs grâce aux volumeClaimTemplates.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: production
spec:
  serviceName: "postgres"        # Service headless obligatoire pour DNS stable
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  # La magie du StatefulSet : un PVC dédié par réplica
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources:
          requests:
            storage: 50Gi
# Résultat : 3 PVCs créés automatiquement :
# data-postgres-0 → Pod postgres-0 (toujours le même !)
# data-postgres-1 → Pod postgres-1
# data-postgres-2 → Pod postgres-2

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 7))
ax.set_xlim(0, 13)
ax.set_ylim(0, 8)
ax.axis('off')
ax.set_title("StatefulSet : identité stable et stockage dédié", fontsize=13, fontweight='bold')

def boite(ax, x, y, w, h, label, sublabel="", fc="#4A90D9", tc="white", fs=9):
    ax.add_patch(FancyBboxPatch((x-w/2, y-h/2), w, h, boxstyle="round,pad=0.1",
                                 linewidth=1.5, edgecolor="white", facecolor=fc, alpha=0.9))
    ax.text(x, y+(0.18 if sublabel else 0), label, ha='center', va='center',
            color=tc, fontsize=fs, fontweight='bold')
    if sublabel:
        ax.text(x, y-0.32, sublabel, ha='center', va='center', color=tc, fontsize=7.5, alpha=0.88)

pods = [
    ("postgres-0", "10.244.1.10", "Primary (leader)", "#E74C3C", 2.5),
    ("postgres-1", "10.244.2.11", "Replica", "#E67E22", 6.5),
    ("postgres-2", "10.244.3.12", "Replica", "#E67E22", 10.5),
]

for pod_name, ip, role, color, x in pods:
    # Nœud
    ax.add_patch(FancyBboxPatch((x-1.8, 5.0), 3.6, 3.5, boxstyle="round,pad=0.2",
                                 facecolor=color, alpha=0.06, edgecolor=color, lw=1.5, linestyle='dashed'))
    ax.text(x, 8.3, f"Node", ha='center', fontsize=8, color=color, style='italic')

    # Pod
    boite(ax, x, 7.3, 3.2, 1.0, pod_name, f"{ip} · {role}", color, fontsize=8.5)

    # PVC
    boite(ax, x, 5.7, 3.2, 0.9, f"PVC: data-{pod_name}", "50Gi · fast-ssd", "#8E44AD", fontsize=8)

    # PV
    ax.add_patch(FancyBboxPatch((x-1.4, 3.5), 2.8, 1.2, boxstyle="round,pad=0.1",
                                 facecolor="#8E44AD", alpha=0.75, edgecolor='none'))
    ax.text(x, 4.1, f"PV (auto-créé)", ha='center', va='center', fontsize=8,
            fontweight='bold', color='white')
    ax.text(x, 3.75, f"→ Disque SSD dédié", ha='center', va='center', fontsize=7.5, color='#ddd')

    ax.annotate("", xy=(x, 6.55), xytext=(x, 6.82),
                arrowprops=dict(arrowstyle="-|>", color="#8E44AD", lw=1.5))
    ax.annotate("", xy=(x, 4.7), xytext=(x, 5.25),
                arrowprops=dict(arrowstyle="-|>", color="#8E44AD", lw=1.5))

# Service headless
ax.add_patch(FancyBboxPatch((3.5, 1.0), 6, 1.2, boxstyle="round,pad=0.15",
                             facecolor="#4A90D9", alpha=0.85, edgecolor='none'))
ax.text(6.5, 1.6, "Service Headless : postgres", ha='center', va='center',
        fontsize=10, fontweight='bold', color='white')
ax.text(6.5, 1.15, "DNS : postgres-0.postgres.prod.svc.cluster.local", ha='center', va='center',
        fontsize=8.5, color='#cce')

# Propriétés clés
for i, prop in enumerate([
    "• Nom stable : postgres-0/1/2 (jamais de hash aléatoire)",
    "• Ordre de création : 0 → 1 → 2  /  Suppression : 2 → 1 → 0",
    "• Chaque Pod retrouve TOUJOURS son PVC après redémarrage",
]):
    ax.text(0.5, 0.75 - i * 0.28, prop, fontsize=8.5, color="#444")

plt.tight_layout()
plt.savefig("13_statefulset.png", dpi=120, bbox_inches='tight')
plt.show()

Backup et restauration : Velero#

Velero : sauvegarde des ressources et des volumes

Velero est l’outil de référence pour sauvegarder un cluster Kubernetes. Il peut sauvegarder à la fois les ressources K8s (manifestes) et les données des volumes (via les snapshots CSI ou une copie de fichiers avec Restic/Kopia).

# Installation de Velero (avec le plugin AWS S3)
velero install \
  --provider aws \
  --plugins velero/velero-plugin-for-aws:v1.8.0 \
  --bucket mon-bucket-backup \
  --backup-location-config region=eu-west-1

# Créer une sauvegarde complète du namespace production
velero backup create backup-prod-20260321 \
  --include-namespaces production \
  --snapshot-volumes

# Lister les sauvegardes
velero backup get
# NAME                     STATUS      ERRORS   CREATED                  EXPIRES
# backup-prod-20260321     Completed   0        2026-03-21 10:30:00      29d

# Restaurer depuis une sauvegarde
velero restore create --from-backup backup-prod-20260321

# Snapshots CSI natifs (alternative moderne à Velero pour les volumes)
kubectl apply -f - <<EOF
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: snapshot-postgres-20260321
spec:
  volumeSnapshotClassName: csi-aws-vsc
  source:
    persistentVolumeClaimName: data-postgres-0
EOF

Récapitulatif#

Hide code cell source

data = {
    "Type de volume": ["emptyDir", "hostPath", "PV statique", "PV dynamique\n(StorageClass)", "CSI"],
    "Persistance": ["Non", "Oui (nœud)", "Oui", "Oui", "Oui"],
    "Partage entre Pods": ["Même Pod", "Même nœud", "Selon AccessMode", "Selon AccessMode", "Selon driver"],
    "Provisionnement": ["Auto", "Manuel", "Manuel (admin)", "Automatique", "Automatique"],
    "Cas d'usage": ["Cache/tmp", "Monitoring", "On-prem statique", "Cloud production", "Universel"],
}

df = pd.DataFrame(data)

fig, ax = plt.subplots(figsize=(14, 4))
ax.axis('off')

colors_row = ["#EBF5FB", "#FDFEFE", "#EBF5FB", "#FDFEFE", "#EBF5FB"]
table = ax.table(
    cellText=df.values,
    colLabels=df.columns,
    cellLoc='center',
    loc='center',
    cellColours=[[c]*len(df.columns) for c in colors_row]
)
table.auto_set_font_size(False)
table.set_fontsize(9)
table.scale(1, 2.2)

for j in range(len(df.columns)):
    table[0, j].set_facecolor("#2C3E50")
    table[0, j].set_text_props(color='white', fontweight='bold')

ax.set_title("Comparatif des types de volumes Kubernetes", fontsize=13,
             fontweight='bold', pad=20)
plt.tight_layout()
plt.savefig("13_recapitulatif.png", dpi=120, bbox_inches='tight')
plt.show()

Dans ce chapitre, nous avons résolu le problème du stockage éphémère en explorant l’écosystème complet de Kubernetes : de emptyDir pour les besoins temporaires aux StatefulSets avec PVCs dédiés pour les bases de données. Le prochain chapitre aborde l’exposition des services vers le monde extérieur via Ingress et Gateway API.