12k
All articles

Einen Node-Server ordnungsgemäß herunterfahren

Graceful Node Shutdown mit SIGTERM: Readiness umschalten, Requests mit server.close drainen, Ressourcen geordnet schließen und 502s bei Rollouts vermeiden.

OpenReplay Team
OpenReplay Team
Einen Node-Server ordnungsgemäß herunterfahren

Ein sauberes Node-Shutdown verarbeitet SIGTERM in einer festen Reihenfolge: Readiness-Check auf Fehler setzen, warten, bis der Load Balancer keinen Traffic mehr weiterleitet, laufende Requests mit server.close() abarbeiten, Ressourcen in Abhängigkeitsreihenfolge schließen und dann den Prozess beenden.

Wenn Sie das meiste davon bereits umsetzen und trotzdem bei jedem Rollout 502er und Verbindungsabbrüche sehen, liegt das Problem oft nicht im Handler selbst. Drei Dinge in seinem Umfeld sind typischerweise die Ursache: Das Signal erreicht Node nie, der Drain wird nie abgeschlossen, oder der Orchestrator killt den Pod, bevor der Drain fertig ist. Dieser Artikel baut einen kleinen Handler auf, arbeitet alle drei Punkte durch und endet mit einem Test, den Sie ausführen können.

Die wichtigsten Erkenntnisse

  • Ohne SIGTERM-Handler beendet sich Node sofort, und jeder laufende Request endet als Verbindungsabbruch oder abgeschnittene Antwort.
  • Warten Sie mit await auf server.close(), bevor Sie den Datenbank-Pool schließen; wird der Pool zuerst geschlossen, wird jede laufende Query zum Fehler.
  • Seit Node 19.0.0 schließt server.close() inaktive Keep-alive-Verbindungen selbst; das alte Problem „Close-Callback feuert nie” ist Geschichte.
  • Ein CMD in Shell-Form im Dockerfile führt dazu, dass die Shell und nicht Node das SIGTERM erhält – es läuft also kein einziger Zeile Handler-Code.
  • Setzen Sie einen prozessinternen Timer für den erzwungenen Exit unterhalb von terminationGracePeriodSeconds, damit der Prozess seinen Exit selbst bestimmt, statt ein SIGKILL zu kassieren.

Was passiert ohne SIGTERM-Handler?

Nodes Standardreaktion auf SIGTERM ist die Beendigung des Prozesses, wie in der Dokumentation zu Signal-Events beschrieben. Alles, was in diesem Moment in Bearbeitung war, wird mitten in der Antwort abgeschnitten: Der Client sieht einen Reset oder einen unvollständigen Body, und der Load Balancer protokolliert einen 502er. Im Session Replay hat dieser Fehler ein wiedererkennbares Muster: eine Nutzeraktion, die sich dreht und dann fehlschlägt, gehäuft in einem engen Zeitfenster, das zu einem Rollout passt und nicht zu irgendetwas, das der Nutzer getan hat. Diese clientseitige Perspektive ist üblicherweise der Ort, an dem der Bug zuerst auffällt.

Die korrekte Reihenfolge beim Graceful Shutdown in Node

Ein korrekter Handler erledigt fünf Dinge in dieser Reihenfolge: ein Shutdown-Flag setzen, damit die Readiness fehlschlägt, kurz warten, bis der Load Balancer reagiert, mit await auf server.close() warten, damit laufende Requests abgeschlossen werden, die restlichen Ressourcen schließen und dann beenden. Das await auf diesem Close ist der entscheidende Teil. Ein Handler, der server.close() aufruft, ohne auf dessen Callback zu warten, Ressourcen aufräumt und sich beendet, hat genau den Absturz reproduziert, den er verhindern sollte.

  1. Ein Shutdown-Flag setzen, sodass der Readiness-Probe 503 zurückgibt.
  2. Kurz warten, bis der Load Balancer keinen neuen Traffic mehr weiterleitet.
  3. Mit await auf server.close() warten, damit laufende Requests abgeschlossen werden.
  4. Die restlichen Ressourcen in Abhängigkeitsreihenfolge schließen.
  5. Den Prozess beenden.
