Featured image of post コード探偵ロックの事件簿【Circuit Breaker】燃え広がる障害〜ブレーカーを落とせ〜

コード探偵ロックの事件簿【Circuit Breaker】燃え広がる障害〜ブレーカーを落とせ〜

障害サービスへの無限リトライがカスケード障害を引き起こすメカニズムを解剖し、Circuit BreakerパターンのClosed/Open/Half-Open 3状態管理で障害伝播を断ち切る方法をPerl/Mooで解説。

事件の発端 — 燃えている

木曜の23時過ぎだった。

オフィスのフロア照明は半分消えていて、私のデスク周辺だけモニタの光が白く浮かんでいた。左のモニタにGrafanaのダッシュボード。決済APIのレスポンスタイムが赤い折れ線を描いて、p99が30秒に張り付いている。中央のモニタにSlackの障害チャンネル。部下の安藤が22時に「決済API応答なし。前回と同じパターンです」と報告したあと、私が「対応する。帰っていい」と返した。右のモニタにターミナル。tail -f でアプリケーションログが流れている。Connection pool exhausted の行が赤く繰り返される。

3週間で3回目だ。先週の金曜は3時間止まった。火曜は1時間。今日はまだ30分——30分で済んでいるのは、リトライのタイムアウトを60秒に伸ばしたからだ。

いや、済んでいない。接続プールは埋まっている。新しい注文は全部失敗している。

リトライを5回に増やした。タイムアウトを60秒にした。それでも足りなかったのか。次は7回にするか? タイムアウトを90秒にするか?

——いや。何かがおかしい。リトライを増やすたびに、停止時間が短くなるどころか——。

思考を遮るように、フロアの奥でエレベーターの到着音が鳴った。

この時間にフロアに来る人間はいない。振り返ると、非常灯のぼんやりした光の中を、長身の男が歩いてくる。ツイードのジャケット。片手にペットボトル。もう片方の手はジャケットのポケットに入っている。

「あの——どちら様ですか」

男は私のデスクまで来て、3台のモニタを順に見た。私の顔は見ない。Grafanaの赤い折れ線、Slackの障害チャンネル、ターミナルの Connection pool exhausted

「……燃えているな」

「え?」

男はようやく私を見た。

「メールをくれただろう。LCIのロックだ」

「ロック……さん、ですよね? でも、明日の——」

「返信がなかった。住所は送ってもらったから来た。——ビルの入り口が開いていたのは幸運だ」

前任のCTOが社内Wikiに書き残していた連絡先。「レガシー問題全般: LCI(レガシー・コード・インベスティゲーション)」。こんな時間にアポなしで来る人だとは書いてなかった。——いや、今はそんなことを気にしている場合じゃない。

「すみません、今ちょうど障害対応中で——」

ロックさんは私の椅子の隣に置いてあった折りたたみ椅子を引き寄せて座った。ペットボトルの蓋を開けながら。

「知っている。だからこの時間に来た。——障害が起きているときの方が、現場検証がしやすい」

モニタの光に照らされたロックさんの横顔は、困っている人間の顔ではなかった。好奇心に満ちている。面白がっている。

「症状を聞こう、ワトソン君。何が倒れた?」

「ワトソン——いえ、私は……」

ロックさんは私を見ていなかった。中央のモニタのログに目を走らせている。

「それで、患部はどこだ?」

名乗らせてもらえないらしい。——気を取り直して、報告する。

「外部の決済APIが3週間前から不安定です。先週金曜に完全に応答しなくなりました。うちの注文確認サービスが決済APIを呼び出すんですが、タイムアウト待ちでコネクションプールが枯渇して、注文確認サービス自体が応答不能になりました」

「在庫と配送は?」

「在庫サービスも配送サービスも注文確認サービスを経由するので、連鎖的に止まりました。3時間の全面停止です」

「対策は?」

少し胸を張った。テックリードとして打った手だ。

「リトライ回数を3回から5回に増やしました。タイムアウトも30秒から60秒に伸ばしました。一時的な障害なら、リトライで回復するはずなので」

ロックさんはペットボトルを置いた。右端のモニタ——ターミナルのログを見つめている。Connection pool exhausted が赤く繰り返し流れている。

「……火曜にも落ちたと、メールに書いていたね」

「はい。1時間。でも前回の3時間よりは——」

「改善したと思っているのか」

「……はい。リトライの強化が——」

「ワトソン君、この行を見たまえ」

ロックさんが指した行を読む。

