Aller au contenu

Robustness testing sur Kubernetes

Objectifs pédagogiques

À la fin de ce TP, vous devez être capables de :

  • disposer d'une observabilité suffisante pour mesurer un état stable avant de le perturber
  • traduire des SLO en thresholds k6 et en critères de réussite
  • conduire une campagne de charge nominale, de stress, de pic et d'endurance
  • prouver l'élasticité d'un service (HPA) et sa continuité (PodDisruptionBudget)
  • reconnaître les signatures d'une surcharge CPU (throttling) et mémoire (OOMKill)
  • mesurer le point de rupture d'un service et identifier son goulot d'étranglement
  • produire des preuves horodatées exploitables lors d'un audit

Prérequis

Consignes de sécurité

  • Ne ciblez que votre propre plateforme : un test de charge contre un service tiers est une attaque
  • Lancez k6 depuis le poste et non depuis la VM : un générateur de charge qui partage les ressources de sa cible fausse les mesures
  • Bornez toujours la durée des injections (--timeout pour stress-ng, durée des scénarios k6)
  • Prévoyez un retour arrière : le bootstrap de securepay-platform doit pouvoir recréer le cluster si une surcharge mémoire le rend instable

Fil conducteur du TP

La question posée par la direction est simple : « Jusqu'où la plateforme tient-elle, et que se passe-t-il au-delà ? »

Vous allez y répondre en trois temps :

  • Observer : rendre l'état stable mesurable (Atelier 0)
  • Contraindre : charge, stress, pic, endurance, surcharge machine et pod (Ateliers 1 à 4)
  • Conclure : point de rupture, goulot d'étranglement et dimensionnement argumenté (Atelier 5)

Protocole de preuve

Chaque test suit la même séquence, consignée dans docs/resilience.md :

  1. Hypothèse : ce qui doit rester vrai (ex : « à 50 req/s, 99,5 % de succès et p95 < 300 ms »)
  2. Injection : commande exacte, heure de début et de fin (UTC)
  3. Mesure : métriques observées (SLI, réplicas, CPU, mémoire, redémarrages)
  4. Résultat : hypothèse validée ou réfutée, écart chiffré
  5. Preuve : résumé k6 et captures Grafana nommés AAAA-MM-JJ_HHMM_<scenario>.<ext> dans docs/preuves/, et référencés dans docs/preuves/README.md

Une hypothèse réfutée est un résultat, pas un échec : elle révèle ce qu'il faut corriger.

Atelier 0 : Prérequis d'observabilité

Contexte

Sans mesure de l'état stable, un test de robustesse ne prouve rien. Avant toute injection, la plateforme doit exposer les SLI de l'API, l'état des pods et les ressources des nœuds.

Travail demandé

  1. Déployez kube-prometheus-stack par ArgoCD (ou helm upgrade --install le temps de la mise au point) dans le namespace monitoring, avec le nom de release kube-prometheus-stack et une version de chart épinglée.

Valeurs minimales pour k3s (observability/kube-prometheus-stack-values.yaml) :

# k3s embarque etcd, le scheduler, le controller-manager et kube-proxy
# dans son propre processus : leurs endpoints standards ne sont pas exposés
kubeEtcd:
  enabled: false
kubeControllerManager:
  enabled: false
kubeScheduler:
  enabled: false
kubeProxy:
  enabled: false

prometheus:
  prometheusSpec:
    retention: 7d
    # Reçoit les métriques envoyées par k6 (sortie remote write)
    enableRemoteWriteReceiver: true
    # Découvre les ServiceMonitor et PrometheusRule de tous les namespaces,
    # même sans le label de la release Helm
    serviceMonitorSelectorNilUsesHelmValues: false
    podMonitorSelectorNilUsesHelmValues: false
    ruleSelectorNilUsesHelmValues: false

Composants désactivés

Désactiver ces quatre cibles évite des alertes TargetDown permanentes, mais vous perdez leur supervision. Documentez ce choix dans un ADR : c'est un écart conscient, pas un oubli.

  1. Vérifiez que l'API expose ses métriques (http_requests_total et l'histogramme http_request_duration_seconds, avec les labels method, route et code) puis déclarez un ServiceMonitor.

Le Service de l'API doit nommer son port (http) :

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: securepay-api
  namespace: securepay
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: securepay-api
  endpoints:
    - port: http
      path: /metrics
      interval: 15s

