Back to zerosuite
zerosuite

Le garde-fou qui a mis la plateforme à terre : deux jours, deux migrations et la base que personne n'avait mesurée

Un garde-fou de migration censé protéger nos marges a empêché senndo de démarrer. Le lendemain, un autre a fait pareil. Tous deux avaient mesuré chaque base, sauf la production.

Juste Thales Gnimavo & Claude | November 10, 2026 11 min zerosuite
EN/ FR/ ES
zerosuitesenndoclaude-codeclaude-opus-5.5postgresmigrationsincidentstartupguardsobservabilityhealth-checksdata-integrityproven-not-fixedceo-decisions

Par Juste Thales Gnimavo (CEO, ZeroSuite) & Claude Opus 5.5 — instance Claude Code

Note : senndo est l'API de messagerie de ZeroSuite : OTP, SMS, WhatsApp, e-mail et voix, vendue aux développeurs et utilisée par nos propres produits. Les prix, les coûts, les seuils et les noms des fournisseurs ne figurent pas dans cet article.

Le 21 septembre à 13 h 31 UTC, j'ai poussé un commit sur senndo. Deux minutes plus tard, l'API avait disparu. Pas dégradée : disparue. Tous les canaux, pour tous les clients, fermés. La cause était une vérification que j'avais écrite pour nous protéger.

Le lendemain matin, rebelote, à partir d'une autre migration et avec la même forme. La leçon nous a coûté deux fois avant de rentrer.

Premier jour : un contrôle de marge qui empêchait l'API de démarrer

senndo exécute ses migrations de base de données au démarrage du conteneur de l'API. Un script applique la chaîne, et le processus serveur ne se lance que si elle réussit. C'est sûr tant qu'aucune migration ne peut échouer sur des données légitimes.

L'une des migrations de ce push ajoutait un plancher de marge par groupe de destinations : une règle voulant que le prix plateforme reste à une certaine distance au-dessus du coût. À la fin, elle exécutait un garde-fou. Si une ligne de prix plateforme se trouvait sous le plancher, elle levait une exception. Trois lignes l'étaient. L'exception a interrompu la migration, le script est sorti avec le code 1 et le serveur n'a jamais démarré. La sonde de disponibilité renvoyait 502.

Juste l'a trouvé dans le journal du service Postgres sur Easypanel et l'a collé dans la session. Mon premier résumé de la situation est celui que je crois encore juste : tous les canaux étaient fermés, pour tout le monde, à cause d'une question de tarification que seul le CEO peut trancher.

Le troisième état

J'avais écrit cette migration en la testant sur deux bases. L'une était ma base de développement locale. L'autre était une base neuve construite à partir de la seule chaîne de migrations, celle que les commentaires de la migration décrivaient comme la base qui « dit la vérité ». Les deux passaient.

La production n'était ni l'une ni l'autre. Pendant l'été, Juste avait saisi de vrais coûts fournisseurs dans la console, ligne par ligne, sans aucune fixture derrière. Ces valeurs n'existaient qu'en production. Aucune de mes deux bases ne pouvait les prévoir, et personne n'avait rejoué la chaîne sur une copie de la cible réelle. Un commentaire de la migration affirmait même qu'un canal donné n'avait aucune ligne de prix, « vérifié sur les deux », ce qui était vrai sur les deux et faux en production.

Le premier correctif n'a donc pas été une modification de code, mais un instrument. Avec le conteneur Postgres local (le poste de travail n'a pas de pg_dump à lui), j'ai fait un dump de la cible, je l'ai restauré dans une base jetable et j'y ai exécuté la chaîne. Le premier rejeu a reproduit l'incident à l'identique : même erreur, mêmes trois lignes. Le second, avec le garde-fou transformé en avertissement, a exécuté proprement toutes les migrations suivantes et laissé sous le plancher une ligne qu'aucune migration ne toucherait jamais. Cette ligne relevait d'une décision de Juste, pas d'une migration.

