Featured image of post コード探偵ロックの事件簿【Bulkhead】全館停電の共犯者〜沈まない船の設計図〜

コード探偵ロックの事件簿【Bulkhead】全館停電の共犯者〜沈まない船の設計図〜

全機能が共有するDBコネクションプールでバッチ暴走→全サービス停止。Bulkheadで機能別にリソースを隔離し、一区画の障害が全体に波及しない設計をPerl/Mooで実装。

怪しい看板の3階

火曜日の午後2時。都内の雑居ビル。

スマホの地図アプリと、Slackのスクリーンショットを交互に見ながら、狭い階段を上がった。2週間前、社内Slackの#tech-helpチャンネルに流れた匿名の投稿。「レガシーコードの構造問題ならLCIに相談してみてください。雑居ビルの3階、怪しい看板のところです。腕は確かです」。投稿者は不明。「レガシー・コード・インベスティゲーション」でGoogle検索したら、住所と「コード問題、解決します。LCI」だけの簡素なウェブサイトが出てきた。

2階の踊り場を過ぎた。3階に小さなプレートが1枚。「レガシー・コード・インベスティゲーション(LCI)」。看板というより表札。

——本当に怪しい。

ドアの前に立つ。ノックした。返答がない。もう一度ノックする。

中から低い声。「……開いているよ」

ドアを開けた。まず匂い。接着剤の有機溶剤と、甘ったるいエナジードリンク。デスクにモニタが3台。足元にエナジードリンク缶が積み上がっている。ホワイトボードに何か書いてあるが、ドアからでは読めない。

デスクの端で——男が椅子に座り、木製の帆船模型を組み立てていた。右手にピンセット、左手に薄い木の板。木くずが散っている。

「……隔壁が合わないな。0.3ミリ厚すぎる」

わたしのことは見ていない。

「あの——Slackで紹介されて来たんですが。LCIの——」

男がようやく顔を上げた。わたしを見る。2秒。それから椅子を回した。

「ワトソン君。遅かったね」

「——え?」

「座りたまえ」

「……わたし、ワトソンではないんですが」

完全に無視された。椅子を指している。

2秒待った。反応はない。……まあいいか。訂正しても聞いていないし。争うほどのことではない。

椅子に座る。鞄からノートPCと方眼ノートを取り出した。

「ロック……さん、ですか?」

「ロックだ。探偵だ」

探偵。一瞬ノートPCの手が止まった。処理して、流した。

「先月末、うちのECサイトが30分間止まりました」

ノートPCを開いた。自分で描いたシステム構成図を映す。3つの機能——注文API、商品検索API、レポートバッチ——が1つのDBコネクションプール(max: 20)から矢印で接続されている。手描き。色分けしてある。

「月末のレポートバッチが走ったら、注文も検索も全部止まりました。原因はコネクションプールの枯渇です。バッチが20本中15本を占有して」

ロックさんは図を覗き込んでいた。同時に手元の船模型をいじっている。集中していないように見えるが——どうだろう。

「コネクションプールの上限が20で、バッチが15本使うと残りが5本。秒間80リクエストを5本では捌けません」

ロックさんが船模型の部品を置いた。構成図を見ている。

「20本のコネクション。注文、検索、バッチ。——全員が同じ部屋にいるわけだ」

「はい。バッチの同時実行数を減らせば当面は凌げますが——」

「対症療法だ。バッチの数を減らしても、部屋は1つのままだ。別の何かが暴走すれば、また全員が道連れになる」

少し間を置いた。

「……はい。それで、根本的にどうすればいいのかがわからなくて」

隔壁のない船

ロックさんが船模型の船体を持ち上げた。わたしに見せる。船体の内部は1つの空洞。仕切りがない。

「これを見たまえ。この船には、まだ隔壁が入っていない。船体の中は1つの空間だ。——もし、ここに穴が開いたら?」

「水が入って……全体が沈みます」

「君のシステムは——この船だ。20本のコネクションが1つの部屋にある。1つの機能が暴走すれば、全員が溺れる」

ノートPCで現在のコードを見せた。

Beforeコード: 全機能が共有するコネクションプール

 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
