درس 5 — Exploitation en production

Autoscaling et test de charge

9 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Autoscaling et test de charge

Le lundi à 9 h, 300 salariés de Ferrand Médical rouvrent leur session et une partie d'entre eux bute sur le VPN. Le nombre de questions à l'assistant est multiplié par dix pendant une demi-heure, puis retombe. Deux pods fixes coûtent trop la nuit et ne suffisent pas le lundi. L'HorizontalPodAutoscaler (HPA) ajuste le nombre de pods, à condition de lui donner un signal qui monte vraiment avec la charge. Pour un service qui attend un modèle distant, le CPU n'est pas ce signal : cette leçon le mesure.

Le mécanisme

Toutes les 15 secondes, l'HPA lit la métrique, calcule répliques voulues = ceil(répliques actuelles × valeur mesurée / cible), puis applique les règles de behavior qui limitent la vitesse de montée et de descente.

sequenceDiagram
  participant L as "Test de charge (24 clients)"
  participant P as "Pods API"
  participant MS as "metrics-server"
  participant H as "HPA"
  participant D as "Deployment"
  L->>P: "POST /v1/ask (backend fake, 40 ms de CPU)"
  MS->>P: "lecture CPU (kubelet) toutes les 15 s"
  H->>MS: "CPU moyen = 197 % des requests"
  H->>H: "ceil(2 × 197 / 60) = 7, limité à +2 pods / 15 s"
  H->>D: "replicas = 4, puis 6 (maxReplicas)"
  Note over H,D: "Fin de charge : 60 s de stabilisation, puis -1 pod / 30 s"
files/chart/inference/templates/hpa.yaml
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - { type: Pods, value: 2, periodSeconds: 15 }
    scaleDown:
      stabilizationWindowSeconds: 60
      policies:
        - { type: Pods, value: 1, periodSeconds: 30 }

Le pourcentage est calculé par rapport aux requests (250m), pas aux limits : 200 % signifie 500m consommés. La fenêtre de descente vaut 300 s par défaut dans Kubernetes ; le lab la réduit à 60 s pour voir la descente sans attendre.

La mise en œuvre

make load bascule la release en backend fake (40 ms de CPU par requête, aucun appel Gemini), envoie 24 clients en parallèle pendant 180 s, observe 150 s de plus, puis remet la configuration normale. Le script loadtest.py interroge l'HPA toutes les 15 s.

terminal
make load

Le CPU ne suit pas la charge d'un proxy Gemini

Le même script, lancé contre le backend gemini réel avec 8 clients, a donné : 440 requêtes, 2,2 requêtes/s, latence médiane 3,6 s, CPU des pods entre 1 % et 4 %. L'HPA est resté à 2 pods. Un pod qui attend Gemini ne calcule rien : il tient des connexions ouvertes. Si la charge décuple, la latence et le nombre de requêtes en cours décuplent, le CPU à peine.

Le bon signal est le nombre de requêtes en cours par pod, exposé par /metrics (inference_inflight_requests). D'après la loi de Little, requêtes en cours = débit × latence : 2,2 req/s × 3,6 s ≈ 8 requêtes en cours sur 2 pods, soit 4 par pod. Avec une cible de 8 par pod et un délestage à 32, le service garde de la marge avant de refuser du travail.

Le chart prévoit cette métrique, désactivée par défaut parce qu'elle exige un composant qui la serve à l'API custom.metrics.k8s.io :

files/chart/inference/templates/hpa.yaml
    {{- if .Values.autoscaling.inflight.enabled }}
    - type: Pods
      pods:
        metric: { name: inference_inflight_requests }
        target: { type: AverageValue, averageValue: {{ .Values.autoscaling.inflight.averageValue | quote }} }
    {{- end }}

Deux façons de la servir :

OptionFonctionnementRecommandation
Prometheus + prometheus-adapterPrometheus collecte /metrics, l'adapter traduit une requête PromQL en métrique par podsi Prometheus existe déjà (projet suivant) ; une règle seriesQuery: 'inference_inflight_requests{namespace!="",pod!=""}' suffit
KEDAun ScaledObject interroge Prometheus et pilote un HPA qu'il crée lui-mêmesi vous voulez aussi descendre à 0 pod la nuit, ce que l'HPA seul ne fait pas

Cette métrique n'a pas été exercée sur le cluster de ce projet (pas de Prometheus ici) ; le test de rendu test_custom_metric_is_optional vérifie le manifeste.

Ce qui casse en production

Le premier test de charge (image 0.1.0) a produit 3 réponses 502 sur 31 794. Les journaux de l'Ingress donnent la cause :

[error] recv() failed (104: Connection reset by peer) while reading response header from upstream,
  upstream: "http://10.244.1.8:8000/v1/ask"