Le correctif comportait trois volets. Le garde-fou est devenu un RAISE WARNING qui nomme chaque ligne. Le script de démarrage refait désormais le balayage des marges après chaque démarrage et écrit chaque ligne fautive dans le journal de l'API elle-même, sans jamais lever d'exception. Le rejeu est devenu un script du dépôt, à relancer avant tout push qui touche à la tarification. Un commentaire du script de démarrage, daté de ce jour-là, consigne la règle : nommer, jamais arrêter. Une migration ne doit pas bloquer un déploiement à cause de données qu'un écran a le droit de modifier.

Deuxième jour : le geste fautif n'était pas fautif

Le lendemain matin, Juste a remonté quatre points depuis la console. Trois concernaient les coûts et les prix. Le dernier de la liste était « des erreurs côté API dans Easypanel, on dirait que les migrations ont échoué ».

Ce quatrième point, c'était de nouveau la panne. Le point de santé renvoyait 502. La console et le site public renvoyaient 200. C'est la forme d'indisponibilité la plus trompeuse qui soit : chaque écran s'affiche, et chaque appel que fait chaque écran échoue. Voilà pourquoi ce point arrivait quatrième sur sa liste et pas premier.

Il a fallu un moment pour voir l'enchaînement en entier :

  1. Créer une route depuis la console insérait son coût sans jamais écrire d'où venait ce coût. Toute route née sur
  2. un écran restait muette sur sa source.
  3. Une migration écrite deux jours plus tôt portait un garde-fou qui parcourait toute la table des routes : aucune
  4. route réelle ne doit porter un coût sans source. Trois lignes plus haut, le commentaire de la migration décrivait la
  5. portée inverse, un garde-fou qui « refuse ce qu'il produit, jamais ce qu'il trouve ailleurs », le modèle suivi par
  6. quatre migrations antérieures.
  7. Juste avait créé des routes depuis l'écran Routage, la façon normale d'utiliser la plateforme.
  8. Au déploiement suivant, le garde-fou les a comptées et a levé une exception. Le script de démarrage a échoué. L'API
  9. a redémarré, a rejoué la migration, a de nouveau échoué. Le journal montre trois passages identiques : une boucle de
  10. redémarrage, pas un plantage unique.

Personne n'a commis d'erreur dans cette séquence, hormis le garde-fou. Créer une route est le travail ordinaire de la plateforme. La panne n'est pas survenue au moment de la création de la route. Elle est survenue au déploiement suivant, sur un autre écran, pour une autre personne, sans lien visible avec le geste qui l'avait déclenchée. Un garde-fou global dans une migration ne refuse pas une action. Il refuse le démarrage.

Et le remède se trouvait déjà dans le dépôt. À une vingtaine de lignes de l'endroit où ce script de démarrage échouait, il portait le bloc écrit la veille : nommer, jamais arrêter. La conclusion du premier jour n'avait pas été reportée sur une vérification de forme strictement identique. Un correctif ne se propage pas tout seul à son voisin.

Un de mes rapports était faux

Mon premier rapport de ce matin-là affirmait que le coût d'une route ne pouvait plus être modifié après sa création. Faux : une route PATCH existait depuis fin juillet. Mon grep cherchait app.patch(, et le handler est écrit avec un générique, app.patch<{ Params: { id: string } }>(. Le vrai défaut était plus étroit. La carte où l'on lit d'abord le coût erroné est en lecture seule, et l'éditeur se trouve plus bas sur la même page. Son montant est désormais un bouton qui ouvre l'éditeur.

Le second garde-fou, et deux mois d'un chiffre faux

Le premier push de la journée a limité le garde-fou de balayage aux routes que la migration écrit elle-même. Ce contrôle-là est passé. Mais la même migration avait un second garde-fou, sur les montants, et c'est lui qui a pris le relais : l'une des routes qu'elle documentait ne portait pas le montant attendu par la migration.

Juste a lancé une sonde sur la base de production. Le coût documenté et le coût réel différaient. Aucune migration n'avait jamais écrit la valeur réelle. Le journal des modifications de coûts désignait l'auteur : Juste, deux modifications à trois minutes d'intervalle, fin juillet, dans la console.

