Каждый путь корректного завершения, который вы никогда не тестировали

У вашего веб-сервиса есть обработчик завершения. Он сбрасывает буферы, закрывает соединения, записывает чекпоинты. Вы тестировали его раз, может быть. В продакшене он, вероятно, выполняется раз в год во время планового деплоя. В остальное время ваш сервис погибает от OOM kill, выселения ноды, потери питания или от деплоя, который таймаутится и получает SIGKILL.

Crash-only софт переворачивает это. Нет корректного завершения. Есть только крах и восстановление. Один и тот же путь выполняется после SIGKILL, segfault или kernel panic. Если ваш путь восстановления работает, ваш сервис в безопасности. Если нет, вы узнаёте об этом сразу, а не во время аварии в три часа ночи.

Что на самом деле означает crash-only

Термин происходит из статьи Кандеа и Фокса 2003 года о crash-only computing. Их аргумент был прост: если компоненты всё равно упадут, проектируйте их так, чтобы крах и восстановление были единственными состояниями. Никакого тёплого завершения. Никакого чистого выхода. Никакого «пожалуйста, доделайте свои запросы сначала».

Для веб-сервиса это означает три вещи:

  1. Всё долговечное состояние живёт вне процесса. Память по определению эфемерна. Всё, что вам нужно после рестарта, должно быть в базе данных, write-ahead log или долговечной очереди сообщений до того, как вы подтвердите выполнение работы.

  2. Восстановление — это единственный путь запуска. Один и тот же код выполняется, будь то чистый старт или рестарт после краха. Нет специального режима «восстановить из чекпоинта». Есть только «прочитать лог и наверстать упущенное».

  3. Запросы атомарны или идемпотентны. Клиент повторяет попытку. Частичный запрос не оставляет повреждённого состояния. Сервису безразлично, завершилась ли предыдущая попытка, упала ли или была убита на лету.

Почему корректное завершение скрывает баги

Корректное завершение даёт вам ложное чувство безопасности. Вы верите, что ваш сервис завершается чисто, потому что ваш обработчик выполняется. Но обработчик — это фантазия. Linux отправляет SIGKILL через 30 секунд, готовы вы или нет. Kubernetes выселяет поды без предупреждения. Ваш дата-центр теряет питание.

Когда у вас есть путь завершения, вы получаете два пути кода: счастливый, который вы тестируете, и путь краха, который реально выполняется. Они расходятся. Баги прячутся в промежутке. Я видел сервисы, которые «корректно» сбрасывали буфер на диск, но никогда не делали fsync, так что файл оказывался пустым после потери питания. Обработчик завершения выглядел правильным. Просто это был не тот путь, который имел значение.

Crash-only убирает фантазию. Есть только один путь. Если он неправильный, вы узнаёте об этом сразу, потому что ваш сервис не запускается.

Как это выглядит на практике

Вот минимальный crash-only HTTP-воркер на Python. Он забирает задачи из Redis-очереди, обрабатывает их и сохраняет результаты. Нет обработчика завершения.

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

Ключевые детали:

  • recover() выполняется при каждом старте. Это не особый случай.
  • Задачи используют ID, сгенерированные клиентом, и INSERT OR REPLACE. Повторные попытки безопасны.
  • Нет atexit, нет обработчика SIGTERM, нет connection draining. Процесс может умереть в любой момент и безопасно перезапуститься.

Сравните это с типичным сервисом с корректным завершением:

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

Если процесс получает SIGKILL между db_conn.commit() и db_conn.close(), ничего страшного не происходит. Но если он умирает между двумя записями, которые должны быть атомарными, вы повредили состояние. Обработчик завершения даёт вам уверенность, которую вы не заслужили.

О компромиссах, о которых никто не говорит

Crash-only не бесплатен. Стоимость проявляется в трёх местах.

Усиление хранения. Каждая мутация должна стать долговечной до того, как вы её подтвердите. Это означает fsync, write-ahead log или реплицированные записи. Ваша задержка растёт. Обновление только в памяти, которое занимало микросекунды, теперь занимает миллисекунды.

