Scalabilité et résilience#

Un des atouts majeurs de Kubernetes est sa capacité à adapter automatiquement les ressources à la charge et à maintenir la disponibilité en cas de défaillance. Ce chapitre couvre les mécanismes d’autoscaling (HPA, VPA, KEDA), la résilience (PDB, topologie, affinité) et l’ingénierie du chaos.

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 math
import random

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

HPA : Horizontal Pod Autoscaler#

Le Horizontal Pod Autoscaler ajuste automatiquement le nombre de réplicas d’un Deployment (ou StatefulSet) en fonction des métriques observées.

Configuration du HPA#

# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mon-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mon-api

  minReplicas: 2     # Jamais en dessous de 2 (haute disponibilité)
  maxReplicas: 20    # Jamais au-dessus de 20 (contrôle des coûts)

  metrics:
    # Scaling basé sur l'utilisation CPU
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70    # Cible : 70% du CPU request

    # Scaling basé sur la mémoire
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

  behavior:
    # Cooldown avant de réduire (éviter les oscillations)
    scaleDown:
      stabilizationWindowSeconds: 300    # Attendre 5 min avant de scale down
      policies:
        - type: Pods
          value: 1                       # Retirer au plus 1 Pod par fenêtre
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0     # Scale up immédiat
      policies:
        - type: Percent
          value: 100                    # Doubler au maximum par fenêtre
          periodSeconds: 15

Algorithme de scaling HPA#

L’algorithme HPA utilise la formule suivante pour calculer le nombre de réplicas désiré :

réplicas_désirés = ceil(réplicas_actuels × (métrique_actuelle / métrique_cible))
import math

def algorithme_hpa(replicas_actuels: int, metrique_actuelle: float,
                   metrique_cible: float, min_replicas: int,
                   max_replicas: int) -> int:
    """
    Simule l'algorithme de calcul du HPA Kubernetes.
    Retourne le nombre de réplicas désiré.
    """
    ratio = metrique_actuelle / metrique_cible
    replicas_desires = math.ceil(replicas_actuels * ratio)
    # Appliquer les bornes min/max
    return max(min_replicas, min(max_replicas, replicas_desires))


print("Simulation de l'algorithme HPA")
print("=" * 60)
print(f"Configuration : CPU cible = 70%, min=2, max=20")
print()
print(f"{'CPU actuel':<15} {'Réplicas actuels':<20} {'Réplicas désirés':<20} {'Action'}")
print("-" * 65)

scenarios = [
    (35, 5), (50, 5), (65, 5), (70, 5), (85, 5), (95, 5), (110, 5),
    (140, 10), (30, 10), (20, 8), (15, 4),
]

resultats = []
for cpu_actuel, replicas_actuels in scenarios:
    replicas_desires = algorithme_hpa(replicas_actuels, cpu_actuel, 70, 2, 20)
    ratio = cpu_actuel / 70

    if replicas_desires > replicas_actuels:
        action = f"↑ Scale UP : +{replicas_desires - replicas_actuels}"
    elif replicas_desires < replicas_actuels:
        action = f"↓ Scale DOWN : -{replicas_actuels - replicas_desires}"
    else:
        action = "= Stable"

    print(f"  {cpu_actuel}%{'':<12} {replicas_actuels:<20} {replicas_desires:<20} {action}")
    resultats.append((cpu_actuel, replicas_actuels, replicas_desires))
Simulation de l'algorithme HPA
============================================================
Configuration : CPU cible = 70%, min=2, max=20

CPU actuel      Réplicas actuels     Réplicas désirés     Action
-----------------------------------------------------------------
  35%             5                    3                    ↓ Scale DOWN : -2
  50%             5                    4                    ↓ Scale DOWN : -1
  65%             5                    5                    = Stable
  70%             5                    5                    = Stable
  85%             5                    7                    ↑ Scale UP : +2
  95%             5                    7                    ↑ Scale UP : +2
  110%             5                    8                    ↑ Scale UP : +3
  140%             10                   20                   ↑ Scale UP : +10
  30%             10                   5                    ↓ Scale DOWN : -5
  20%             8                    3                    ↓ Scale DOWN : -5
  15%             4                    2                    ↓ Scale DOWN : -2

