Featured image of post コード探偵ロックの事件簿【Health Check】緑の証言者〜嘘をつかない診断書〜

コード探偵ロックの事件簿【Health Check】緑の証言者〜嘘をつかない診断書〜

緑の /health が壊れた canary を見逃す状況を、Liveness/Readiness 分離と Composite な HealthChecker で正します

緑の画面、落ちる最初の一件

日曜の朝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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package App::Web::Health;
use v5.36;
use Moo;
use Types::Standard qw(CodeRef);

has clock => (
    is      => 'ro',
    isa     => CodeRef,
    default => sub { sub { '1970-01-01T00:00:00Z' } },
);

sub health_response ($self, $service = undef) {
    return {
        http_status => 200,
        body => {
            status     => 'ok',
            checked_at => $self->clock->(),
        },
    };
}

これでは、tenant 設定ストアが未接続でも、検索 index が warming 中でも、/health は平然と 200 を返す。

一方で、実際のリクエスト側は required dependency をちゃんと見ている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
sub serve_report_request ($self) {
    return {
        http_status => 503,
        error       => 'tenant_store_not_ready',
    } unless $self->tenant_store->is_ready;

    return {
        http_status => 503,
        error       => 'search_warming',
    } unless $self->search_index->is_warmed;

    return {
        http_status => 200,
        body        => { status => 'ok' },
    };
}

つまり、外向きの診断書と、本物の業務リクエストが見ている現実がずれていた。

ロックさんは 200 の横に、小さく to whom? と書いた。

「君たちは一枚の診断書に、三人分の質問を書いている」

「三人分」

「load balancer、orchestrator、人間の運用者だ。彼らは同じことを知りたがってはいない」

たしかにそうだった。

読み手本当に知りたいこと同じ /health に押し込むと起きること
Load balancer今この instance に流してよいかoptional な揺らぎまで route 判定へ混ざる
Orchestrator再起動で改善する自己異常かdependency の一時障害で healthy な process まで落とす
人間の運用者どこがどの程度まずいか200 では情報が足りず、503 では粒度が粗すぎる

問題は 200 が軽いことではない。意味が混線していることだった。

全部載せ health check も別の嘘になる

ここで次に出やすい短絡は、だいたい同じだ。

「だったら DB も検索も外部 API も、全部 /health で見ればいいのでは」

わたしも、そう考えかけていた。

でも、それは別の種類の嘘になる。

1
2
3
4
5
6
sub handle_health ($service) {
    return _fail('tenant_store') unless $service->tenant_store->is_ready;
    return _fail('search_index') unless $service->search_index->is_warmed;
    return _fail('payment_gateway') unless $service->payment_gateway->ping;
    return _ok();
}

この種の endpoint を liveness にも readiness にも使うと、依存先の一時的な不調でプロセス自身まで再起動対象にしてしまう。再起動しても tenant 設定ストアの障害は治らないし、warming 中の index も早まらない。むしろ probe が増えるぶん、壊れている依存先を余計に叩く。

Health Check は observability 全体の代用品ではない。ログもメトリクスも synthetic monitoring も必要だ。そのうえで Health Check が担うのは、「この instance を今どう扱うべきか」という契約の部分だけだ。

そこが抜けると、緑も赤も信用できなくなる。

推理披露 — 三枚の診断票

ロックさんは、さっき書いた三語の下へ短い説明を足した。

  • live: 再起動で改善しうるほど process 自身が詰まっているか
  • ready: required dependency と warmup が終わり、いま traffic を受けてよいか
  • details: 人間が読む診断書として、内訳をどこまで見せるか

shallowdeep という呼び方をすることはある。だが、それは実務上のラベルにすぎない。重要なのは名前ではない。誰が何に使うのかだよ」

今回はそこから実装を組み直した。

まず 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 中200503再起動は不要だが配送は止める
process 自身が stalled503503再起動対象
required dependency は揃っている200200配送可

要するに、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 の結果も配列で抱える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package HealthStatus;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef Enum Num Str);

has name       => (is => 'ro', isa => Str, required => 1);
has level      => (is => 'ro', isa => Enum[qw(healthy degraded unhealthy)], required => 1);
has summary    => (is => 'ro', isa => Str, required => 1);
has latency_ms => (is => 'ro', isa => Num, required => 1);
has checked_at => (is => 'ro', isa => Str, required => 1);
has checks     => (is => 'ro', isa => ArrayRef, default => sub { [] });

sub http_status ($self) {
    return $self->level eq 'unhealthy' ? 503 : 200;
}

CompositeHealthCheck は、複数の leaf check を束ねて最悪値を top-level に引き上げる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package CompositeHealthCheck;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef Str);

use HealthStatus;

has name => (is => 'ro', isa => Str, required => 1);
has children => (is => 'ro', isa => ArrayRef, default => sub { [] });