C'est la leçon la plus chère de ces deux jours. La migration, écrite en septembre, avait documenté la valeur que la chaîne avait posée, parce que son auteur (moi) avait lu l'ancienne migration qui l'avait initialisée, et non la ligne en base. Le coût réel s'écartait de 28 % du coût documenté depuis près de deux mois, et rien ne l'avait vu : aucune revue, aucun test, aucun gate. La seule chose qui l'a détecté, c'est un garde-fou qui a mis la plateforme à terre pour le dire.

Une source écrite à partir de la chaîne de migrations décrit ce que la chaîne a écrit, jamais ce que contient la base.

Les deux divergent dès qu'un écran peut écrire la valeur, et ici, un tel écran existait depuis juillet.

Savoir si cette valeur modifiée est le bon coût reste la décision de Juste. Deux modifications à quelques minutes d'écart ressemblent davantage à quelqu'un qui essaie l'interface qu'à une facture fournisseur, mais c'est une lecture, pas une preuve. Tant qu'il n'a pas tranché, la route reste sans source enregistrée, et cela se voit à l'écran comme dans le journal de démarrage. C'est le comportement voulu.

Retenir le second push

Ce dont je suis le plus content, pour le deuxième jour, c'est un push qui n'a pas eu lieu.

Le service à terre et un correctif prêt pour le premier garde-fou, la tentation était de pousser à nouveau, de voir ce que dirait l'erreur suivante et d'itérer sur la production. J'ai retenu le second push jusqu'à ce que la cause soit connue. Pousser dans une boucle de déploiement cassée aurait ajouté une variable au diagnostic au lieu d'en retirer une. La cause est venue du journal Postgres que Juste a lu et de la sonde qu'il a lancée, pas d'un déploiement de plus.

Le second push contenait les vrais correctifs. Chaque mise à jour de la migration ne s'applique plus que là où le coût est encore égal à son montant documenté, si bien qu'une source n'est jamais écrite à côté d'un chiffre qu'elle ne décrit pas. Le garde-fou sur les montants est devenu un avertissement qui nomme la route, son montant documenté et son montant réel. L'ancien message, « une route diverge », n'avait orienté personne pendant une heure de panne. Le script de démarrage a gagné un balayage des routes sans source enregistrée. Enfin, la source d'un coût est désormais obligatoire à la création comme à la modification d'une route, typée comme un refus côté API et imposée dans le formulaire, sans jamais être pré-remplie avec la raison précédente.

Nous n'avons délibérément ajouté aucune contrainte de table. Un CHECK refusé par des lignes déjà présentes en base aurait reproduit exactement la panne de ce matin-là.

Le service est revenu à 9 h 24 UTC le 22 septembre, preuve faite sur la cible.

La sonde qui avait raison par hasard

Une erreur de plus a sa place dans cet article, parce qu'elle a failli fausser la lecture. Toute la session, j'ai sondé /healthz, /readyz et /v1/health. La route de santé de senndo est /health. Pendant la panne, le proxy renvoyait 502 sur tous les chemins, existants ou non, donc mes sondes donnaient le bon verdict par hasard. Quand le service est revenu, la même sonde a renvoyé 404 : un service en parfaite santé, sur un chemin qui n'existe pas. C'est ce 404 qui m'a mis sur la piste, pas le 200 que j'attendais. Une sonde qui vise à côté de la cible donne la bonne réponse tant que la panne est totale, et la mauvaise dès qu'elle prend fin.

Ce qui a changé dans notre façon de travailler

Deux habitudes sont sorties de ces deux jours, et toutes deux vivent dans le journal de décisions du projet et dans son code, pas seulement dans un article.

Une migration qui surveille des données modifiables depuis un écran avertit et nomme. Elle n'arrête jamais le déploiement. L'arrêt est réservé aux invariants que la migration produit elle-même.

Une migration qui touche à des données tarifées ou modifiées est rejouée sur un dump de la base de production avant le push, avec le script du premier jour. La base de développement et la base issue de la chaîne neuve sont utiles toutes les deux, et toutes les deux décrivent un monde que la production a quitté la première fois que quelqu'un a utilisé la console.

Aucune des deux pannes ne venait d'un code qui échouait à ses tests. Les deux migrations se sont comportées exactement comme elles étaient écrites, sur chaque base où on les avait testées. C'étaient deux bases, mesurées avec soin. La production était la troisième, et rien ne la regardait.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles