緑の画面、落ちる最初の一件
日曜の朝5時台。war room の壁に並んだモニタは、どれも穏やかな色をしていた。
Lazy Loading を入れてから、起動時間は目に見えて縮んだ。重かったワーカーは軽くなった。Object Pool で接続の使い捨てはやめたし、Bulkhead で月末バッチの巻き込みも止めた。数字だけを見るなら、ここ数回の修正は全部うまくいっていた。
前夜、Lazy Loading のあとに残った違和感が拭えなくて、わたしはロックさんへ「明朝の判定だけ見てほしい」と連絡していた。だから、この早朝の war room にあの人がいること自体は不自然ではなかった。
だからこそ、その一件は気味が悪かった。
canary deploy のヘルスチェックは通った。/health は緑のままだった。なのに、QA が最初に開いたテナント別売上レポートの画面だけが 500 を返した。
検索 index がまだ warming 中で、Lazy Loading された tenant 設定の初回参照とぶつかった。利用者相当のリクエストは失敗している。けれど、dashboard は落ち着いたままだった。
緑のまま壊れている。
それが、今回の事件だった。
ロックさんは、わたしが停止ボタンを押した rollout 画面を一瞥すると、ホワイトボードに三つだけ単語を書いた。
live / ready / details
それだけだった。今回は帆船模型も、妙な箱もない。代わりに、言葉だけが妙に重かった。
「ワトソン君。緑は出ている。だが、その緑は誰の証言だね」
「少なくとも、利用者の証言ではありません」
「結構。では今日は、色ではなく契約を調べよう」
その言い方で、ようやく腹に落ちた。
Health Check の問題は、200 を返したことそのものではない。誰に向けて、何を保証する 200 なのかを曖昧にしたことだ。
現場検証 — 緑は誰に向けた証言か
いまの実装は、かなり単純だった。単純すぎる、と言った方が正確かもしれない。
Beforeコード: 緑を返すだけの endpoint
| |
これでは、tenant 設定ストアが未接続でも、検索 index が warming 中でも、/health は平然と 200 を返す。
一方で、実際のリクエスト側は required dependency をちゃんと見ている。
| |
つまり、外向きの診断書と、本物の業務リクエストが見ている現実がずれていた。
ロックさんは 200 の横に、小さく to whom? と書いた。
「君たちは一枚の診断書に、三人分の質問を書いている」
「三人分」
「load balancer、orchestrator、人間の運用者だ。彼らは同じことを知りたがってはいない」
たしかにそうだった。
| 読み手 | 本当に知りたいこと | 同じ /health に押し込むと起きること |
|---|---|---|
| Load balancer | 今この instance に流してよいか | optional な揺らぎまで route 判定へ混ざる |
| Orchestrator | 再起動で改善する自己異常か | dependency の一時障害で healthy な process まで落とす |
| 人間の運用者 | どこがどの程度まずいか | 200 では情報が足りず、503 では粒度が粗すぎる |
問題は 200 が軽いことではない。意味が混線していることだった。
全部載せ health check も別の嘘になる
ここで次に出やすい短絡は、だいたい同じだ。
「だったら DB も検索も外部 API も、全部 /health で見ればいいのでは」
わたしも、そう考えかけていた。
でも、それは別の種類の嘘になる。
| |
この種の endpoint を liveness にも readiness にも使うと、依存先の一時的な不調でプロセス自身まで再起動対象にしてしまう。再起動しても tenant 設定ストアの障害は治らないし、warming 中の index も早まらない。むしろ probe が増えるぶん、壊れている依存先を余計に叩く。
Health Check は observability 全体の代用品ではない。ログもメトリクスも synthetic monitoring も必要だ。そのうえで Health Check が担うのは、「この instance を今どう扱うべきか」という契約の部分だけだ。
そこが抜けると、緑も赤も信用できなくなる。
推理披露 — 三枚の診断票
ロックさんは、さっき書いた三語の下へ短い説明を足した。
live: 再起動で改善しうるほど process 自身が詰まっているかready: required dependency と warmup が終わり、いま traffic を受けてよいかdetails: 人間が読む診断書として、内訳をどこまで見せるか
「shallow と deep という呼び方をすることはある。だが、それは実務上のラベルにすぎない。重要なのは名前ではない。誰が何に使うのかだよ」
今回はそこから実装を組み直した。
まず live と ready を分ける
live に入れるのは process 自身の前進可能性だけに絞る。deadlock、heartbeat 停止、致命的な自己矛盾。逆に、DB の一時遅延や外部 API の不調、Lazy Loading 後の warmup 未完了は live に入れない。
その代わり ready には、required dependency と startup 後の準備完了を入れる。今回の service なら、tenant 設定ストアの接続と検索 index の warming 完了がそこに当たる。
この分離だけで、war room の矛盾はだいぶ解ける。
| 状況 | /live | /ready | 意味 |
|---|---|---|---|
| process は動くが search index が warming 中 | 200 | 503 | 再起動は不要だが配送は止める |
| process 自身が stalled | 503 | 503 | 再起動対象 |
| required dependency は揃っている | 200 | 200 | 配送可 |
要するに、live=true / ready=false を恥ずかしがらず表に出すことが大事だった。
Composite で依存ツリーを一枚の診断書にまとめる
次に必要なのは、細かな check をばらばらに生やすことではない。集約の仕方を first-class にすることだ。
そのために、Phase 2 では次のような責務分割にした。
classDiagram
class HealthChecker {
+run_profile(profile, ctx) HealthStatus
}
class HealthCheck {
<<role>>
+name()
+run(ctx) HealthStatus
}
class CompositeHealthCheck {
+children()
+run(ctx) HealthStatus
}
class HealthStatus {
+level()
+summary()
+latency_ms()
+checked_at()
+checks()
+http_status()
}
HealthChecker --> HealthCheck
CompositeHealthCheck --> HealthCheck
HealthCheck --> HealthStatus
HealthStatus は値オブジェクトだ。名前、level、summary、latency、checked_at を持ち、子 check の結果も配列で抱える。
| |
CompositeHealthCheck は、複数の leaf check を束ねて最悪値を top-level に引き上げる。
| |
この形にしておくと、Health Check の主役が endpoint ではなく check tree になる。HTTP handler はただの薄い包装になる。
route 判定と診断の詳細は、同じ level でも同じ意味ではない
ここで効いてくるのが degraded だ。
Bulkhead の空きが減っている。Object Pool の headroom が低い。Circuit Breaker が open か half-open に寄っている。どれも「いま即座に route を止める」べきとは限らない。でも「healthy と同じ顔をさせる」のは違う。
だから details profile では、こうした状態を degraded で返す。
| |
実際の details profile には、次のような check をぶら下げた。
ProcessAliveCheckTenantStoreReadyCheckSearchWarmupCheckConnectionPoolHeadroomCheckBulkheadHeadroomCheckCircuitStateCheck
この配置だと、Lazy Loading で遅らせた初回失敗は ready で止められる。いっぽう、Bulkhead や Pool の逼迫は details で degraded として見える。Circuit Breaker の open も、人間には見せるが route 判定へ自動では混ぜない。
つまり、これまで別々に実装してきた防衛線が、ここで初めて一枚の診断書になる。
deep check は正しく深く、ただし重くしすぎない
deep check を入れるときに忘れやすいのは、health check 自身が事故の増幅器になることだ。
今回の実装では、details profile だけを短 TTL でキャッシュし、短時間の連続 probe では pool や bulkhead や circuit を何度も採取し直さないようにした。Phase 2 のテストでも、2 秒以内の再実行では deep probe の回数が増えず、TTL を超えたところで再評価されることを確認している。
これは「楽をするための cache」ではなく、「診断書の採血をやりすぎない」ための cache だ。
JSON は house contract だと明示する
Health Check の JSON 形式には参考になる草案がある。ただ、IETF の正式標準として固まった聖典があるわけではない。
だから、field 名を何にするかよりも、誰がその field を読み、どう扱うかを曖昧にしないことが先に来る。今回の記事では status ではなく level を使っているが、それは「この service ではこう読む」という house contract を明示するためだ。
たとえば details endpoint の payload は、次のような形になる。
| |
ここでの 200 は「配送可」の意味ではなく、「診断書としては読める」の意味だ。route 判定はあくまで /ready へ閉じる。
事件の終わり — 止めるべきものだけ止まる
Phase 2 のテストで見えた差は、かなりはっきりしていた。
| 状況 | Before | After |
|---|---|---|
| tenant 設定ストア未準備 | /health は 200 のまま | /live は 200、/ready は 503 |
| search index warming 中 | /health は 200 のまま | /live は 200、/ready は 503 |
| process 自身が stalled | /health は 200 のまま | /live も /ready も 503 |
| pool / bulkhead の headroom が低い | 外から分からない | /details が degraded を返す |
| circuit が open | 外から分からない | /details が degraded を返す |
| 連続 probe | 同じ 200 を返すだけ | details は TTL 内で deep check を再利用 |
重要なのは、落とす場所が適切になったことだ。
検索 index が warming 中なら、instance はまだ traffic を受けるべきではない。だから ready は落とす。でも process 自身は死んでいないから、live までは落とさない。
逆に、Bulkhead の headroom が減っている、Pool が細くなっている、Circuit が開き気味だ、という状態は「すぐ route を止める」とは限らない。そこで必要なのは 503 より先に、degraded という言葉で運用者へ正直に伝えることだ。
war room の最後に、わたしは deployment gate の設定を書き換えた。
- rollout 判定は
/ready - restart 判定は
/live - 人間が読む診断書は
/health/details
それで、ようやく最初の緑に意味が戻った。
Circuit Breaker で外向きの失敗を封じ、Timeout で待ちすぎを止め、Bulkhead で巻き込みを防ぎ、Object Pool で再利用資源を清潔に保ち、Lazy Loading で重い初期化を必要時へ押し戻した。
Health Check Pattern は、そのどれか一つの代用品ではない。防衛線を敷いたあとで、「この instance をいまどう扱うべきか」を嘘なく外へ伝える監視役だ。
ロックさんはホワイトボードの三列を見返してから、ペンを置いた。
「診断書は安心のためではない。処置を誤らないためのものだ」
今回は、その言葉に言い返す必要がなかった。
緑が出ていることではなく、緑の意味を説明できることの方が大事だと、ようやく分かったからだ。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
always 200 のダミー health endpoint | live / ready / details の責務分離 | 起動済みだが未準備の instance を配送対象から外せる |
dependency の一時障害まで liveness に背負わせる設計 | liveness は process 自身の前進可能性だけに絞る | healthy な process を無駄に再起動し続ける事故を防げる |
| すべての deep check を毎回直列実行する | timeout と TTL cache を付けた details profile | probe 自体が downstream への負荷源になる事故を防げる |
| 防衛機構ごとの状態が散らばっている | Composite な診断書へ集約する | Bulkhead / Pool / Circuit / warmup の状態を一度に読める |
| HTTP status と health level を同一視する | route 判定と human-readable details を分ける | LB と運用者が別々の意味で同じ payload を誤読しなくなる |
推理のステップ
- いまの health endpoint を読む相手を
LBorchestratorhuman operatorに分ける livenessに入れる条件を「再起動で改善しうる自己異常」に限定する- required dependency と warmup 完了を
readinessへ寄せる HealthStatusHealthCheckCompositeHealthCheckHealthCheckerに責務を分けるdegradedを details endpoint で表現し、route 判定から切り離す- deep check に timeout と cache を入れ、health check 自身が障害増幅装置にならないようにする
ロックより
防衛線を敷くだけでは足りないのだよ、ワトソン君。隔壁も、回路遮断器も、遅延初期化も、それ自体は沈黙している。沈黙した防衛線は、外から見れば存在しないのと同じだ。
ゆえに最後に必要なのは、派手な赤信号ではない。誰に向けた合図かを失わない、正直な診断書だ。緑を出したまえ。ただし、その緑が何を保証し、何を保証しないのかを、必ず書き添えるのだ。
