Featured image of post コード探偵ロックの事件簿【Fallback Pattern】最後の砦〜優雅に壊れる技術〜

コード探偵ロックの事件簿【Fallback Pattern】最後の砦〜優雅に壊れる技術〜

フォールバックが主経路と同じDB依存で障害を増幅するアンチパターンを解剖し、FallbackChain(CachedValue→DefaultValue→GracefulDegradation)で外部依存ゼロのフォールバック設計をPerl/Mooで実装。

事件の発端 — 自分の手で壊した防衛線

金曜の夜21時。自宅のリビング。ダイニングテーブルにノートPCを置いて、ビデオ通話アプリを開いた。

連絡先リストからロックさんの名前を選ぶ。少し迷って、発信ボタンを押した。

2コール目で応答があった。

画面に映ったロックさんは——いつものツイードジャケットではなく、黒のカーディガンだった。背景には壁一面の本棚と、棚に3台並んだヴィンテージキーボード。窓の外に夜景が小さく見える。

……事務所じゃない。ここがロックさんの家なのか。

「夜分に何事だ、ワトソン君」

「すみません、夜遅くに。——今日のインシデントの件で、相談したいことがあります。緊急ではないんですが、今日中に理解しておきたくて」

ロックさんがカメラ越しに私の顔を見た。何かに気づいた顔をして、それから短く言った。

「画面を見せたまえ」

画面共有を開始した。Grafanaのインシデントタイムライン。

「CB + Retry + Timeout、全部正常です。……ただ、私が追加したフォールバックが、問題を起こしました」

ロックさんは黙っている。

タイムラインを指差した。水曜14:00、在庫照会APIのレスポンスタイム上昇。14:03、DB接続プールのアクティブ接続急増。14:05、在庫DBのCPU使用率100%。14:06、配送料計算APIも応答不能。14:08、注文処理全面停止。

「30分間の全面停止です。……CB回の3時間よりは短いですが、CBを入れた後の想定停止時間は30秒でした。それが30分」

「在庫照会APIの遅延が、DBまで波及している。——ワトソン君、在庫照会のフォールバック処理を見せてくれ」

コードエディタに切り替えた。少し間が空いた。

声が小さくなる。

「前回のセッションの後、CachedValueフォールバックを実装しました。ロックさんが教えてくれた通りに。——ただ、それだけでは足りなくて……」

 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
42
43
44
45
46
47
48
49
package InventoryClient;
use v5.36;
use Moo;
use Types::Standard qw(InstanceOf Object);

has timeout_wrapper => (is => 'ro', isa => Object, required => 1);
has fallback_cache  => (is => 'ro', isa => Object, required => 1);
has http_client     => (is => 'ro', isa => Object, required => 1);
has inventory_db    => (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) {
        # フォールバック1: キャッシュ
        my $cached = $self->fallback_cache->get($product_id);
        if (defined $cached) {
            return $cached;
        }

        # フォールバック2: DB直接クエリ(私が追加した)
        my $db_result = eval {
            $self->inventory_db->selectrow_hashref(
                "SELECT stock_quantity FROM inventory WHERE product_id = ?",
                undef, $product_id,
            );
        };
        if ($db_result && !$@) {
            return { stock => $db_result->{stock_quantity}, source => 'db_fallback' };
        }

        die "All fallbacks failed: $err";
    }

    $self->fallback_cache->update($product_id, $result);
    return $result;
}

1;

ロックさんがコードを静かに読んでいる。inventory_db の行で指が止まった。

「……inventory_db。在庫DBへの直接接続だ」

現場検証 — フォールバックの指紋

「新商品にはキャッシュがないんです。タイムアウト後にCachedValueを見ても何も返らない。PMから"新商品だけ注文できない"と言われて——」

「ワトソン君、あの時の私の言葉を覚えているか」

黙った。3秒。

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

「覚えていた」

「覚えていました。……でも、これは違うと思ったんです。キャッシュミスの問題で、DB負荷の問題じゃない。だからDBに直接クエリしても——」

「——DB負荷が原因の遅延が来るまでは、問題なく動いた」

「……はい。火曜のデプロイから水曜の14時まで、完璧に動いていました。新商品の注文も通るようになって、PMにも喜ばれました。——水曜の午後に在庫DBのバキューム処理が走って負荷が上がり、在庫照会APIが遅延を始めた瞬間に——」

「——全インスタンスのフォールバックが、同じDBを叩いた」

「……はい」

沈黙。

ロックさんの声のトーンが穏やかだった。責めていない。