1
[23:12:05] PaymentAPI request timeout (attempt 4/5, elapsed 240s)

「リトライ5回、タイムアウト60秒。1リクエストが最終的に失敗するまでに最大300秒。コネクションプールは?」

「50です」

「50本の接続が、それぞれ300秒間、応答のない相手に向かって手を伸ばし続ける。——計算してみたまえ」

計算した。顔から血の気が引くのがわかった。

「……10リクエストが同時に来たら、50接続のうち10本が300秒間占有されて——新しいリクエストが来ても接続が足りなく——」

「3週間前、リトライは3回、タイムアウトは30秒だった。1リクエストの最大占有時間は90秒。今は300秒。君が延命措置だと思って増やしたリトライが、システムの血管を詰まらせている

沈黙。モニタのログを見つめ直す。Connection pool exhausted が流れている。3週間前より頻度が上がっている。

「倒れた相手にもう一度立てと叫び続けている。相手は立てない。叫んでいる間に、君自身が酸欠になる。——それが今のこのシステムだ」

椅子の背もたれに体を預けた。

リトライを増やすほど、悪化していた。私がやったことは——対策ではなく、延焼だった。

現場検証 — 燃えているのは誰だ

ロックさんはモニタに手を伸ばした。私が「どうぞ」と言う前に。もう止めなかった。

エディタが開いた。注文確認サービスの決済API呼び出し部分。私が3週間かけて「強化」したコードだ。

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

has max_retries => (is => 'ro', isa => Int, default => 5);
has timeout     => (is => 'ro', isa => Num, default => 60);
has http_client => (is => 'ro', isa => Object, required => 1);

sub charge ($self, $order) {
    my $last_error;
    for my $attempt (1 .. $self->max_retries) {
        my $response = eval {
            $self->http_client->post(
                'https://api.payment-provider.example/charge',
                { amount => $order->{amount}, order_id => $order->{id} },
                { timeout => $self->timeout },
            );
        };
        return $response if $response && $response->{success};
        $last_error = $@ || $response->{error} || 'Unknown error';
    }
    die "Payment failed after ${\$self->max_retries} attempts: $last_error";
}

「リトライは5回。タイムアウトは60秒。リトライ間隔はなし——失敗したら即座にもう一度。障害中のサービスに対する機関銃だ」

「リトライ間隔を入れるべきでした? Exponential backoffとか——」

「Backoffは一時的障害への緩和策だ。今の問題はもっと根深い」

ロックさんはコードに目を戻した。

「ワトソン君、このメソッドに質問がある。このコードは、決済APIが1時間落ちているときと、0.1秒だけ途切れたときで、同じ振る舞いをするのか?

考えた。口を開きかけて、閉じた。もう一度考えた。

「……同じです。どちらも5回リトライして、60秒ずつ待つ」

障害が一時的なのか持続的なのかを区別していない。散発的なタイムアウトと、サービスの完全停止と、このコードにとっては同じだ。0.1秒の途切れに対しても300秒かけてリトライする。1時間の停止に対しても300秒のリトライを——何度でも繰り返す」

「じゃあリトライをやめればいいんですか? でもリトライしなかったら、一時的な障害でも即失敗しますよね。ネットワークの一瞬の途切れでも注文が失敗するのは——」

「リトライをやめろとは言っていない」

ロックさんがモニタのログを時系列で指し示した。

「23時12分。最初のタイムアウト。この時点では散発的だ。リトライは正しい。——23時15分。連続5回のタイムアウト。この時点でリトライは有害だ。この間に何が変わった?

「決済APIの障害が……持続的になった」

「そう。だが君のコードには、その変化を知る仕組みがない。12分の時点と15分の時点で、まったく同じコードが動いている。判定がない

「……判定する仕組みが要る、ということですか」

「それがCircuit Breakerだ」

推理披露 — ブレーカーを落とせ

ロックさんはオフィスの隅にあるスタンド式のホワイトボードに向かった。私はデスクチェアに座ったまま、その背中を見ていた。

3つの箱を描く。左から「Closed」「Open」「Half-Open」。

「電気のブレーカーを知っているだろう」

「過電流が流れたら回路を遮断する——あの、分電盤の」

「それと同じだ。ソフトウェアのCircuit Breakerは、外部サービスへの呼び出しを監視する。障害が一定の閾値に達したら——回路を遮断する。リクエストを外に出さない。即座にエラーを返す

ロックさんがClosedの箱を指した。

「通常はClosedだ。リクエストはそのまま下流に送られる。電気が流れている状態」

