Gli hook di chiusura giravano due volte: quando SIGTERM ha due padroni
A ogni deploy, Sentry segnalava un errore Prisma P2028. Ero entrato per sistemare l'ordine di chiusura, e ho scoperto che enableShutdownHooks di NestJS e la libreria di graceful shutdown intercettavano entrambi SIGTERM, facendo eseguire gli shutdown hook due volte a ogni chiusura.
In sintesi
Quando arrivava un SIGTERM durante il deploy, il DB si chiudeva prima dei job ancora in corso e usciva Prisma P2028. La causa era che la chiusura dei worker e quella del DB si trovavano nella stessa fase del lifecycle - ho corretto l'ordine facendo drenare i worker nella fase più precoce beforeApplicationShutdown. Ma durante la review è emerso un problema più profondo. enableShutdownHooks() e http-graceful-shutdown intercettavano entrambi SIGTERM, e gli shutdown hook giravano due volte prima ancora che il drain HTTP finisse. Solo grazie a hook idempotenti la cosa è rimasta un odore nei log invece che un incidente vero. Ho eliminato una riga (enableShutdownHooks) per assegnare a un solo proprietario il segnale, e ho verificato il tutto inviando un vero SIGTERM.
In questa pagina
A ogni deploy, su Sentry compariva sempre lo stesso errore: Transaction already closed. È l'errore che Prisma lancia come P2028 quando prova a eseguire una query dopo che la transazione è già stata chiusa. Per gli utenti non c'era alcun danno visibile: il retry di BullMQ faceva subito completare il job su un altro task, e i log di completamento proseguivano senza interruzioni. Eppure la struttura del problema si ripresentava silenziosamente ogni volta che un deploy coincideva con del traffico. Inseguendo questo errore, ho scoperto che gli shutdown hook del nostro server venivano in realtà eseguiti due volte a ogni chiusura.
Atto primo: il DB muore prima dei job
Incrociando l'orario dell'errore con i log di CloudWatch, la timeline tornava esatta. Il rolling deploy manda SIGTERM al vecchio task. Sei secondi dopo, app.close() avvia la pulizia e il motore Prisma chiude la transazione. Subito dopo, la updateMany di un job asset-processing ancora in corso viene eseguita sopra una transazione già chiusa e fallisce con P2028. L'utente stava caricando audio in sequenza, quindi al momento del deploy c'era un job attivo, e la sua query è morta lì.
La causa era l'ordine di chiusura. NestJS, quando si spegne, chiama gli hook del lifecycle in un ordine prestabilito. Il problema era che chiudere i worker BullMQ e disconnettere Prisma si trovavano nella stessa fase di chiusura (onApplicationShutdown). All'interno della stessa fase è l'ordine di registrazione dei moduli a decidere l'ordine di esecuzione, e se per sfortuna Prisma si disconnette per primo, la query di un worker ancora impegnato in un job resta senza destinazione.
La direzione della correzione era chiara: bastava chiudere i worker in una fase precedente. Gli shutdown hook di NestJS hanno un ordine, e beforeApplicationShutdown si completa prima dell'onApplicationShutdown di qualsiasi modulo. Ho creato un servizio che trova tutti i worker e li drena (aspetta che i job attivi finiscano) proprio in questa fase precoce.
@Injectable()
export class WorkerDrainService implements BeforeApplicationShutdown {
constructor(private readonly discovery: DiscoveryService) {}
async beforeApplicationShutdown() {
// Trova tutti i WorkerHost registrati e aspetta che i job attivi terminino.
// Questa fase si conclude prima dell'onApplicationShutdown in cui Prisma si disconnette.
const workers = this.discovery
.getProviders()
.filter((p) => p.instance instanceof WorkerHost);
await Promise.all(workers.map((w) => w.instance.worker.close()));
}
}
Worker.close() restituisce la stessa promise anche se richiamato più volte, quindi non entra in conflitto col codice che già chiudeva i worker, e negli ambienti senza Redis non fa semplicemente nulla. Ora, anche se il deploy coincide con un job, il job finisce prima che il DB si chiuda. Ho aggiunto i test unitari e aperto la PR. Fin qui, era solo la correzione ordinaria di un bug d'ordine.
Atto secondo: SIGTERM aveva due padroni
Il colpo di scena è arrivato in review. Chi stava esaminando questa correzione, ripercorrendo l'intero percorso di chiusura, ha fatto una domanda: ma SIGTERM, in definitiva, chi lo gestisce?
Controllando, ho trovato due proprietari. In main.ts c'erano queste due righe, separate.
// ① Affida a Nest stesso la gestione del segnale di chiusura (chiamato senza argomenti registra sia SIGTERM che SIGINT)
app.enableShutdownHooks();
// ② Affida la chiusura anche alla libreria http-graceful-shutdown
setupGracefulShutdown(server, {
onShutdown: async () => {
await app.close(); // qui viene eseguito l'intero set di hook del lifecycle
},
});
Entrambi intercettano SIGTERM. Quando arriva un segnale, Node chiama tutti i listener registrati in ordine, senza aspettare che l'uno finisca prima dell'altro. Così, nel momento esatto in cui arriva un vero SIGTERM, succede questo: il listener registrato da Nest esegue una prima volta la stessa sequenza di chiusura di app.close(), senza nemmeno aspettare il drain HTTP. Indipendentemente da questo, http-graceful-shutdown svuota a modo suo le connessioni HTTP e poi, dentro onShutdown, chiama di nuovo app.close(). Gli shutdown hook girano due volte.
Non potendo esserne certo solo per deduzione, sono tornato a leggere i log di chiusura di produzione. Le prove erano tutte lì. Venti millisecondi dopo l'orario registrato per SIGTERM, ancor prima che il drain HTTP finisse, partiva onModuleDestroy, e i log degli shutdown hook di SpeechService e QueueMonitorService comparivano ciascuno due volte. L'unico motivo per cui questo non si era mai trasformato in un incidente è che gli hook di ogni servizio erano scritti per essere sicuri anche se chiamati due volte (idempotenti). Grazie a questa scrittura difensiva, l'"esecuzione doppia" era rimasta un odore nei log invece che un guasto, e quell'odore è stato ciò che alla fine ha permesso di scovare il bug.
Si elimina una riga
La correzione consisteva nell'eliminare una sola riga.
// main.ts
- app.enableShutdownHooks();
Eliminando enableShutdownHooks(), il proprietario di SIGTERM si riduce a uno solo: http-graceful-shutdown. Non si perde nulla, perché l'app.close() chiamato dentro il suo onShutdown esegue comunque l'intero set di hook del lifecycle. Cambia solo che gli hook girano una volta, non due, e per di più dopo che il drain HTTP è terminato.
Proprio perché la correzione era di una sola riga, serviva una verifica sul campo. Ho inviato un vero SIGTERM al server buildato e ho osservato la sequenza a occhio nudo.
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
In produzione, ciò che prima girava due volte prima del drain HTTP, ora gira una sola volta dopo il drain. La verifica è stata fatta insieme alla correzione del drain dei worker dell'atto primo, quindi ho controllato anche il comportamento integrato delle due correzioni insieme.
Ho annotato anche una trappola. Con NODE_ENV=development, http-graceful-shutdown funziona in modalità di chiusura immediata e non passa affatto dal percorso graceful. Questa verifica non è quindi riproducibile in modalità sviluppo. Ho potuto misurarla sul campo solo dopo aver impostato NODE_ENV=staging con segreti fittizi, per far passare il codice sullo stesso percorso della produzione. Quando si verifica un percorso di chiusura, va prima controllato che l'ambiente di verifica passi davvero da quel percorso.
Cosa ho imparato dal codice di chiusura
La lezione di questa storia va oltre il palcoscenico ristretto della sequenza di chiusura.
Un segnale deve avere un solo proprietario. Se hai collegato una libreria di graceful shutdown, non devi tenere acceso in parallelo anche l'auto-registrazione degli shutdown hook offerta dal framework. Nel momento in cui entrambi intercettano lo stesso SIGTERM, la sequenza di chiusura viene eseguita due volte in silenzio. Quando introduci un nuovo strumento, devi prima capire di quale segnale o evento diventerà proprietario, e se esiste già del codice che intercetta la stessa cosa.
Anche la chiusura ha un ordine. Il codice che ripulisce qualcosa deve girare solo dopo che tutto ciò che ne dipende è finito. Il DB va chiuso dopo che i job che lo usano sono terminati, e per questo bisogna svuotare i job in una fase precedente. Il fatto che il framework divida gli shutdown hook in fasi non è un dettaglio decorativo: serve esattamente a esprimere questo ordine.
Il codice scritto in modo difensivo ti compra tempo. Se gli shutdown hook non fossero stati scritti in modo sicuro anche per una doppia chiamata, questo bug si sarebbe manifestato prima come un incidente sui dati, non come un odore nei log. Grazie a hook scritti in modo idempotente, capaci di reggere una doppia esecuzione, l'abbiamo scoperto nei log invece che in un incidente. Se avessimo scritto quegli hook dando per scontato che venissero eseguiti una sola volta, la storia sarebbe finita molto peggio.
Domande frequenti
Quando chiudo un'app NestJS, gli hook del lifecycle (onModuleDestroy e simili) vengono eseguiti due volte. Perché succede?
È molto probabile che ci siano due proprietari che gestiscono SIGTERM. app.enableShutdownHooks() fa sì che sia Nest stesso a registrare i listener per i segnali di chiusura; se in parallelo usi anche una libreria di graceful shutdown (per esempio http-graceful-shutdown), anche questa intercetta SIGTERM e chiama app.close() dentro onShutdown. Node invoca tutti i listener registrati su un segnale, quindi la sequenza di chiusura viene eseguita due volte. Basta lasciare che solo uno dei due sia il proprietario del segnale.
Se uso una libreria di graceful shutdown, non devo chiamare enableShutdownHooks()?
Se la libreria chiama app.close() dentro il callback onShutdown, enableShutdownHooks() diventa ridondante, perché app.close() esegue già tutti gli hook del lifecycle. Un segnale deve avere un solo proprietario. Se lasci che sia la libreria a possedere il segnale e rimuovi enableShutdownHooks(), gli hook vengono eseguiti una sola volta, e solo dopo che il drain HTTP è completato.
Articoli correlati
- 💻 Sviluppo
Lo sconto che non stavamo incassando: perché Spot e Graviton costano meno
La fattura AWS ha superato il budget per due mesi di fila. Cercando risorse da tagliare, ho scoperto che la vera leva non era 'quanto' si usa, ma 'come' lo si compra. Perché Spot e Graviton costano meno, e perché un costo che sembra spreco (RDS Proxy) in realtà non va spento - una storia sul 'perché' dietro ogni cifra della fattura.
- 💻 Sviluppo
«Basta disattivarlo in staging» - l'alert 404 rumoroso che nascondeva il vero bug
Ogni giorno, in staging, arrivava lo stesso alert per un 404 di Google Play. Disattivare la fonte l'avrebbe fatto tacere, ma zittire l'alert e correggere la modalità di fallimento sono due problemi diversi. Il 404 era un errore permanente, inutile da ritentare, eppure veniva mescolato ai 5xx e ritentato comunque.
- 💻 Sviluppo
Creare un blog MDX con Next.js da zero: come ho costruito questo blog
Come ho costruito con Next.js 16 App Router un blog che gira solo con file markdown, senza CMS. Struttura delle cartelle, configurazione MDX e SEO, spiegati con il flusso di codice reale.