Cada Ruta de Graceful Shutdown Que Nunca Probaste

Tu servicio web tiene un shutdown handler. Hace flush de los buffers, cierra conexiones, escribe checkpoints. Lo probaste una vez, tal vez. En producción, probablemente se ejecuta una vez al año durante un deploy planificado. El resto del tiempo, tu servicio muere por un OOM kill, una node eviction, una pérdida de energía, o un deploy que hace timeout y recibe SIGKILL.

El software crash-only invierte esto. No hay graceful shutdown. Solo hay crash y recover. La misma ruta se ejecuta después de un SIGKILL, un segfault, o un kernel panic. Si tu recovery path funciona, tu servicio está a salvo. Si no, te enteras inmediatamente, no durante un outage a las 3 AM.

Qué Significa Crash-Only Realmente

El término proviene del paper de 2003 de Candea y Fox sobre crash-only computing. Su argumento era simple: si los componentes van a crashear de todos modos, diseñalos de modo que crash y recovery sean los únicos estados. No warm shutdown. No clean exit. No “por favor, termina tus requests primero.”

Para un servicio web, esto significa tres cosas:

  1. Todo el estado durable vive fuera del proceso. La memoria es efímera por definición. Cualquier cosa que necesites después de un restart debe estar en una base de datos, un write-ahead log, o una durable message queue antes de confirmar el trabajo.

  2. El recovery es la única ruta de startup. El mismo código se ejecuta tanto si estás iniciando desde cero como si estás reiniciando después de un crash. No hay un modo especial de “restore from checkpoint”. Solo hay “read the log and catch up”.

  3. Los requests son atomics o idempotentes. Un cliente reintenta. Un request parcial no deja estado corrupto. Al servicio no le importa si el intento anterior terminó, crasheó, o fue killed en pleno vuelo.

Por Qué el Graceful Shutdown Oculta Bugs

El graceful shutdown te da una falsa sensación de seguridad. Crees que tu servicio se apaga limpiamente porque tu handler se ejecuta. Pero el handler es una fantasía. Linux envía SIGKILL después de 30 segundos estés listo o no. Kubernetes evicta pods sin avisar. Tu data center pierde energía.

Cuando tienes una ruta de shutdown, terminas con dos rutas de código: la feliz que pruebas, y la de crash que realmente se ejecuta. Divergen. Los bugs se esconden en la brecha. He visto servicios que hicieron flush de un buffer a disco de manera “graceful” pero nunca hicieron fsync, así que el archivo quedó vacío después de una pérdida de energía. El shutdown handler parecía correcto. Simplemente no era la ruta que importaba.

El crash-only elimina la fantasía. Solo hay una ruta. Si está mal, lo sabes inmediatamente porque tu servicio no inicia.

Cómo Se Ve Esto en la Práctica

Aquí hay un worker HTTP crash-only mínimo en Python. Extrae jobs de una cola de Redis, los procesa, y almacena resultados. No hay 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()

Los detalles clave:

  • recover() se ejecuta en cada inicio. No es un caso especial.
  • Los jobs usan IDs generados por el cliente y INSERT OR REPLACE. Los retries son seguros.
  • No hay atexit, no SIGTERM handler, no connection draining. El proceso puede morir en cualquier momento y reiniciarse de forma segura.

Compara esto con un servicio típico con 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)

Si el proceso recibe SIGKILL entre db_conn.commit() y db_conn.close(), no pasa nada terrible. Pero si muere entre dos escrituras que deberían ser atómicas, has corrompido el estado. El shutdown handler te da una confianza que no te has ganado.

Los Trade-Offs de los Que Nadie Habla

El crash-only no es gratis. El costo aparece en tres lugares.

Amplificación de almacenamiento. Cada mutación debe ser durable antes de que la confirmes. Eso significa fsyncs, write-ahead logs, o replicated writes. Tu latencia sube. Una actualización solo-en-memoria que tomaba microsegundos ahora toma milisegundos.