Hide code cell source

# Simulation d'un pic de charge sur 24h
heures = np.linspace(0, 24, 288)  # 1 point toutes les 5 minutes

# Courbe de charge CPU simulée (pic le midi et le soir)
charge_cpu = (
    25 +
    30 * np.exp(-((heures - 12) ** 2) / 8) +   # Pic midi
    20 * np.exp(-((heures - 20) ** 2) / 5) +   # Pic soir
    5 * np.random.randn(len(heures))            # Bruit
)
charge_cpu = np.clip(charge_cpu, 10, 120)

# Calculer les réplicas correspondants
replicas_hpa = np.zeros(len(heures))
replicas_courant = 3
for i, cpu in enumerate(charge_cpu):
    replicas_courant = algorithme_hpa(replicas_courant, cpu, 70, 2, 10)
    replicas_hpa[i] = replicas_courant

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

# Graphique 1 : charge CPU
ax1 = axes[0]
ax1.fill_between(heures, charge_cpu, alpha=0.3, color="#f44336")
ax1.plot(heures, charge_cpu, color="#c62828", linewidth=1.5, label="Charge CPU (%)")
ax1.axhline(y=70, color="#ffa726", linestyle="--", linewidth=2, label="Cible HPA (70%)")
ax1.fill_between(heures, 70, charge_cpu, where=(charge_cpu > 70),
                  alpha=0.2, color="#f44336", label="Surcharge → Scale UP")
ax1.fill_between(heures, charge_cpu, 70, where=(charge_cpu < 70),
                  alpha=0.2, color="#42a5f5", label="Sous-charge → Scale DOWN")
ax1.set_ylabel("Utilisation CPU (%)")
ax1.set_title("Simulation HPA — Autoscaling sur 24h", fontweight="bold")
ax1.set_ylim(0, 130)
ax1.legend(loc="upper right", fontsize=9)
ax1.grid(True, alpha=0.3)
sns.despine(ax=ax1)

# Graphique 2 : réplicas
ax2 = axes[1]
ax2.step(heures, replicas_hpa, color="#1565c0", linewidth=2, label="Réplicas actifs", where="post")
ax2.fill_between(heures, replicas_hpa, step="post", alpha=0.2, color="#42a5f5")
ax2.axhline(y=2, color="#c8e6c9", linestyle="--", linewidth=1.5, label="min=2")
ax2.axhline(y=10, color="#ffcdd2", linestyle="--", linewidth=1.5, label="max=10")
ax2.set_xlabel("Heure de la journée")
ax2.set_ylabel("Nombre de réplicas")
ax2.set_xticks(range(0, 25, 2))
ax2.set_xticklabels([f"{h}h" for h in range(0, 25, 2)])
ax2.legend(loc="upper right", fontsize=9)
ax2.set_ylim(0, 12)
ax2.grid(True, alpha=0.3)
sns.despine(ax=ax2)

# Annotation coût estimé
cout_moyen = replicas_hpa.mean()
ax2.text(0.01, 0.97, f"Réplicas moyens : {cout_moyen:.1f}\nÉconomie vs max fixe (10) : "
         f"{(10 - cout_moyen)/10*100:.0f}%",
         transform=ax2.transAxes, va="top", fontsize=9, color="#1b5e20",
         bbox=dict(boxstyle="round,pad=0.3", facecolor="#e8f5e9", edgecolor="#2e7d32"))

plt.tight_layout()
plt.savefig("_static/19_hpa_simulation.png", dpi=130, bbox_inches="tight")
plt.show()
_images/9f3df4b3bb979ddf8c7958b0e64a36a713cc5861bbc43d7fde4fd0fb49d6f651.png

