Skip to content

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

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.

UNE CONNEXION, VUE DES DEUX BOUTS Envoy uvicorn le proxy le pod connexion établie (TCP puis TLS) GET / 200 OK × des milliers aucun trafic sur CETTE connexion (les autres connexions travaillent) t t + 5 s uvicorn ferme : 5 s atteintes Envoy émet une requête les deux se croisent 503 — flag UC upstream_reset_before_response_started
La requête part sur une connexion qu'uvicorn est en train de fermer. Aucun octet de réponse n'est jamais envoyé : le serveur n'a rien traité. Envoy le signale par 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-alive d'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.