Featured image of post コード探偵ロックの事件簿【Timeout】永遠の待ち人〜待たない勇気という防衛線〜

コード探偵ロックの事件簿【Timeout】永遠の待ち人〜待たない勇気という防衛線〜

タイムアウト未設定の外部API呼び出しがスレッドを無限占有しCBもRetryも機能しなくなるメカニズムを解剖し、接続/読み取りタイムアウトの分離設定とフォールバック戦略をPerl/Mooで解説。

事件の発端 — 凍りついた注文

木曜の夕方17時。自分のオフィスの小会議室を予約していた。

ホワイトボードには、前回と前々回のセッションで描いたCircuit BreakerとRetryの構成図がうっすら残っている。消し忘れたのではなく、あえて消さなかった。今日の相談で使うかもしれないと思ったからだ。

ノートPCを開き、Grafanaのダッシュボードを映しておく。水曜日の在庫照会APIのレスポンスタイムのグラフ。80ms前後で安定していたラインが、14:30を境に55秒に張り付いている。同じ時刻に、注文処理のスループットがゼロに落ちている。

3回目の相談だ。最初は深夜にアポなしで来た。2回目は土曜にLCIの事務所を訪ねた。3回目は——普通に会議室を予約して呼んだ。「レガシー・コード・インベスティゲーション」に会議室を予約する日が来るとは。

時間通りにドアが開いた。ロックさんだ。いつものツイードのジャケット。片手にペットボトル——前回はエナジードリンクだったが、今回はただの水だ。

ロックさんは椅子に座らず、まず窓に近づいた。夕暮れの空を見ている。

「いい色だ。東京の夕焼けは、工場の煤のせいでロンドンに似ている」

「……ロックさん、ロンドンに住んだことあるんですか」

「ない」

一瞬、呆れかけた。でも笑ってしまった。以前なら「何を言っているんだこの人は」と思っただろう。今は「ああ、この人だ」と思える。

ロックさんが椅子に座り、ノートPCの画面を見た。

「で、今度は何が燃えた」

「燃えたというか——凍った、が正確です」

Grafanaのダッシュボードを示した。水曜14:30からの在庫照会APIのレスポンスタイムグラフ。

「在庫照会APIが水曜の午後に遅延しました。応答に55秒かかる状態が30分間続いて、その間、注文処理が完全に止まりました」

「決済APIは?」

「正常です。CBもRetryも期待通りに動いています」

ロックさんが静かにグラフを見ている。決済APIのレスポンスタイムのグラフも確認する。そちらは安定している。

「CBもRetryも正常で、注文処理が止まった」

「はい。在庫照会APIは——CBの対象にしていませんでした。社内の別チームが運用しているAPIなので、まさか遅延するとは……」

ロックさんが私を見た。前回のような鋭い視線ではなく、どこか穏やかな目だった。

「ワトソン君。決済API以外の外部呼び出しを全て挙げてくれ」

「在庫照会API、配送料計算API、ポイント残高API。いずれも社内の別チームが運用しています」

「それぞれのタイムアウト設定は?」

ノートPCでコードを確認する。少し間が空いた。自分でも顔が曇るのがわかった。

「……在庫照会は、HTTP::Tinyのデフォルト60秒です。配送料計算も60秒。ポイント残高は30秒——先月、ポイントチームから『30秒以内に返らなければ障害と見なして』と指示があって設定しました」

「在庫照会と配送料計算は、デフォルトのまま。意図的に設定したのではなく、設定しなかった。——それが60秒になっている」

「……はい」

「60秒のタイムアウトで、在庫照会APIが55秒かかっている。タイムアウトしない。ギリギリで返ってくる。——CBから見れば"成功"だ。遅いだけで、エラーではない」

「……だからCBがOpen遷移しなかった。55秒で返ってきているから、失敗として検知されない」

「そして10スレッドのワーカープールが、全て55秒の在庫照会を待っている。新しい注文が来ても、スレッドが空いていない」

CBを入れた。Retryを直した。2回直したのに、3回目に止まった。しかも今回は、CBもRetryも正常に動いている。問題はその外にあった。

「……在庫照会にもCBを追加すればいいんじゃないかと思ったんですが」

「CBを追加する。いい。——だが、在庫照会APIが55秒間応答を返さない状態で、CBはいつOpenに遷移する?」

「失敗閾値5回で……60秒のタイムアウトが5回……300秒」

「300秒。5分。その間、10スレッドは全て埋まっている。CBがOpenになる前に、システムが止まる

間が空いた。

