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"
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.
make loadLe 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 :
{{- 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 :
| Option | Fonctionnement | Recommandation |
|---|---|---|
| Prometheus + prometheus-adapter | Prometheus collecte /metrics, l'adapter traduit une requête PromQL en métrique par pod | si Prometheus existe déjà (projet suivant) ; une règle seriesQuery: 'inference_inflight_requests{namespace!="",pod!=""}' suffit |
| KEDA | un ScaledObject interroge Prometheus et pilote un HPA qu'il crée lui-même | si 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ôme | Cause | Parade |
|---|---|---|
502 rares et aléatoires sous charge, Connection reset by peer | keep-alive du serveur plus court que celui du proxy | keep-alive applicatif > keep-alive du proxy (75 s contre 60 s) |
| L'HPA ne bouge pas alors que la latence triple | métrique CPU sur un service qui attend une API distante | métrique de requêtes en cours (prometheus-adapter ou KEDA) |
| Oscillation 2 → 6 → 2 → 6 toutes les 2 minutes | fenêtre de descente trop courte pour une charge en dents de scie | stabilizationWindowSeconds de 300 s en production |
| Débit plafonné alors que l'HPA est au maximum | maxReplicas atteint, ou quota Gemini atteint | lire currentReplicas == maxReplicas et les 429 du modèle avant d'augmenter maxReplicas |
| Nouveaux pods prêts mais 429 Gemini en hausse | plus de pods n'augmente pas le quota du projet Gemini | plafond 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.
maxReplicasse 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 :
scaleUpsans 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 ?
Le service attend Gemini : le CPU ne reflète pas la charge. Une cible de 5 % ferait réagir l'HPA au bruit (une sonde, un GC). Augmenter la limite CPU ne change rien à un pod qui attend. 20 pods permanents règlent le lundi mais paient 20 pods toute la semaine. La métrique de requêtes en cours monte avec la latence et le débit, ce qui est exactement le symptôme.
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 ?
Le calcul brut donne ceil(2 × 197 / 60) = 7, mais la politique de montée limite l'ajout à 2 pods par période de 15 s : 2 + 2 = 4. Au cycle suivant, l'HPA vise à nouveau plus que 6 et s'arrête à maxReplicas = 6. C'est exactement la séquence mesurée : 2, 4, 6.
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 msMê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