12k
All articles

Arrêter proprement un serveur Node

Arrêt gracieux de Node avec SIGTERM: gérez readiness, videz les requêtes avec server.close, fermez les ressources dans l ordre et évitez les 502.

OpenReplay Team
OpenReplay Team
Arrêter proprement un serveur Node

Un arrêt propre (« graceful shutdown ») sous Node traite SIGTERM dans un ordre précis : faire échouer la sonde de disponibilité (readiness), attendre que le load balancer cesse d’acheminer le trafic, drainer les requêtes en cours avec server.close(), fermer les ressources dans l’ordre des dépendances, puis quitter le processus.

Si vous appliquez déjà l’essentiel de cette séquence et que vous continuez à observer des 502 et des réinitialisations de connexion à chaque déploiement, le problème ne vient généralement pas du gestionnaire lui-même. Trois éléments de son environnement en sont le plus souvent la cause : le signal n’atteint jamais Node, le drainage ne se termine jamais, ou l’orchestrateur tue le pod avant la fin du drainage. Cet article construit un petit gestionnaire, traite ces trois cas, et se termine par un test que vous pouvez exécuter.

Points clés

  • Sans gestionnaire SIGTERM, Node quitte immédiatement et chaque requête en cours se termine par une réinitialisation de connexion ou une réponse tronquée.
  • Faites un await sur server.close() avant de fermer le pool de connexions à la base de données ; fermer le pool en premier transforme chaque requête en cours en erreur.
  • Depuis Node 19.0.0, server.close() ferme lui-même les connexions keep-alive inactives ; le blocage historique du « callback de fermeture qui ne se déclenche jamais » appartient au passé.
  • Un CMD en forme shell dans un Dockerfile signifie que c’est le shell, et non Node, qui reçoit SIGTERM : aucun code de gestionnaire ne sera donc exécuté.
  • Définissez un minuteur de sortie forcée dans le processus, avec une valeur inférieure à terminationGracePeriodSeconds, afin que le processus choisisse sa propre sortie plutôt que de recevoir un SIGKILL.

Que se passe-t-il sans gestionnaire SIGTERM ?

Le comportement par défaut de Node face à SIGTERM est de terminer le processus, comme l’indique la documentation sur les événements de signaux. Tout ce qui était en cours à cet instant est interrompu en pleine réponse : le client observe une réinitialisation ou un corps de réponse partiel, et le load balancer journalise un 502. En session replay, cette défaillance a une signature reconnaissable : une action utilisateur qui tourne dans le vide puis échoue, concentrée dans une fenêtre étroite correspondant à un déploiement plutôt qu’à quoi que ce soit fait par l’utilisateur. C’est généralement par cette vue côté client que le bug est repéré en premier.

L’ordre correct pour un arrêt propre sous Node

Un gestionnaire correct effectue cinq opérations dans l’ordre : basculer un drapeau d’arrêt pour faire échouer la readiness, attendre brièvement que le load balancer réagisse, faire un await sur server.close() pour que les requêtes en cours s’achèvent, fermer les ressources restantes, puis quitter. Le await sur cette fermeture est le point déterminant. Un gestionnaire qui appelle server.close() sans attendre son callback, nettoie les ressources et quitte a recréé exactement le crash qu’il était censé éviter.

  1. Basculer un drapeau d’arrêt afin que la sonde de readiness renvoie 503.
  2. Attendre brièvement que le load balancer cesse d’acheminer du nouveau trafic.
  3. Faire un await sur server.close() pour laisser les requêtes en cours s’achever.
  4. Fermer les ressources restantes dans l’ordre des dépendances.
  5. Quitter le processus.
// server.js (Express)
const { setTimeout: sleep } = require('node:timers/promises');

let shuttingDown = false;

app.get('/health/live', (req, res) => res.sendStatus(200));
app.get('/health/ready', (req, res) => res.sendStatus(shuttingDown ? 503 : 200));