VPA : Vertical Pod Autoscaler#

Le Vertical Pod Autoscaler recommande (ou applique) les bons requests et limits pour les conteneurs, basés sur l’utilisation réelle.

# vpa.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: mon-api-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mon-api

  updatePolicy:
    updateMode: "Off"    # Off = recommandations seulement (ne touche pas aux Pods)
    # "Initial" = applique au démarrage des Pods
    # "Auto"    = redémarre les Pods pour appliquer les changements
    # "Off"     = recommandations uniquement → idéal pour démarrer avec VPA

  resourcePolicy:
    containerPolicies:
      - containerName: "*"
        minAllowed:
          cpu: "50m"
          memory: "64Mi"
        maxAllowed:
          cpu: "2"
          memory: "2Gi"
# Voir les recommandations VPA
kubectl describe vpa mon-api-vpa

# Résultat :
# Recommendation:
#   Container Recommendations:
#     Container Name: app
#       Lower Bound:  cpu: 50m, memory: 128Mi
#       Target:       cpu: 250m, memory: 512Mi   ← recommandation principale
#       Upper Bound:  cpu: 1, memory: 1Gi

HPA + VPA : compatibilité

Ne pas utiliser HPA (basé CPU) et VPA (mode Auto) ensemble sur le même Deployment — ils entrent en conflit. La bonne pratique est :

  • VPA en mode Off pour des recommandations de sizing initial

  • HPA pour le scaling horizontal en production

  • Ou KEDA (événementiel) qui peut compléter les deux

KEDA : scaling basé sur les événements#

KEDA (Kubernetes Event-Driven Autoscaling) permet de scaler des Deployments basé sur des métriques externes : longueur d’une file de messages, lag Kafka, requêtes HTTP en attente…

# keda-scaledobject.yaml — Scaler selon la longueur d'une file RabbitMQ
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker-scaler
spec:
  scaleTargetRef:
    name: worker-deployment
  minReplicaCount: 0         # Peut descendre à 0 ! (scale to zero)
  maxReplicaCount: 50
  triggers:
    - type: rabbitmq
      metadata:
        queueName: "taches-a-traiter"
        mode: QueueLength
        value: "10"           # 1 réplica pour 10 messages en attente
        host: amqp://rabbitmq.production.svc:5672/

    # Trigger basé sur le cron (en plus de la file)
    - type: cron
      metadata:
        timezone: Europe/Paris
        start: "0 8 * * 1-5"    # Lundi-vendredi 8h : pré-chauffer
        end: "0 20 * * 1-5"
        desiredReplicas: "3"

Cluster Autoscaler : ajouter des nœuds automatiquement#

Le Cluster Autoscaler surveille les Pods en état Pending (aucun nœud disponible pour les placer) et ajoute automatiquement des nœuds via l’API du cloud provider.

# Installer le Cluster Autoscaler (exemple GKE)
# Les fournisseurs cloud ont des intégrations natives :
# GKE : Node Auto-provisioning intégré
# EKS : Cluster Autoscaler ou Karpenter
# AKS : Cluster Autoscaler intégré

# Annoter le node group pour l'autoscaling
kubectl annotate nodegroup mon-node-group \
  cluster-autoscaler.kubernetes.io/node-template/label/node-type=general

# Vérifier les décisions du Cluster Autoscaler
kubectl logs -n kube-system -l app=cluster-autoscaler --tail=50

PodDisruptionBudget : disponibilité pendant les maintenances#

Un PodDisruptionBudget (PDB) garantit qu’un certain nombre minimum de Pods reste disponible lors des disruptions volontaires (mise à jour de nœud, drain pour maintenance…).

# pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: mon-api-pdb
spec:
  # Option 1 : nombre minimum de Pods disponibles
  minAvailable: 2

  # Option 2 : nombre maximum de Pods indisponibles
  # maxUnavailable: 1

  selector:
    matchLabels:
      app: mon-api