NetworkPolicies

Si le namespace securepay applique un refus par défaut, autorisez explicitement le trafic entrant depuis le namespace monitoring sur le port de l'API, sinon la cible restera DOWN.

  1. Ajoutez les recording rules des SLI (observability/rules/securepay-sli.yaml) :
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: securepay-sli
  namespace: monitoring
spec:
  groups:
    - name: securepay-sli
      rules:
        - record: securepay:sli_errors:ratio_rate1m
          expr: |
            sum(rate(http_requests_total{job="securepay-api", route=~"/api/payments/.*", code=~"5.."}[1m]))
            /
            sum(rate(http_requests_total{job="securepay-api", route=~"/api/payments/.*"}[1m]))
        - record: securepay:sli_latency_under_300ms:ratio_rate1m
          expr: |
            sum(rate(http_request_duration_seconds_bucket{job="securepay-api", route=~"/api/payments/.*", le="0.3"}[1m]))
            /
            sum(rate(http_request_duration_seconds_count{job="securepay-api", route=~"/api/payments/.*"}[1m]))
        - record: securepay:latency_seconds:p95_rate1m
          expr: |
            histogram_quantile(0.95,
              sum by (le) (rate(http_request_duration_seconds_bucket{job="securepay-api", route=~"/api/payments/.*"}[1m])))

Une fenêtre d'une minute est volontairement courte : elle permet de lire l'effet de chaque palier de charge. Les alertes burn rate, elles, s'appuient sur des fenêtres plus longues.

  1. Créez un dashboard Grafana « Robustness » regroupant :

  2. débit, taux d'erreur et latence p95 de l'API (recording rules ci-dessus)

  3. nombre de réplicas courant et désiré du HPA (kube_horizontalpodautoscaler_status_current_replicas, kube_horizontalpodautoscaler_status_desired_replicas)
  4. CPU consommé et throttling des conteneurs de l'API
  5. mémoire container_memory_working_set_bytes des conteneurs de l'API, comparée à leur limite
  6. redémarrages (kube_pod_container_status_restarts_total)
  7. charge et mémoire disponible des nœuds (node_load1, node_memory_MemAvailable_bytes)

Requête de throttling CPU :

sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{namespace="securepay", container="securepay-api"}[1m]))
/
sum by (pod) (rate(container_cpu_cfs_periods_total{namespace="securepay", container="securepay-api"}[1m]))
  1. Exposez Prometheus sur le poste pour la suite du TP :
kubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090

Ce que vous devez observer

  • la cible securepay-api est UP dans Prometheus
  • les trois recording rules renvoient des valeurs dès que l'API reçoit du trafic
  • le dashboard affiche un état stable lisible : taux d'erreur nul, latence p95 stable

Critère de réussite de l'atelier 0

Une capture horodatée du dashboard au repos, référencée dans docs/preuves/README.md comme état stable de référence.

Atelier 1 : Campagne de tests k6

Contexte

Chaque type de test répond à une question différente : la charge nominale vérifie l'objectif, le stress cherche la limite, le pic teste l'élasticité, l'endurance révèle les dérives lentes (fuites mémoire, saturation progressive).

Travail demandé

  1. Versionnez les scénarios dans tests/robustness/ avec une bibliothèque commune.

tests/robustness/lib/payment.js :

import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://api.securepay.local';

export function payment() {
  const payload = JSON.stringify({
    amount: Math.round(Math.random() * 500) + 1,
    currency: 'EUR',
    recipientId: `merchant-${Math.floor(Math.random() * 1000)}`,
  });
  const res = http.post(`${BASE_URL}/api/payments/process`, payload, {
    headers: { 'Content-Type': 'application/json' },
    tags: { name: 'POST /api/payments/process' },
  });
  check(res, { 'paiement accepté (201)': (r) => r.status === 201 });
}

tests/robustness/lib/thresholds.js :

// Seuils dérivés des SLO : 99,5 % de succès et p95 < 300 ms
export const sloThresholds = {
  http_req_failed: ['rate<0.005'],
  http_req_duration: ['p(95)<300'],
};
  1. Écrivez les quatre scénarios. Les executors *-arrival-rate imposent un débit fixe, indépendant de la latence du service : c'est indispensable pour un test de robustesse, car un executor à nombre de VUs fixe ralentit sa propre charge quand le service ralentit.
