テストしたことのないグレースフルシャットダウンパス
あなたのWebサービスにはシャットダウンハンドラがある。バッファをフラッシュし、コネクションを閉じ、チェックポイントを書き出す。一度くらいはテストしたかもしれない。本番では、計画的なデプロイのときに年に一度動く程度だ。残りの時間、サービスはOOMキル、ノードの追放、停電、あるいはタイムアウトしてSIGKILLを受けるデプロイで死ぬ。
クラッシュオンリーソフトウェアはこれをひっくり返す。グレースフルシャットダウンは存在しない。存在するのはクラッシュとリカバリーだけだ。SIGKILL、セグメンテーション違反、カーネルパニックの後でも同じパスが走る。リカバリーパスが機能すれば、サービスは安全だ。機能しなければ、深夜3時の障害時ではなく、即座に気づく。
クラッシュオンリーが実際に意味すること
この用語は、2003年のCandeaとFoxのクラッシュオンリーコンピューティングに関する論文に由来する。彼らの主張は単純だった: コンポーネントはどうせクラッシュするのだから、クラッシュとリカバリーのみが状態となるように設計せよ。温かいシャットダウンも、きれいな終了も、「リクエストを終わらせてから」もない。
Webサービスにとって、これは3つのことを意味する:
-
すべての永続的な状態はプロセスの外に存在する。 メモリーは定義上揮発的だ。再起動後に必要なものは、すべて作業を承認する前にデータベース、write-ahead log、あるいは耐久性のあるメッセージキューに置かれていなければならない。
-
リカバリーが唯一の起動パスだ。 新規起動でもクラッシュ後の再起動でも、同じコードが走る。「チェックポイントから復元する」特別なモードは存在しない。存在するのは「ログを読んで追いつく」だけだ。
-
リクエストはアトミックであるか、冪等でなければならない。 クライアントはリトライする。部分的なリクエストは破損した状態を残さない。サービスは、前回の試行が完了したか、クラッシュしたか、実行中にキルされたかを気にしない。
なぜグレースフルシャットダウンはバグを隠すのか
グレースフルシャットダウンは、虚構の安心感を与える。ハンドラが動くから、サービスがきれいに終了すると信じている。だがそのハンドラは幻想だ。Linuxは30秒後にSIGKILLを送る。完了しているかどうかは関係ない。Kubernetesは警告なしにPodを追放する。データセンターは停電する。
シャットダウンパスを持つと、2つのコードパスができる: テストした幸せなパスと、実際に動くクラッシュパスだ。両者は分岐する。バグはその隙間に潜む。バッファをディスクに「グレースフルに」フラッシュしたが、fsyncを決して行わなかったため、停電後にファイルが空になったサービスを見たことがある。シャットダウンハンドラは正しそうに見えた。重要なパスではなかっただけだ。
クラッシュオンリーは幻想を取り除く。存在するのは1つのパスだけだ。間違っていれば、サービスが起動しないので即座にわかる。
実際にはどう見えるか
以下は、Pythonによる最小限のクラッシュオンリーHTTPワーカーだ。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ハンドラも、コネクションの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を受けても、深刻なことは起きない。だが、2つの書き込みがアトミックでなければならない場所で死んだら、状態は破損する。シャットダウンハンドラは、得られていない自信を与えてくれる。
誰も語らないトレードオフ
クラッシュオンリーはタダではない。コストは3つの場所に現れる。
ストレージの増幅。 すべての変更は、承認する前に耐久性を持たなければならない。つまりfsync、write-ahead log、あるいはレプリケートされた書き込みが必要だ。レイテンシが上がる。マイクロ秒で済んでいたメモリー内の更新が、ミリ秒かかるようになる。
冪等性は必須で、任意ではない。 状態を変えるすべての操作はリトライを処理できなければならない。これは追加のコードと追加の思考だ。単純なINSERTはINSERT ... ON CONFLICTになる。ファイル書き込みは、一時ファイルとリネームのダンスになる。
リカバリー時間は無制限だ。 耐久性のあるログが増えれば、起動は遅くなる。ログの圧縮、スナップショット、チャンク化されたリプレイが必要だ。これらの仕組みは書いてテストしなければならないコードだ。皮肉なことに、それら自身もクラッシュオンリーでなければならないパスでもある。
クラッシュオンリーは障害モードを単純化するが、消滅させるわけではない。予測不可能なシャットダウンバグを、予測可能なリカバリーレイテンシに変換する。これは通常、良いトレードだ。だがトレードであることに変わりはない。
サービスをクラッシュオンリーへ移行する方法
すべてを作り直す必要はない。漸進的に移行できる。
シャットダウンハンドラを監査する。 SIGTERMハンドラがあれば、自問せよ: これが動かなかったらどうなる? 答えがデータ損失や破損なら、それが本当のバグだ。シャットダウンハンドラではなく、リカバリーパスを直せ。
ステートマシンを明示的にする。 クラッシュ時に失われるすべてのインメモリー構造を書き出せ。それぞれについて決定せよ: ログから再構築する、データベースからリロードする、あるいは損失を受け入れる。キャッシュやメトリクスにとって「損失を受け入れる」は妥当な答えだ。
冪等性キーを使う。 状態を変えるすべてのエンドポイントは、クライアント生成の冪等性キーを受け付けるべきだ。サーバーは(key, result)を保存し、リトライ時に保存された結果を返す。Stripeがこれを教科書的に書いた。ほとんどのWebフレームワークには今やそのためのミドルウェアがある。
シャットダウンパスではなく、クラッシュパスをテストする。 インテグレーションテストで、リクエスト中にサービスにSIGKILLを送れ。再起動して、システムが整合的であることをアサートする。グレースフルシャットダウンだけをテストすれば、虚構をテストしているに過ぎない。
クラッシュオンリーがオーバーキルになる場面
すべてのプロセスがこれを必要とするわけではない。静的ファイルサーバーは、リカバリーロジックなしで死んで再起動できる。使い捨てのCLIツールに冪等性キーは必要ない。サービスがステートレスで、すべてのリクエストが自己完結していれば、すでにクラッシュオンリーだ。美しさのために複雑性を追加するな。
目標は純粋性ではない。目標は、テストされた1つのパスを持つことであって、テストされた1つのパスと架空の1つのパスを持つことではない。
FAQ
クラッシュオンリーはSIGTERMを無視することを意味するのか?
いいえ。SIGTERMで終了しても構わない。ただし、ハンドラ内で非自明な作業をするな。ソケットを閉じるのは構わないが、まだ耐久性を持たせていない状態をフラッシュするな。
コネクションドレーニングはどうか?
ロードバランサーは、Podが死ぬ前にトラフィックの送信を停止する必要がある。それはインフラストラクチャ層で起こり、プロセス内ではない。ドレインは短く保て。Kubernetesのデフォルトは30秒だ。その後、どうせSIGKILLを受ける。
データベースにも適用されるのか?
データベースはクラッシュオンリーシステムの原型だ。PostgreSQLのWAL、SQLiteのロールバックジャーナル、MySQLのredo log、いずれもプロセスがいつでも死ぬ可能性を前提としている。リカバリーコードが起動コードだ。データベースエンジニアはこれを何十年も前から知っている。
進行中のHTTPリクエストはどうか?
クライアントは障害時にリトライすべきだ。エンドポイントが冪等なら、リトライは安全だ。冪等でなければ、クラッシュオンリーは救わない。修正は冪等性であり、より長いシャットダウンタイムアウトではない。
シャットダウンハンドラを削除することから始めろ
隠されたクラッシュバグを見つける最速の方法は、幻想を取り除くことだ。SIGTERMハンドラをコメントアウトせよ。インテグレーションテストを走らせよ。リクエスト中にSIGKILLを送れ。何が壊れるか見よ。それらを直せ。それがあなたの本当のシステムだ。他のすべては、穴を隠す安心の毛布に過ぎない。
クラッシュオンリーは障害を消滅させるわけではない。それを退屈なものにする。退屈な障害こそが、あなたが眠り抜けるものだ。