# Lors d'un drain de nœud, le PDB est respecté
kubectl drain node-1 --ignore-daemonsets
# Si le PDB l'empêche, kubectl drain attend que les conditions soient réunies

Quand le PDB est-il utile ?

Le PDB protège contre les disruptions volontaires (maintenance, mise à jour K8s, redimensionnement du cluster), pas contre les pannes matérielles. Configurez un PDB pour tout service qui ne doit pas descendre à 0 réplica pendant les opérations de maintenance.

topologySpreadConstraints : distribution géographique#

Les topologySpreadConstraints permettent de distribuer les Pods de manière équilibrée sur des zones de disponibilité ou des nœuds.

# deployment avec spread contraints
spec:
  template:
    spec:
      topologySpreadConstraints:
        # Distribuer sur les zones de disponibilité
        - maxSkew: 1                         # Différence max de 1 Pod entre zones
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule   # Refuser si non satisfait
          labelSelector:
            matchLabels:
              app: mon-api

        # Distribuer aussi entre les nœuds
        - maxSkew: 2
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway  # Essayer mais ne pas bloquer
          labelSelector:
            matchLabels:
              app: mon-api

Affinity et anti-affinity#

Les règles d”affinity et d”anti-affinity contrôlent plus finement le placement des Pods.

spec:
  affinity:
    # Anti-affinity : éviter que deux Pods de la même app soient sur le même nœud
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:   # Règle dure (obligatoire)
        - labelSelector:
            matchLabels:
              app: mon-api
          topologyKey: kubernetes.io/hostname

    # Affinity nœud : préférer les nœuds avec des SSDs
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:  # Règle souple (préférence)
        - weight: 80
          preference:
            matchExpressions:
              - key: storage-type
                operator: In
                values: ["ssd"]

Hide code cell source

# Visualisation : distribution des Pods avec et sans topologySpreadConstraints
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

zones_data = {
    "Sans contraintes": {
        "Zone A (eu-west-1a)": 7,
        "Zone B (eu-west-1b)": 2,
        "Zone C (eu-west-1c)": 1,
    },
    "Avec topologySpreadConstraints\n(maxSkew=1)": {
        "Zone A (eu-west-1a)": 4,
        "Zone B (eu-west-1b)": 3,
        "Zone C (eu-west-1c)": 3,
    }
}

def dessiner_zones(ax, titre, zones, total_pods):
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 8)
    ax.axis("off")
    ax.set_title(titre, fontsize=11, fontweight="bold")

    zone_colors = ["#bbdefb", "#c8e6c9", "#fff9c4"]
    x_positions = [1.8, 5.0, 8.2]

    for (zone_nom, n_pods), x, color in zip(zones.items(), x_positions, zone_colors):
        # Zone box
        z_box = FancyBboxPatch((x - 1.5, 1.0), 3.0, 5.5, boxstyle="round,pad=0.15",
                                facecolor=color, edgecolor="#546e7a", linewidth=2, alpha=0.6)
        ax.add_patch(z_box)
        ax.text(x, 6.25, zone_nom.replace(" (", "\n("), ha="center", va="center",
                fontsize=8, fontweight="bold", color="#37474f", linespacing=1.3)

        # Pods (représentés par des cercles)
        cols = 2
        for i in range(n_pods):
            row = i // cols
            col = i % cols
            px = x - 0.4 + col * 0.9
            py = 5.5 - row * 0.9
            circle = plt.Circle((px, py), 0.35, facecolor="#42a5f5",
                                  edgecolor="#1565c0", linewidth=1.5)
            ax.add_patch(circle)
            ax.text(px, py, str(i+1), ha="center", va="center",
                    fontsize=7, color="white", fontweight="bold")

        ax.text(x, 1.3, f"{n_pods} Pods", ha="center", va="center",
                fontsize=10, fontweight="bold", color="#1565c0")

    # Indicateur de déséquilibre
    valeurs = list(zones.values())
    skew = max(valeurs) - min(valeurs)
    couleur_skew = "#c62828" if skew > 2 else "#2e7d32"
    ax.text(5, 0.4, f"Déséquilibre max (skew) : {skew} Pods", ha="center", va="center",
            fontsize=10, fontweight="bold", color=couleur_skew,
            bbox=dict(boxstyle="round,pad=0.3",
                      facecolor="#ffebee" if skew > 2 else "#e8f5e9",
                      edgecolor=couleur_skew))

