Todo Caminho de Encerramento Graceful Que Você Nunca Testou

Seu serviço web tem um handler de encerramento. Ele faz flush de buffers, fecha conexões, escreve checkpoints. Você testou isso uma vez, talvez. Em produção, ele provavelmente roda uma vez por ano durante um deploy planejado. O resto do tempo, seu serviço morre por um OOM kill, uma evicção de node, uma queda de energia, ou um deploy que estoura o timeout e recebe SIGKILL.

Software crash-only inverte isso. Não há encerramento graceful. Há apenas crash e recuperação. O mesmo caminho roda após um SIGKILL, um segfault, ou um kernel panic. Se seu caminho de recuperação funciona, seu serviço está seguro. Se não, você descobre imediatamente, não durante uma interrupção às 3 da manhã.

O Que Crash-Only Realmente Significa

O termo vem do artigo de 2003 de Candea e Fox sobre crash-only computing. O argumento deles era simples: se componentes vão crashar de qualquer jeito, projete-os de forma que crash e recuperação sejam os únicos estados. Sem warm shutdown. Sem saída limpa. Sem “por favor, termine suas requisições primeiro.”

Para um serviço web, isso significa três coisas:

  1. Todo estado durável vive fora do processo. Memória é efêmera por definição. Qualquer coisa que você precisa após um restart deve estar em um banco de dados, um write-ahead log, ou uma message queue durável antes de você reconhecer o trabalho.

  2. Recuperação é o único caminho de startup. O mesmo código roda se você está iniciando do zero ou reiniciando após um crash. Não há um modo especial de “restore from checkpoint”. Há apenas “ler o log e alcançar o estado atual.”

  3. Requisições são atômicas ou idempotentes. Um cliente faz retry. Uma requisição parcial não deixa estado corrompido. O serviço não se importa se a tentativa anterior terminou, deu crash, ou foi morta em pleno voo.

Por Que o Encerramento Graceful Esconde Bugs

Encerramento graceful te dá uma falsa sensação de segurança. Você acredita que seu serviço desliga limpamente porque seu handler roda. Mas o handler é uma fantasia. Linux envia SIGKILL após 30 segundos, esteja você pronto ou não. Kubernetes despeja pods sem aviso. Seu data center perde energia.

Quando você tem um caminho de encerramento, você acaba com dois caminhos de código: o feliz que você testa, e o de crash que realmente roda. Eles divergem. Bugs se escondem na lacuna. Já vi serviços que “gracefully” fizeram flush de um buffer para o disco mas nunca fizeram fsync, então o arquivo ficou vazio após uma queda de energia. O handler de encerramento parecia correto. Ele simplesmente não era o caminho que importava.

Crash-only remove a fantasia. Há apenas um caminho. Se ele estiver errado, você sabe imediatamente porque seu serviço não inicia.

Como Isso Parece na Prática

Aqui está um worker HTTP crash-only mínimo em Python. Ele puxa jobs de uma queue Redis, processa-os, e armazena resultados. Não há handler de encerramento.

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()

Os detalhes chave:

  • recover() roda em toda inicialização. Não é um caso especial.
  • Jobs usam IDs gerados pelo cliente e INSERT OR REPLACE. Retries são seguros.
  • Não há atexit, não há SIGTERM handler, não há connection draining. O processo pode morrer a qualquer momento e reiniciar com segurança.

Compare isso a um serviço típico com encerramento graceful:

# 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)

Se o processo recebe SIGKILL entre db_conn.commit() e db_conn.close(), nada terrível acontece. Mas se ele morre entre duas escritas que deveriam ser atômicas, você corrompeu o estado. O handler de encerramento te dá confiança que você não conquistou.

As Trade-offs Que Ninguém Fala

Crash-only não é de graça. O custo aparece em três lugares.

Amplificação de storage. Toda mutação deve ser durável antes de você reconhecê-la. Isso significa fsyncs, write-ahead logs, ou escritas replicadas. Sua latência aumenta. Uma atualização apenas em memória que levava microssegundos agora leva milissegundos.

