Le 503 sous charge : un symptôme, deux mécanismes¶
Un client reçoit une erreur 503 alors que tous les pods sont Running et ready.
Cette page explique les deux pannes différentes qui produisent ce même code, comment
le proxy les distingue tout seul, et où lire l'information quand kubectl logs ne suffit
plus.
- En trois phrases
- Les mots du sujet
- Le mécanisme : pourquoi une connexion reste ouverte
- Panne 1 : la spirale de readiness (flag UH)
- Panne 2 : la course de fermeture (flag UC)
- Le champ qui distingue les deux
- Où lire ces logs : Loki, pas kubectl logs
- Ce qui s'est passé
- La correction
- Ce que ça ne fait pas
- Ce qu'il faut retenir
En trois phrases¶
Le 2026-08-12, sous charge, l'API servait des 503 en continu : la limite CPU faisait expirer la sonde de readiness, les pods sortaient du service, et Envoy finissait sans personne à qui parler. Corrigé (#169), puis mesuré le 2026-08-16 : plus une seule sonde en échec.
La même mesure a révélé une seconde panne, cachée derrière la première. 35 requêtes sur 200 580 recevaient encore un 503, pour une raison sans aucun rapport : uvicorn ferme ses connexions inactives au bout de 5 secondes, Envoy les gardait bien plus longtemps, et une requête partait parfois sur une connexion en train de se fermer. Corrigé le même jour (#172, INC-067), revérifié à zéro 503.
Les deux pannes portent le même code d'erreur et se distinguent par un seul champ du
log d'accès Envoy, le response_flags.
Les mots du sujet¶
À lire une fois, tout le reste s'appuie dessus.
| Terme | Ce que c'est |
|---|---|
| Envoy | Le proxy qui reçoit tout le trafic public et le redistribue aux pods. Ici piloté par Envoy Gateway, qui traduit les ressources Kubernetes (Gateway, HTTPRoute) en configuration Envoy. |
| upstream | Vu d'Envoy, la destination à qui il transmet la requête, donc un pod de l'application. Le mot qu'on entend en entreprise : ne pas dire « le pod derrière le proxy ». |
| pool de connexions | Envoy ne rouvre pas une connexion TCP à chaque requête, ce serait ruineux. Il en garde un stock ouvert et les réutilise pour les requêtes suivantes. C'est le keep-alive. |
| délai d'inactivité (idle timeout) | Au bout de combien de temps sans trafic un bout ferme une connexion inutilisée. Chaque bout a le sien, et ils ne se parlent pas. C'est le cœur de la seconde panne. |
| readiness probe | La sonde qui demande périodiquement au pod « es-tu prêt à recevoir du trafic ? ». Un échec ne tue pas le pod : il le retire du service. |
| EndpointSlice | La liste des pods qu'un Service considère joignables. Un pod dont la readiness échoue en sort, et Envoy cesse aussitôt de lui envoyer des requêtes. |
| response flag | Un code de deux ou trois lettres qu'Envoy écrit sur chaque ligne de log, disant ce qui s'est passé de son point de vue. C'est lui qui nomme la panne. |
| uvicorn | Le serveur qui exécute l'application FastAPI dans le conteneur (Dockerfile:67). C'est lui, et non FastAPI, qui gère les connexions TCP. |
| Alloy / Loki | La chaîne de logs du cluster : Alloy lit les fichiers de log sur chaque nœud et les pousse vers Loki, qui les stocke et les rend interrogeables depuis Grafana. |
Le mécanisme : pourquoi une connexion reste ouverte¶
Ouvrir une connexion coûte cher : une poignée de main TCP, puis une poignée de main TLS, soit plusieurs allers-retours réseau avant la moindre donnée utile.
Un proxy qui traite des centaines de requêtes par seconde ne peut pas payer ça à chaque fois. Il garde donc ses connexions vers les pods ouvertes entre deux requêtes, et les réutilise. C'est un gain énorme, et c'est aussi ce qui crée la panne 2.
Le point d'attention
Une connexion ouverte est un objet partagé entre deux programmes qui ne se consultent pas. Chacun décide seul quand il la ferme, selon son propre réglage. Rien dans TCP n'oblige à prévenir l'autre à l'avance.
Panne 1 : la spirale de readiness (flag UH)¶
C'est INC-066, découvert le 2026-08-12. Le conteneur était plafonné à limits.cpu: 500m
pour une requests.cpu: 100m.
Au-delà du plafond, Linux ne tue pas le processus : il le met en pause par
intermittence. C'est le throttling CPU. Le pod ralentit alors sur tout, y compris pour
répondre à sa sonde de readiness, qui fait un SELECT 1 sur la base.
graph LR
A["La limite CPU serre<br/>(throttling)"] --> B["Le pod ralentit<br/>sur tout"]
B --> C["La readiness dépasse<br/>ses 3 s"]
C --> D["Le pod sort de<br/>l'EndpointSlice"]
D --> E["Envoy perd<br/>un upstream"]
E --> F["La charge se reporte<br/>sur les survivants"]
F --> B
La boucle est le vrai défaut. Un pod est retiré du service parce qu'il est occupé, et le retirer rend les autres plus occupés encore. Le système se prive de capacité au moment précis où il en manque.
Quand il ne reste plus aucun pod dans la liste, Envoy répond 503 avec le flag UH,
no healthy upstream : il n'a plus personne à qui parler.
Corrigé et vérifié
limits.cpu a été retirée du conteneur applicatif (#169, !299), conformément à ce que
la Décision 2 de l'ADR 025 prescrivait déjà. Mesure du 2026-08-16 sous 150 connexions
simultanées pendant 5 minutes : zéro context deadline exceeded, l'EndpointSlice
reste complet du début à la fin.
Panne 2 : la course de fermeture (flag UC)¶
Celle-ci n'était visible qu'une fois la première corrigée. Elle ne tient à aucune saturation : elle tient à deux réglages qui ne se connaissent pas.
| Qui | Réglage | Valeur |
|---|---|---|
| uvicorn | --timeout-keep-alive |
5 s (défaut, aucun réglage dans Dockerfile:67) |
| Envoy | délai d'inactivité vers l'upstream | de l'ordre de l'heure (défaut) |
Uvicorn ferme donc une connexion inactive depuis 5 secondes. Envoy, lui, la considère encore valable et la garde dans son pool. Il n'est prévenu de rien.
UC et par le détail upstream_reset_before_response_started,
qui dit littéralement « réinitialisé avant qu'une réponse ne commence ».
Pourquoi c'est rare, et pourquoi ce n'est pas nul¶
Il faut que deux conditions se rencontrent : une connexion doit rester inactive plus de 5 secondes, et une requête doit arriver pile à l'instant de sa fermeture.
Sous forte charge, les connexions sont rarement inactives, d'où la rareté. Mais quand le HPA ajoute des pods, le pool se redistribue et certaines connexions se retrouvent sans travail : c'est là que la mesure a vu les 503 se grouper.
Le détail qui confirme le mécanisme
Le rapport de charge du 2026-08-16 affiche Sockets used: 185 (for perfect keepalive,
would be 150) et Code 503 : 35. 35 sockets en trop, exactement 35 erreurs.
Chaque 503 a coûté sa connexion, ce qui n'arriverait pas si le serveur avait
simplement répondu un code d'erreur.
Le champ qui distingue les deux¶
C'est la partie réutilisable de cette page. Envoy écrit son verdict sur chaque ligne, et il n'y a rien à deviner.
| Flag | Ce qu'il dit | Où chercher |
|---|---|---|
- |
Rien à signaler, la réponse vient du pod | — |
UH |
Plus aucun upstream sain : la liste était vide | readiness, EndpointSlice, panne 1 |
UC |
L'upstream a fermé la connexion avant de répondre | délais d'inactivité, arrêt de pod, panne 2 |
UF |
Envoy n'a pas pu se connecter au pod | NetworkPolicy, port, pod qui démarre |
UO |
Trop de connexions en attente, le garde-fou d'Envoy a coupé | limites de connexions du proxy |
UT |
Le pod a mis trop de temps à répondre | lenteur applicative, base |
Trois lettres différentes, trois correctifs différents. Sans ce champ, on suppose ; avec lui, on sait.
Où lire ces logs : Loki, pas kubectl logs¶
C'est le geste qui a permis le diagnostic, après deux tentatives ratées.
Un conteneur écrit sur sa sortie standard. Le runtime enregistre ça dans un fichier sur le
nœud, et kubelet fait tourner ce fichier tous les 10 Mo en n'en gardant qu'une
poignée. kubectl logs ne lit que ces fichiers-là.
Envoy à 667 requêtes par seconde produit environ 160 Mo de JSON en cinq minutes. La
fenêtre visible par kubectl logs se réduit alors à une ou deux minutes : les lignes
cherchées étaient déjà effacées.
Alloy lit ces mêmes fichiers au fil de l'eau et pousse chaque ligne vers Loki avant que la rotation ne la supprime. La requête, dans Grafana, menu Explore, source Loki :
{namespace="envoy-gateway-system", container="envoy"} |= `"response_code":503`
Entre accolades, quel flux de logs. Après le |=, quelle chaîne doit apparaître
dans la ligne.
La règle générale
kubectl logs est la fenêtre vivante, Loki est la mémoire. Au-delà de la minute
écoulée, ou sur un pod bavard, la question n'est plus « quelle commande » mais « quel
outil du cluster couvre cette donnée » : Loki pour les logs, Prometheus pour les
métriques, Tempo pour le trajet d'une requête, Hubble pour les flux réseau.
Ce qui s'est passé¶
| Date | Fait |
|---|---|
| 2026-08-12 | 503 en continu sous charge, découverts par accident pendant le test de #167. Readiness en échec sur les 4 pods, context deadline exceeded. INC-066, issue #169 |
| 2026-08-15 | limits.cpu retirée du conteneur applicatif (!299), sur les 3 environnements. Validation live impossible, cluster détruit |
| 2026-08-16 | Charge rejouée par le chemin Envoy : zéro readiness en échec. #169 tient |
| 2026-08-16 | La même mesure montre 35 × 503 sur 200 580. Flag UC, cause identifiée, issue #172 ouverte |
| 2026-08-16 | Le log Envoy attribue 8 requêtes sur 10 aux pods ajoutés par le HPA : #167 est démontrée par la même mesure |
| 2026-08-16 | BackendTrafficPolicy à 3 s posée sur les 3 envs (!309). Rejeu : zéro 503 sur 87 578, aucun UC dans Loki. INC-067 |
La correction¶
Panne 1, livrée. Retirer limits.cpu plutôt qu'allonger le timeoutSeconds de la
readiness. La seconde piste traite le symptôme sans lever le throttling, et retarde
d'autant la détection d'une vraie panne de base. Aligner la configuration sur l'ADR 025,
qui prescrivait déjà une limite large ou absente sur le latency-sensitive, a été préféré à
l'ajout d'un réglage compensatoire.
Panne 2, livrée le même jour (#172, !309). La règle : le délai d'inactivité du
proxy doit être plus court que celui de l'application, pour que ce soit toujours le
proxy qui ferme, au moment qu'il choisit. Un BackendTrafficPolicy pose
connectionIdleTimeout: 3s, sous les 5 s d'uvicorn, sans toucher à l'application ni
reconstruire l'image.
Il vise la HTTPRoute de l'environnement et non le Gateway partagé : targetRefs est un
LocalPolicyTargetReference, et viser le Gateway aurait couvert Grafana et ArgoCD, dont
les upstreams n'ont rien à voir avec uvicorn.
Écartée : allonger --timeout-keep-alive côté uvicorn. Il faudrait dépasser le délai
d'Envoy, soit une heure, et immobiliser des connexions pour rien.
Écartée aussi, mais gardée en réserve : un retry sur reset. Il masquerait le symptôme
au lieu de traiter la cause, et surtout changer deux choses à la fois aurait rendu le tir
de validation ininterprétable.
Vérifié le 2026-08-16, après sync
87 578 requêtes en 120 s à 729,2 req/s : zéro 503, là où le rythme précédent en
aurait fait attendre une quinzaine. Les 9 sockets en trop (159 pour 150) correspondent
exactement aux 9 abandons côté client de l'injecteur sous saturation, phénomène
distinct et antérieur au correctif. Plus une seule socket imputable à la course de
fermeture, et aucun UC dans Loki sur la route fastapi.
Ce que ça ne fait pas¶
- Le délai d'inactivité par défaut d'Envoy n'a jamais été lu sur le cluster, seulement le défaut documenté. Le correctif n'en dépend pas — il impose sa propre valeur — mais l'ordre de grandeur « de l'ordre de l'heure » reste une lecture de documentation.
- Les deux valeurs se lisent ensemble, et rien ne le vérifie. Relever le
--timeout-keep-alived'uvicorn sans toucher aux 3 s serait sans effet ; le baisser sous 3 s rouvrirait la panne. Le lien est écrit en commentaire dans le manifeste, il n'est porté par aucun contrôle automatique — c'est exactement la faiblesse que la leçon d'INC-066 pointait déjà. - Le résidu de 9 sockets n'a pas été instruit. Ce sont les abandons de l'injecteur après son propre délai d'attente, sous saturation. Ils étaient présents avant le correctif et ne produisent aucun 503, mais personne n'a vérifié ce que voit un vrai client dans ce cas.
- Rien de tout ça ne survit au teardown. Loki écrit dans un
emptyDir, donc ses données meurent avec le pod, exactement comme l'historique Prometheus (#154). Loki répond dans la séance, jamais entre deux séances. - La capacité n'a pas été traitée. Les 4 pods se placent sur le même nœud
(anti-affinité en
preferred), et le HPA colle àmaxReplicas. Le plafond n'a pas disparu, il est passé du conteneur au nœud : c'est #163 (Karpenter).
Ce qu'il faut retenir¶
Un code d'erreur n'est pas un diagnostic. Deux pannes sans rapport ont produit le même
503 ; c'est le response_flags d'Envoy qui les sépare, et il était disponible dès le
premier jour.
Corriger une panne en découvre une autre. La seconde était masquée par la première, pas créée par le correctif. Une validation qui se contente de vérifier que le symptôme a baissé aurait fermé le dossier trop tôt.
Deux délais qui ne se parlent pas finissent par se croiser. Dès qu'un réglage de durée existe des deux côtés d'une connexion, écrire les deux valeurs en regard, et faire en sorte que ce soit toujours le même bout qui ferme en premier.
Débugger commence par l'inventaire de ce qui est déjà là. La réponse était dans Loki,
qui tourne depuis #75, pendant qu'on s'acharnait sur kubectl logs.
Voir aussi : ADR 025 (la Décision 2 qui annonçait
la panne 1), Capacité et ressources (requests, limits, QoS),
Observabilité (la chaîne Alloy → Loki → Grafana), et
docs/validation-runbook.md section 2 pour la commande de charge.