Openの箱を指す。

「失敗が閾値——たとえば5回連続失敗——を超えたら、Openに遷移する。ブレーカーが落ちた状態。以降のリクエストは下流に送らない。即座にエラーを返す。タイムアウトを待たない。接続を消費しない」

「タイムアウトを待たない……つまり、接続プールを占有しない」

「0.1秒でエラーが返る。30秒や60秒のタイムアウト待ちではなく。——ブレーカーが落ちている間、システムは決済APIが死んでいることを知っている。知っているから、無駄なリクエストを送らない」

「でも、Openのまま永遠に拒否し続けたら、復旧した決済APIにいつ気づくんですか」

ロックさんはHalf-Openの箱を指し、Open→Half-Openの矢印を描いた。矢印に「timeout: 60s」と添える。

「Openは永遠ではない。タイマーがある。60秒経ったら——門を少しだけ開ける。Half-Openだ」

「Half-Openでは、リクエストを1つだけ通す。偵察部隊だ。この1つが成功すれば——相手は復旧した、門を開けろ」

Half-Open→Closedの矢印に「success」。

「失敗すれば——まだ倒れている、門を閉じ直せ」

Half-Open→Openの矢印に「failure」。

「1つだけ通す……」

考えていた。ロックさんは待った。

「……復旧直後のサービスに大量のリクエストを流したら、また倒れますよね。だからプローブは1つだけ」

ロックさんは頷いた。

「Half-Openは復旧中のサービスを保護するためにもある。回復しかけた病人に全力で仕事をさせたらどうなる? また倒れる。——段階的に門を開ける。それがHalf-Openだ」

ホワイトボードに3つの状態の遷移が完成した。

	stateDiagram-v2
    [*] --> Closed
    Closed --> Open : failure_threshold超過
    Open --> HalfOpen : timeout経過
    HalfOpen --> Closed : success_threshold達成
    HalfOpen --> Open : failure発生

「この3つの状態を、1つのオブジェクトが管理する。それがCircuit Breakerだ」

ロックさんはホワイトボードから離れて、私のデスクのエディタに向かった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package CircuitBreaker;
use v5.36;
use Moo;
use Types::Standard qw(Int Num Str);
use Time::HiRes qw(time);
use Carp qw(croak);

use constant {
    STATE_CLOSED    => 'closed',
    STATE_OPEN      => 'open',
    STATE_HALF_OPEN => 'half_open',
};

has name              => (is => 'ro', isa => Str, required => 1);
has failure_threshold => (is => 'ro', isa => Int, default => 5);
has success_threshold => (is => 'ro', isa => Int, default => 2);
has timeout           => (is => 'ro', isa => Num, default => 60);

has _state             => (is => 'rw', default => STATE_CLOSED);
has _failure_count     => (is => 'rw', default => 0);
has _success_count     => (is => 'rw', default => 0);
has _last_failure_time => (is => 'rw', default => 0);

name はブレーカーの識別名。failure_threshold は何回失敗したらOpenにするか。success_threshold はHalf-Openで何回成功したらClosedに戻すか。timeout はOpenからHalf-Openに遷移するまでの秒数」

「内部状態は _state で管理するんですね。失敗回数、成功回数、最後の失敗時刻……」

「核はこの call メソッドだ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
sub call ($self, $code) {
    my $state = $self->current_state;

    if ($state eq STATE_OPEN) {
        croak sprintf(
            "CircuitBreaker '%s' is open (failures: %d, retry after: %.0fs)",
            $self->name,
            $self->_failure_count,
            $self->_remaining_timeout,
        );
    }

    my $result = eval { $code->() };
    if (my $err = $@) {
        $self->_on_failure;
        die $err;
    }

    $self->_on_success;
    return $result;
}

「コードリファレンスを渡す。Openなら即座にエラー。ClosedかHalf-Openなら実行して、結果に応じて _on_failure_on_success を呼ぶ」

current_state はどうなっていますか? 内部状態と違うんですか?」

「いい質問だ。見たまえ」

1
2
3
4
5
6
7
8
9
sub current_state ($self) {
    if ($self->_state eq STATE_OPEN) {
        if ((time() - $self->_last_failure_time) >= $self->timeout) {
            return STATE_HALF_OPEN;
        }
        return STATE_OPEN;
    }
    return $self->_state;
}

「私の名前は——」

聞いていない。

