Jeder Graceful-Shutdown-Pfad, den du nie getestet hast

Dein Web-Service hat einen Shutdown-Handler. Er flushed Buffer, schließt Verbindungen, schreibt Checkpoints. Du hast ihn vielleicht einmal getestet. In Production läuft er wahrscheinlich einmal im Jahr bei einem geplanten Deploy. Den Rest der Zeit stirbt dein Service durch einen OOM-Kill, eine Node-Eviction, einen Stromausfall oder einen Deploy, der timed out und SIGKILL bekommt.

Crash-only-Software dreht das um. Es gibt keinen graceful Shutdown. Es gibt nur Crash und Recovery. Der gleiche Pfad läuft nach einem SIGKILL, einem Segfault oder einer Kernel-Panic. Wenn dein Recovery-Pfad funktioniert, ist dein Service sicher. Wenn nicht, findest du es sofort heraus, nicht während eines Ausfalls um 3 Uhr nachts.

Was Crash-Only wirklich bedeutet

Der Begriff stammt aus Candea und Fox’ Paper von 2003 über Crash-only Computing. Ihr Argument war einfach: Wenn Komponenten sowieso crashen, entwirf sie so, dass Crash und Recovery die einzigen Zustände sind. Kein warmer Shutdown. Kein sauberer Exit. Kein „bitte beende zuerst deine Requests“.

Für einen Web-Service bedeutet das drei Dinge:

  1. Der gesamte durable State lebt außerhalb des Prozesses. Memory ist per Definition ephemeral. Alles, was du nach einem Restart brauchst, muss in einer Datenbank, einem Write-ahead Log oder einer durable Message Queue sein, bevor du die Arbeit acknowledgest.

  2. Recovery ist der einzige Startup-Pfad. Der gleiche Code läuft, egal ob du neu startest oder nach einem Crash restartest. Es gibt keinen speziellen „Restore from Checkpoint“-Modus. Es gibt nur „lies das Log und hol auf“.

  3. Requests sind atomic oder idempotent. Ein Client retryed. Ein partieller Request hinterlässt keinen korrupten State. Der Service ist es egal, ob der vorherige Versuch beendet wurde, gecrasht ist oder mitten im Flug gekillt wurde.

Warum Graceful Shutdown Bugs versteckt

Graceful Shutdown gibt dir ein falsches Sicherheitsgefühl. Du glaubst, dein Service shutted sauber down, weil dein Handler läuft. Aber der Handler ist eine Fantasie. Linux sendet nach 30 Sekunden SIGKILL, egal ob du fertig bist oder nicht. Kubernetes evicted Pods ohne Warnung. Dein Rechenzentrum verliert den Strom.

Wenn du einen Shutdown-Pfad hast, endest du mit zwei Code-Pfaden: dem glücklichen, den du testest, und dem Crash-Pfad, der tatsächlich läuft. Sie divergieren. Bugs verstecken sich in der Lücke. Ich habe Services gesehen, die „graceful” einen Buffer auf Disk geflusht, aber nie gefsynct haben, sodass die Datei nach einem Stromausfall leer war. Der Shutdown-Handler sah korrekt aus. Es war einfach nicht der Pfad, der zählte.

Crash-only entfernt die Fantasie. Es gibt nur einen Pfad. Wenn der falsch ist, weißt du es sofort, weil dein Service nicht startet.

Wie das in der Praxis aussieht

Hier ist ein minimaler Crash-only-HTTP-Worker in Python. Er pullt Jobs aus einer Redis-Queue, verarbeitet sie und speichert Ergebnisse. Es gibt keinen Shutdown-Handler.

import json
import redis
import sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler

DB_PATH = "/data/results.db"
REDIS_URL = "redis://queue:6379"