Idempotência é obrigatória, não opcional. Toda operação que muda estado deve lidar com retries. Isso é código extra e pensamento extra. Um INSERT ingênuo se torna INSERT ... ON CONFLICT. Uma escrita de arquivo se torna uma dança de temp-file-and-rename.

O tempo de recuperação é ilimitado. Se seu log durável cresce, o startup fica mais lento. Você precisa de log compaction, snapshots, ou chunked replay. Esses mecanismos são código que você tem que escrever e testar. Eles também são, ironicamente, caminhos que devem ser crash-only eles mesmos.

Crash-only simplifica seus modos de falha mas não os elimina. Ele converte bugs de encerramento imprevisíveis em latência de recuperação previsível. Isso geralmente é uma boa troca. Mas é uma troca.

Como Mover um Serviço Rumo ao Crash-Only

Você não precisa reconstruir tudo. Você pode mudar incrementalmente.

Audite seus shutdown handlers. Se você tem um SIGTERM handler, pergunte: o que acontece se ele não rodar? Se a resposta é perda ou corrupção de dados, esse é seu bug de verdade. Corrija o caminho de recuperação, não o handler de encerramento.

Torne sua state machine explícita. Anote toda estrutura em memória que seria perdida em um crash. Para cada uma, decida: reconstruir do log, recarregar do banco de dados, ou aceitar a perda. “Aceitar a perda” é uma resposta válida para caches e metrics.

Use idempotency keys. Todo endpoint mutante deve aceitar uma idempotency key gerada pelo cliente. O servidor armazena (key, result) e retorna o resultado armazenado no retry. Stripe escreveu o livro sobre isso. A maioria dos web frameworks tem middleware para isso agora.

Teste o caminho de crash, não o caminho de encerramento. Em seus integration tests, envie SIGKILL para seu serviço no meio de uma requisição. Reinicie-o. Afirme que o sistema está consistente. Se você só testa encerramento graceful, você está testando ficção.

Quando Crash-Only é Exagero

Nem todo processo precisa disso. Um servidor de arquivos estáticos pode morrer e reiniciar sem nenhuma lógica de recuperação. Uma ferramenta CLI one-off não precisa de idempotency keys. Se seu serviço é stateless e toda requisição é autocontida, você já é crash-only. Não adicione complexidade pela estética.

O objetivo não é pureza. O objetivo é ter um caminho testado em vez de um caminho testado e um caminho imaginário.

FAQ

Crash-only significa que eu ignore SIGTERM?

Não. Você ainda pode sair em SIGTERM. Apenas não faça trabalho não-trivial no handler. Feche um socket se quiser, mas não faça flush de estado que você ainda não tornou durável.

E connection draining?

Load balancers precisam parar de enviar tráfego antes que um pod morra. Isso acontece na camada de infraestrutura, não no seu processo. Mantenha o drain curto. Kubernetes usa 30 segundos como padrão. Depois disso, você recebe SIGKILL de qualquer jeito.

Isso se aplica a bancos de dados?

Bancos de dados são os sistemas crash-only originais. O WAL do PostgreSQL, o rollback journal do SQLite, o redo log do MySQL, todos assumem que o processo pode morrer a qualquer momento. O código de recuperação é o código de startup. Engenheiros de banco de dados sabem disso há décadas.

E requisições HTTP em andamento?

Clientes devem fazer retry em caso de falha. Se seu endpoint é idempotente, retries são seguros. Se ele não é idempotente, crash-only não vai salvá-lo. A correção é idempotência, não um timeout de encerramento mais longo.

Comece Deletando Seu Shutdown Handler

A maneira mais rápida de encontrar seus bugs de crash escondidos é remover a ficção. Comente seu SIGTERM handler. Rode seus integration tests. Envie SIGKILL no meio de uma requisição. Veja o que quebra. Corrija essas coisas. Esse é seu sistema de verdade. Todo o resto é um cobertor de conforto que esconde os buracos.

Crash-only não faz falhas desaparecerem. Ele as torna chatas. E falhas chatas são aquelas pelas quais você pode dormir tranquilo.