for ax, (titre, zones) in zip(axes, zones_data.items()):
    dessiner_zones(ax, titre, zones, sum(zones.values()))

plt.tight_layout()
plt.savefig("_static/19_topology_spread.png", dpi=130, bbox_inches="tight")
plt.show()
_images/9bb55a5cbbce84971b4bb812816ab8c383ae03588a7000335350d1979d7f13d5.png

QoS Classes : garanties de ressources#

Kubernetes classe les Pods en trois catégories de Qualité de Service selon leurs requests et limits.

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 5.5))
ax.set_xlim(0, 13)
ax.set_ylim(0, 6)
ax.axis("off")
ax.set_title("Classes QoS Kubernetes — Ressources et priorité d'éviction",
             fontsize=13, fontweight="bold", pad=12)

classes_qos = [
    (
        2.2, "Guaranteed", "#c8e6c9", "#2e7d32",
        "requests == limits\npour CPU et mémoire",
        "Priorité maximale\nN'est jamais évicté\nsauf OOM critique",
        "Production, BDD\napps critiques",
        0,
    ),
    (
        6.5, "Burstable", "#fff9c4", "#f9a825",
        "requests < limits\nou requests partiels",
        "Priorité intermédiaire\nÉvicté si nœud sous\npression mémoire",
        "La plupart des apps\nen production",
        1,
    ),
    (
        10.8, "BestEffort", "#ffcdd2", "#c62828",
        "Pas de requests\nni de limits définis",
        "Priorité minimale\nPremier évicté\nen cas de pression",
        "Jobs de calcul\nnon critiques",
        2,
    ),
]

for x, nom, fc, ec, config, comportement, use_case, priorite in classes_qos:
    # Boîte principale
    main_box = FancyBboxPatch((x - 2.0, 0.5), 4.0, 5.0, boxstyle="round,pad=0.15",
                               facecolor=fc, edgecolor=ec, linewidth=2.5)
    ax.add_patch(main_box)

    # Titre
    ax.text(x, 5.15, nom, ha="center", va="center",
            fontsize=12, fontweight="bold", color=ec)

    # Numéro de priorité d'éviction
    prio_circle = plt.Circle((x + 1.5, 5.15), 0.3, facecolor=ec, edgecolor="white", linewidth=1.5)
    ax.add_patch(prio_circle)
    ax.text(x + 1.5, 5.15, f"{priorite+1}", ha="center", va="center",
            fontsize=10, color="white", fontweight="bold")

    # Configuration
    ax.text(x, 4.15, "Configuration :", ha="center", va="center",
            fontsize=8.5, fontweight="bold", color="#37474f")
    ax.text(x, 3.7, config, ha="center", va="center",
            fontsize=8.5, color="#37474f", linespacing=1.4)

    # Comportement
    ax.text(x, 2.85, "Éviction :", ha="center", va="center",
            fontsize=8.5, fontweight="bold", color=ec)
    ax.text(x, 2.35, comportement, ha="center", va="center",
            fontsize=8.5, color="#37474f", linespacing=1.4)

    # Use case
    ax.text(x, 1.3, "Utilisation :", ha="center", va="center",
            fontsize=8.5, fontweight="bold", color="#546e7a")
    ax.text(x, 0.85, use_case, ha="center", va="center",
            fontsize=8.5, color="#546e7a", linespacing=1.4)

# Flèche de priorité d'éviction
ax.annotate("", xy=(12.5, 2.5), xytext=(0.5, 2.5),
            arrowprops=dict(arrowstyle="<-", color="#9e9e9e", lw=2))