# Recovery: the ONLY startup path.
def recover():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS results (
            job_id TEXT PRIMARY KEY,
            result TEXT,
            processed_at INTEGER
        )
    """)
    conn.commit()
    return conn

# Every job is identified by a client-generated UUID.
# If we crash mid-processing, the client retries with the same ID.
# INSERT OR REPLACE makes the store idempotent.
def process_job(conn, job_id, payload):
    result = f"processed-{payload['value'] * 2}"
    conn.execute(
        "INSERT OR REPLACE INTO results (job_id, result, processed_at) VALUES (?, ?, strftime('%s','now'))",
        (job_id, result)
    )
    conn.commit()
    return result

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        job = json.loads(body)
        process_job(db_conn, job["id"], job["payload"])
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

# Crash-only: we do not register signal handlers for graceful shutdown.
# We connect, we recover, we serve. When we die, we die.
db_conn = recover()
server = HTTPServer(("0.0.0.0", 8080), Handler)
server.serve_forever()

Die wichtigsten Details:

  • recover() läuft bei jedem Start. Es ist kein Sonderfall.
  • Jobs verwenden client-generierte IDs und INSERT OR REPLACE. Retries sind sicher.
  • Es gibt kein atexit, keinen SIGTERM-Handler, kein Connection Draining. Der Prozess kann jederzeit sterben und sicher restarten.

Vergleiche das mit einem typischen Service mit graceful Shutdown:

# The trap: this code makes you feel safe while hiding the real bug.
def graceful_shutdown(signum, frame):
    # What if we're killed here, before the commit?
    db_conn.commit()
    db_conn.close()
    server.shutdown()

signal.signal(signal.SIGTERM, graceful_shutdown)

Wenn der Prozess SIGKILL zwischen db_conn.commit() und db_conn.close() bekommt, passiert nichts Schlimmes. Aber wenn er zwischen zwei Schreibvorgängen stirbt, die atomic sein sollten, hast du den State korruptiert. Der Shutdown-Handler gibt dir ein Selbstvertrauen, das du dir nicht verdient hast.

Die Trade-offs, über die niemand spricht

Crash-only ist nicht umsonst. Die Kosten zeigen sich an drei Stellen.

Storage Amplification. Jede Mutation muss durable sein, bevor du sie acknowledgest. Das bedeutet Fsyncs, Write-ahead Logs oder replizierte Writes. Deine Latenz steigt. Ein Memory-only-Update, das Mikrosekunden gedauert hat, dauert jetzt Millisekunden.

Idempotenz ist Pflicht, nicht optional. Jede Operation, die State ändert, muss Retries handhaben. Das ist extra Code und extra Denkarbeit. Ein naives INSERT wird zu INSERT ... ON CONFLICT. Ein File-Write wird zu einem Temp-File-and-Rename-Tanz.

Recovery-Zeit ist unbegrenzt. Wenn dein durable Log wächst, wird der Startup langsamer. Du brauchst Log Compaction, Snapshots oder Chunked Replay. Diese Mechanismen sind Code, den du schreiben und testen musst. Sie sind auch, ironischerweise, Pfade, die selbst crash-only sein müssen.

Crash-only vereinfacht deine Failure Modes, eliminiert sie aber nicht. Es wandelt unvorhersehbare Shutdown-Bugs in vorhersehbare Recovery-Latenz um. Das ist meistens ein guter Trade. Aber es ist ein Trade.

Wie man einen Service in Richtung Crash-Only bewegt

Du musst nicht alles neu bauen. Du kannst inkrementell verschieben.

Auditiere deine Shutdown-Handler. Wenn du einen SIGTERM-Handler hast, frag dich: Was passiert, wenn er nicht läuft? Wenn die Antwort Datenverlust oder Korruption ist, ist das dein echter Bug. Fixe den Recovery-Pfad, nicht den Shutdown-Handler.

Mache deine State Machine explizit. Schreibe jede In-Memory-Struktur auf, die bei einem Crash verloren ginge. Für jede entscheide: aus Log rekonstruieren, aus Datenbank reloaden oder den Verlust akzeptieren. „Den Verlust akzeptieren“ ist eine gültige Antwort für Caches und Metriken.

Verwende Idempotency Keys. Jeder mutierende Endpoint sollte einen client-generierten Idempotency Key akzeptieren. Der Server speichert (key, result) und gibt das gespeicherte Ergebnis bei einem Retry zurück. Stripe hat das Buch dazu geschrieben. Die meisten Web-Frameworks haben mittlerweile Middleware dafür.

Teste den Crash-Pfad, nicht den Shutdown-Pfad. Sende in deinen integration tests SIGKILL zu deinem Service mitten in einem Request. Starte ihn neu. Asserte, dass das System konsistent ist. Wenn du nur graceful Shutdown testest, testest du Fiktion.

Wann Crash-Only Overkill ist

Nicht jeder Prozess braucht das. Ein statischer File-Server kann sterben und ohne jede Recovery-Logik neu starten. Ein One-off-CLI-Tool braucht keine Idempotency Keys. Wenn dein Service stateless ist und jeder Request self-contained, bist du bereits crash-only. Füge keine Komplexität für die Ästhetik hinzu.

Das Ziel ist nicht Reinheit. Das Ziel ist, einen getesteten Pfad zu haben, anstatt einen getesteten Pfad und einen imaginären Pfad.

FAQ

Bedeutet Crash-Only, dass ich SIGTERM ignoriere?

Nein. Du kannst immer noch bei SIGTERM exiten. Mach nur keine nicht-triviale Arbeit im Handler. Schließe einen Socket, wenn du willst, aber flushe keinen State, den du nicht bereits durable gemacht hast.

Was ist mit Connection Draining?

Load Balancer müssen aufhören, Traffic zu senden, bevor ein Pod stirbt. Das passiert auf der Infrastruktur-Ebene, nicht in deinem Prozess. Halte das Draining kurz. Kubernetes defaulted auf 30 Sekunden. Danach bekommst du sowieso SIGKILL.

Gilt das auch für Datenbanken?

Datenbanken sind die ursprünglichen Crash-only-Systeme. PostgreSQLs WAL, SQLite’s Rollback Journal, MySQLs Redo Log – sie alle gehen davon aus, dass der Prozess jederzeit sterben kann. Der Recovery-Code ist der Startup-Code. Datenbank-Engineer wissen das seit Jahrzehnten.

Was ist mit laufenden HTTP-Requests?

Clients sollten bei einem Fehler retryen. Wenn dein Endpoint idempotent ist, sind Retries sicher. Wenn er nicht idempotent ist, wird dich Crash-only nicht retten. Der Fix ist Idempotenz, nicht ein längerer Shutdown-Timeout.

Fang damit an, deinen Shutdown-Handler zu löschen

Der schnellste Weg, deine versteckten Crash-Bugs zu finden, ist, die Fiktion zu entfernen. Kommentiere deinen SIGTERM-Handler aus. Führe deine integration tests aus. Sende SIGKILL mitten im Request. Schau, was kaputt geht. Fixe diese Dinge. Das ist dein echtes System. Alles andere ist eine Kuscheldecke, die die Löcher versteckt.

Crash-only lässt Failures nicht verschwinden. Es macht sie langweilig. Und langweilige Failures sind die, durch die du durchschlafen kannst.