# Les health checks qui mentent : six façons pour votre service de dire « prêt » quand il ne l'est pas

Un health check est un contrat entre votre application et la chose qui lui route le trafic. Le routeur demande « peux-tu prendre une requête ? » et l'application répond. Quand la réponse est fausse, le routeur fait exactement ce qu'on lui a dit : il envoie des utilisateurs vers une instance qui va les faire échouer, ou il cesse d'envoyer des utilisateurs vers des instances qui allaient bien. Les deux sont des pannes, et les deux ont été causées par un health check qui, techniquement, fonctionnait.

Nous avons été mordus par six variétés distinctes de ce problème sur App Runner, ECS, Lambda derrière une passerelle d'API, et un cluster Kubernetes. Voici le catalogue, à quoi ressemble chacune de l'extérieur, et la route de health à laquelle nous avons abouti.

## Deux questions, pas une

La racine de la plupart de ces problèmes, c'est que « en bonne santé » écrase deux questions différentes en un seul point de terminaison.

**Liveness : le processus est-il vivant et doit-on le laisser tranquille ?** La réponse n'est « non » que lorsque le processus est coincé : interblocage, plus de mémoire, boucle infinie. La bonne réaction à un échec de liveness est de tuer et de remplacer l'instance.

**Readiness : cette instance peut-elle prendre du trafic maintenant ?** La réponse est « non » tant qu'elle démarre encore, préchauffe des caches, attend un pool de connexions ou, temporairement, quand elle est surchargée. La bonne réaction est de cesser de router vers elle et de revérifier peu après. Pas de la tuer.

Kubernetes rend ces deux sondes explicites. App Runner et ECS vous donnent un seul health check et l'utilisent pour les deux usages, ce qui va bien tant que vous savez qu'un check en échec fait *remplacer* l'instance, et que vous concevez le check pour qu'il n'échoue que lorsque le remplacement est la bonne réponse.

## Les six mensonges

**1. TCP est ouvert, donc nous sommes prêts.** Le défaut sur App Runner et sur les target groups ECS est un check TCP : si le port accepte une connexion, l'instance est en bonne santé. Node ouvre son port dans la première seconde du démarrage, avant que le framework ait chargé les routes, avant que le pool de base de données existe, avant que les caches soient chauds. L'instance entre en rotation et sert les trente premières secondes de trafic lentement ou avec des erreurs. Nous [en avons parlé sur App Runner](/fr/blog/aws-app-runner-review-six-months) : le correctif est un check HTTP sur une route qui renvoie 503 tant que le démarrage n'est pas réellement terminé.

**2. Un 200 statique.** L'erreur inverse. `app.get('/health', (req, res) => res.send('ok'))`. Il n'échoue jamais. Une instance dont le pool de base de données est épuisé, dont la boucle d'événements est bloquée des secondes entières, dont le disque est plein, se déclare en bonne santé pour toujours, et le routeur continue de lui envoyer du trafic pendant que l'instance d'à côté, qui va bien, reçoit aussi sa part. C'est le health check que la plupart des bases de code ont, parce que c'est le premier que quelqu'un a écrit.

**3. Le check profond qui échoue sur la panne de quelqu'un d'autre.** La surcorrection de 2 : la route de health pinge la base, le cache, la file et une API tierce, et renvoie 503 si l'un d'eux échoue. Puis l'API tierce passe une mauvaise heure, chaque instance se déclare en mauvaise santé, la plateforme les remplace toutes, les remplaçantes se déclarent en mauvaise santé aussi, et vous avez transformé une fonctionnalité dégradée en panne totale d'un service qui aurait pu continuer à servir chaque requête n'ayant pas besoin de cette API. La readiness ne doit pas dépendre de choses que l'instance ne peut pas réparer en redémarrant.

**4. Le check emprunte un chemin de code différent du trafic.** La route de health est enregistrée sur un port interne séparé, ou avant la pile de middleware, ou sur un chemin exclu du routeur par lequel passent les vraies requêtes. Elle passe pendant que le vrai chemin est cassé : un mauvais déploiement de middleware, un routeur qui ne se charge pas, une mauvaise configuration TLS sur le port public. Le check doit passer par autant du vrai chemin que possible sans faire de vrai travail.