package AppService;
use v5.36;
use Moo;
use Types::Standard qw(InstanceOf);

has connection_pool => (
    is       => 'ro',
    isa      => InstanceOf['ConnectionPool'],
    required => 1,
);

sub process_order ($self, $order) {
    my $conn = $self->connection_pool->get_connection;
    my $result = $self->_execute_order($conn, $order);
    $self->connection_pool->release($conn);
    return $result;
}

sub search_products ($self, $query) {
    my $conn = $self->connection_pool->get_connection;
    my $result = $self->_execute_search($conn, $query);
    $self->connection_pool->release($conn);
    return $result;
}

sub generate_report ($self, $params) {
    my $conn = $self->connection_pool->get_connection;
    my $result = $self->_execute_report($conn, $params);
    $self->connection_pool->release($conn);
    return $result;
}

3つの機能が、すべて同じconnection_poolからコネクションを取得している。壁がない。1つの船体に穴が開いたら、全部が沈む構造。

方眼ノートに図を描いた。1つの大きな四角——コネクションプール:20——に、3つの矢印が入っている。バッチの矢印だけ太くした。接続を大量に使うから。

「レポート1件で平均5秒。月末に50件が並行で走ると——」

「15本のコネクションが5秒間占有される。その間に来た注文リクエストは——5本のコネクションの空き待ちだ」

「コネクション取得のタイムアウトが60秒に設定してあって——」

「60秒待って、結局取れなければ503。——ワトソン君、60秒は長い」

「……はい。その60秒の間に新しいリクエストがさらに溜まって——」

「雪崩だ」

30分の全館停電。社長がSlackに「EC止まってるぞ」と書き込んだのを見て、わたしがバッチをkillして仮復旧した。あの30分間のことを考えると、声が固くなる。

沈まない船の設計図

ロックさんがピンセットで薄い木の板を1枚取った。接着剤を塗り、船体の内側に嵌める。

「この1枚の板が——隔壁だ。これで、船体が2つの部屋に分かれた」

「図にしていいですか?」

ノートを取り出した。

「コネクションプールを分ける……ということですか」

3つのプールに分けた図を描いた。注文API:8本、検索API:8本、バッチ:4本。合計20本。——描いた後、自分の図を見て眉をひそめた。

「でも、分けたら全体の効率が落ちませんか? バッチが走らない日は4本が遊んでいます。注文が混んでも使えない」

ロックさんが船模型を置いた。

「船の隔壁も、貨物室の容積を減らす。隔壁の板の分だけ、荷物が積めなくなる。——しかし、ワトソン君。1箇所の穴で船を失うのと、貨物室が少し狭くなるのと、どちらが高くつく?」

3秒の沈黙。図を見ている。

「……30分の全館停電の方が、はるかに高いです」

「そうだ。Bulkheadの本質は——効率を少し犠牲にして、壊滅を防ぐことだ。完璧な効率は、完璧な脆さの裏返しでもある」

Rate Limitingとの違い

ロックさんがBulkheadのコード概念を説明している間に、ノートに「セマフォ」と書いた。その横に「Rate Limiting?」と疑問符付きで。

「ロックさん、1つ聞いていいですか。これは……セマフォですよね? コネクションの数を数えて、上限に達したら止める。Rate Limitingとは違うんですか?」

ロックさんが椅子を少し回した。

「いい質問だ。——Rate Limitingは『入口の門番』だ。1秒間に何人入れるかを制御する。時間あたりの流量の制限」

「Bulkheadは?」

「Bulkheadは『部屋の壁』だ。入ってきた人を、どの部屋にどれだけ入れるかを制御する。同時に存在できる数の制限」

ノートに描いた。入口に門番、中に壁。

「門番は……入る速度を制御。壁は……中の配分を制御」

「そうだ。門番がいても、壁がなければ——全員が同じ部屋に殺到する。壁があっても、門番がいなければ——際限なく人が入ってきて、全ての部屋が溢れる」

図を見ている。ゆっくりと。

「……両方要る、ということですか」

「二重の防衛線だ。入口と内部。——どちらか一方では足りない」

Afterコード: Bulkheadクラス

ロックさんがホワイトボードに向かった。コードを書き始める。

 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
