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

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

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

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

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

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

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

```yaml
# 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"
```

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

```{admonition} HPA + VPA : compatibilité
:class: warning
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...

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

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

```yaml
# 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
```

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

```{admonition} Quand le PDB est-il utile ?
:class: tip
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.

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

```yaml
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"]
```

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

## QoS Classes : garanties de ressources

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

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

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

```{admonition} Principe de Chaos Engineering
:class: tip
"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 :**

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

```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
```

```yaml
# 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

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

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

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