La idempotencia es obligatoria, no opcional. Cada operación que cambia estado debe manejar retries. Esto es código extra y pensamiento extra. Un INSERT ingenuo se convierte en INSERT ... ON CONFLICT. Una escritura de archivo se convierte en un baile de temp-file-and-rename.

El tiempo de recovery es ilimitado. Si tu durable log crece, el startup se ralentiza. Necesitas log compaction, snapshots, o chunked replay. Esos mecanismos son código que debes escribir y probar. También son, irónicamente, rutas que deben ser crash-only ellos mismos.

El crash-only simplifica tus modos de fallo pero no los elimina. Convierte bugs de shutdown impredecibles en latencia de recovery predecible. Eso suele ser un buen trade. Pero es un trade.

Cómo Mover un Servicio Hacia el Crash-Only

No tienes que reconstruir todo. Puedes cambiar incrementalmente.

Audita tus shutdown handlers. Si tienes un SIGTERM handler, pregúntate: ¿qué pasa si no se ejecuta? Si la respuesta es pérdida de datos o corrupción, ese es tu bug real. Arregla la ruta de recovery, no el shutdown handler.

Haz tu state machine explícita. Anota cada estructura in-memory que se perdería en un crash. Para cada una, decide: reconstruct from log, reload from database, o accept the loss. “Accept the loss” es una respuesta válida para caches y metrics.

Usa idempotency keys. Cada endpoint mutador debería aceptar una idempotency key generada por el cliente. El servidor almacena (key, result) y devuelve el resultado almacenado en el retry. Stripe escribió el libro sobre esto. La mayoría de los web frameworks tienen middleware para ello ahora.

Prueba la ruta de crash, no la ruta de shutdown. En tus integration tests, envía SIGKILL a tu servicio en medio de un request. Reinícialo. Asegúrate de que el sistema sea consistente. Si solo pruebas el graceful shutdown, estás probando ficción.

Cuándo el Crash-Only Es Un Exceso

No todo proceso necesita esto. Un static file server puede morir y reiniciarse sin ninguna recovery logic. Una CLI tool de un solo uso no necesita idempotency keys. Si tu servicio es stateless y cada request es autocontenido, ya eres crash-only. No añadas complejidad por la estética.

El objetivo no es la pureza. El objetivo es tener una ruta probada en lugar de una ruta probada y una imaginaria.

Preguntas Frecuentes

¿Significa crash-only que ignoro SIGTERM?

No. Todavía puedes salir con SIGTERM. Solo no hagas trabajo no trivial en el handler. Cierra un socket si quieres, pero no hagas flush de estado que no hayas hecho durable ya.

¿Qué hay del connection draining?

Los load balancers necesitan dejar de enviar tráfico antes de que un pod muera. Eso sucede en la capa de infraestructura, no en tu proceso. Mantén el drain corto. Kubernetes usa 30 segundos por defecto. Después de eso, recibes SIGKILL de todos modos.

¿Esto aplica a las bases de datos?

Las bases de datos son los sistemas crash-only originales. El WAL de PostgreSQL, el rollback journal de SQLite, el redo log de MySQL, todos asumen que el proceso puede morir en cualquier momento. El recovery code es el startup code. Los database engineers han sabido esto durante décadas.

¿Qué hay de los HTTP requests en progreso?

Los clientes deberían reintentar ante un fallo. Si tu endpoint es idempotent, los retries son seguros. Si no es idempotent, el crash-only no te salvará. La solución es la idempotencia, no un timeout de shutdown más largo.

Empieza Eliminando Tu Shutdown Handler

La forma más rápida de encontrar tus bugs de crash ocultos es eliminar la ficción. Comenta tu SIGTERM handler. Ejecuta tus integration tests. Envía SIGKILL en medio de un request. Ve qué se rompe. Arregla esas cosas. Ese es tu sistema real. Todo lo demás es una manta de confort que oculta los agujeros.

El crash-only no hace que los fallos desaparezcan. Los hace aburridos. Y los fallos aburridos son aquellos a través de los que puedes dormir.