「ワトソン君。**CBは"呼び出しが返ってくること"を前提にしている。**返ってこなければ——あるいは返ってくるのが遅すぎれば——失敗のカウントすら始まらない。CBの速度は、タイムアウトの速度で決まる」

窓の外を見た。夕焼けが少し暗くなっている。

「……最初にCBを入れた。次にRetryを直した。でも一番内側の——タイムアウトが設定されていなかった」

「基礎を最後に学ぶのは珍しいことではない。**問題は外側から見えるからだ。**カスケード障害はCBで防げる。リトライストームはRetryで防げる。しかし"待ちすぎ"は外からは見えない。スレッドが静かに待っているだけだから」

ロックさんが言葉を切った。

沈黙の障害は、最も発見が遅い

現場検証 — タイムアウトの指紋

ノートPCで在庫照会の呼び出しコードを開いた。二人で画面を見る。

「これが在庫照会の呼び出し部分です」

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

has http_client => (is => 'ro', isa => Object, required => 1);

sub check ($self, $product_id) {
    # タイムアウト未設定 — HTTP::Tiny のデフォルト 60 秒がそのまま使われる
    my $res = $self->http_client->get(
        "http://inventory-api.internal/stock/$product_id"
    );

    if ($res->{success}) {
        return $res->{content};
    }

    die "Inventory API error: $res->{status} $res->{reason}";
}

1;

ロックさんがコードを読む。指が HTTP::Tiny の呼び出し行で止まった。

http_client にタイムアウトを渡していない。——デフォルトは何秒だ」

「HTTP::Tinyのデフォルトは60秒です」

「60秒。1つのリクエストが在庫照会に60秒間接続を保持する。ワーカープールは10スレッド。全スレッドが同時に在庫照会を呼んでいたら?」

「10スレッド × 60秒……全員が60秒間占有されて、その間は新しいリクエストを処理できない」

「そしてHTTP::Tinyの timeout は1つの値だ。接続の確立にも、レスポンスの読み取りにも、同じ秒数が適用される。——ワトソン君、TCP接続の確立と、サーバーの処理は同じものか?」

「……違います。接続はネットワーク経路の問題で、処理はサーバーの負荷の問題です」

「**接続に1秒以上かかるなら、サーバーに到達できていない。**ネットワーク障害か、サーバーが停止している。この場合、60秒待っても意味がない。0.5秒で切るべきだ」

「接続タイムアウトを短くする——」

「一方、接続は0.01秒で成功したが、レスポンスが5秒かかる。これはサーバーが処理中だ。このとき0.5秒で切ったら?」

「正常なリクエストまで殺してしまう」

接続と読み取りでは、“待っている対象"が違う。対象が違えば、適切な待機時間も違う。——接続タイムアウトは短く設定して到達不能をすぐに検知する。読み取りタイムアウトは下流サービスのパーセンタイルに基づいて設定する——正常な処理を殺さない範囲で」

「読み取りタイムアウトはどうやって値を決めるんですか? 在庫照会APIのレスポンスタイムは日によってバラバラで……」

ロックさんがホワイトボードの前に立った。CB/Retryの消えかけた図の横に、横軸がレスポンスタイム、縦軸がリクエスト数のヒストグラム概念図を描く。左端に大きな山、右裾が長く伸びる分布。

「ワトソン君のチームは、在庫照会APIの偽タイムアウト——正常なリクエストをタイムアウトで殺すこと——をどの程度許容できる?」

「……0.1%未満なら許容範囲です」

「ではp99.9を見たまえ。**タイムアウト値は、下流サービスのレイテンシの許容パーセンタイルで決める。**p99.9が1.5秒なら、タイムアウトは1.5秒に設定する。99.9%のリクエストは正常に返り、0.1%だけがタイムアウトになる」

「水曜のように55秒かかっている状態は、p99.9の外——つまりタイムアウトで切られる」

「そうだ。55秒待つ必要がなくなる。1.5秒でタイムアウト。CBの失敗カウントが即座に進む。5回連続タイムアウト——7.5秒でOpen遷移」

計算して目を丸くした。

「……300秒から7.5秒。40倍速い」

タイムアウト値が適切であれば、CBは設計通りの速度で動く。CBが遅かったのではない。タイムアウトが遅かったのだ

推理披露 — 待たない勇気の設計

TimeoutWrapper — 全体を包む制限時間

ロックさんがホワイトボードに構造を描く。

「HTTPクライアントのタイムアウトだけでは足りない場合がある。DNS解決やTLSハンドシェイクがタイムアウトに含まれない実装もある。——全体を包む TimeoutWrapper を作る」