// 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);

Warum kann der Drain hängen bleiben?

In jedem unterstützten Node-Release stoppt server.close() neue Verbindungen, schließt inaktive Keep-alive-Sockets und wartet darauf, dass Verbindungen mit einem laufenden Request abgeschlossen werden. Die vielfach wiederholte Warnung, dass inaktive Keep-alive-Sockets das Auslösen des Close-Callbacks verhindern, beschreibt Node vor Version 19.0.0 – auf unterstützten Versionen tritt dieses Hängenbleiben also nicht mehr auf. Falls Sie ältere Runtimes betreiben müssen, rufen Sie server.closeIdleConnections() (hinzugefügt in Node 18.2.0) unmittelbar nach dem Initiieren des Close auf, nicht davor, um ein Race mit neu erzeugten Verbindungen zu vermeiden.

Was einen Drain weiterhin blockieren kann, ist legitime aktive Arbeit: langsame Handler, Server-Sent Events und Sockets, die auf ein anderes Protokoll upgegradet wurden. Genau dafür existiert der unten beschriebene Timer für den erzwungenen Exit.

Ressourcen in Abhängigkeitsreihenfolge schließen

Schließen Sie zuerst den HTTP-Server, dann Queue-Worker und Hintergrundjobs, dann Redis, dann den Datenbank-Pool. Die Reihenfolge folgt der Abhängigkeitskette: Request-Handler und Jobs nutzen Redis und den Pool. Schließt man den Pool, während sie noch laufen, wird jede laufende Query zum Fehler – womit der gerade abgewartete Drain hinfällig ist.

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
}

Jobs, die länger laufen als die Grace Period, brauchen Checkpointing, damit sie fortgesetzt werden können – kein Draining.

Welche Container-Einstellungen zerstören das Graceful Shutdown?

Node muss der Prozess sein, der das Signal tatsächlich empfängt, sonst ist alles Obige irrelevant. Die Dockerfile-Referenz stellt explizit klar, dass die Shell-Form Ihren Befehl unter /bin/sh -c ausführt, und diese Shell leitet Signale nicht an ihr Child weiter. Ein CMD in Shell-Form bedeutet also, dass das SIGTERM von docker stop Node nie erreicht.

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

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

Wenn Sie einen Init-Prozess zum Einsammeln von Zombie-Prozessen benötigen: tini leitet Signale an sein Child weiter, sodass der Handler weiterhin läuft. Beachten Sie, dass docker stop unter Linux standardmäßig nach 10 Sekunden auf SIGKILL eskaliert – konfigurierbar über -t.

In Kubernetes setzen Sie terminationGracePeriodSeconds (Standard: 30) über Ihre gesamte Drain-Zeit und ergänzen einen preStop-Sleep: Die Entfernung des Endpoints wird parallel zu SIGTERM ausgewertet und propagiert asynchron über EndpointSlices – Requests treffen also noch einen Moment nach dem Signal ein. Sorgen Sie dafür, dass der Liveness-Probe weiterhin erfolgreich ist, während die Readiness fehlschlägt; ein Liveness-Probe, der auf denselben fehlschlagenden Endpoint zeigt, führt zum Neustart des Containers mitten im Drain.

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

Bestimmen Sie Ihren Exit, bevor SIGKILL es tut

Starten Sie ganz am Anfang des Handlers einen Timer für den erzwungenen Exit, eingestellt unterhalb der Grace Period des Orchestrators, damit ein hängender Drain mit Ihrer Logzeile und Ihrem Exit-Code endet statt mit SIGKILL. Als letztes Mittel ruft er server.closeAllConnections() (hinzugefügt in Node 18.2.0) auf, was jede offene Verbindung abreißt – auch solche, die noch einen Request bearbeiten. Verbindungen, die auf ein anderes Protokoll gewechselt sind, überleben das; WebSockets brauchen daher ihren eigenen Close-Frame-Broadcast.

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