50
51
52
package Bulkhead;
use v5.36;
use Moo;
use Types::Standard qw(Int Str);
use Carp qw(croak);

has name => (
    is       => 'ro',
    isa      => Str,
    required => 1,
);

has max_concurrent => (
    is       => 'ro',
    isa      => Int,
    required => 1,
);

has _active_count => (
    is      => 'rw',
    isa     => Int,
    default => 0,
);

sub execute ($self, $code) {
    if ($self->_active_count >= $self->max_concurrent) {
        croak sprintf(
            "Bulkhead '%s' is full (%d/%d)",
            $self->name,
            $self->_active_count,
            $self->max_concurrent,
        );
    }

    $self->_active_count($self->_active_count + 1);
    my $result = eval { $code->() };
    my $err = $@;
    $self->_active_count($self->_active_count - 1);

    croak $err if $err;
    return $result;
}

sub available ($self) {
    return $self->max_concurrent - $self->_active_count;
}

sub is_full ($self) {
    return $self->_active_count >= $self->max_concurrent;
}

1;

「図にしていいですか?」

ノートにBulkheadオブジェクトの箱を描いた。中に「max: 8」「active: 3」と書く。「あと5本空いている」。

execute()の中身を追った。呼ばれるとまずカウンタをチェックする。上限に達していたら即座にcroak——例外を投げる。達していなければカウンタを+1して、渡されたコードを実行する。evalで包んでいるから、コード内で例外が発生しても必ずカウンタを-1してから再送出する。リソースのリークがない。

BulkheadRegistry

 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
package BulkheadRegistry;
use v5.36;
use Moo;
use Types::Standard qw(HashRef InstanceOf);
use Carp qw(croak);

has _bulkheads => (
    is      => 'ro',
    isa     => HashRef[InstanceOf['Bulkhead']],
    default => sub { {} },
);

sub register ($self, %args) {
    my $name = $args{name}
        // croak "Bulkhead name is required";

    croak "Bulkhead '$name' already registered"
        if exists $self->_bulkheads->{$name};

    my $bulkhead = Bulkhead->new(%args);
    $self->_bulkheads->{$name} = $bulkhead;
    return $bulkhead;
}

sub get ($self, $name) {
    return $self->_bulkheads->{$name}
        // croak "Unknown bulkhead: '$name'";
}

1;

「Registryがあると、機能名で引けるんですね。注文は注文用、検索は検索用……」

ロックさんがうなずいた。

隔壁の粒度

ノートの図を発展させた。3つのプール(注文/検索/バッチ)を描いた後、注文APIの中をさらに分割する線を描きかけて、手を止めた。

「隔壁の粒度はどう決めるんですか? いま3つに分けましたけど——注文APIの中でも、決済呼び出しと在庫照会は分けた方がいいですか?」

「ワトソン君、船の話をしよう。タイタニック号を知っているね?」

「……隔壁があったのに沈んだ船、ですか」

「16の水密区画があった。設計上は4区画の浸水に耐えられるはずだった。——しかし氷山は6区画を破った。隔壁の上端が低すぎて、1つの区画から次の区画へ水が溢れた」

「隔壁が多ければ安全というわけではない……」

「逆もまた真だ。隔壁が少なすぎれば、1室の浸水で沈む。——粒度の正解は、障害の影響範囲で決める」

ノートに戻る。

「注文APIの中で、決済と在庫照会を分ける必要があるかどうかは——」

「どちらかが遅延したとき、もう一方を巻き添えにしたくないなら、分ける。巻き添えでも許容できるなら、分けない。——ビジネスの判断だ」

「決済が止まるのと、在庫照会が止まるのとでは……決済が止まる方が致命的です」

「ならば決済は独立した隔壁を持つべきだ。在庫照会は注文APIの残りと同居でもいい。——隔壁の数は、守りたいものの数で決まる」

即時拒否の原則

「制限に達したらどうなるんですか? 処理を待たせますか? 即座に断りますか?」

「2つの方針がある。ワトソン君、ノートに描きたまえ」

素直にペンを取った。

「1つ目:即座に拒否。Bulkheadが満杯なら、リクエストを例外で返す。呼び出し元はすぐに別の手を打てる」