1
2
3
4
5
TimeoutWrapper
├── connect_timeout: 0.5秒(TCP接続確立の制限)
├── read_timeout: 1.5秒(レスポンス待ちの制限)
├── overall_timeout: 3秒(全体のデッドライン)
└── fallback: FallbackStrategy(タイムアウト時の代替処理)

「3つのタイムアウトを設定する。接続タイムアウト、読み取りタイムアウト、そして全体タイムアウト——DNS解決、TLS、接続、読み取りの合計がこの値を超えたら強制終了する」

「全体のデッドラインがある……Retryの回数制限に似ていますね。個々のリトライ間隔とは別に、全体の試行時間に上限を設ける」

ロックさんが少し目を細めた。前回の知識を自発的に接続したことに気づいたようだ。

「その通りだ」

Perl での実装 — alarm + eval

「Perlでのタイムアウトの基本形は alarmeval の組み合わせだ」

 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
28
29
30
31
32
33
34
package TimeoutWrapper;
use v5.36;
use Moo;
use Types::Standard qw(Num Maybe CodeRef);
use Time::HiRes qw(alarm);
use Carp qw(croak);

has connect_timeout => (is => 'ro', isa => Num, required => 1);
has read_timeout    => (is => 'ro', isa => Num, required => 1);
has overall_timeout => (is => 'ro', isa => Num, required => 1);
has fallback        => (is => 'ro', isa => Maybe[CodeRef], default => sub { undef });

sub execute ($self, $action) {
    my $result = eval {
        local $SIG{ALRM} = sub { die "TIMEOUT\n" };
        alarm $self->overall_timeout;
        my $ret = $action->($self->connect_timeout, $self->read_timeout);
        alarm 0;
        $ret;
    };
    my $err = $@;
    alarm 0;    # 例外発生時もタイマーをクリア

    if ($err) {
        if ($err eq "TIMEOUT\n" && $self->fallback) {
            return $self->fallback->();
        }
        die $err;
    }

    return $result;
}

1;

alarmSIGALRM をプロセスに送る。eval の中で die をキャッチする。——シンプルだが、注意点がある」

alarm 0 が2箇所あるのは?」

eval の中の alarm 0 は正常終了時にタイマーを止める。外の alarm 0例外が起きた場合にタイマーを止める。——タイマーが残ったまま別の処理に入ると、無関係の処理がタイムアウトで死ぬ。必ず後始末をする」

$SIG{ALRM}local にしているのは、元のハンドラを壊さないため?」

「そうだ。localeval を抜けた時点で元に戻る。alarm はプロセスグローバルだ——1つしかタイマーを持てない。もし別のライブラリが alarm を使っていたら、上書きしてしまう。これが alarm の最大の弱点だ」

FallbackStrategy — タイムアウト後の選択

「タイムアウト後にどうするか——フォールバック戦略を、タイムアウト制御とは分離する」

 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package FallbackStrategy::CachedValue;
use v5.36;
use Moo;
use Types::Standard qw(Num Any);
use Time::HiRes qw(time);

has max_age       => (is => 'ro', isa => Num, default => sub { 300 });
has _cached_value => (is => 'rw', default => sub { undef });
has _cached_at    => (is => 'rw', default => sub { 0 });

sub get ($self) {
    if (defined $self->_cached_value
        && (time() - $self->_cached_at) < $self->max_age) {
        return $self->_cached_value;
    }
    return undef;
}

sub update ($self, $value) {
    $self->_cached_value($value);
    $self->_cached_at(time());
}

1;

package FallbackStrategy::DefaultValue;
use v5.36;
use Moo;
use Types::Standard qw(Any);

has default_value => (is => 'ro', isa => Any, required => 1);

sub get ($self) {
    return $self->default_value;
}

sub update ($self, $value) {
    # DefaultValue は更新しない
}

1;

CachedValue は正常時にレスポンスを保存し、タイムアウト時に古い値を返す。DefaultValue は常に同じデフォルト値を返す。——どちらを使うかはサービスごとに決める」

「フォールバックは安全なんですか? 古いキャッシュで在庫ありと返したのに、実際は欠品だったら……」

「正しい懸念だ。AWSの設計者が言っている——フォールバックは避けるべきだ、と。フォールバック経路はめったにトリガーされないから、テストが不十分になりやすい。本番で初めて動いたときにバグがある」

「じゃあ、タイムアウト時はエラーを返すべき?」

