Skip to content
Les health checks qui mentent : six façons pour votre service de dire « prêt » quand il ne l'est pas
← ← Retour aux Réflexions Cloud

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 : 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, 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.

1 · TCP ouvert ≠ prêtle port s'ouvre à la seconde 1routes et pools prêts à la seconde 40l'instance froide sert 30 s d'erreurs 2 · un 200 statiqueres.send('ok'), toujourspool épuisé, boucle bloquée : toujours 200l'instance cassée garde sa part de trafic 3 · check profond sur un fournisseur503 si l'API tierce est en pannechaque instance échoue en même tempsune fonctionnalité dégradée devient panne totale 4 · un chemin de code différentport interne, avant le middlewarevrai chemin cassé, le check passe quand mêmesain sur le papier, en échec en production 5 · un résultat en cacheagrégat en cache 60 scasse à la seconde 1, ment pendant 59toute la flotte ment à l'unisson 6 · bloqué par le WAF ou l'authsans User-Agent, sans en-tête d'auth → 403la plateforme lit 403 comme malsaindes instances saines remplacées en boucle

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.

// 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 après une bascule et ce que vous collez dans le canal d'incident.
Ce que la route répond, et pourquoi conditionréponsepourquoi préchauffe encore caches et pools503 · startingla plateforme attend avant de router ; rien à remplacer latence de la boucle d'événements > 1 s503 · wedgedun redémarrage est le correctif ; laisser la plateforme le faire base de données injoignable200 · db: failedun redémarrage ne la réparera pas ; les lectures en cache servent encore prestataire de paiement en panne200 · payments: failedune fonctionnalité dégradée, pas tout le service le check arrive sans auth ni User-Agentexempté explicitementdans le middleware et dans le WAF, limité à ce chemin

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. 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 ».