ax.text(6.5, 0.1, "← Plus résistant à l'éviction                                  "
        "Évicté en premier →",
        ha="center", va="center", fontsize=8.5, color="#9e9e9e")

plt.tight_layout()
plt.savefig("_static/19_qos_classes.png", dpi=130, bbox_inches="tight")
plt.show()
_images/6b211046fd9776ccd3fb9653d4eac5455de9fc37dac8a9f5e9bd115c2eae429b.png

Chaos Engineering : tester la résilience#

L”ingénierie du chaos consiste à introduire volontairement des défaillances pour vérifier que le système reste résilient.

Principe de Chaos Engineering

« Identifier les faiblesses du système avant qu’une panne réelle ne les révèle. »

Processus :

  1. Définir l’état stable (métriques nominales : latence P99, taux d’erreur…)

  2. Formuler une hypothèse (« Si je tue 30% des Pods, le service reste disponible »)

  3. Injecter la défaillance en production ou staging

  4. Observer si l’hypothèse est vérifiée

  5. Corriger si non

Outils de chaos engineering pour Kubernetes :

# Chaos Mesh — injection de pannes dans K8s
kubectl apply -f https://mirrors.chaos-mesh.org/v2.6.1/install.yaml

# Exemple : tuer des Pods aléatoirement (comme Netflix Chaos Monkey)
# podcast-kill.yaml
# chaos-pod-kill.yaml
apiVersion: chaos-mesh.org/v1alpha1
kind: PodChaos
metadata:
  name: kill-api-pods
spec:
  action: pod-kill
  mode: one           # Tuer 1 Pod aléatoire parmi le sélecteur
  selector:
    namespaces: ["production"]
    labelSelectors:
      app: mon-api
  scheduler:
    cron: "@every 10m"  # Toutes les 10 minutes
# chaos-network-latency.yaml — Injecter de la latence réseau
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
  name: latence-api-db
spec:
  action: delay
  mode: all
  selector:
    labelSelectors:
      app: mon-api
  delay:
    latency: "200ms"    # Ajouter 200ms de latence
    correlation: "50"
    jitter: "50ms"
  direction: to
  target:
    selector:
      labelSelectors:
        app: postgres
  duration: "5m"

Simulation Python : algorithme HPA et simulation de chaos#

import random
import math
from dataclasses import dataclass, field
from typing import List, Tuple

@dataclass
class SimulateurHPA:
    """Simulation complète d'un HPA avec stabilisation et cooldown."""
    nom: str
    min_replicas: int
    max_replicas: int
    cible_cpu: float              # Cible en % (ex: 70)
    stabilisation_scale_down: int  # Secondes de cooldown avant scale down
    tick_secondes: int = 15        # Intervalle de réévaluation

    def simuler(self, charges: List[float]) -> Tuple[List[float], List[int]]:
        """
        Simule le comportement du HPA sur une série de charges CPU.
        Retourne (charges, réplicas_par_tick).
        """
        replicas = self.min_replicas
        historique_replicas = []
        fenetre_stabilisation = []  # Historique pour le cooldown

        for charge in charges:
            # Calcul du nombre désiré
            desires = math.ceil(replicas * (charge / self.cible_cpu))
            desires = max(self.min_replicas, min(self.max_replicas, desires))

            # Stabilisation au scale down : prendre le max sur la fenêtre
            fenetre_stabilisation.append(desires)
            ticks_stabilisation = self.stabilisation_scale_down // self.tick_secondes
            if len(fenetre_stabilisation) > ticks_stabilisation:
                fenetre_stabilisation.pop(0)

            if desires < replicas:
                # Scale down : utiliser le max de la fenêtre (conservateur)
                desires = max(fenetre_stabilisation)
            # Scale up : immédiat

            replicas = desires
            historique_replicas.append(replicas)

        return charges, historique_replicas