import { payment } from './lib/payment.js';
import { sloThresholds } from './lib/thresholds.js';

export const options = {
  scenarios: {
    nominal: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.RATE || 20),
      timeUnit: '1s',
      duration: __ENV.DURATION || '10m',
      preAllocatedVUs: 20,
      maxVUs: 100,
    },
  },
  thresholds: sloThresholds,
};

export default function () {
  payment();
}
import { payment } from './lib/payment.js';

export const options = {
  scenarios: {
    paliers: {
      executor: 'ramping-arrival-rate',
      startRate: 20,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 500,
      stages: [
        { target: 50, duration: '1m' }, { target: 50, duration: '2m' },
        { target: 100, duration: '1m' }, { target: 100, duration: '2m' },
        { target: 200, duration: '1m' }, { target: 200, duration: '2m' },
        { target: 400, duration: '1m' }, { target: 400, duration: '2m' },
        { target: 800, duration: '1m' }, { target: 800, duration: '2m' },
      ],
    },
  },
  thresholds: {
    // Garde-fou : arrêt quand le service est franchement rompu
    http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '30s' }],
    // Seuil SLO informatif : son échec est attendu au-delà du point de rupture
    http_req_duration: ['p(95)<300'],
  },
};

export default function () {
  payment();
}
import { payment } from './lib/payment.js';
import { sloThresholds } from './lib/thresholds.js';

export const options = {
  scenarios: {
    pic: {
      executor: 'ramping-arrival-rate',
      startRate: 20,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 400,
      stages: [
        { target: 20, duration: '1m' },
        { target: 300, duration: '10s' },
        { target: 300, duration: '1m' },
        { target: 20, duration: '10s' },
        { target: 20, duration: '3m' },
      ],
    },
  },
  thresholds: sloThresholds,
};

export default function () {
  payment();
}
import { payment } from './lib/payment.js';
import { sloThresholds } from './lib/thresholds.js';

export const options = {
  scenarios: {
    endurance: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.RATE || 15),
      timeUnit: '1s',
      duration: __ENV.DURATION || '30m',
      preAllocatedVUs: 20,
      maxVUs: 100,
    },
  },
  thresholds: sloThresholds,
};

export default function () {
  payment();
}

Thresholds et paliers

Les thresholds k6 s'évaluent sur l'ensemble du test, pas palier par palier. Pour le stress, le seuil de latence sert d'indicateur global et le garde-fou abortOnFail sur le taux d'erreur borne le rayon d'impact. Le point de rupture se lit palier par palier dans Grafana (Atelier 5).

  1. Validez les scripts sans générer de charge :
cd tests/robustness
for f in load stress spike soak; do k6 inspect --execution-requirements "$f.js" > /dev/null && echo "$f OK"; done
  1. Exécutez chaque scénario en envoyant ses métriques dans Prometheus et en exportant le résumé :
export BASE_URL=http://api.securepay.local
export K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write
export K6_PROMETHEUS_RW_TREND_STATS="p(95),p(99),max"

TESTID="load-$(date -u +%Y%m%d-%H%M)"
k6 run -o experimental-prometheus-rw --tag testid="$TESTID" \
  --summary-export "../../docs/preuves/$(date -u +%Y-%m-%d_%H%M)_load-nominal.json" load.js

Le tag testid devient un label des séries k6_* dans Prometheus : il permet d'isoler chaque exécution dans Grafana. La sortie experimental-prometheus-rw est fournie avec k6 ; vérifiez ses options dans la documentation de la version que vous utilisez.

  1. Lancez soak.js en tâche de fond pendant les ateliers suivants, puis analysez la courbe de mémoire de l'API sur toute sa durée.

Ce que vous devez observer

  • la charge nominale respecte les deux thresholds (code de retour 0)
  • le stress finit par violer le seuil de latence, puis éventuellement le garde-fou d'erreurs (code de retour 99)
  • le pic dégrade temporairement la latence, puis le service revient à l'état stable
  • l'endurance montre une mémoire stable, ou au contraire une croissance continue qui signale une fuite

Critère de réussite de l'atelier 1

Quatre scénarios versionnés, exécutés, avec leurs résumés JSON et une capture Grafana par scénario dans le dossier de preuves.

Atelier 2 : Élasticité et continuité

Contexte

