每一个你从未测试过的优雅关闭路径
你的 Web 服务有一个关闭处理器。它会刷写缓冲区、关闭连接、写入检查点。也许你曾经测试过一次。在生产环境中,它可能只在每年一次的有计划部署时才会运行。其余时间,你的服务死于 OOM kill、节点驱逐、断电,或是超时后被 SIGKILL 的部署。
Crash-only 软件颠覆了这一点。没有优雅关闭,只有崩溃和恢复。无论是 SIGKILL、segfault 还是 kernel panic,之后运行的都是同一条路径。如果你的恢复路径有效,你的服务就是安全的。如果无效,你会立刻发现,而不是在凌晨 3 点的故障中才意识到。
Crash-Only 到底意味着什么
这个词源自 Candea 和 Fox 2003 年关于 crash-only computing 的论文。他们的论点很简单:如果组件反正都会崩溃,那就把它们设计成只有崩溃和恢复两种状态。没有温和关闭,没有干净退出,没有“请先处理完你的请求”。
对于 Web 服务来说,这意味着三件事:
-
所有持久化状态都存在于进程之外。 内存本身就是短暂的。任何你在重启后需要的东西,都必须在确认工作完成之前就已经写入数据库、write-ahead log 或持久化消息队列。
-
恢复是唯一的启动路径。 无论你是全新启动还是崩溃后重启,运行的都是同一份代码。没有特殊的“从 checkpoint 恢复”模式,只有“读取 log 并追赶”。
-
请求是原子的或幂等的。 客户端会重试。部分完成的请求不会留下损坏的状态。服务并不关心上一次尝试是完成了、崩溃了,还是在半空中被 kill 掉。
为什么优雅关闭会隐藏 Bug
优雅关闭给了你一种虚假的安全感。你以为服务是干净关闭的,因为处理器运行了。但这个处理器只是一种幻想。Linux 不管你是否完成,30 秒后就会发送 SIGKILL。Kubernetes 会毫无预警地驱逐 pod。你的数据中心会断电。
当你有一条关闭路径时,你最终会有两条代码路径:一条是你测试过的理想路径,另一条是实际运行的崩溃路径。它们会分叉。Bug 就藏在缝隙里。我见过一些服务“优雅地”把缓冲区刷写到磁盘,但从来没有 fsync,所以断电后文件是空的。关闭处理器看起来是正确的,但它并不是真正重要的那条路径。
Crash-only 消除了这种幻想。只有一条路径。如果它是错的,你会立刻知道,因为你的服务启动不了。
实践中是什么样
下面是一个极简的 crash-only HTTP worker,用 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)
如果进程在 db_conn.commit() 和 db_conn.close() 之间收到 SIGKILL,没什么可怕的。但如果它在两次本该原子的写入之间死去,你就已经损坏了状态。关闭处理器给了你没资格拥有的信心。
没人谈论的权衡
Crash-only 不是免费的。代价体现在三个方面。
存储放大。 每一次变更都必须在确认之前变成持久的。这意味着 fsync、write-ahead log 或复制写入。你的延迟会上升。一次只修改内存的更新原本只需要微秒,现在需要毫秒。
幂等是强制的,不是可选的。 每一次改变状态的操作都必须能处理重试。这需要额外的代码和额外的思考。一个朴素的 INSERT 变成 INSERT ... ON CONFLICT。一次文件写入变成临时文件加重命名的舞步。
恢复时间是无界的。 如果你的持久化 log 不断增长,启动就会变慢。你需要 log compaction、snapshot 或分块回放。讽刺的是,这些机制是你必须编写和测试的代码,而且它们本身也必须是 crash-only 的路径。
Crash-only 简化了你的故障模式,但并没有消除它们。它将不可预测的关闭 bug 转化成了可预测的恢复延迟。这通常是一笔好交易。但它确实是交易。
如何将服务推向 Crash-Only
你不必推倒重来。你可以逐步迁移。
审计你的关闭处理器。 如果你有一个 SIGTERM 处理器,问问自己:如果它不运行会怎样?如果答案是数据丢失或损坏,那才是你真正的 bug。修复恢复路径,而不是关闭处理器。
让你的状态机显式化。 写下每一个在崩溃时会丢失的内存中的数据结构。对每一个,决定:从 log 重建、从数据库重新加载,还是接受丢失。对于缓存和指标来说,“接受丢失”是一个有效的答案。
使用幂等键。 每一个会改变状态的端点都应该接受一个客户端生成的幂等键。服务端存储 (key, result),并在重试时返回已存储的结果。Stripe 在这方面写下了经典。大多数 Web 框架现在都有对应的中间件。
测试崩溃路径,而不是关闭路径。 在你的集成测试中,在请求中途向你的服务发送 SIGKILL。重启它。断言系统是一致的。如果你只测试优雅关闭,你测试的是虚构。
什么时候 Crash-Only 是过度设计
不是每个进程都需要这个。一个静态文件服务器可以死去并重启,根本不需要任何恢复逻辑。一个一次性的 CLI 工具不需要幂等键。如果你的服务是无状态的,而且每个请求都是自包含的,那你已经是 crash-only 了。不要为了审美而增加复杂度。
目标不是纯粹。目标是只有一条经过测试的路径,而不是一条经过测试的路径加上一条想象中的路径。
常见问题
Crash-only 意味着我忽略 SIGTERM 吗?
不。你仍然可以在收到 SIGTERM 时退出。只是不要在处理器里做非 trivial 的工作。如果你想,可以关闭一个 socket,但不要刷写你还没有持久化的状态。
Connection draining 怎么办?
负载均衡器需要在 pod 死去之前停止发送流量。这发生在基础设施层,而不是你的进程里。让 draining 保持短暂。Kubernetes 默认是 30 秒。之后,你反正会收到 SIGKILL。
这适用于数据库吗?
数据库是最原始的 crash-only 系统。PostgreSQL 的 WAL、SQLite 的 rollback journal、MySQL 的 redo log,它们都假设进程可以在任何时候死去。恢复代码就是启动代码。数据库工程师几十年前就已经知道了。
进行中的 HTTP 请求怎么办?
客户端应该在失败时重试。如果你的端点是幂等的,重试就是安全的。如果它不是幂等的,crash-only 救不了你。解决办法是幂等,而不是更长的关闭超时。
从删除你的关闭处理器开始
找到你隐藏的崩溃 bug 的最快方法,就是移除这种虚构。把你的 SIGTERM 处理器注释掉。运行你的集成测试。在请求中途发送 SIGKILL。看看什么会坏。修复它们。那才是你真实的系统。其他的一切都只是掩盖漏洞的安慰毯。
Crash-only 不会让失败消失。它让失败变得无聊。而无聊的失败,才是你能睡过去的失败。