Wie weisen Sie nach, dass das Shutdown funktioniert?

Der Abnahmetest ist mechanisch: den Server starten, einen Request an eine bewusst langsame Route abfeuern, mitten im Flug SIGTERM senden und prüfen, dass die Antwort trotzdem mit 200 eintrifft und der Prozess mit 0 beendet wird.

// 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');
});

Wenn das lokal durchläuft, Rollouts aber weiterhin Requests verlieren, bleiben als Verdächtige die Container-Ebene: ein CMD in Shell-Form oder eine Grace Period, die kleiner ist als Ihr Drain-Budget.

Der Handler selbst umfasst vierzig Zeilen; die Zuverlässigkeit ergibt sich aus der Reihenfolge und dem Umfeld drumherum. Binden Sie den Test in Ihre CI ein, damit das nächste Refactoring den gerade behobenen „Reset beim Deploy”-Bug nicht unbemerkt wieder einschleppen kann.

FAQs

Kann ein Node.js-Prozess SIGKILL abfangen oder behandeln?

Nein. Node verweigert das Registrieren eines Listeners für SIGKILL, und das Signal beendet den Prozess auf jeder Plattform, egal was Ihr Code sagt – es läuft also kein Cleanup. Auch auf SIGSTOP kann nicht gelauscht werden. Genau deshalb ist der prozessinterne Timer für den erzwungenen Exit wichtig: Er muss feuern, bevor die Grace Period des Orchestrators abläuft, damit der Prozess auf SIGTERM hin drainiert und sich beendet, statt ohne Reaktionsmöglichkeit gekillt zu werden.

Was ist der Unterschied zwischen closeIdleConnections und closeAllConnections?

Beide Methoden wurden in Node 18.2.0 hinzugefügt. closeIdleConnections schließt nur Sockets, die zwischen Requests untätig sind – alles, was sich mitten in einem Request befindet, darf zu Ende laufen. closeAllConnections ist die grobe Variante: Sie reißt jede offene Verbindung ab, auch solche, die noch einen Request bearbeiten, wobei Verbindungen, die auf ein anderes Protokoll wie WebSocket gewechselt sind, das überleben. Seit Node 19.0.0 räumt server.close inaktive Verbindungen von selbst ab, sodass sich closeIdleConnections nur dann lohnt, wenn Sie noch ältere Runtimes unterstützen.

Wartet process.exit auf laufende Requests oder ausstehende asynchrone Arbeit?

Nein. process.exit fährt den Prozess sofort herunter und verwirft jede noch eingereihte asynchrone Arbeit – bis hin zu Ausgaben, die noch nicht vollständig nach stdout oder stderr geschrieben wurden. Deshalb wartet der Shutdown-Handler mit await auf server.close und das Ressourcen-Cleanup, bevor er exit aufruft; ein früherer Aufruf von exit reproduziert genau den Absturz mit verworfenen Requests, den der Handler verhindern soll. Außerhalb eines Handlers setzen Sie besser process.exitCode und lassen den Prozess auf natürlichem Weg enden.

Funktionieren SIGTERM-Handler unter Windows?

Nicht auf dieselbe Weise. Windows kennt keine POSIX-Signale, und die Node.js-Dokumentation führt SIGTERM dort als nicht unterstützt auf, auch wenn Ihr Code weiterhin einen Listener dafür registrieren kann. Strg+C löst überall SIGINT aus – deshalb lauscht der Handler für die lokale Entwicklung zusätzlich auf SIGINT. Testen Sie den SIGTERM-Drain-Pfad in einem Linux-Container, wo docker stop und Kubernetes das Signal tatsächlich zustellen.

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.