Un service robuste absorbe la hausse de charge en ajoutant des réplicas et reste disponible pendant les opérations de maintenance. Le HPA calcule l'utilisation CPU en pourcentage des requests : sans requests CPU, il ne peut pas fonctionner.

Travail demandé

  1. Fixez des requests et limits explicites dans le chart de l'API (valeurs de départ à ajuster) :
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 128Mi
  1. Ajoutez un HPA et un PodDisruptionBudget :
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: securepay-api
  namespace: securepay
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: securepay-api
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 120
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: securepay-api
  namespace: securepay
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: securepay-api

HPA et GitOps

Ne fixez pas spec.replicas dans le Deployment quand un HPA le pilote : ArgoCD et le HPA se disputeraient le nombre de réplicas. Si l'API est déployée par un Rollout Argo Rollouts, ciblez-le dans scaleTargetRef (apiVersion: argoproj.io/v1alpha1, kind: Rollout).

  1. Rejouez stress.js et suivez le HPA en parallèle :
kubectl -n securepay get hpa securepay-api -w
  1. Pendant load.js, drainez un agent pour vérifier le PDB :
kubectl drain k3d-securepay-agent-1 --ignore-daemonsets --delete-emptydir-data
kubectl uncordon k3d-securepay-agent-1

Ce que vous devez observer

  • le délai entre la hausse de charge et l'arrivée de nouveaux réplicas prêts : c'est la fenêtre que la capacité initiale doit absorber
  • le retour à 2 réplicas après la fenêtre de stabilisation
  • le drain qui respecte le PDB : au moins un pod reste disponible, le SLI ne chute pas
  • le plafond : à 6 réplicas, la latence se dégrade à nouveau

Critère de réussite de l'atelier 2

Une capture montrant sur la même période la charge k6, le nombre de réplicas et le SLI, avec le délai de scale-out mesuré.

Atelier 3 : Surcharge de la machine

Contexte

Les pods partagent les ressources de la VM. Une surcharge de l'hôte (processus voisin, sauvegarde, fuite d'un autre service) dégrade tous les services, même correctement dimensionnés.

Travail demandé

  1. Installez stress-ng sur la VM :
multipass exec securepay-prod -- sudo apt-get install -y stress-ng
  1. Pendant load.js, saturez le CPU de la VM pendant 5 minutes :
multipass exec securepay-prod -- stress-ng --cpu 4 --timeout 300s --metrics-brief
  1. Après retour à l'état stable, appliquez une pression mémoire progressive, toujours sous charge nominale :
multipass exec securepay-prod -- stress-ng --vm 2 --vm-bytes 50% --timeout 300s --metrics-brief
# Puis, si la plateforme tient :
multipass exec securepay-prod -- stress-ng --vm 2 --vm-bytes 75% --timeout 300s --metrics-brief

Pression mémoire

La mémoire demandée s'ajoute à celle déjà consommée par k3d et la stack d'observabilité. Au-delà de la mémoire disponible, l'OOM killer du noyau peut tuer des processus du cluster. Montez par paliers et gardez le bootstrap à portée de main.

  1. Pendant chaque injection, relevez l'état des nœuds :
kubectl describe nodes | grep -A6 Conditions
kubectl get events -A --field-selector reason=Evicted

Ce que vous devez observer

  • sous saturation CPU : hausse de la latence p95 et de node_load1, sans redémarrage de pod
  • sous pression mémoire : baisse de node_memory_MemAvailable_bytes, condition MemoryPressure et évictions éventuelles selon les seuils du kubelet
  • le retour (ou non) à l'état stable après la fin de l'injection, et son délai

Critère de réussite de l'atelier 3

Pour chaque injection, une entrée complète du protocole de preuve dans docs/resilience.md, avec l'impact chiffré sur le SLI.

Atelier 4 : OOMKill provoqué et redimensionnement

Contexte

Le CPU est une ressource compressible : au-delà de sa limite, un conteneur est bridé. La mémoire ne l'est pas : au-delà de sa limite, le noyau tue le processus et Kubernetes affiche OOMKilled. Savoir reconnaître et corriger cette signature est indispensable à l'exploitation.

Travail demandé

  1. Mesurez la mémoire de l'API au repos puis sous stress.js :
max by (pod) (container_memory_working_set_bytes{namespace="securepay", container="securepay-api"})
  1. Fixez une limite mémoire à peine supérieure à la consommation au repos (par Git, via ArgoCD), puis relancez stress.js.

  2. Identifiez l'OOMKill par trois sources concordantes :