Идемпотентность обязательна, а не опциональна. Каждая операция, изменяющая состояние, должна обрабатывать повторные попытки. Это дополнительный код и дополнительные размышления. Наивный INSERT становится INSERT ... ON CONFLICT. Запись файла становится танцем с временным файлом и переименованием.

Время восстановления не ограничено. Если ваш долговечный лог растёт, запуск замедляется. Вам нужна compaction логов, снапшоты или пофрагментный replay. Эти механизмы — код, который вам нужно написать и протестировать. Они также, что иронично, сами по себе должны быть crash-only.

Crash-only упрощает ваши режимы отказа, но не устраняет их. Он превращает непредсказуемые баги завершения в предсказуемую задержку восстановления. Обычно это хороший обмен. Но это обмен.

Как сдвинуть сервис в сторону crash-only

Вам не нужно перестраивать всё. Вы можете сдвигаться постепенно.

Аудитируйте ваши обработчики завершения. Если у вас есть обработчик SIGTERM, спросите: что произойдёт, если он не выполнится? Если ответ — потеря данных или повреждение, это ваш настоящий баг. Исправьте путь восстановления, а не обработчик завершения.

Сделайте вашу машину состояний явной. Запишите каждую in-memory структуру, которая была бы потеряна при крахе. Для каждой решите: реконструировать из лога, перезагрузить из базы данных или принять потерю. «Принять потерю» — валидный ответ для кэшей и метрик.

Используйте ключи идемпотентности. Каждый мутирующий эндпоинт должен принимать ключ идемпотентности, сгенерированный клиентом. Сервер хранит (key, result) и возвращает сохранённый результат при повторной попытке. Stripe написала книгу об этом. Большинство веб-фреймворков теперь имеют для этого middleware.

Тестируйте путь краха, а не путь завершения. В ваших интеграционных тестах отправляйте SIGKILL вашему сервису посреди запроса. Перезапустите его. Утверждайте, что система консистентна. Если вы тестируете только корректное завершение, вы тестируете вымысел.

Когда crash-only — это оверкилл

Не каждому процессу это нужно. Статический файловый сервер может умереть и перезапуститься вообще без логики восстановления. Одноразовый CLI-инструмент не нуждается в ключах идемпотентности. Если ваш сервис stateless и каждый запрос самодостаточен, вы уже crash-only. Не добавляйте сложности ради эстетики.

Цель — не чистота. Цель — иметь один протестированный путь вместо одного протестированного пути и одного воображаемого.

FAQ

Означает ли crash-only, что я должен игнорировать SIGTERM?

Нет. Вы всё ещё можете выходить по SIGTERM. Просто не делайте нетривиальной работы в обработчике. Закройте сокет, если хотите, но не сбрасывайте состояние, которое вы ещё не сделали долговечным.

А что насчёт connection draining?

Балансировщики нагрузки должны прекратить отправку трафика до того, как под умрёт. Это происходит на уровне инфраструктуры, а не в вашем процессе. Держите drain коротким. Kubernetes по умолчанию даёт 30 секунд. После этого вам всё равно придёт SIGKILL.

Применимо ли это к базам данных?

Базы данных — это оригинальные crash-only системы. WAL PostgreSQL, rollback journal SQLite, redo log MySQL — все они предполагают, что процесс может умереть в любой момент. Код восстановления — это код запуска. Инженеры баз данных знают это десятилетиями.

А что насчёт выполняющихся HTTP-запросов?

Клиенты должны повторять попытки при сбое. Если ваш эндпоинт идемпотентен, повторные попытки безопасны. Если он не идемпотентен, crash-only вас не спасёт. Исправление — в идемпотентности, а не в более длинном таймауте завершения.

Начните с удаления вашего обработчика завершения

Самый быстрый способ найти ваши скрытые баги краха — убрать вымысел. Закомментируйте ваш обработчик SIGTERM. Запустите ваши интеграционные тесты. Отправьте SIGKILL посреди запроса. Посмотрите, что сломается. Исправьте эти вещи. Это ваша настоящая система. Всё остальное — утешительное одеяло, которое скрывает дыры.

Crash-only не заставляет отказы исчезать. Он делает их скучными. А скучные отказы — это те, через которые вы можете спать.