@dataclass
class SimulateurChaos:
    """Simulation de l'impact du chaos engineering sur un service."""

    def simuler_pod_kill(self, n_replicas: int, n_tues: int) -> dict:
        """Simule le kill de n_tues Pods et le retour à l'état normal."""
        disponibles = n_replicas - n_tues
        taux_disponibilite = disponibles / n_replicas * 100

        # Temps de récupération estimé (création d'un nouveau Pod)
        temps_recovery_s = 30 + random.randint(5, 20)  # 30-50s typique

        return {
            "replicas_avant": n_replicas,
            "pods_tues": n_tues,
            "disponibles_pendant_chaos": disponibles,
            "taux_disponibilite": taux_disponibilite,
            "temps_recovery_s": temps_recovery_s,
            "impact_service": "MAJEUR" if taux_disponibilite < 50
                              else ("MODÉRÉ" if taux_disponibilite < 80 else "FAIBLE"),
        }

    def simuler_latence_reseau(self, latence_ajoutee_ms: float,
                                latence_baseline_ms: float,
                                timeout_ms: float) -> dict:
        """Simule l'impact d'une injection de latence réseau."""
        latence_totale = latence_baseline_ms + latence_ajoutee_ms
        taux_timeout = max(0, (latence_totale - timeout_ms * 0.8) / timeout_ms) * 100

        return {
            "latence_baseline": f"{latence_baseline_ms}ms",
            "latence_ajoutee": f"+{latence_ajoutee_ms}ms",
            "latence_totale": f"{latence_totale}ms",
            "timeout_config": f"{timeout_ms}ms",
            "taux_timeout_estime": f"{min(100, taux_timeout):.1f}%",
            "recommandation": "Augmenter le timeout ou optimiser les requêtes DB"
                              if taux_timeout > 10 else "Système résilient à cette latence",
        }


# Test HPA
hpa_sim = SimulateurHPA(
    nom="api-hpa",
    min_replicas=2,
    max_replicas=15,
    cible_cpu=70,
    stabilisation_scale_down=300,  # 5 minutes
    tick_secondes=15,
)

# Charges simulées (288 ticks = 72 minutes à 15s/tick)
charges_test = (
    [40] * 20 +               # Charge normale
    [80, 90, 100, 110, 95] * 8 +  # Montée en charge
    [120, 130, 115, 105] * 5 +    # Pic
    [85, 70, 60, 50, 40] * 8 +    # Redescente
    [30] * 20                    # Calme
)

charges, replicas = hpa_sim.simuler(charges_test)

# Tests chaos
chaos = SimulateurChaos()

print("=" * 55)
print("Simulation de Chaos Engineering")
print("=" * 55)

for n_replicas, n_tues in [(5, 1), (5, 2), (5, 3), (10, 3), (2, 1)]:
    res = chaos.simuler_pod_kill(n_replicas, n_tues)
    print(f"\n  Kill {n_tues}/{n_replicas} Pods :")
    print(f"    Disponibilité : {res['taux_disponibilite']:.0f}% — Impact : {res['impact_service']}")
    print(f"    Recovery estimé : {res['temps_recovery_s']}s")

print("\n--- Injection de latence ---")
for latence in [50, 150, 300, 500]:
    res = chaos.simuler_latence_reseau(latence, 20, 500)
    print(f"  +{latence}ms : latence totale {res['latence_totale']}, "
          f"timeout estimé : {res['taux_timeout_estime']}")
    if float(res["taux_timeout_estime"].rstrip("%")) > 10:
        print(f"    → {res['recommandation']}")
=======================================================
Simulation de Chaos Engineering
=======================================================

  Kill 1/5 Pods :
    Disponibilité : 80% — Impact : FAIBLE
    Recovery estimé : 38s

  Kill 2/5 Pods :
    Disponibilité : 60% — Impact : MODÉRÉ
    Recovery estimé : 35s

  Kill 3/5 Pods :
    Disponibilité : 40% — Impact : MAJEUR
    Recovery estimé : 43s

  Kill 3/10 Pods :
    Disponibilité : 70% — Impact : MODÉRÉ
    Recovery estimé : 42s

  Kill 1/2 Pods :
    Disponibilité : 50% — Impact : MODÉRÉ
    Recovery estimé : 42s