kubectl -n securepay get pods
kubectl -n securepay describe pod <pod> | grep -A5 "Last State"
kube_pod_container_status_last_terminated_reason{namespace="securepay", reason="OOMKilled"}
  1. Redimensionnez de façon argumentée :

  2. limite mémoire = pic mesuré sous stress + marge (30 à 50 %)

  3. requests mémoire égales à la limite, pour éviter la surréservation du nœud
  4. taille de heap Node.js explicite et cohérente avec la limite (NODE_OPTIONS=--max-old-space-size=<environ 75 % de la limite, en Mo>), plutôt que de dépendre du comportement par défaut de la version de Node

  5. Rejouez stress.js pour valider la correction.

Ce que vous devez observer

  • la raison OOMKilled et le compteur de redémarrages qui augmente
  • l'impact sur le SLI pendant les redémarrages, atténué si plusieurs réplicas restent disponibles
  • l'absence de redémarrage après redimensionnement

Critère de réussite de l'atelier 4

Une preuve de l'OOMKill (sortie describe et métrique), puis une preuve de stabilité après correction, avec la justification chiffrée de la nouvelle limite.

Atelier 5 : Point de rupture et goulot d'étranglement

Contexte

Le point de rupture est le débit maximal que le service tient sous SLO. Au-delà, la question n'est plus « est-ce que ça casse ? » mais « comment ça casse ? » : dégradation progressive, erreurs franches, redémarrages en cascade.

Travail demandé

  1. Rejouez stress.js dans sa configuration finale (requests, limits, HPA et PDB en place).

  2. Pour chaque palier, relevez dans Grafana les valeurs côté serveur, en vous appuyant sur les recording rules de l'Atelier 0 :

Palier (req/s) Réplicas Taux d'erreur p95 serveur Throttling CPU SLO tenu ?
50
100
200
400
800

Le débit réellement servi se lit avec sum(rate(k6_http_reqs_total{testid="<testid>"}[1m])). Appuyez la latence sur les métriques serveur : elles se lisent par fenêtre d'une minute, alors que les statistiques de tendance exportées par k6 se prêtent mal à une lecture palier par palier.

  1. Identifiez le goulot d'étranglement : HPA au maximum, throttling CPU des pods, saturation des nœuds, Traefik, ou le processus Node.js mono-thread lui-même.

  2. Rédigez la conclusion dans docs/resilience.md : point de rupture, goulot, comportement au-delà, et action proposée (limite CPU, maxReplicas, optimisation applicative, nombre de nœuds).

Ce que vous devez observer

  • un palier à partir duquel le SLO n'est plus tenu
  • une cause unique ou dominante, visible dans les signaux USE (utilisation, saturation)
  • un comportement au-delà du point de rupture que vous savez décrire et justifier

Critère de réussite de l'atelier 5

Le tableau complété, le point de rupture chiffré, le goulot identifié et une recommandation de dimensionnement argumentée.

Questions de synthèse

  • Pourquoi un executor à débit d'arrivée fixe est-il préférable à un nombre de VUs fixe pour un test de robustesse ?
  • Que se passe-t-il si les requests CPU sont surdimensionnées ? Et sous-dimensionnées ?
  • Pourquoi la latence p95 côté client peut-elle être supérieure à celle mesurée par l'API ?
  • Un PDB vous protège-t-il de l'arrêt brutal d'un nœud ? Que faut-il en plus ?
  • Quelle limite CPU fixeriez-vous pour un processus Node.js mono-thread, et pourquoi ?
  • Quel test aurait détecté une fuite mémoire qui n'apparaît qu'après six heures ?

Livrables

  • tests/robustness/ : scénarios k6 et bibliothèque commune
  • observability/ : valeurs de la stack, ServiceMonitor, recording rules, dashboard exporté en JSON
  • docs/resilience.md : protocole de preuve complété pour chaque test et conclusion de l'Atelier 5
  • docs/preuves/ : résumés k6, captures horodatées et index README.md

Pour aller plus loin

  • Exécuter les scénarios dans le cluster avec le k6-operator (ressource TestRun), au plus près de la cible
  • Déclencher load.js en job planifié de la CI pour détecter les régressions de performance
  • Remplacer les recording rules manuelles par Sloth ou Pyrra et suivre l'error budget consommé par chaque campagne