「ワトソン君。それはフォールバックの設計が間違っていたのではない。フォールバックという選択肢を技術だけで決めたことが問題だ」

時系列の解剖

「時系列を整理しよう。水曜14:00に何が起きた」

「在庫DBのバキューム処理が開始。CPU負荷が上昇。在庫照会APIの応答が遅延開始。——ここまでは前回と同じです。タイムアウトが1.5秒で切れて、CBの失敗カウントが進む」

「そこまではTimeoutとCBが設計通りに動いている。——14:03に何が変わった」

「タイムアウトが発火した後のフォールバックです。CachedValueがキャッシュヒットしたリクエストは問題なし。しかしキャッシュミスのリクエスト——新商品、TTL切れの商品——が、DBに直接クエリを送りました」

「何インスタンスだ」

「10インスタンスです。各インスタンスのタイムアウト後のフォールバックが、全て同じDBに接続しました」

「10インスタンスが、バキューム処理で既に負荷の高いDBに、SELECTを送り込んだ。——在庫照会APIもそのDBを使っている」

「……在庫照会APIの負荷 + フォールバックの負荷で、DBが完全にロックしました」

「そしてそのDBは在庫照会APIだけのものではない」

「……配送料計算APIも同じDBクラスタを参照しています。配送料計算も在庫テーブルの重量情報を使っているので」

「**フォールバックが、在庫照会の障害を配送料計算に伝播させた。**フォールバックなしなら、在庫照会のタイムアウト→CBがOpen→在庫照会だけ一時停止。他のサービスは無傷。——フォールバックを入れたことで、爆発半径が広がった」

メモを取った。手が少し震えていた。

「……フォールバックが、障害を増幅した」

Amazon 2001年の教訓

「2001年のAmazonで、まったく同じことが起きた」

「……?」

「Amazonの小売サイトで、配送速度の表示機能にキャッシュレイヤーを追加した。キャッシュ障害時のフォールバックとして、DBへの直接クエリを入れた。——キャッシュが全て同時に障害を起こしたとき、全WebサーバーがDBに直接アクセスし、DBが完全ロック。配送速度の表示ができないだけで済むはずが、サイト全体がダウンし、フルフィルメントセンターが全停止した。部分障害がサイト全体停止に拡大した」

「……まさに今日の私と同じ構造です」

フォールバックが主経路と同じリソースに依存していると、フォールバック自体が障害になる。——AWSはこの教訓から、分散システムにおけるフォールバックをほぼ全面的に避けるべきと主張している」

避けるべきか、避けられないか

「AWSは"フォールバックは避けるべきだ"と言っている、と聞いたことがあります。……今回の件を見ると、その通りなんじゃないかと」

ロックさんが珍しく少し間を置いた。カーディガンの袖を直す動作。

「AWSの主張は正しい。分散システムにおいて、フォールバックは障害を増幅するリスクがある。テストが困難で、潜在バグの温床で、バイモーダル動作を生む」

「バイモーダル動作?」

「システムが2つのモードを持つことだ。通常モード障害モード。通常モードのコードは毎日実行され、十分にテストされている。障害モードのコード——フォールバック——は最も混沌とした瞬間にだけ実行される。テストが不十分な経路が、最悪のタイミングで初めて動く

「……今日のDBフォールバックがまさにそうです。火曜にデプロイして、水曜に初めて本番で動いて——壊れました」

「しかし、AWSの記事はもう少し先まで言っている。“フォールバックをフェイルオーバーに変換せよ"と。つまり、フォールバックを"障害時にだけ動くコード"にするな。両方の経路を常時動かせ。——だが、それが常にできるか?」

「……新商品の在庫情報がない場合に、“常時動くフェイルオーバー"は作れません。データがそもそも存在しないので」

「そう。AWSの助言は同等品質で代替できる冗長系がある場合に最も有効だ。品質を落とすことでしか対応できない場合——エラーを返すか、古い情報を返すか、“わかりません"と表示するか——は、フォールバックの領域だ。避けられない」

「フォールバックは避けるべきだけど、避けられない場面がある——」

避けられないなら、毒を薬に変えるルールが要る

技術者だけで決めない

「技術だけで決めた、というのは——PMに相談すべきだったということですか?」

「ワトソン君。この inventory_db のフォールバックは、在庫情報が取れなかったとき、DBから値を取って返す。——つまり、在庫切れかもしれない商品を"在庫あり"と返す可能性がある。これは技術の判断か?」

「……ビジネスの判断です」

