每一條你從沒測試過的 Graceful Shutdown 路徑
你的 Web Service 有一個 shutdown handler。它會 flush buffer、關閉連線、寫入 checkpoint。你也許測試過一次。在生產環境,它大概一年只在計畫性部署時執行一次。其他時候,你的服務死於 OOM kill、node eviction、斷電,或是部署超時後被 SIGKILL。
Crash-only software 把這整個邏輯翻轉過來。沒有 graceful shutdown,只有 crash 與 recover。SIGKILL、segfault 或 kernel panic 之後,走的是同一條路徑。如果你的 recovery 路徑沒問題,服務就是安全的;如果有問題,你會立刻知道,而不是在凌晨三點的 outage 才發現。
Crash-Only 真正的意思是什麼
這個術語來自 Candea 和 Fox 2003 年的 crash-only computing 論文。他們的論點很簡單:既然元件本來就會 crash,那就把它們設計成只有 crash 和 recovery 兩種狀態。沒有 warm shutdown,沒有 clean exit,沒有「請先處理完手上的 request」。
對 Web Service 來說,這代表三件事:
-
所有 durable state 都在 process 外面。 Memory 本質上就是 ephemeral。任何重啟後還需要的資料,必須在確認完成工作之前就已經寫入 database、write-ahead log 或 durable message queue。
-
Recovery 是唯一的啟動路徑。 無論是全新啟動還是 crash 後重啟,跑的程式碼都一樣。沒有什麼特殊的「從 checkpoint 還原」模式,只有「讀取 log 並追上進度」。
-
Request 必須是 atomic 或 idempotent。 客戶端會重試。一個處理到一半的 request 不會留下腐壞的狀態。服務不在乎前一次嘗試是完成了、crash 了,還是在半空中被 kill 掉。
為什麼 Graceful Shutdown 會隱藏 Bug
Graceful shutdown 給你一種虛假的安全感。你以為服務會乾淨地關閉,因為你的 handler 有執行。但這個 handler 只是幻想。Linux 30 秒後一定會送 SIGKILL,不管你做完沒有。Kubernetes 會毫無預警地驅逐 pod。你的資料中心會斷電。
當你有一條 shutdown 路徑,最後就會有兩條程式路徑:一條是你測試過的快樂路徑,另一條是實際會跑的 crash 路徑。它們會分歧,Bug 就藏在落差裡。我看過有些服務「優雅地」把 buffer flush 到磁碟,卻從來沒有 fsync,所以斷電後檔案是空的。Shutdown handler 看起來沒錯,但那條路徑根本不重要。
Crash-only 移除了這個幻想。只剩下一條路徑。如果它錯了,你會立刻知道,因為服務根本啟動不起來。
實務上長什麼樣子
這裡有一個最小化的 crash-only HTTP worker,用 Python 寫成。它從 Redis queue 拉出工作、處理、儲存結果。沒有 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()
關鍵細節:
recover()每次啟動都會執行。它不是特殊情況。- 工作使用 client-generated ID 與
INSERT OR REPLACE。重試是安全的。 - 沒有
atexit、沒有 SIGTERM handler、沒有 connection draining。Process 隨時可以死掉並安全重啟。
對比一下典型的 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)
如果 process 在 db_conn.commit() 和 db_conn.close() 之間收到 SIGKILL,其實不會發生太嚴重的事。但如果它在兩個應該 atomic 的寫入之間死掉,你就腐壞了狀態。Shutdown handler 給了你一種你還沒賺到的信心。
沒人談論的 Trade-Offs
Crash-only 不是免費的。成本出現在這三個地方。
Storage amplification。 每一次變更都必須在確認前變成 durable。這代表 fsync、write-ahead log 或 replicated write。你的 latency 會上升。一個原本只要微秒的 memory-only update,現在可能要毫秒。
Idempotency 是強制的,不是選項。 每一個會改變狀態的操作都必須能處理重試。這是額外的程式碼與額外的思考。一個天真的 INSERT 要變成 INSERT ... ON CONFLICT。一個檔案寫入要變成 temp-file-and-rename 的舞步。
Recovery time 是無上限的。 如果你的 durable log 愈長,啟動就愈慢。你需要 log compaction、snapshot 或 chunked replay。這些機制是你必須撰寫與測試的程式碼。而且,諷刺的是,這些路徑本身也必須是 crash-only。
Crash-only 簡化了你的 failure mode,但沒有消滅它們。它把不可預測的 shutdown bug 轉換成可預測的 recovery latency。這通常是筆好交易。但它終究是一筆交易。
如何逐步把服務移向 Crash-Only
你不需要打掉重練。你可以漸進地轉移。
Audit 你的 shutdown handler。 如果你有 SIGTERM handler,問自己:如果它沒執行會怎樣?如果答案是資料遺失或腐壞,那就是你真正的 Bug。修復 recovery 路徑,而不是 shutdown handler。
把你的 state machine 變得明確。 把每一個 crash 後會遺失的 in-memory 結構都寫下來。對每一個結構做出決定:從 log 重建、從 database 重新載入,或接受遺失。「接受遺失」對 cache 和 metric 來說是合理的答案。
使用 idempotency key。 每一個會改變狀態的 endpoint 都應該接受 client-generated idempotency key。伺服器儲存 (key, result),並在重試時回傳已儲存的結果。Stripe 寫過這方面的經典文章。現在大多數 Web framework 都有對應的 middleware。
測試 crash 路徑,而不是 shutdown 路徑。 在你的整合測試中,在 request 處理到一半時對服務送 SIGKILL。重啟它。斷言系統仍然一致。如果你只測試 graceful shutdown,你測試的是虛構故事。
什麼時候 Crash-Only 是 Overkill
不是每個 process 都需要這套。靜態檔案伺服器死掉重啟完全不需要 recovery logic。一次性的 CLI tool 不需要 idempotency key。如果你的服務是 stateless,而且每個 request 都自給自足,那你已經是 crash-only 了。不要為了美學而增加複雜度。
目標不是純粹。目標是擁有一條測試過的路徑,而不是一條測試過的路徑加上一條想像中的路徑。
FAQ
Crash-only 代表我可以忽略 SIGTERM 嗎?
不。你仍然可以在收到 SIGTERM 時離開。只是不要在 handler 裡做非瑣碎的工作。如果你想,可以關掉 socket,但不要 flush 你還沒 durable 的狀態。
Connection draining 怎麼辦?
Load balancer 需要在 pod 死掉之前停止送流量。這發生在基礎設施層,不在你的 process 裡。把 drain 時間壓短。Kubernetes 預設是 30 秒。之後,反正你還是會收到 SIGKILL。
這也適用於 database 嗎?
Database 是最早的 crash-only 系統。PostgreSQL 的 WAL、SQLite 的 rollback journal、MySQL 的 redo log,它們都假設 process 隨時可能死掉。Recovery code 就是 startup code。Database 工程師幾十年前就知道這件事了。
進行中的 HTTP request 呢?
客戶端應該在失敗時重試。如果你的 endpoint 是 idempotent,重試就是安全的。如果不是 idempotent,crash-only 救不了你。該修的是 idempotency,不是延長 shutdown timeout。
從刪掉你的 Shutdown Handler 開始
找出你隱藏的 crash bug 最快的方式,就是移除那個虛構故事。把你的 SIGTERM handler 註解掉。跑你的整合測試。在 request 處理到一半時送 SIGKILL。看看什麼會壞掉。修復那些東西。那才是你真正的系統。其他的一切只是蓋住破洞的安撫毯。
Crash-only 不會讓失敗消失。它會讓失敗變得無聊。而無聊的失敗,才是那種你可以睡著覺錯過的失敗。