Health check à 200 mais service en panne : ce que le code cache
Publié le 26 août 2026
Un health check qui renvoie 200 sans toucher à ses dépendances ne prouve qu'une chose : le serveur HTTP est debout. La base peut être arrêtée, le pool de connexions saturé, le service est en panne mais le contrôle reste vert. Pour dire la vérité, un contrôle doit exécuter une vraie requête vers chaque dépendance et garder la latence de chaque relevé : c'est elle qui dérive en premier quand le système sature.
Que prouve un 200, exactement ?
Qu'un processus écoute sur le port, qu'il a lu la requête et qu'il a écrit trois octets de réponse. Rien de plus. Pour le montrer, nous avons reproduit la panne classique sur une application Node de quarante lignes : une route /healthz qui répond ok sans rien demander à personne, et une route /orders qui compte les lignes d'une table PostgreSQL à travers un pool de dix connexions.
$ curl -s -o /dev/null -w "%{http_code} en %{time_total}s\n" http://127.0.0.1:8200/healthz
200 en 0.014117s
$ curl -s http://127.0.0.1:8200/orders
{"orders":100000}On arrête la base, et on repose exactement les deux mêmes questions :
$ curl -s -o /dev/null -w "%{http_code} en %{time_total}s\n" http://127.0.0.1:8200/healthz
200 en 0.007722s
$ curl -s -w "-> %{http_code} en %{time_total}s\n" http://127.0.0.1:8200/orders
error: connect ECONNREFUSED 127.0.0.1:5432
-> 500 en 0.003153sLe tableau de bord qui interroge /healthz affiche un service vert, et chaque client reçoit une erreur. Les deux lectures sont justes ; elles ne mesurent pas la même chose.
Que change un contrôle profond ?
Tout, et il tient en huit lignes : la même question, posée à la dépendance plutôt qu'au processus.
+if (req.url === '/health/deep') {
+ try {
+ await pool.query('select 1')
+ res.writeHead(200); res.end('ok\n')
+ } catch (e) {
+ res.writeHead(503); res.end(`degraded: ${e.message}\n`)
+ }
+ return
+}
Base en marche, puis base arrêtée :
$ curl -s -w "-> %{http_code} en %{time_total}s\n" http://127.0.0.1:8200/health/deep
ok
-> 200 en 0.018470s
$ curl -s -w "-> %{http_code} en %{time_total}s\n" http://127.0.0.1:8200/health/deep
degraded: connect ECONNREFUSED 127.0.0.1:5432
-> 503 en 0.001677sChaque niveau de contrôle prouve un peu plus, et aucun ne prouve tout :
| Le contrôle | Ce qu'il prouve | Ce qu'il rate |
|---|---|---|
| La poignée de main TCP | un processus accepte les connexions | tout le reste |
GET /healthz sans dépendance | le serveur HTTP répond | la base, le pool, la file, le cache |
| 200 avec un mot attendu dans le corps | la bonne page répond, pas une page d'erreur servie en 200 | les dépendances, encore |
| Une vraie requête vers la dépendance | la base répond à cet instant | la dérive qui précède la panne |
La dernière ligne du tableau est le sujet du reste de cet article : même un contrôle profond, lu comme un booléen, arrive en retard.
Pourquoi la latence bouge-t-elle avant le code ?
Parce qu'entre « tout va bien » et « erreur », la plupart des systèmes passent par « ça attend ». Un pool de connexions, une file de requêtes, un disque saturé : la demande dépasse la capacité, l'attente s'allonge, et le code de retour ne bascule qu'au moment où une limite explicite est atteinte, presque toujours un délai d'expiration.
Nous l'avons mesuré sur la même application. Un flot de requêtes lourdes sature le service peu à peu : chacune tient une connexion du pool pendant une seconde, il en arrive douze par seconde sur un pool de dix, et une sonde interroge /orders toutes les deux secondes.
Trente secondes de dérive et huit relevés encore verts avant le premier code d'erreur.
La sonde passe de 10 ms à 4,6 s par paliers réguliers, en code 200 du premier au huitième relevé. Le premier 500 n'arrive qu'à la trente-deuxième seconde, quand l'attente d'une connexion dépasse le délai du pool, cinq secondes ici, et son corps ne ressemble plus au refus d'une base arrêtée :
$ curl -s -w "-> %{http_code} en %{time_total}s\n" --max-time 30 http://127.0.0.1:8200/orders
error: timeout exceeded when trying to connect
-> 500 en 5.001626sPendant toute cette agonie, le contrôle superficiel n'a rien vu :
$ curl -s -o /dev/null -w "%{http_code} en %{time_total}s\n" http://127.0.0.1:8200/healthz
200 en 0.000797s
$ curl -s -o /dev/null -w "%{http_code} en %{time_total}s\n" --max-time 30 http://127.0.0.1:8200/orders
500 en 5.001004sL'inverse est vrai aussi, et il faut le dire : la mort subite ne prévient pas. Quand nous avons arrêté la base d'un coup, le refus est arrivé en trois millisecondes, sans la moindre dérive avant lui. La latence annonce les pannes qui s'installent, pas celles qui tombent.
Combien de relevés faut-il pour voir la dérive ?
Assez pour que la dérive tienne dedans, et notre incident donne l'échelle. Trente secondes de préavis avec une sonde toutes les trente secondes, c'est un relevé : un point anormal, indistinguable d'un hoquet de réseau. Avec une sonde toutes les deux secondes, ce sont huit points alignés, et personne ne confond huit paliers croissants avec un hasard.
Il faut donc deux choses que le code de retour ne donne pas : la latence de chaque relevé, gardée, et une fenêtre assez longue pour y lire une tendance. Un seuil de lenteur transforme ensuite la courbe en signal : à 800 ms, notre incident se déclare au troisième relevé, vingt-sept secondes avant le premier 500. Le zèle inverse a son piège : déclarer la panne sur un seul relevé raté réveille quelqu'un à chaque hoquet, et deux échecs d'affilée sont un bien meilleur seuil.
La section Santé de Kestro applique ces réglages tels quels : une mesure par service toutes les trente secondes par défaut, la latence gardée relevé par relevé avec un état « lent » au-delà de 800 ms, la panne déclarée au deuxième échec seulement, et un mot attendu dans le corps pour attraper les pages d'erreur servies en 200. Une boucle de curl dans un terminal fait la même chose, si vous ne voulez pas d'un outil de plus. Et le voyant vert qui ment n'est pas propre au HTTP : un tunnel SSH survit à la veille du Mac avec un processus vivant et une connexion morte, et c'est la même leçon.
Ce qui reste
Ces sorties viennent d'une machine Linux x86_64, le 24 août 2026 : Node v22.21.1, PostgreSQL 16.15 dans Docker (image postgres:16), pilote pg 8.23.0, curl 8.14.1. Les chiffres de la courbe dépendent des réglages choisis : nos requêtes lourdes durent une seconde parce que nous l'avons décidé, et le premier 500 tombe à cinq secondes parce que c'est le délai de connexion configuré sur le pool. La forme de la courbe se transporte ; les trente secondes, non.
Le préavis suppose une panne qui s'installe. Une base tuée net, un câble débranché, un processus emporté par le manque de mémoire ne laissent aucune dérive à lire, et notre propre première session le montre.
Enfin, un contrôle profond a un coût que nous n'avons pas mesuré ici : il exécute une vraie requête à chaque relevé, sur la dépendance que vous cherchez justement à ménager, et le nôtre partage le pool de l'application, donc il fait la queue avec elle. À quel intervalle ce coût devient-il un problème ? Nous ne le savons pas encore, et c'est la question à poser avant de multiplier les sondes.