“古い在庫情報を返してよいか”。“在庫確認なしで注文を受け付けてよいか”。“在庫不明のとき画面にどう表示するか”。——全てビジネス判断だ。コードで解決できる問題ではない。PMが決めるべきことだ」

「……前回の3つのルール。フォールバック経路が主経路と同じリソースに依存しない。定期的にテストする。主経路より単純にする。それは技術のルールでした。でも——」

「4つ目のルールだ。フォールバックで何を犠牲にするかは、技術者が決めない

推理披露 — フォールバックチェーンの設計

FallbackChainの構造

ロックさんが画面共有を自分に切り替えた。テキストエディタを開き、構造を描き始める。ビデオ通話越しのペアプログラミングだ。

「フォールバックは単一の代替手段ではない。**優先度付きのチェーンだ。**Strategy パターンで各戦略をカプセル化し、Chain of Responsibility で優先順に試行する」

1
2
3
4
5
6
7
8
FallbackChain
├── 戦略1: CachedValue(インメモリキャッシュ)
│   └── 主経路との独立性: ✓(メモリ上のデータ。外部依存なし)
├── 戦略2: DefaultValue(静的デフォルト値)
│   └── 主経路との独立性: ✓(ハードコードまたはローカル設定ファイル。外部依存なし)
├── 戦略3: GracefulDegradation(機能縮退)
│   └── 主経路との独立性: ✓(在庫確認を省略して注文仮受付。外部呼び出し自体を行わない)
└── 全て失敗: エラー返却

「……3段階とも、在庫DBに依存していない」

「**それがルールだ。各戦略が、主経路と独立した障害源——あるいは外部依存自体を持たない。**DBフォールバックの何が間違いだったか。在庫照会APIの背後にあるDBをフォールバックとして使った。APIが遅い原因がそのDBにあるなら——」

「フォールバックが原因に向かって突進する」

「そう。フォールバックは主経路から離れなければならない。近づいてはいけない

FallbackChainの実装

「Chain of Responsibility で戦略を連鎖させる」

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

has strategies => (
    is       => 'ro',
    isa      => ArrayRef[CodeRef],
    required => 1,
);

sub execute ($self) {
    for my $strategy ($self->strategies->@*) {
        my $result = eval { $strategy->() };
        return $result if defined $result && !$@;
    }
    return undef;
}

1;

「……シンプルですね。各戦略がCodeRefで、成功したらその値を返す。失敗したら次の戦略を試す」

「**フォールバックチェーン自体は単純でなければならない。チェーンの構造に複雑さを入れると、チェーン自体がバグの温床になる。**複雑さは各戦略の中に閉じ込める」

各戦略の実装

「CachedValue は前回のものがそのまま使える。TTL内のキャッシュがあれば返す」

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

has max_age => (is => 'ro', isa => Num, default => sub { 300 });
has _cache  => (is => 'ro', isa => HashRef, default => sub { {} });

sub get ($self, $key) {
    my $entry = $self->_cache->{$key};
    return undef unless $entry;
    return undef if (time() - $entry->{cached_at}) >= $self->max_age;
    return $entry->{value};
}

sub update ($self, $key, $value) {
    $self->_cache->{$key} = { value => $value, cached_at => time() };
}

1;

「前回のCachedValueにキーを追加しただけですね。product_id ごとにキャッシュする」

「そうだ。——次に、DefaultValue。外部依存のない静的な値を返す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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, $key) {
    return $self->default_value;
}

1;

「DefaultValueは——でも、商品ごとに在庫数のデフォルト値を持つのは無理がありませんか。新商品のデフォルト在庫数なんて——」

「正しい懸念だ。在庫のデフォルト値は意味がない。しかし状態のデフォルト値は意味がある。——“在庫不明"というステータスを返すのだ」

「“在庫不明”……」

「在庫ありでもなしでもない。“確認中”。——在庫照会のレスポンスを数値ではなく状態で返すように設計し直す。availableout_of_stockunknown。DefaultValueは全商品に対して unknown を返す」

unknown を受け取った注文処理は——」

それがビジネス判断だ。 unknown のとき注文を受け付けるか、受け付けないか。受け付けるなら仮注文にするか、確定注文にするか。——ワトソン君が決めることではない。PMが決めることだ」

黙って頷いた。

「3つ目。GracefulDegradation」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package FallbackStrategy::GracefulDegradation;
use v5.36;
use Moo;
use Types::Standard qw(CodeRef);

has degraded_action => (is => 'ro', isa => CodeRef, required => 1);