async function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;                    // readiness now returns 503

  await sleep(LB_WAIT_MS);                // matches your preStop sleep

  await new Promise((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );

  await closeResources();                 // next section
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Pourquoi le drainage peut-il rester bloqué ?

Sur toute version de Node encore maintenue, server.close() refuse les nouvelles connexions, ferme les sockets keep-alive inactifs et attend la fin des connexions ayant une requête en cours. L’avertissement souvent répété selon lequel les sockets keep-alive inactifs empêcheraient le callback de fermeture de se déclencher décrit le comportement de Node antérieur à 19.0.0 ; ce blocage ne se produit donc plus sur les versions supportées. Si vous devez exécuter des runtimes plus anciens, appelez server.closeIdleConnections() (ajouté dans Node 18.2.0) immédiatement après avoir initié la fermeture, et non avant, pour éviter une situation de concurrence avec les connexions nouvellement créées.

Ce qui bloque encore un drainage, ce sont les traitements réellement actifs : gestionnaires lents, server-sent events et sockets basculés vers un autre protocole. C’est précisément la raison d’être du minuteur de sortie forcée présenté plus bas.

Fermer les ressources dans l’ordre des dépendances

Fermez d’abord le serveur HTTP, puis les workers de file d’attente et les tâches de fond, ensuite Redis, et enfin le pool de la base de données. Cet ordre suit la chaîne de dépendances : les gestionnaires de requêtes et les jobs utilisent Redis et le pool ; fermer le pool alors qu’ils tournent encore transforme chaque requête en cours en erreur, ce qui annule le bénéfice du drainage que vous venez d’attendre.

async function closeResources() {
  await worker.close();   // BullMQ or similar: finish the active job
  await redis.quit();
  await pool.end();       // pg pool last: nothing queries after this
}

Les jobs dont la durée dépasse la période de grâce ont besoin de points de reprise (checkpointing) pour pouvoir reprendre : les drainer ne suffit pas.

Quels réglages de conteneur cassent l’arrêt propre ?

Node doit être le processus qui reçoit effectivement le signal, sinon tout ce qui précède est sans effet. La référence Dockerfile est explicite : la forme shell exécute votre commande via /bin/sh -c, qui ne transmet pas les signaux à son processus fils. Un CMD en forme shell signifie donc que le SIGTERM émis par docker stop n’atteint jamais Node.

# Broken: /bin/sh -c receives SIGTERM, node never does
CMD node server.js

# Correct: node is the signal target
CMD ["node", "server.js"]

Si vous avez besoin d’un processus init pour récupérer les zombies, tini transmet les signaux à son processus fils, ce qui permet au gestionnaire de s’exécuter malgré tout. Notez que docker stop escalade vers SIGKILL au bout de 10 secondes par défaut sous Linux, valeur configurable avec -t.

Sous Kubernetes, définissez terminationGracePeriodSeconds (30 par défaut) au-delà de votre durée totale de drainage, et ajoutez un sleep en preStop : la suppression de l’endpoint est évaluée en parallèle du SIGTERM et se propage de façon asynchrone via les EndpointSlices, si bien que des requêtes continuent d’arriver pendant un court instant après le signal. Faites en sorte que la sonde de liveness continue de réussir pendant que la readiness échoue ; une sonde de liveness branchée sur le même endpoint en échec entraînerait un redémarrage du conteneur en pleine phase de drainage.

terminationGracePeriodSeconds: 30   # > preStop + LB wait + drain + cleanup
lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]       # LB_WAIT_MS should match

Choisissez votre sortie avant que SIGKILL ne le fasse

Armez un minuteur de sortie forcée au début du gestionnaire, avec une valeur inférieure à la période de grâce de l’orchestrateur, afin qu’un drainage bloqué se termine par votre ligne de log et votre code de sortie plutôt que par un SIGKILL. En dernier recours, il appelle server.closeAllConnections() (ajouté dans Node 18.2.0), qui détruit toutes les connexions ouvertes, y compris celles qui traitent encore une requête. Les connexions ayant basculé vers un autre protocole y survivent : les WebSockets nécessitent donc leur propre diffusion de trame de fermeture (close frame).