「内部状態が open でも、timeoutが経過していたら half_open を返す。時間が状態遷移のトリガーだ。Open中に時間が流れて、Half-Openに切り替わる。コードが明示的に状態を変えるのではなく、時間が自動的に門を開ける」

「……なるほど。Open状態の間は何もしなくていい。60秒待てば自動でHalf-Openになる」

ロックさんは失敗時と成功時のコールバックを書いた。

 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
26
27
sub _on_failure ($self) {
    $self->_failure_count($self->_failure_count + 1);
    $self->_success_count(0);
    $self->_last_failure_time(time());

    if ($self->_failure_count >= $self->failure_threshold) {
        $self->_state(STATE_OPEN);
    }
}

sub _on_success ($self) {
    if ($self->current_state eq STATE_HALF_OPEN) {
        $self->_success_count($self->_success_count + 1);
        if ($self->_success_count >= $self->success_threshold) {
            $self->_reset;
        }
    } else {
        $self->_reset;
    }
}

sub _reset ($self) {
    $self->_state(STATE_CLOSED);
    $self->_failure_count(0);
    $self->_success_count(0);
    $self->_last_failure_time(0);
}

_on_failure は失敗をカウントする。閾値を超えたらOpenに。——_on_success は、状態による分岐がある」

「Half-Openの場合は成功回数をカウントして、success_threshold に達したらリセット。それ以外——Closedの場合は、成功した時点でカウンターをリセットする」

「Closedでの成功リセットは何のために?」

「散発的な障害でOpenに遷移しないためだ。失敗が2回あっても、間に成功が1回あれば——散発的な障害だ。カウンターをゼロに戻す。連続して失敗したときだけ、持続的障害と判断する」

「なるほど……。じゃあ、PaymentClientはどう変わるんですか」

 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
26
27
package PaymentClient;
use v5.36;
use Moo;
use Types::Standard qw(Int Num Object);

has max_retries     => (is => 'ro', isa => Int, default => 2);
has timeout         => (is => 'ro', isa => Num, default => 10);
has http_client     => (is => 'ro', isa => Object, required => 1);
has circuit_breaker => (is => 'ro', isa => Object, required => 1);

sub charge ($self, $order) {
    return $self->circuit_breaker->call(sub {
        my $last_error;
        for my $attempt (1 .. $self->max_retries) {
            my $response = eval {
                $self->http_client->post(
                    'https://api.payment-provider.example/charge',
                    { amount => $order->{amount}, order_id => $order->{id} },
                    { timeout => $self->timeout },
                );
            };
            return $response if $response && $response->{success};
            $last_error = $@ || $response->{error} || 'Unknown error';
        }
        die "Payment failed after ${\$self->max_retries} attempts: $last_error\n";
    });
}

2つのコードを見比べた。

「リトライは2回に戻して、タイムアウトも10秒に……」

「リトライはCircuit Breakerの内側にある。ブレーカーがClosedの間だけリトライが動く。Openになったら——リトライすら始まらない。ブレーカーが即座にエラーを返す」

「今までは5回×60秒=最大300秒。これだと2回×10秒=最大20秒。しかもOpenになったら0秒」

「接続の占有時間が300秒から最大20秒に縮む。Openになれば0秒だ。コネクションプールは詰まらない。——在庫サービスも配送サービスも、巻き添えにならない」

「これは決済APIだけに適用するんですか? 在庫サービスと配送サービスにも同じ問題がありますよね」

「いい問いだ。だが注意がいる。サービスごとに独立したCircuit Breakerを持て。1つのブレーカーで複数のサービスを守ろうとしたら——決済APIの障害で在庫サービスへのリクエストも遮断される。正常なサービスを巻き添えにする」

「……それは、今起きている問題と同じ構造ですね。1サービスの障害が全体を止める」

「そうだ。Circuit Breakerの目的は障害の隔離だ。台所のショートでリビングの電気まで落ちては困る。部屋ごとにブレーカーがある」

事件の終わり — 部分停止という選択

ロックさんがターミナルでテストを走らせた。

テスト結果がターミナルに流れる。Closed→Openの遷移。Open中の即時拒否——下流への呼び出しがゼロであること。timeout経過後のHalf-Open遷移。プローブ成功でClosedに復帰。プローブ失敗で再びOpen。

すべてのテストが通った。

「テストの即時拒否を見たまえ。Openの間は下流への呼び出しがゼロだ。接続を消費していない。——そして60秒後にプローブが1つだけ通る。成功すれば門が開く」

「Closedの間はリトライが動いている。散発的な障害なら、従来通りリトライで回復する」