**5. Le résultat mis en cache.** Pour rendre le check bon marché, quelqu'un met son résultat en cache pendant soixante secondes. Une instance qui a cassé à la seconde une se déclare en bonne santé pendant cinquante-neuf de plus, et un problème à l'échelle du cluster est invisible pendant une minute durant laquelle chaque instance ment à l'unisson. Mettez en cache les *sous-vérifications* coûteuses s'il le faut, avec un TTL court, mais jamais l'agrégat.

**6. Le check qui se fait bloquer.** La route de health n'a pas de `User-Agent`, vient d'une plage d'IP internes et ne porte pas l'en-tête d'authentification, donc la [règle `NoUserAgent_HEADER` du WAF](/fr/blog/waf-for-a-nextjs-app-managed-rules-that-block-legit-traffic), ou le limiteur de débit, ou le middleware d'authentification la rejette, et la plateforme voit un 403 comme malsain. Chaque instance est remplacée en boucle jusqu'à ce que quelqu'un remarque les métriques WAF. Les health checks ont besoin d'une exemption explicite dans tout ce qui se trouve devant l'app, limitée à la source de la plateforme et au seul chemin de health.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Six modes d'échec des health checks en grille. TCP ouvert mais pas prêt : l'instance froide sert des erreurs. 200 statique : l'instance cassée garde du trafic. Check profond sur une API tierce : une panne de fournisseur fait tomber toute la flotte. Chemin de code différent : passe pendant que les vraies requêtes échouent. Résultat en cache : une minute de mensonge à l'unisson. Bloqué par le WAF ou l'auth : des instances saines remplacées en boucle.">
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<rect x="20" y="20" width="270" height="105" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="155" y="44" text-anchor="middle" fill="#ff6b8a" font-weight="700">1 · TCP ouvert ≠ prêt</text><text x="155" y="66" text-anchor="middle" fill="#f1f3ff">le port s'ouvre à la seconde 1</text><text x="155" y="84" text-anchor="middle" fill="#f1f3ff">routes et pools prêts à la seconde 40</text><text x="155" y="108" text-anchor="middle" fill="#9aa3c7">l'instance froide sert 30 s d'erreurs</text>
<rect x="315" y="20" width="270" height="105" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="450" y="44" text-anchor="middle" fill="#ff6b8a" font-weight="700">2 · un 200 statique</text><text x="450" y="66" text-anchor="middle" fill="#f1f3ff">res.send('ok'), toujours</text><text x="450" y="84" text-anchor="middle" fill="#f1f3ff">pool épuisé, boucle bloquée : toujours 200</text><text x="450" y="108" text-anchor="middle" fill="#9aa3c7">l'instance cassée garde sa part de trafic</text>
<rect x="610" y="20" width="270" height="105" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="745" y="44" text-anchor="middle" fill="#ff6b8a" font-weight="700">3 · check profond sur un fournisseur</text><text x="745" y="66" text-anchor="middle" fill="#f1f3ff">503 si l'API tierce est en panne</text><text x="745" y="84" text-anchor="middle" fill="#f1f3ff">chaque instance échoue en même temps</text><text x="745" y="108" text-anchor="middle" fill="#9aa3c7">une fonctionnalité dégradée devient panne totale</text>
<rect x="20" y="145" width="270" height="105" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="155" y="169" text-anchor="middle" fill="#ff6b8a" font-weight="700">4 · un chemin de code différent</text><text x="155" y="191" text-anchor="middle" fill="#f1f3ff">port interne, avant le middleware</text><text x="155" y="209" text-anchor="middle" fill="#f1f3ff">vrai chemin cassé, le check passe quand même</text><text x="155" y="233" text-anchor="middle" fill="#9aa3c7">sain sur le papier, en échec en production</text>
<rect x="315" y="145" width="270" height="105" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="450" y="169" text-anchor="middle" fill="#ff6b8a" font-weight="700">5 · un résultat en cache</text><text x="450" y="191" text-anchor="middle" fill="#f1f3ff">agrégat en cache 60 s</text><text x="450" y="209" text-anchor="middle" fill="#f1f3ff">casse à la seconde 1, ment pendant 59</text><text x="450" y="233" text-anchor="middle" fill="#9aa3c7">toute la flotte ment à l'unisson</text>
<rect x="610" y="145" width="270" height="105" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="745" y="169" text-anchor="middle" fill="#ff6b8a" font-weight="700">6 · bloqué par le WAF ou l'auth</text><text x="745" y="191" text-anchor="middle" fill="#f1f3ff">sans User-Agent, sans en-tête d'auth → 403</text><text x="745" y="209" text-anchor="middle" fill="#f1f3ff">la plateforme lit 403 comme malsain</text><text x="745" y="233" text-anchor="middle" fill="#9aa3c7">des instances saines remplacées en boucle</text>
</g>
</svg>
</div>