const forceExit = setTimeout(() => {
  server.closeAllConnections();
  process.exit(1);
}, GRACE_MS - 2_000);   // grace period minus a buffer, never a fixed default
forceExit.unref();

Comment prouver que l’arrêt fonctionne ?

Le test d’acceptation est mécanique : lancer le serveur, envoyer une requête vers une route délibérément lente, envoyer SIGTERM en pleine exécution, puis vérifier que la réponse arrive tout de même avec un 200 et que le processus quitte avec le code 0.

// verify-shutdown.js
const { spawn } = require('node:child_process');
const assert = require('node:assert');

const child = spawn('node', ['server.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', async (chunk) => {
  if (!chunk.toString().includes('listening')) return;
  const pending = fetch('http://localhost:3000/slow'); // route awaits ~2s
  setTimeout(() => child.kill('SIGTERM'), 100);
  const res = await pending;
  assert.equal(res.status, 200);
  const code = await new Promise((r) => child.on('exit', r));
  assert.equal(code, 0);
  console.log('graceful shutdown verified');
});

Si ce test passe en local mais que les déploiements perdent toujours des requêtes, les coupables restants se situent au niveau de la couche conteneur : un CMD en forme shell, ou une période de grâce inférieure à votre budget de drainage.

Le gestionnaire lui-même tient en quarante lignes ; la fiabilité, elle, vient de l’ordonnancement et de l’environnement qui l’entoure. Intégrez le test à votre CI afin que le prochain refactoring ne puisse pas réintroduire silencieusement le bug de réinitialisation au déploiement que vous venez de corriger.

FAQ

Un processus Node.js peut-il intercepter ou traiter SIGKILL ?

Non. Node refuse d'attacher un listener à SIGKILL, et ce signal met fin au processus sur toutes les plateformes quoi que fasse votre code : aucun nettoyage n'est donc exécuté. SIGSTOP ne peut pas non plus être écouté. C'est pourquoi le minuteur de sortie forcée dans le processus est important : il doit se déclencher avant l'expiration de la période de grâce de l'orchestrateur, afin que le processus draine et quitte sur SIGTERM au lieu d'être tué sans aucune possibilité de réagir.

Quelle est la différence entre closeIdleConnections et closeAllConnections ?

Les deux méthodes ont été ajoutées dans Node 18.2.0. closeIdleConnections ne ferme que les sockets inactifs entre deux requêtes, laissant ainsi s'achever tout ce qui est en cours de traitement. closeAllConnections est la méthode brutale : elle détruit toutes les connexions ouvertes, y compris celles qui traitent encore une requête, même si les connexions ayant basculé vers un autre protocole comme WebSocket y survivent. Depuis Node 19.0.0, server.close ferme de lui-même les connexions inactives : closeIdleConnections ne vaut donc la peine d'être appelée que si vous prenez encore en charge des runtimes plus anciens.

process.exit attend-il les requêtes en cours ou les traitements asynchrones en attente ?

Non. process.exit arrête le processus immédiatement et abandonne tous les traitements asynchrones encore en file d'attente, jusqu'aux sorties dont l'écriture sur stdout ou stderr n'est pas terminée. C'est pour cette raison que le gestionnaire d'arrêt fait un await sur server.close et sur le nettoyage des ressources avant d'appeler exit ; appeler exit plus tôt recrée exactement le crash avec pertes de requêtes que le gestionnaire est censé éviter. En dehors d'un gestionnaire, préférez définir process.exitCode et laisser le processus se terminer naturellement.

Les gestionnaires SIGTERM fonctionnent-ils sous Windows ?

Pas de la même manière. Windows n'a pas de signaux POSIX, et la documentation Node.js indique SIGTERM comme non pris en charge sur cette plateforme, même si votre code peut malgré tout y enregistrer un listener. Ctrl+C déclenche bien SIGINT partout, ce qui explique pourquoi le gestionnaire écoute également SIGINT pour le développement local. Testez le chemin de drainage sur SIGTERM dans un conteneur Linux, là où docker stop et Kubernetes délivrent réellement le signal.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.