sub get ($self, $key) {
    return $self->degraded_action->($key);
}

1;

「GracefulDegradation の degraded_action は、具体的に何をするんですか」

「サービスごとに異なる。在庫照会の場合——例えば、在庫確認を省略して注文を仮受付し、在庫API復旧後に在庫を確認してユーザーに通知する。あるいは、在庫情報なしで商品ページを表示し、注文ボタンを一時的にグレーアウトする」

「Graceful Degradationの"注文仮受付"って、在庫確認なしで注文を受けるんですよね。それで欠品だったら——」

「正しい懸念だ。——ワトソン君、欠品のリスク全面停止のリスク、どちらがビジネスにとって深刻だ?」

「……全面停止のほうが、明らかに」

「では、“在庫確認なしで注文を受け付けて、後から在庫切れなら謝罪メールを送る"のと、“在庫確認できるまで注文画面を止める"のと、どちらがまだましだ」

「……それはPMに聞きます」

ロックさんが小さく頷いた。カメラ越しでも頷きが見えた。

それが正解だ

FallbackChainの統合

「統合するとこうなる」

 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
42
43
44
45
46
47
package InventoryClient;
use v5.36;
use Moo;
use Types::Standard qw(InstanceOf Object);

use FallbackChain;

has timeout_wrapper  => (is => 'ro', isa => Object, required => 1);
has fallback_cache   => (is => 'ro', isa => Object, required => 1);
has fallback_default => (is => 'ro', isa => Object, required => 1);
has fallback_degrade => (is => 'ro', isa => Object, required => 1);
has http_client      => (is => 'ro', isa => Object, required => 1);
# ★ inventory_db は削除。主経路と同じDBへの依存を断つ

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) {
        # フォールバックチェーン: 主経路と独立した障害源のみ
        my $chain = FallbackChain->new(
            strategies => [
                sub { $self->fallback_cache->get($product_id) },
                sub { $self->fallback_default->get($product_id) },
                sub { $self->fallback_degrade->get($product_id) },
            ],
        );
        my $fallback_result = $chain->execute;
        return $fallback_result if defined $fallback_result;
        die "All fallbacks exhausted: $err";
    }

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

1;

inventory_db が消えた。——DBへの直接接続がない」

「**3つの戦略とも、外部サービスやDBに一切接続していない。**CachedValueはメモリ上。DefaultValueはハードコード。GracefulDegradationはロジックだけで完結する。——フォールバックチェーンの中に、主経路と同じ障害源が1つも含まれていない」

「……前のコードでは、フォールバックがDBに接続していた。今のコードでは——フォールバックが外の世界に一切触れていない」

フォールバックは内に閉じる。外に手を伸ばした瞬間に、それは新しい障害源になる

防衛線の4層構成

「ここまでの4回のセッションで積み上げた防衛線を図にする」

	graph LR
    A[リクエスト] --> B[Retry]
    B -->|should_retry? → yes| B
    B --> C[Circuit Breaker]
    C -->|Open → 即座に| F
    C -->|Closed/Half-Open| D[Timeout]
    D -->|接続TO + 読み取りTO| E[外部API]
    D -->|タイムアウト| F[FallbackChain]
    E -->|エラー| F
    F --> F1[CachedValue]
    F1 -->|miss| F2[DefaultValue]
    F2 -->|miss| F3[GracefulDegradation]
    F3 -->|miss| X[エラー返却]

「Retry → CB → Timeout → FallbackChain。……4層になった」

外側から順に。Retryが"もう一度やるか"を決める。CBが"そもそもやるか"を決める。Timeoutが"どこまで待つか"を決める。FallbackChainが"ダメだったとき何を返すか"を決める

「……“何を返すか"の判断だけが、技術者だけで決められない」

「そうだ。Retry、CB、Timeoutは技術パラメータで制御できる。FallbackChainの最終戦略——“何を犠牲にするか”——はビジネス判断だ

解決 — 安全なフォールバックの証明

「Before——DBフォールバックがある状態。在庫照会APIの遅延がDB負荷起因の場合、フォールバックが同じDBを叩いて障害を増幅。30分の全面停止」

「After——FallbackChainで、主経路と独立した3段の戦略。在庫照会APIの遅延がDB負荷起因でも、フォールバックはDBに触れない。在庫照会のみ一時停止。他サービスへの影響ゼロ。キャッシュがあれば古い在庫情報を返却。なければ"unknown"ステータス。注文処理は——」

「——PMと決めます」