--- Injection de latence ---
  +50ms : latence totale 70ms, timeout estimé : 0.0%
  +150ms : latence totale 170ms, timeout estimé : 0.0%
  +300ms : latence totale 320ms, timeout estimé : 0.0%
  +500ms : latence totale 520ms, timeout estimé : 24.0%
    → Augmenter le timeout ou optimiser les requêtes DB

Hide code cell source

# Visualisation de la simulation HPA complète
fig, axes = plt.subplots(2, 1, figsize=(14, 7), sharex=True)

temps_min = [i * 15 / 60 for i in range(len(charges))]

ax1 = axes[0]
ax1.fill_between(temps_min, charges, alpha=0.25, color="#f44336")
ax1.plot(temps_min, charges, color="#c62828", linewidth=1.5, label="CPU (%)")
ax1.axhline(y=70, color="#ffa726", linestyle="--", linewidth=2, label="Cible 70%", alpha=0.8)
ax1.set_ylabel("CPU (%)")
ax1.set_title("Simulation HPA — Charge et réponse en réplicas", fontweight="bold")
ax1.set_ylim(0, 145)
ax1.legend(loc="upper right", fontsize=9)
ax1.grid(True, alpha=0.3)
sns.despine(ax=ax1)

ax2 = axes[1]
ax2.step(temps_min, replicas, color="#1565c0", linewidth=2.5,
         label="Réplicas (avec stabilisation)", where="post")
ax2.fill_between(temps_min, replicas, step="post", alpha=0.2, color="#42a5f5")
ax2.axhline(y=2,  color="#c8e6c9", linestyle="--", linewidth=1.5, label="min=2")
ax2.axhline(y=15, color="#ffcdd2", linestyle="--", linewidth=1.5, label="max=15")
ax2.set_xlabel("Temps (minutes)")
ax2.set_ylabel("Réplicas")
ax2.legend(loc="upper right", fontsize=9)
ax2.set_ylim(0, 18)
ax2.grid(True, alpha=0.3)

# Annotation scale-up/down
peak_idx = charges.index(max(charges))
peak_t = temps_min[peak_idx]
ax1.annotate(f"Pic : {max(charges):.0f}%",
             xy=(peak_t, max(charges)), xytext=(peak_t - 5, 135),
             fontsize=9, color="#c62828",
             arrowprops=dict(arrowstyle="->", color="#c62828"))

sns.despine(ax=ax2)
plt.tight_layout()
plt.savefig("_static/19_hpa_full.png", dpi=130, bbox_inches="tight")
plt.show()
_images/cdd3227db5519f8ac9e59ceb6216d0c1c897a2faeefca658a36660ac3ee0d093.png

Points clés à retenir#

  • Le HPA scale horizontalement (nombre de Pods) en fonction de métriques (CPU, mémoire, custom) avec stabilizationWindowSeconds pour éviter les oscillations

  • Le VPA recommande les bons requests/limits — commencer en mode Off pour observer, avant de passer en mode Initial ou Auto

  • KEDA permet le scale-to-zero et le scaling basé sur des métriques externes (queues, Kafka, cron) que HPA ne supporte pas nativement

  • Le PodDisruptionBudget garantit qu’un minimum de Pods reste disponible pendant les maintenances volontaires

  • topologySpreadConstraints distribue les Pods équitablement sur les zones de disponibilité — essentiel pour la résilience multi-zone

  • Les classes QoS (Guaranteed/Burstable/BestEffort) déterminent l’ordre d’éviction en cas de pression mémoire — utiliser Guaranteed pour les services critiques

  • L”ingénierie du chaos (Chaos Mesh, Litmus) permet de valider la résilience avant qu’une vraie panne ne le fasse