## La route de health à laquelle nous avons abouti

Une seule route, `/api/health`, avec un paramètre de requête qui sélectionne la profondeur, parce que le check de la plateforme et le check d'un humain veulent des choses différentes.

```ts
// app/api/health/route.ts
const startedAt = Date.now();
let warm = false;                       // mis à true par la tâche de préchauffage après le chargement des caches

export async function GET(req: Request) {
  const deep = new URL(req.url).searchParams.get('deep') === '1';

  // readiness : seulement ce qu'un redémarrage corrigerait
  if (!warm) return json({ status: 'starting', uptimeMs: Date.now() - startedAt }, 503);
  if (eventLoopLagMs() > 1000) return json({ status: 'wedged' }, 503);

  const checks: Record<string, string> = { app: 'ok', release: process.env.RELEASE_SHA ?? 'dev' };

  if (deep) {
    // informatif seulement : ne transforme jamais la réponse en 503
    checks.db = await timed(() => db.query('select 1'), 500);
    checks.cache = await timed(() => cache.ping(), 200);
    checks.payments = await timed(() => payments.ping(), 800);
  }
  return json({ status: 'ok', ...checks }, 200);
}
```

Les propriétés qui comptent :

- **Elle renvoie 503 pour exactement deux raisons, toutes deux corrigées par un redémarrage :** l'instance n'a pas fini de préchauffer, ou la boucle d'événements est coincée. C'est la readiness et la liveness dans une seule route, avec des conditions d'échec choisies pour que « remplacer cette instance » soit toujours la bonne réaction à un 503.
- **Les dépendances sont rapportées, jamais imposées.** Avec `?deep=1`, la route vérifie la base, le cache et le prestataire de paiement, chacun avec un délai, et met le résultat dans le corps. Un humain ou un tableau de bord le lit. La plateforme n'appelle pas la variante profonde, donc une panne de fournisseur ne peut pas faire tomber la flotte. Si la base est injoignable, l'instance dit quand même 200, parce que la redémarrer ne réparera pas la base et que les requêtes qui n'en ont pas besoin (pages statiques, lectures en cache) fonctionnent toujours.
- **Elle passe par le vrai chemin.** Même port, même pile de middleware, même routeur que le trafic utilisateur. Le middleware a un retour anticipé explicite pour ce chemin qui saute l'auth et le limiteur de débit mais rien d'autre, et le WAF a un scope-down qui l'exempte de la règle User-Agent pour la source de health check de la plateforme.
- **Rien n'est en cache.** La route est bon marché : deux lectures en mémoire pour la version superficielle. La version profonde fait de vraies E/S et est appelée par des humains, rarement.
- **Elle rapporte la release.** Le corps porte le SHA git, c'est ce que [le script de déploiement vérifie](/fr/blog/same-tag-deploy-and-the-deploy-script-without-ci) après une bascule et ce que vous collez dans le canal d'incident.

