Les hooks d'arrêt tournaient deux fois - quand SIGTERM a deux maîtres
À chaque déploiement, Sentry recevait une erreur Prisma P2028. En corrigeant l'ordre d'arrêt, j'ai découvert qu'enableShutdownHooks de NestJS et une bibliothèque de graceful shutdown captaient toutes les deux SIGTERM, faisant tourner les hooks d'arrêt deux fois à chaque fois.
L'essentiel
Quand SIGTERM arrivait en plein déploiement, la base de données se fermait avant les jobs encore en cours, provoquant un Prisma P2028. La cause : l'arrêt des workers et celui de la base se trouvaient dans la même phase du cycle de vie - j'ai réordonné les choses en drainant les workers dans un beforeApplicationShutdown plus précoce. Mais la revue a révélé un problème plus profond. enableShutdownHooks() et http-graceful-shutdown captaient tous deux SIGTERM, faisant tourner les hooks d'arrêt deux fois avant même la fin du drain HTTP. Grâce à des hooks idempotents, ce n'était pas un incident mais juste une odeur suspecte dans les logs. J'ai supprimé une ligne (enableShutdownHooks) pour ne laisser qu'un seul maître du signal, et vérifié le tout avec un vrai SIGTERM.
Sur cette page
À chaque déploiement, la même erreur tombait dans Sentry. Transaction already closed. Prisma tentait d'exécuter une requête après la fermeture de la transaction et mourait avec un P2028. Aucun impact pour les utilisateurs : les retries de BullMQ faisaient immédiatement réussir la tâche sur une autre exécution, et les logs de complétion restaient continus. Mais la structure du problème refaisait surface silencieusement à chaque fois qu'un déploiement chevauchait du trafic actif. En traquant cette erreur, j'ai découvert que les hooks d'arrêt de notre serveur s'exécutaient en réalité deux fois à chaque fois.
Acte 1 : la base de données meurt avant les jobs
En recoupant l'heure de l'erreur avec les logs CloudWatch, la chronologie collait parfaitement. Le déploiement rolling envoie SIGTERM à l'ancienne tâche. Six secondes plus tard, app.close() démarre le nettoyage et le moteur Prisma ferme la transaction. Juste après, le updateMany d'un job asset-processing encore en cours s'exécute sur une transaction déjà fermée et échoue avec un P2028. Un utilisateur uploadait des fichiers audio en continu, il y avait donc un job actif au moment du déploiement, et sa requête est morte.
La cause était l'ordre d'arrêt. À l'arrêt, NestJS appelle les hooks du cycle de vie dans un ordre défini. Le problème : fermer les workers BullMQ et couper la connexion Prisma se trouvaient dans la même phase d'arrêt (onApplicationShutdown). À l'intérieur d'une même phase, c'est l'ordre d'enregistrement des modules qui détermine l'ordre d'exécution - et il se trouvait que Prisma se coupait en premier, laissant en plan les requêtes d'un worker encore en train de traiter un job.
La correction était claire : fermer les workers dans une phase plus précoce. Les hooks d'arrêt de NestJS ont un ordre, et beforeApplicationShutdown se termine toujours avant le onApplicationShutdown de n'importe quel module. J'ai créé un service qui retrouve tous les workers et les draine (attend la fin des jobs actifs) dans cette phase précoce.
@Injectable()
export class WorkerDrainService implements BeforeApplicationShutdown {
constructor(private readonly discovery: DiscoveryService) {}
async beforeApplicationShutdown() {
// Retrouve tous les WorkerHost enregistrés et attend la fin de leurs jobs actifs.
// Cette phase se termine avant onApplicationShutdown, où Prisma se coupe.
const workers = this.discovery
.getProviders()
.filter((p) => p.instance instanceof WorkerHost);
await Promise.all(workers.map((w) => w.instance.worker.close()));
}
}
Worker.close() renvoie la même promise si on le rappelle, donc pas de conflit avec le code existant qui fermait déjà les workers, et il ne fait tout simplement rien dans les environnements sans Redis. Même quand un déploiement chevauche un job, les jobs se terminent désormais avant la fermeture de la base. J'ai ajouté des tests unitaires et ouvert la PR. Jusque-là, une correction d'ordre banale.
Acte 2 : SIGTERM avait deux maîtres
Le rebondissement est venu de la revue. Un relecteur, en examinant l'ensemble du chemin d'arrêt de cette correction, a posé la question : mais au juste, qui traite SIGTERM ?
Il y avait bien deux maîtres. Dans main.ts, ces deux lignes séparées existaient :
// ① Confie le signal d'arrêt à Nest lui-même (appelé sans argument, ça enregistre SIGTERM et SIGINT)
app.enableShutdownHooks();
// ② Confie aussi l'arrêt à la bibliothèque http-graceful-shutdown
setupGracefulShutdown(server, {
onShutdown: async () => {
await app.close(); // c'est ici que tous les hooks du cycle de vie s'exécutent
},
});
Les deux captent SIGTERM. Quand le signal arrive, Node appelle tous les listeners enregistrés dans l'ordre, sans attendre que l'un termine avant l'autre. Voici donc ce qui se passe réellement quand SIGTERM arrive : le listener enregistré par Nest exécute la même séquence d'arrêt que app.close(), immédiatement, sans même attendre le drain HTTP. Séparément, http-graceful-shutdown vide les connexions HTTP à sa façon, puis appelle une deuxième fois app.close() dans onShutdown. Les hooks d'arrêt tournent deux fois.
Le raisonnement seul ne suffisait pas à en être sûr, alors j'ai relu les logs d'arrêt en production. La preuve était là. Vingt millisecondes après l'horodatage de SIGTERM, avant même la fin du drain HTTP, onModuleDestroy démarrait déjà, et les logs des hooks d'arrêt de SpeechService et QueueMonitorService apparaissaient chacun deux fois. La seule raison pour laquelle cela n'avait jamais causé d'incident : les hooks de chaque service étaient conçus pour être sûrs même appelés deux fois (idempotents). Cette prudence défensive avait transformé une « double exécution » en simple odeur de log plutôt qu'en panne - et c'est cette odeur qui a finalement permis de trouver le bug.
Supprimer une ligne
La correction tenait en une ligne supprimée.
// main.ts
- app.enableShutdownHooks();
En retirant enableShutdownHooks(), le seul maître de SIGTERM devient http-graceful-shutdown. Rien n'est perdu : le app.close() appelé par son onShutdown exécute déjà tous les hooks du cycle de vie. Le seul changement, c'est que les hooks tournent une fois au lieu de deux, et seulement après la fin du drain HTTP.
Une correction d'une ligne mérite d'autant plus une vérification réelle. J'ai envoyé un vrai SIGTERM au serveur buildé et observé la séquence de mes propres yeux.
Received SIGTERM → health check 503 → HTTP connections closed
→ shutdown hooks run only once, after HTTP close
→ Drained 56/56 BullMQ workers in 211ms → shutdown completed → clean exit
En production, les hooks qui tournaient deux fois avant le drain HTTP ne tournent désormais qu'une fois, après. Cette vérification a été faite avec la correction du drain des workers de l'acte 1 déjà en place, donc elle valide aussi le comportement combiné des deux correctifs.
J'ai aussi noté un piège au passage. En NODE_ENV=development, http-graceful-shutdown bascule en mode d'arrêt immédiat et ne passe jamais par le chemin graceful - cette vérification ne peut donc pas se reproduire en mode développement. Il a fallu passer en NODE_ENV=staging avec des secrets factices pour emprunter le même chemin qu'en production avant de pouvoir mesurer quoi que ce soit. Pour vérifier un chemin d'arrêt, il faut d'abord s'assurer que l'environnement de test emprunte réellement ce chemin.
Ce que ce bug d'arrêt nous apprend
La leçon de cette histoire dépasse le cadre étroit de la séquence d'arrêt.
Un signal doit avoir un seul maître. Si vous branchez une bibliothèque de graceful shutdown, n'activez pas en même temps l'enregistrement automatique des hooks d'arrêt fourni par le framework. Dès que les deux captent le même SIGTERM, la séquence d'arrêt s'exécute deux fois, silencieusement. Avant d'adopter un nouvel outil, vérifiez de quel signal ou événement il devient le maître, et s'il n'existe pas déjà du code qui captait la même chose.
L'arrêt a lui aussi un ordre. Le code qui nettoie quelque chose doit s'exécuter après que tout ce qui en dépend est terminé. La base de données doit se fermer après les jobs qui l'utilisent, ce qui implique de vider ces jobs dans une phase plus précoce. Si le framework découpe les hooks d'arrêt en phases, ce n'est pas un détail cosmétique - c'est fait pour exprimer exactement cet ordre.
Un code défensif achète du temps. Si les hooks d'arrêt n'avaient pas été conçus pour rester sûrs même appelés deux fois, ce bug se serait d'abord manifesté comme un incident de données, pas comme une odeur dans les logs. C'est parce que ces hooks avaient été écrits pour être idempotents qu'ils ont pu encaisser la double exécution, et que nous avons pu le repérer dans les logs plutôt que dans un incident. Si on avait supposé, en les écrivant, qu'ils ne s'exécuteraient jamais qu'une seule fois, l'histoire aurait été bien pire.
Questions fréquentes
Les hooks de cycle de vie (onModuleDestroy, etc.) de mon app NestJS s'exécutent deux fois à l'arrêt. Pourquoi ?
Il y a probablement deux maîtres pour SIGTERM. app.enableShutdownHooks() fait en sorte que Nest enregistre lui-même des listeners sur les signaux d'arrêt. Si vous utilisez en parallèle une bibliothèque de graceful shutdown (comme http-graceful-shutdown), celle-ci capte aussi SIGTERM et appelle app.close() dans son onShutdown. Node appelle tous les listeners enregistrés sur un même signal, donc la séquence d'arrêt s'exécute deux fois. Il suffit de faire en sorte qu'un seul des deux soit le maître du signal.
Si j'utilise une bibliothèque de graceful shutdown, ne dois-je pas appeler enableShutdownHooks() ?
Si la bibliothèque appelle app.close() dans son callback onShutdown, alors app.close() exécute déjà tous les hooks du cycle de vie - enableShutdownHooks() devient redondant. Un signal doit avoir un seul maître. En laissant la bibliothèque être ce maître et en retirant enableShutdownHooks(), les hooks ne s'exécutent qu'une seule fois, et seulement après la fin du drain HTTP.
Articles similaires
- 💻 Dev
« On n'a qu'à le désactiver en staging » - le vrai bug que cachait une alerte bruyante
Une alerte Google Play 404 qui tombait chaque jour en staging. Désactiver la source la fait taire, mais ce n'est pas la même chose que corriger le mode de défaillance. L'histoire d'un 404 - une erreur permanente qu'il ne sert à rien de réessayer - qui se retrouvait mélangé aux 5xx dans le même circuit de réessai.
- 💻 Dev
La remise que vous ne touchiez pas : pourquoi Spot et Graviton coûtent si peu
La facture AWS a dépassé le budget deux mois de suite. En cherchant quelle ressource couper, j'ai compris que le vrai levier n'était pas « combien on consomme » mais « comment on achète ». Pourquoi Spot et Graviton sont si peu chers, et pourquoi certains coûts qui ressemblent à du gaspillage (RDS Proxy) ne doivent surtout pas être coupés - l'histoire du « pourquoi » derrière chaque ligne de la facture.
- 💻 Dev
Construire un blog MDX avec Next.js : comment ce blog a été fait
Comment j'ai construit un blog qui tourne uniquement avec des fichiers markdown, sans CMS, avec Next.js 16 et l'App Router. Structure des dossiers, config MDX et SEO, avec le vrai flux de code.