「**理想はそうだ。**しかし現実には——在庫照会がタイムアウトしたときにユーザーに"注文できません"を返すのか、“在庫情報を確認中です、注文を仮受付します"と返すのか。——**ビジネス判断だ。**技術者が一人で決めることではない」

「……チームで決める必要がある」

「そうだ。フォールバック戦略はコードの問題ではない。サービスレベルの設計判断だ。——ただし、もしフォールバックを使うなら、3つのルールを守れ。(1) フォールバック経路を定期的にテストする。(2) 主経路より単純にする。(3) 主経路と同じリソースに依存しない

「……在庫照会のフォールバックとして、在庫DBに直接クエリを投げるのは?」

「在庫照会APIが遅延している原因がDB負荷だったら? フォールバックが主経路と同じ障害源を叩くことになる。——フォールバックが障害を増幅する

ペンを取り、メモを書き始めた。フォールバックの3つのルール。主経路と同じリソースに依存しない。

After コード — TimeoutWrapper の統合

「TimeoutWrapperとフォールバックを在庫照会に適用すると——」

 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
28
29
30
31
32
33
34
35
36
package InventoryClient;
use v5.36;
use Moo;
use Types::Standard qw(InstanceOf Object);

has timeout_wrapper => (is => 'ro', isa => InstanceOf['TimeoutWrapper'], required => 1);
has fallback_cache  => (is => 'ro', isa => Object, required => 1);
has http_client     => (is => 'ro', isa => Object, required => 1);

sub check ($self, $product_id) {
    my $result = eval {
        $self->timeout_wrapper->execute(sub ($connect_to, $read_to) {
            my $res = $self->http_client->get(
                "http://inventory-api.internal/stock/$product_id",
                { timeout => $read_to },
            );
            die "Inventory API error: $res->{status}" unless $res->{success};
            return $res->{content};
        });
    };
    my $err = $@;

    if ($err && $err eq "TIMEOUT\n") {
        # タイムアウト時: キャッシュされた値を返す
        my $cached = $self->fallback_cache->get;
        return $cached if defined $cached;
        die "Inventory API timeout and no cached data available";
    }
    die $err if $err;

    # 正常時: キャッシュを更新して返す
    $self->fallback_cache->update($result);
    return $result;
}

1;

「正常時はレスポンスをキャッシュに保存する。タイムアウト時はキャッシュを確認する。キャッシュもなければ——潔くエラーを返す」

{ timeout => $read_to } ——HTTP::Tinyのタイムアウトと、alarm の全体タイムアウト、二重に設定しているわけですね」

「そうだ。HTTP::Tinyは接続と読み取りを分離できない。**全体タイムアウト(alarm)が外側のガードになる。**接続の確立に異常に時間がかかる場合は、全体タイムアウトが先に発火する。——完璧ではないが、“タイムアウトなし"からは天と地ほど違う」

CB + Retry + Timeout — 3層の防衛線

「CBとRetryとTimeout、3つを全部組み合わせると、呼び出しの順序はどうなるんですか?」

ロックさんがホワイトボードの前に立ち、大きく図を描いた。

	graph LR
    A[リクエスト] --> B[Retry]
    B -->|should_retry? → yes| B
    B --> C[Circuit Breaker]
    C -->|Open? → 即エラー| X[エラー返却]
    C --> D[Timeout]
    D -->|接続TO + 読み取りTO + 全体TO| E[外部API]

「外側から内側に向かって——Retryが最も外側。CBがその内側。Timeoutが最も内側。Timeoutが"呼び出しが返ってくるまでの時間"を制限する。CBがTimeoutの結果を見て状態遷移する。Retryが"もう一度やるか"を判断する

「Timeoutが最も内側——基盤ということですか」

Timeoutがなければ、CBは"呼び出しが返ってくるまで"待つ。Retryは"CBが判断するまで"待つ。——全てが遅くなる。Timeoutは"待つ限界"を定義する。限界がなければ、全てのパターンは無限を相手にする

ホワイトボードの図を見つめた。CB、Retry、Timeout。3層が重なっている。

「……最初にCBを入れた。次にRetryを直した。でも一番内側のTimeoutが未設定だった。——基礎が抜けていた」

「基礎を最後に学ぶのは珍しいことではない。**問題は外側から見えるからだ。**カスケード障害はCBで防げる。リトライストームはRetryで防げる。しかし"待ちすぎ"は外からは見えない。スレッドが静かに待っているだけだから。——沈黙の障害は、最も発見が遅い

解決 — 平和なビルド

テストを回した。