<div class="article-figure">
<svg viewBox="0 0 900 220" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Table de décision pour ce qu'un health check doit faire. Instance encore en préchauffage : 503, la plateforme attend. Boucle d'événements coincée : 503, la plateforme remplace. Base injoignable : 200 avec db failed dans le corps, parce qu'un redémarrage ne la réparera pas et que les autres requêtes fonctionnent encore. API tierce en panne : 200 avec payments failed dans le corps. Check bloqué par le WAF ou l'auth : doit être exempté explicitement.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Ce que la route répond, et pourquoi</text>
<g fill="#9aa3c7"><text x="20" y="54" font-weight="700" fill="#f1f3ff">condition</text><text x="330" y="54" font-weight="700" fill="#f1f3ff">réponse</text><text x="520" y="54" font-weight="700" fill="#f1f3ff">pourquoi</text></g>
<line x1="20" y1="62" x2="880" y2="62" stroke="#2a3150"/>
<text x="20" y="86" fill="#f1f3ff">préchauffe encore caches et pools</text><text x="330" y="86" fill="#ffd166" font-weight="700">503 · starting</text><text x="520" y="86" fill="#9aa3c7">la plateforme attend avant de router ; rien à remplacer</text>
<text x="20" y="112" fill="#f1f3ff">latence de la boucle d'événements > 1 s</text><text x="330" y="112" fill="#ff6b8a" font-weight="700">503 · wedged</text><text x="520" y="112" fill="#9aa3c7">un redémarrage est le correctif ; laisser la plateforme le faire</text>
<text x="20" y="138" fill="#f1f3ff">base de données injoignable</text><text x="330" y="138" fill="#4fffb0" font-weight="700">200 · db: failed</text><text x="520" y="138" fill="#9aa3c7">un redémarrage ne la réparera pas ; les lectures en cache servent encore</text>
<text x="20" y="164" fill="#f1f3ff">prestataire de paiement en panne</text><text x="330" y="164" fill="#4fffb0" font-weight="700">200 · payments: failed</text><text x="520" y="164" fill="#9aa3c7">une fonctionnalité dégradée, pas tout le service</text>
<text x="20" y="190" fill="#f1f3ff">le check arrive sans auth ni User-Agent</text><text x="330" y="190" fill="#7b8cff" font-weight="700">exempté explicitement</text><text x="520" y="190" fill="#9aa3c7">dans le middleware et dans le WAF, limité à ce chemin</text>
<line x1="20" y1="200" x2="880" y2="200" stroke="#2a3150"/>
</g>
</svg>
</div>

## Plateforme par plateforme

| Plateforme | Ce qu'elle supporte | Ce qu'il faut configurer |
|---|---|---|
| App Runner | Un seul check, TCP ou HTTP, échec = remplacement | HTTP sur `/api/health`, intervalle 10 s, seuil de mauvaise santé 3, délai 5 s |
| ECS sur Fargate derrière un ALB | Health check de conteneur (définition de tâche) + health check de target group | Les deux en HTTP sur la même route ; le target group contrôle le trafic, le check de conteneur déclenche le remplacement |
| Lambda derrière API Gateway | Pas de health check ; chaque invocation est sa propre instance | Concurrence provisionnée pour le problème de préchauffage ; un canary synthétique qui appelle `/api/health?deep=1` pour le problème de visibilité |
| Kubernetes | Sondes de liveness et de readiness séparées, plus une sonde de démarrage | Readiness sur `/api/health`, liveness sur la même avec une période plus longue, sonde de démarrage avec un seuil d'échec généreux pour que les démarrages lents ne soient pas tués |

Sur chacune, le chiffre qui compte le plus est le *seuil de mauvaise santé multiplié par l'intervalle* : c'est la durée pendant laquelle une instance cassée continue de servir avant d'être retirée. Dix secondes fois trois, c'est trente secondes de mauvaises requêtes. Nous préférons 5 × 2, et payer les appels de check supplémentaires, qui sont gratuits.

## La version courte

Un health check est une promesse. Faites-le échouer uniquement quand un redémarrage est le remède, rapportez tout le reste dans le corps pour les humains, faites-le passer par le vrai chemin de requête, exemptez-le explicitement de tout ce qui garde ce chemin, et ne mettez jamais la réponse en cache. Puis réglez l'intervalle et le seuil pour qu'un mensonge, quand il arrive quand même, dure dix secondes et non une minute.

Si votre route de health est un `res.send('ok')`, [nous vous aiderons à la remplacer](/contact). C'est un après-midi, et c'est généralement l'après-midi qui clôt le ticket « 502 aléatoires après déploiement ».