描きながら、口が先に動いた。

「待ち時間ゼロ。でも、一時的なピークでも容赦なく弾く……」

「2つ目:一定時間だけ待つ。Bulkheadに空きが出るまで最大N秒待つ。空きが出なければ拒否」

「待つ場合……スレッドが待機している間、そのスレッド自体がリソースを消費しますよね。Bulkheadで守ろうとしているリソースとは別の場所でリソースが詰まる」

ロックさんが少しだけ目を見開いた。すぐに戻った。

「……そうだ。待機そのものがリソースを消費する。隔壁の手前に行列ができて、その行列がシステムを圧迫する——本末転倒だ」

「じゃあ、即座に拒否が基本で——待つのは特殊なケースだけ、ですか」

「正解だ。隔壁の原則は速やかな失敗。空きがなければ即座に『ここは満室だ』と断る。——断り方さえ誠実であれば、客は別の店に行ける」

Afterコード: 機能別Bulkheadで隔離

 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
package AppService;
use v5.36;
use Moo;
use Types::Standard qw(InstanceOf);

has connection_pool => (
    is       => 'ro',
    isa      => InstanceOf['ConnectionPool'],
    required => 1,
);

has bulkhead_registry => (
    is       => 'ro',
    isa      => InstanceOf['BulkheadRegistry'],
    required => 1,
);

sub process_order ($self, $order) {
    my $bulkhead = $self->bulkhead_registry->get('order');
    return $bulkhead->execute(sub {
        my $conn = $self->connection_pool->get_connection;
        my $result = $self->_execute_order($conn, $order);
        $self->connection_pool->release($conn);
        return $result;
    });
}

sub search_products ($self, $query) {
    my $bulkhead = $self->bulkhead_registry->get('search');
    return $bulkhead->execute(sub {
        my $conn = $self->connection_pool->get_connection;
        my $result = $self->_execute_search($conn, $query);
        $self->connection_pool->release($conn);
        return $result;
    });
}

sub generate_report ($self, $params) {
    my $bulkhead = $self->bulkhead_registry->get('batch');
    return $bulkhead->execute(sub {
        my $conn = $self->connection_pool->get_connection;
        my $result = $self->_execute_report($conn, $params);
        $self->connection_pool->release($conn);
        return $result;
    });
}

各メソッドが最初にやることが変わった。connection_pool->get_connectionではなく、bulkhead_registry->get('order')。まずBulkheadを取得し、そのexecute()の中でコネクションを使う。バッチ用Bulkheadが4接続の上限に達しても、注文用Bulkheadの8接続は無傷。隔壁が浸水を閉じ込めている。

バッチの失敗とビジネス判断

ノートの図を見返した。バッチ用Bulkhead——4接続。

「Bulkheadで隔離すれば、バッチの暴走は注文APIに影響しなくなる。——でも、バッチ自体が失敗するのは許容していいんですか?」

「全館停電で全レポートが生成されないのと、4接続の範囲内で優先度の高いレポートだけ先に生成されるのと——どちらが望ましい?」

「……後者です。でも、それは——」

「技術の問題ではなく?」

「経理チームと話す必要があります。どのレポートを先に出すか、優先順位を決めないと」

「Bulkheadは壁を立てるだけだ。壁の向こうに何を入れるかは——」

「ビジネスが決める」

ロックさんが船模型の隔壁を1枚嵌めた。

「そうだ」

	classDiagram
    class Bulkhead {
        -String name
        -Int max_concurrent
        -Int _active_count
        +execute(CodeRef code) Any
        +available() Int
        +is_full() Bool
    }

    class BulkheadRegistry {
        -HashRef~Bulkhead~ _bulkheads
        +register(name, max_concurrent) Bulkhead
        +get(String name) Bulkhead
    }

    class App_Service {
        -ConnectionPool connection_pool
        -BulkheadRegistry bulkhead_registry
        +process_order(order) Result
        +search_products(query) Result
        +generate_report(params) Result
    }

    BulkheadRegistry "1" --> "*" Bulkhead : manages
    App_Service --> BulkheadRegistry : uses
    App_Service --> Bulkhead : executes through

水密区画の完成