タイムアウトなし(Before)——在庫照会APIの遅延時にスレッドが60秒間ブロック。新規リクエストは全てキュー待ち。CBは成功と判定。システム停止。

タイムアウトあり(After)——在庫照会APIの遅延時に1.5秒でタイムアウト。CBの失敗カウントが即座に進む。7.5秒でOpen遷移。フォールバックがキャッシュされた在庫情報を返す。新規リクエストは正常処理。

「……300秒が7.5秒。注文処理は止まらない」

「在庫照会の結果は古いキャッシュだが——注文は受け付けている。全面停止か、在庫情報の多少の遅れか。どちらがましだ」

「全面停止よりは——はるかにまし、です」

ロックさんが立ち上がった。

「**これで3枚目の防衛線だ。**CB、Retry、Timeout。——もうこれで終わりですか、と聞きたい顔をしているな」

苦笑が漏れた。

「……聞きたかったです、正直」

ロックさんが窓の外を見た。夕焼けが暗くなっている。何か言いかけて、一瞬口を閉じた。それから——

「終わらない。防衛線は一度に完成しない。——だが、ワトソン君。今日の1枚は、昨日より確実に厚い」

「……全部にタイムアウトを入れれば完璧ですか」

「**完璧にはならない。**新しいサービスが追加されれば、新しいタイムアウトが必要になる。タイムアウト値は下流サービスのレイテンシが変われば更新しなければならない。フォールバック戦略はビジネス要件の変化で見直しが要る。——防衛線は生き物だ。建てたら終わりではない。メンテナンスが要る

黙って頷いた。疲弊は消えていない。だが、諦めの色ではなくなっている。

事件の余韻 — 共有する防衛線

ロックさんが帰った後、会議室に一人残った。

ホワイトボードには、今日描いた図が残っている。CB、Retry、Timeoutの3層構成図。前回と前々回に残っていた薄い図の上に、今日の図が重なっていた。

スマートフォンでホワイトボードの写真を撮った。Slackの自チームチャンネルを開く。

「明日のスプリントレビューで共有したい図があります。在庫照会の件の対策案です」

送信ボタンを押した。

CBを入れたときは、「これで守れる」と思った。Retryを直したときも、「これで大丈夫だ」と思った。3回目でようやくわかった。防衛線は完成しない。完成しないものを、1枚ずつ積んでいくしかない。

でも、1枚ずつ積むやり方は——もう知っている。

私が学んだことは、私だけのものじゃない。

会議室の電気を消した。窓の外はもう暗い。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
タイムアウト未設定(HTTP::Tinyデフォルト60秒の放置)接続/読み取りタイムアウトの分離設定障害検知が300秒→7.5秒に短縮
全外部呼び出し共通の単一タイムアウトパーセンタイルベースの個別タイムアウト値設定偽タイムアウト率0.1%以下を維持しつつ障害を高速検知
タイムアウト時の即死(エラーのみ)FallbackStrategy による段階的対応全面停止を部分縮退に変換

推理のステップ

  1. タイムアウト設定の棚卸し: 全ての外部呼び出しのタイムアウト設定を確認し、デフォルト値のまま放置されていないかを洗い出す
  2. 接続タイムアウトの短縮: TCP接続確立の制限を短く設定する(0.5秒程度)。到達不能な場合を高速に検知するため
  3. 読み取りタイムアウトのパーセンタイル設定: 下流サービスのレイテンシのp99.9に基づいて読み取りタイムアウトを設定する。正常なリクエストを殺さない範囲を見極める
  4. 全体タイムアウトの設定: alarm + eval で全体のデッドラインを制御する。個々のタイムアウトの合計とは別に、1リクエストに費やせる絶対的な上限を設ける
  5. フォールバック戦略の決定: タイムアウト発生時の対応をサービスごとに決定する。フォールバック経路が主経路と同じリソースに依存していないことを確認する
  6. Timeout → CB → Retry の統合: Timeoutを最も内側に配置し、CBがTimeoutの結果を基に状態遷移し、Retryが最も外側でリトライ判断を行う3層構成を組む

ロックより

待つことは美徳ではない、ワトソン君。待つべきときに待ち、待つべきでないときに断つ——それが防衛線の思想だ。

君のシステムには今日、3枚目の防衛線が加わった。CBが障害を遮断し、Retryが再挑戦の作法を整え、Timeoutがその全てに"待つ限界"を与える。基礎は地味だ。しかし、基礎が抜けたビルは、どれほど美しい外壁を持っていても倒れる。

今日のことを、チームに伝えたまえ。防衛線は1人で守るものではない。

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