「リトライとCircuit Breakerは対立しない。Circuit Breakerはリトライすべきかどうかを判断する門番だ。門が開いていればリトライする。門が閉まっていればリトライすら始めない」

モニタを見た。テスト結果の隣に、Slackの障害チャンネルが映っている。22時15分を最後に、新しいメッセージは止まっていた。

「ブレーカーがOpenの間、リクエストは全部失敗しますよね。ユーザーには何が見えるんですか」

即座にエラーが返る。30秒間の沈黙ではなく、0.1秒でエラーが返る。——どちらがましだ? 30秒待たされて失敗するのと、即座に『今は処理できません』と表示されるのと」

「……即座のほうがましです。でも、エラーはエラーです」

「そうだ。Circuit Breakerは障害をゼロにしない。障害の影響範囲を限定する。決済が止まっても、商品一覧は表示できる。カートに入れられる。——決済以外のすべてが生きている。ブレーカーがなければ? 接続プールが枯渇して、商品一覧すら返せなくなる」

全面停止か、部分停止か。それがCircuit Breakerの問いだ

3週間、私は全面停止と戦っていた。でも戦い方が間違っていた。リトライを増やして全面復旧を目指すのではなく——壊れた部分を切り離して、残りを守るべきだった。

燃えている部屋のドアを開けて水を掛けに行くのではなく、ドアを閉めて延焼を止める。消火は火元の担当者に任せる。君の仕事は、ビル全体を守ることだ」

ロックさんはペットボトルを持って立ち上がった。ツイードのジャケットの裾を正す。

「それでは失礼するよ、ワトソン君」

「あの——ありがとうございます。請求書は——」

「メールで送る。——いや、それより」

ロックさんはエレベーターホールに向かいながら、振り返らずに言った。

「次にリトライの数を増やしたくなったら、まずブレーカーの閾値を確認したまえ」

エレベーターの到着音。扉が閉まる。

オフィスは静かになった。私とモニタの光だけが残った。

エディタを開いた。PaymentClient.pm。自分が3週間かけて「強化」してきたファイル。max_retries => 5timeout => 60。3週間前は 330 だった。

これが延焼の原因だった。

ファイルを閉じた。新しいファイルを開く。空白のエディタに、カーソルが点滅している。

package CircuitBreaker;

窓の外はまだ暗い。だが、モニタの隅でSlackの障害チャンネルが静かになっている。決済APIが復旧し始めている。

リトライを増やすべきだったんじゃない。ブレーカーを落とすべきだった。

燃えている部屋のドアを閉めること。それが防衛線だ。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
障害サービスへの無限リトライCircuit Breaker(状態管理による障害検知)タイムアウト待ちの接続占有を即時遮断に変換
カスケード障害(1サービスの障害が全体を停止)サービスごとの独立したCB(障害の隔離)決済API障害時も在庫・配送サービスは稼働継続
リトライ増幅(リトライが障害を加速)CBの内側にリトライを配置Open時はリトライすら実行せず、下流への負荷ゼロ

推理のステップ

  1. 問題の認識: 外部サービスの障害に対してリトライを繰り返すと、タイムアウト待ちで接続を占有し、自システムのリソースを枯渇させる(リトライ増幅)
  2. 判断基準の導入: 障害が一時的か持続的かを区別する。失敗回数が failure_threshold を超えたら「持続的障害」と判断する
  3. CircuitBreaker オブジェクトの実装: Closed(通常)→ Open(即時拒否)→ Half-Open(プローブ)の3状態をMooクラスで管理
  4. 状態遷移の設計: Closed→Open は失敗カウント。Open→Half-Open は時間経過(timeout)。Half-Open→Closed/Open はプローブの成否
  5. リトライとの統合: リトライロジックはCBの call 内側に配置。CBがOpenならリトライも実行されない
  6. 独立性の確保: サービスごとに独立したCBインスタンスを持ち、障害の隔離を実現

ロックより

燃えている部屋に飛び込むのが勇気だと思っているうちは、ビルは守れない。

ドアを閉めたまえ。延焼を止めろ。火元の消火は火元の持ち主に任せるんだ。——君の仕事は、ビル全体を生かすことだ。

Circuit Breakerは障害をゼロにはしない。だが、1つの障害で全体が倒れる世界を、1つの障害が1つの部屋に留まる世界に変える。それが防衛線だ。

……次の事件は、防衛線の内側で何が起きるかだ。楽しみにしていたまえ、ワトソン君。

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