ロックさんが船模型に最後の隔壁を嵌め終えた。机に置く。3つの区画が隔壁で仕切られている。

「ワトソン君。この船は——もう1箇所の穴では沈まない」

自分のノートの図を見た。3つのBulkheadで区切られたシステム構成図。

「わたしのシステムも……バッチが暴走しても、注文と検索は止まらない」

「ただし——」

「隔壁だけでは完全ではない……ですよね。隔壁の中でリソースが枯渇したら、Circuit Breakerで遮断して。入口にはRate Limitingで流量を制御して」

ロックさんがわたしを見た。船模型から手を離す。

「——そうだ」

少し驚いたように見えた。わたしが自分で言ったからだろうか。Slackで見かけた「Rate Limiting」の話と、今日学んだBulkhead。入口の門番と、内部の壁。2つで1組の防衛線——。

「ワトソン君」

「はい」

——あれ、今「はい」と答えた。……まあ、いいか。

報酬

荷物をまとめた。立ち上がる。

「ロックさん、あの——お代というか、報酬は……」

ロックさんが船模型の横に置いてある接着剤のチューブを手に取った。

「これだ。このエポキシ樹脂——木と木を分子レベルで結合させる。二度と離れない。いい接着剤だ」

「……接着剤、ですか」

「同じものを1本。次に来るときに持ってきたまえ」

鞄にノートPCをしまいながら、接着剤のメーカー名と品番をスマホで撮影した。

「……わかりました」

報酬が接着剤。Slackの投稿者は「腕は確か」とは言っていたが、「変な人」とは言っていなかった。いや——「怪しい看板」とは言っていたか。

ドアを開けて廊下に出た。振り返らない。

階段を降りながら、スマホのノートアプリを開く。今日描いた図の写真を見返した。3つのBulkheadで区切られたシステム構成図。その横に、指でメモを書き足す。「Rate Limiting — 入口の門番?」。ロックさんが最後に言った「二重の防衛線」のことが気になっている。

雑居ビルの出口を出た。4月の午後の日差し。スマホをポケットにしまい、会社に戻る方向に歩き出す。

月末まであと3週間。今度の月末は——全館停電にはならない。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
全機能が共有するリソースプールBulkhead(機能別リソース隔離)1機能の暴走が他機能を道連れにしない
粒度を考慮しない隔壁設計障害影響範囲に基づく粒度決定守るべきものの数で隔壁数を決める
制限到達時の無限待機即時拒否(Fail Fast)待機によるリソース消費の二次被害を防ぐ
隔壁単独での過信CB + Rate Limiting との組み合わせ入口の門番と内部の壁で二重の防衛線

推理のステップ

  1. 共有リソースプールの特定 — 全機能が同一のDBコネクションプールを使っていないか確認する
  2. 機能ごとの使用量調査 — 各機能が通常時・ピーク時にどれだけリソースを消費するか把握する
  3. Bulkheadオブジェクトの設計name(機能名)とmax_concurrent(最大同時実行数)を持つMooクラスを作成する
  4. BulkheadRegistryの導入 — 複数のBulkheadを名前で管理する仕組みを作る
  5. 粒度の決定 — 「この機能が止まったとき、他の何を巻き添えにしたくないか」をビジネス観点で判断する
  6. 制限到達時の振る舞い決定 — 即時拒否を基本とし、呼び出し元で適切にエラーハンドリングする
  7. 他のパターンとの組み合わせ検討 — Circuit Breaker(障害検出・遮断)、Rate Limiting(入口の流量制御)との多層防衛

ロックより

隔壁のない船は、たった1つの穴で沈む。だが、隔壁を入れた船は——穴が開いた区画だけが水を受け入れ、残りの区画は浮力を保つ。

君のシステムも同じだ。全てのリソースを1つの部屋に入れている限り、1つの暴走が全てを道連れにする。壁を立てたまえ。完璧な効率を手放す代わりに、壊滅しないシステムを手に入れるんだ。

それから——壁を立てたら、入口の門番のことも忘れないでくれたまえ。壁だけでは、際限なく人が入ってきて、全ての部屋が溢れる。内部の壁と入口の門番。二重の防衛線が、船を浮かせ続ける。

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