「そうだ。技術的にはここで完成だ。残りは設計ではない。合意だ

事件の余韻 — 問いかける資格

「ロックさん。……一つ聞いていいですか」

「何だ」

「フォールバックの設計って——ロックさんにとっても難しい問題なんですか」

ロックさんの表情が一瞬だけ変わった。カーディガンの袖を直す動作。

「フォールバックの設計は——私が最も嫌う種類の問題だ」

「……嫌う?」

「**コードに正解がない。CBには状態遷移の数学がある。Retryには指数バックオフの数式がある。Timeoutにはパーセンタイルの統計がある。——フォールバックには、“どこまで劣化を許容するか"という、計算できない判断しかない。**技術者として最も不得意な種類の問題だよ、ワトソン君」

……この人にも、苦手な問題がある。

「だからこそ——ワトソン君、最後に1つ」

身構えた。

「あの警告を覚えていて、それでもDBフォールバックを入れた。——それは愚かだったのではない。PMの要望に応えようとした。正しい動機だ。ただ、応え方を間違えた。——次にPMから同じ要望が来たとき、君は"在庫情報なしで注文を受け付けてよいか"と聞き返せる。今日の失敗が、その質問をする資格を君に与えた

「……ありがとうございます」

「礼はいい。——それよりワトソン君、次の月曜に君のオフィスのコーヒーメーカーを使わせてくれ。あのフォールバック問題を眺めた後は、カフェインが必要だ」

小さく笑った。

「フォールバック問題にカフェインのフォールバックですか」

カフェインにはフォールバックが要らない。デフォルトで正しい

通話が切れた。

ノートPCの前に座ったまま、しばらく画面を見ていた。Grafanaのダッシュボードではなく、Slackを開いている。

DMの宛先を選ぶ。プロダクトマネージャーの名前。

メッセージを打つ。

「在庫照会のフォールバック戦略について、来週相談させてください。在庫情報が取得できない場合の注文の扱いについて、ビジネス判断が必要な箇所があります」

送信ボタンを押した。

今日まで、私は「正しいコードを書くこと」が自分の仕事だと思っていた。CBを書いた。Retryを書いた。Timeoutを書いた。——でも、フォールバックで必要なのは、正しいコードじゃない。正しい問いかけだ。

「在庫が確認できないとき、注文を受け付けますか?」

この質問を、来週PMにする。答えは私が出すものじゃない。でも、この質問を出すのは——私の仕事だ。

ノートPCを閉じた。窓の外は暗い。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
フォールバックが主経路と同じDB依存主経路と独立したFallbackChain障害伝播範囲: 全面停止 → 在庫照会のみ一時停止
単一フォールバック(DB直接クエリ)3段の優先度チェーン(Cache→Default→Degrade)フォールバック経路の外部依存: DB接続あり → ゼロ
フォールバック戦略を技術者だけで決定ビジネス判断をPM/PdMと合意“何を犠牲にするか"の判断品質が向上

推理のステップ

  1. フォールバック経路の依存関係を洗い出す: 既存のフォールバックが主経路と同じリソース(DB、外部API、キャッシュサーバー等)に依存していないか確認する
  2. FallbackChainを構築する: 各戦略をStrategyとしてカプセル化し、Chain of Responsibilityで優先順に連鎖させる
  3. 各戦略の独立性を検証する: キャッシュ(インメモリ)、デフォルト値(静的)、機能縮退(ロジックのみ)——外部依存を排除する
  4. フォールバックの4つのルールを適用する: (1) 主経路と同じリソースに依存しない (2) 定期的にテストする (3) 主経路より単純にする (4) 何を犠牲にするかは技術者だけで決めない
  5. ビジネス判断をPM/PdMと合意する: 「在庫不明」時の注文の扱い、「機能縮退」時のユーザー通知方法をサービスレベルとして定義する
  6. フォールバック発動率を監視する: どの戦略が何回発動したかをメトリクスとして記録し、主経路の品質改善に繋げる

ロックより

正しいコードを書くことだけが技術者の仕事ではない、ワトソン君。正しい問いを立てることもまた、技術者の仕事だ。

CB、Retry、Timeoutには数式がある。パーセンタイルがある。閾値がある。——フォールバックにはそれがない。代わりにあるのは「何を犠牲にしてよいか」という判断だ。その判断を1人でしてはいけない。

毒を薬に変えるルールを覚えたまえ。主経路から離れろ。テストを怠るな。単純であれ。そして——犠牲の範囲は、1人で決めるな。

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