NGINX garde ses connexions vers les pods ouvertes 60 s pour les réutiliser ; uvicorn ferme une connexion inactive après 5 s. Quand NGINX envoie une requête sur une connexion que uvicorn ferme au même instant, la requête est perdue. Correctif : --timeout-keep-alive 75 dans l'image 0.2.0, plus long que le délai de NGINX, pour que ce soit toujours le proxy qui ferme. Résultat avec 0.2.0 : 0 erreur sur 34 356 requêtes, latence maximale 817 ms au lieu de 4 501 ms.

SymptômeCauseParade
502 rares et aléatoires sous charge, Connection reset by peerkeep-alive du serveur plus court que celui du proxykeep-alive applicatif > keep-alive du proxy (75 s contre 60 s)
L'HPA ne bouge pas alors que la latence triplemétrique CPU sur un service qui attend une API distantemétrique de requêtes en cours (prometheus-adapter ou KEDA)
Oscillation 2 → 6 → 2 → 6 toutes les 2 minutesfenêtre de descente trop courte pour une charge en dents de sciestabilizationWindowSeconds de 300 s en production
Débit plafonné alors que l'HPA est au maximummaxReplicas atteint, ou quota Gemini atteintlire currentReplicas == maxReplicas et les 429 du modèle avant d'augmenter maxReplicas
Nouveaux pods prêts mais 429 Gemini en hausseplus de pods n'augmente pas le quota du projet Geminiplafond de maxReplicas aligné sur le quota, délestage

La règle de décision

  • CPU pour un service qui calcule (prétraitement, modèle local, embeddings sur CPU). Requêtes en cours pour un service qui attend un modèle distant.
  • maxReplicas se déduit du quota du fournisseur, pas de la taille du cluster : 60 pods qui partagent un quota de 1 000 requêtes par minute ne font pas plus de réponses que 6.
  • Montée rapide, descente lente : scaleUp sans stabilisation, scaleDown à 300 s en production.

Pratique

Votre assistant (proxy Gemini) a une latence p95 qui passe de 4 s à 25 s chaque lundi à 9 h. L'HPA CPU à 60 % reste à 2 pods, CPU mesuré à 5 %. Que changez-vous ?

Pendant le test de charge, l'HPA mesure 197 % de CPU sur 2 pods avec une cible de 60 %. La politique de montée autorise +2 pods toutes les 15 s, maxReplicas vaut 6. Combien de pods 15 s plus tard ?

Exercice

À partir du tableau mesuré ci-dessous, estimez le débit qu'apporterait maxReplicas: 10, et dites ce qu'il faudrait vérifier avant de l'appliquer sur un service qui appelle vraiment Gemini.

Voir la solution

Avec 6 pods saturés, le débit plafonne autour de 200 requêtes/s, soit environ 33 requêtes/s par pod. À 10 pods, on peut espérer environ 330 requêtes/s en mode fake, à condition que les nœuds aient le CPU libre (les deux nœuds kind consommaient déjà 3,6 et 1,8 cœurs).

Avec Gemini, le facteur limitant change : vérifiez d'abord le quota de requêtes par minute du projet et le taux de 429 actuel. Si le quota est déjà atteint, 10 pods produiront le même nombre de réponses et plus d'erreurs ; il faut alors augmenter le quota ou délester, pas ajouter des pods.

Résultat obtenu

Exécution du 2026-10-15, make load sur l'image 0.1.0 (backend fake, 24 clients, 180 s de charge puis 150 s d'observation) :

 t(s) répliques    cpu  req/s  p95(ms)
   15         2      ?   78.3      355
   30         2   197%  117.9      302
   45         4   298%  166.3      224
   60         6   383%  202.7      182
   90         6   362%  200.1      182
  136         6   353%  204.2      178
  181         6   343%  170.9      193
  196         6   344%    0.0        0     <- fin de la charge
  226         6    11%    0.0        0
  286         5     0%    0.0        0     <- 60 s de stabilisation écoulées
  317         4     0%    0.0        0
  332         4     0%    0.0        0

requêtes : 31794  codes : {200: 31791, 502: 3}  pods distincts : 7
latence p50 124 ms, max 4501 ms

Même test sur l'image 0.2.0 (keep-alive corrigé) : requêtes : 34356 codes : {200: 34356}, latence p50 121 ms, max 817 ms. Le débit plafonne à 200 requêtes/s parce que les 6 pods sont à leur limite de 1 CPU (350 % des requests de 250m).

Mode gemini réel, 8 clients, même jour : 440 requêtes, 0 erreur, 2,2 requêtes/s, latence médiane 3 647 ms, CPU de 1 % à 4 %, 2 pods du début à la fin.

Je sais faire

  • Calculer à la main la décision d'un HPA à partir de la métrique et de behavior
  • Mesurer un autoscaling avec un test de charge reproductible et sans coût de modèle
  • Expliquer pourquoi le CPU ne pilote pas un proxy LLM, et quelle métrique le remplace
  • Diagnostiquer un 502 intermittent à partir des journaux de l'Ingress
التالي