with 'HealthCheck';

sub run ($self, $ctx = {}) {
    my @results = map { $_->run($ctx) } $self->children->@*;
    my $level = _worst_level(@results);

    return HealthStatus->new(
        name       => $self->name,
        level      => $level,
        summary    => join(', ', map { $_->summary } @results),
        latency_ms => _sum_latency(@results),
        checked_at => $ctx->{clock}->(),
        checks     => \@results,
    );
}

この形にしておくと、Health Check の主役が endpoint ではなく check tree になる。HTTP handler はただの薄い包装になる。

route 判定と診断の詳細は、同じ level でも同じ意味ではない

ここで効いてくるのが degraded だ。

Bulkhead の空きが減っている。Object Pool の headroom が低い。Circuit Breaker が open か half-open に寄っている。どれも「いま即座に route を止める」べきとは限らない。でも「healthy と同じ顔をさせる」のは違う。

だから details profile では、こうした状態を degraded で返す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package HealthChecker;
use v5.36;
use Moo;
use Types::Standard qw(CodeRef HashRef Num);

has profiles => (is => 'ro', isa => HashRef, required => 1);
has cache_ttl_seconds => (is => 'ro', isa => Num, default => sub { 2 });
has cacheable_profiles => (is => 'ro', isa => HashRef, default => sub { {} });
has clock => (is => 'ro', isa => CodeRef, required => 1);

sub run_profile ($self, $profile, $ctx = {}) {
    my $root = $self->profiles->{$profile}
      or die "Unknown profile: $profile";

    return $root->run({
        %{$ctx},
        clock => $ctx->{clock} // $self->clock,
    });
}

実際の details profile には、次のような check をぶら下げた。

  • ProcessAliveCheck
  • TenantStoreReadyCheck
  • SearchWarmupCheck
  • ConnectionPoolHeadroomCheck
  • BulkheadHeadroomCheck
  • CircuitStateCheck

この配置だと、Lazy Loading で遅らせた初回失敗は ready で止められる。いっぽう、Bulkhead や Pool の逼迫は detailsdegraded として見える。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 は、次のような形になる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  "name": "details",
  "level": "degraded",
  "summary": "connection pool headroom low, outbound circuit open",
  "latency_ms": 20,
  "checked_at": "2026-05-09T07:07:05+09:00",
  "checks": [
    {
      "name": "process_alive",
      "level": "healthy",
      "summary": "process heartbeat ok",
      "latency_ms": 1,
      "checked_at": "2026-05-09T07:07:05+09:00",
      "checks": []
    },
    {
      "name": "connection_pool",
      "level": "degraded",
      "summary": "connection pool headroom low",
      "latency_ms": 3,
      "checked_at": "2026-05-09T07:07:05+09:00",
      "checks": []
    }
  ]
}

ここでの 200 は「配送可」の意味ではなく、「診断書としては読める」の意味だ。route 判定はあくまで /ready へ閉じる。

事件の終わり — 止めるべきものだけ止まる

Phase 2 のテストで見えた差は、かなりはっきりしていた。

状況BeforeAfter
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 が低い外から分からない/detailsdegraded を返す
circuit が open外から分からない/detailsdegraded を返す
連続 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 endpointlive / ready / details の責務分離起動済みだが未準備の instance を配送対象から外せる
dependency の一時障害まで liveness に背負わせる設計liveness は process 自身の前進可能性だけに絞るhealthy な process を無駄に再起動し続ける事故を防げる
すべての deep check を毎回直列実行するtimeout と TTL cache を付けた details profileprobe 自体が downstream への負荷源になる事故を防げる
防衛機構ごとの状態が散らばっているComposite な診断書へ集約するBulkhead / Pool / Circuit / warmup の状態を一度に読める
HTTP status と health level を同一視するroute 判定と human-readable details を分けるLB と運用者が別々の意味で同じ payload を誤読しなくなる

推理のステップ

  1. いまの health endpoint を読む相手を LB orchestrator human operator に分ける
  2. liveness に入れる条件を「再起動で改善しうる自己異常」に限定する
  3. required dependency と warmup 完了を readiness へ寄せる
  4. HealthStatus HealthCheck CompositeHealthCheck HealthChecker に責務を分ける
  5. degraded を details endpoint で表現し、route 判定から切り離す
  6. deep check に timeout と cache を入れ、health check 自身が障害増幅装置にならないようにする

ロックより

防衛線を敷くだけでは足りないのだよ、ワトソン君。隔壁も、回路遮断器も、遅延初期化も、それ自体は沈黙している。沈黙した防衛線は、外から見れば存在しないのと同じだ。

ゆえに最後に必要なのは、派手な赤信号ではない。誰に向けた合図かを失わない、正直な診断書だ。緑を出したまえ。ただし、その緑が何を保証し、何を保証しないのかを、必ず書き添えるのだ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。