Featured image of post コード探偵ロックの事件簿【Rate Limiting】行列のない受付〜防波堤なきAPIの崩壊〜

コード探偵ロックの事件簿【Rate Limiting】行列のない受付〜防波堤なきAPIの崩壊〜

Rate Limitingなしで防衛線4層のシステムが崩壊。Token BucketとSliding Window Counterでクライアント別流量制限をPerl/Mooで実装。

事件の発端 — ノックの間合い

土曜の午前11時。雑居ビルの3階。LCI事務所のドアの前に立った。

2回ノックした。返答を待たずにドアノブに手をかける。迷わなかった。

前にここへ来たのは——Retryの件で相談したとき。あのときは3回ノックして、返答がなくて、15秒くらい迷った。今回は迷う理由がない。

ドアを開けた。

部屋は変わっていなかった。窓際のデスクに3台のモニタ。足元に空のエナジードリンク缶が2本。ホワイトボードにはMermaid風の図が残っている。——ただ今回、机の端に大きな砂時計が置いてあった。上の室の砂が、下の室へ静かに落ちている。

ロックさんは椅子に座って、ペーパーバックを読んでいた。古い翻訳ミステリー。表紙が見えた。エラリー・クイーンの初期作品だ。

「ワトソン君。土曜の朝に来るとは珍しいな」

本から目を離さない。

「ワトソン君、でしたっけ」

自分で言った。あだ名の意味を知っている。最初は心外だった。途中から諦めた。いつの間にか気にならなくなって——今は、まあ、悪くない。

ロックさんの手が一瞬止まった。

本をゆっくり閉じて、私を見た。何かを言いかけて、やめた。代わりに、短く言った。

「……座りたまえ」

反応が少し変だった。何か驚いていたように見えた。が、深く考えない。鞄からノートPCを取り出す。

「昨日のブラックフライデーの件です」

現場検証 — 内向きの防衛線

ノートPCを開いた。Grafanaのダッシュボードを映す。

注文APIのリクエスト数グラフ。通常500 req/s。昨日のピーク時、5,200 req/s。p99レスポンスタイム——通常80ms。ピーク時15秒。503エラー率——0%が、ピーク時40%に跳ね上がっている。

「CB、Retry、Timeout、Fallback。全て正常に機能していました」

ロックさんはGrafanaを一瞥しただけで、砂時計を見ている。

「——それなのにシステムは止まりました

沈黙。砂が落ちる音だけがある。

「5,200。——通常は?」

「500前後です。負荷試験では1,500まで耐えることを確認していました」

「1,500。誰がその数字を決めた?」

少し間を置いた。

「……私です。過去12ヶ月の最大トラフィックの3倍を想定ピークにしました」

「マーケティングチームには聞いたか? キャンペーンの規模を」

沈黙。4秒。

「……聞いていません」

「Fallbackの件を覚えているか。技術だけで決めない。——負荷試験のパラメータは誰が決めるべきだった?」

「……マーケティングと一緒に、です」

CB回で「リトライ回数を増やせばいい」と思い込んでいた。Fallback回で「技術者だけで決めてはいけない」と学んだ。——なのに、負荷試験のパラメータを「技術判断」だと思い込んでいた。見えていなかった境界が、まだある。

ホワイトボードのペンを取った。

アーキテクチャ図を描く。注文API → 在庫照会API。注文API → 決済API。注文API → 配送API。各矢印の上にCB、Retry、Timeout、Fallbackの防衛レイヤを書き込む。

「これが今のシステムです。4層の防衛線。外部サービスの障害には耐えられる。在庫照会が落ちればCBが開いて、フォールバックが動く。決済が遅延すればTimeoutが切れて、Retryが再試行する」

描きながら、手が止まった。

「——これ全部、出て行くリクエストの制御です。入ってくるリクエストの制御がない」

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

「そうだ。君の防衛線は全て——内側を向いている。外から押し寄せる波に対して、何もない」

「Rate Limiting……ですよね。APIにリクエスト流量の上限を設ける」

「知っているのか」

「概念は。ただ——どう設計すべきかは」

ロックさんが砂時計を持ち上げた。

「この砂時計を見たまえ。上の室から下の室に砂が落ちる。落ちる速度は一定だ。上にどれだけ砂を足しても、落ちる速度は変わらない。——これがToken Bucketだ

Beforeコード: 流量制御なし

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

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

sub handle_request ($self, $request) {
    my $result = $self->order_service->process($request);
    return $result;
}

リクエストが来た分だけ、全てorder_serviceに渡す。門がない。いや、門はあるが——いつも全開だ。

推理披露 — 流量制御の設計

Token Bucketの原理

ロックさんは砂時計をデスクに戻した。

「Token Bucketには2つのパラメータがある。rate——1秒あたりに追加されるトークンの数。burst——バケットに保持できるトークンの最大数。リクエストが来るたびにトークンを1つ消費する。トークンがなければ——待て、と言える

「rateでスループットの上限を設定して、burstで瞬間的なスパイクを許容する」

「そうだ。砂時計の砂が落ちる速度がrate。上の室の容量がburst。砂が全て落ちきったら——新しい砂を入れても、落ちる速度は変わらない」

理解した。Token Bucketは「流量の上限」と「瞬間スパイクの許容」を同時に制御する。

「じゃあ、全体のリクエストに1つのToken Bucketを置けばいいですか?」

最もシンプルな構成から始める。Timeout回でロックさんに学んだこと——まずは単純なものから。

ロックさんが私を見た。

「1つのToken Bucket。全リクエストで共有。——ワトソン君、考えてみたまえ。管理画面を開いて商品マスタを確認しているバックオフィスのスタッフと、注文ボタンを押した顧客は、同じ重みか?」

「……違います。注文の方が優先度が高い」

「では、1人のユーザーが1秒間に100回リクエストを送った場合は?」

「1人で全体の枠を食い潰す。……他の全員が締め出される」

「グローバルRate Limiterだけでは、誰が枠を使ったかがわからない。1人の暴走が全員に影響する——これは、Rate Limitingなしの状態と何が違う?」

「……規模が違うだけで、構造は同じです」

「そうだ。クライアント別のRate Limiterが必要だ

CB回で学んだ「各サービスに独立したCircuit Breakerを持て」と同じ構造だ。共有リソースを分離する。原則は変わらない。

クライアント識別キーの設計

「クライアントごとにToken Bucketを持つなら、識別キーはIPアドレスでいいですか? うちのAPIは認証付きなので、APIキーやユーザーIDも使えますが」

テックリードとして、選択肢を列挙する。ロックさんに答えを求めるのではなく、判断に穴がないか確認したい。

「IPアドレスは?」

「企業ユーザーがNAT配下から大量にアクセスする場合、同じIPで複数ユーザーが来ます。1社が制限に引っかかると、同じIPの別ユーザーも巻き添えです」

「APIキーは?」

「認証済みリクエストには正確ですが、認証前のリクエスト——ログイン画面のブルートフォース攻撃——には効きません」

「ワトソン君。答えは出ているだろう」

「……2層。認証前はIPベース。認証後はユーザーIDベース」

ロックさんが薄く笑った。

「そうだ。——CB回と同じだ。サービスごとに独立したCBを持て。クライアント層ごとに独立したRate Limiterを持て。分離の原則は変わらない

Sliding Window Counter

「時間窓をどう管理するか——固定ウィンドウだと境界で問題が出ると聞いたことがあります」

「正しい。固定ウィンドウ——1分のウィンドウで100リクエスト制限としよう。0:00:59に100リクエスト、0:01:00にさらに100リクエスト。2秒間で200リクエスト。制限の2倍だ」

「ウィンドウの境界でバーストが許可される。……スライディングウィンドウなら解決しますか?」

「スライディングウィンドウログ——全リクエストのタイムスタンプを記録する——なら正確だ。だが?」

「メモリが足りません。5,200 req/sでタイムスタンプを全部保持するのは——」

「Cloudflareの方法がある。前のウィンドウのカウントと現在のウィンドウのカウントから加重平均を出す。メモリは2つの数値だけで済む」

「加重平均……ウィンドウの経過時間に応じて前のウィンドウの重みを下げる?」

ロックさんがホワイトボードに計算式を書いた。

1
rate = prev × ((window - elapsed) / window) + current

「精度は近似だが、Cloudflareが4億リクエストで検証して誤判定率0.003%。十分だ」

 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
package SlidingWindowCounter;
use v5.36;
use Moo;
use Types::Standard qw(Num Int);
use Time::HiRes ();

has window_size   => (is => 'ro', isa => Num, required => 1);
has max_requests  => (is => 'ro', isa => Int, required => 1);
has _prev_count   => (is => 'rw', isa => Num, default => 0);
has _curr_count   => (is => 'rw', isa => Num, default => 0);
has _window_start => (is => 'rw', isa => Num, lazy => 1,
    default => sub ($self) { $self->_current_window_start });
has _time_func    => (is => 'ro',
    default => sub { sub { Time::HiRes::time() } });

sub _current_window_start ($self) {
    my $now = $self->_time_func->();
    return int($now / $self->window_size) * $self->window_size;
}

sub _advance_window ($self) {
    my $current_start = $self->_current_window_start;
    if ($current_start > $self->_window_start) {
        if ($current_start - $self->_window_start >= $self->window_size * 2) {
            $self->_prev_count(0);
        }
        else {
            $self->_prev_count($self->_curr_count);
        }
        $self->_curr_count(0);
        $self->_window_start($current_start);
    }
    return;
}

sub estimated_rate ($self) {
    $self->_advance_window;
    my $now     = $self->_time_func->();
    my $elapsed = $now - $self->_window_start;
    my $weight  = ($self->window_size - $elapsed) / $self->window_size;
    $weight = 0 if $weight < 0;
    return $self->_prev_count * $weight + $self->_curr_count;
}

sub allow ($self) {
    my $rate = $self->estimated_rate;
    return 0 if $rate >= $self->max_requests;
    $self->_curr_count($self->_curr_count + 1);
    return 1;
}

2つの数値だけ保持する。前のウィンドウのカウントと、現在のウィンドウのカウント。経過時間に応じて前のウィンドウの重みを減らす。メモリ消費はO(1)。5,200 req/sでもスケールする。

429とRetry-After — 双方向の礼儀作法

「制限を超えたリクエストはどうするんですか? 503を返す?」

「503ではない。429だ。Too Many Requests。——違いがわかるか?」

「503はサーバーが処理不能。429はクライアントが多すぎる。……クライアントの問題であって、サーバーの障害ではない」

「そうだ。そして429にRetry-Afterヘッダを付ける。クライアントにいつ再試行すべきかを伝える。——Retryの件を覚えているか? 闇雲に再試行するのではなく、作法をもって再試行する。429 + Retry-Afterは、サーバーからクライアントへの礼儀作法だ」

小さく笑った。

「Retry回の指数バックオフは、クライアントからサーバーへの礼儀作法。今回はサーバーからクライアントへ」

「そうだ。——防衛線は一方通行ではない。双方向の礼儀で成り立つ

Afterコード: Token Bucket + クライアント別Rate Limiter

 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 TokenBucket;
use v5.36;
use Moo;
use Types::Standard qw(Num Int);
use Time::HiRes ();

has rate  => (is => 'ro', isa => Num, required => 1);
has burst => (is => 'ro', isa => Int, required => 1);

has _tokens      => (is => 'rw', isa => Num, lazy => 1,
    default => sub ($self) { $self->burst });
has _last_refill => (is => 'rw', isa => Num, lazy => 1,
    default => sub ($self) { $self->_time_func->() });
has _time_func   => (is => 'ro',
    default => sub { sub { Time::HiRes::time() } });

sub BUILD ($self, $args) {
    die "rate must be positive"  unless $self->rate > 0;
    die "burst must be positive" unless $self->burst > 0;
}

sub _refill ($self) {
    my $now     = $self->_time_func->();
    my $elapsed = $now - $self->_last_refill;
    my $added   = $elapsed * $self->rate;
    my $new     = $self->_tokens + $added;
    $self->_tokens($new > $self->burst ? $self->burst : $new);
    $self->_last_refill($now);
    return;
}

sub conform ($self, $n = 1) {
    $self->_refill;
    return $self->_tokens >= $n;
}

sub consume ($self, $n = 1) {
    $self->_refill;
    return 0 if $self->_tokens < $n;
    $self->_tokens($self->_tokens - $n);
    return 1;
}

sub tokens_available ($self) {
    $self->_refill;
    return $self->_tokens;
}

rateで1秒あたりのトークン追加レートを、burstでバケットの最大容量を設定する。conformはトークンの有無を確認するだけで消費しない。consumeが実際にトークンを消費する。

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

has global_bucket => (is => 'ro', isa => InstanceOf['TokenBucket'],
    required => 1);
has client_rate   => (is => 'ro', required => 1);
has client_burst  => (is => 'ro', required => 1);
has _client_buckets => (is => 'ro', isa => HashRef, default => sub { {} });

sub _get_client_bucket ($self, $client_id) {
    $self->_client_buckets->{$client_id} //= TokenBucket->new(
        rate  => $self->client_rate,
        burst => $self->client_burst,
    );
    return $self->_client_buckets->{$client_id};
}

sub check ($self, $client_id) {
    return 0 unless $self->global_bucket->conform(1);

    my $client_bucket = $self->_get_client_bucket($client_id);
    return 0 unless $client_bucket->conform(1);

    $self->global_bucket->consume(1);
    $client_bucket->consume(1);
    return 1;
}

sub retry_after ($self, $client_id) {
    my $global_tokens = $self->global_bucket->tokens_available;
    my $client_bucket = $self->_get_client_bucket($client_id);
    my $client_tokens = $client_bucket->tokens_available;

    if ($global_tokens < 1) {
        my $wait = (1 - $global_tokens) / $self->global_bucket->rate;
        return int($wait) + 1;
    }
    if ($client_tokens < 1) {
        my $wait = (1 - $client_tokens) / $self->client_rate;
        return int($wait) + 1;
    }
    return 1;
}

グローバルバケットで全体のスループットを制御し、クライアント別バケットで個々の暴走を防ぐ。2層のToken Bucketが、「1人の暴走が全員を巻き添えにする」問題を構造的に解決する。

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

has order_service => (is => 'ro', isa => Object, required => 1);
has rate_limiter  => (is => 'ro', isa => InstanceOf['RateLimiter'],
    required => 1);
has client_id_extractor => (is => 'ro', isa => CodeRef,
    default => sub {
        sub ($request) {
            return $request->{user_id} // $request->{client_ip} // 'anonymous';
        }
    });

sub handle_request ($self, $request) {
    my $client_id = $self->client_id_extractor->($request);

    unless ($self->rate_limiter->check($client_id)) {
        my $retry_after = $self->rate_limiter->retry_after($client_id);
        return {
            status      => 429,
            error       => 'Too Many Requests',
            retry_after => $retry_after,
        };
    }

    my $result = $self->order_service->process($request);
    return $result;
}

client_id_extractorが識別キーの2層構造を実現する。user_idがあれば認証済みユーザーとして、なければclient_ipで識別。制限超過時は429 Too Many Requestsretry_afterを返す。

テストの緑 — 防衛線の完成と未完成

ロックさんの前でテストを走らせた。

Token Bucketの初期トークン数、消費と枯渇、時間経過によるリフィル、バースト上限。SlidingWindowCounterのウィンドウ内カウント、ウィンドウ遷移、加重平均の精度。RateLimiterのクライアント分離、グローバル制限の強制。OrderAPIの429レスポンス、Retry-Afterヘッダ、認証前IP/認証後ユーザーIDの自動切り替え。

全てグリーン。

ロックさんはモニタを見ていなかった。砂時計を見ている。

「ワトソン君。CB、Retry、Timeout、Fallback、そしてRate Limiting。——5つの防衛線を積んだ。これで完成か?」

少し考えた。

「……完成ではないと思います」

「なぜだ」

「次のブラックフライデーではもっと大きな波が来るかもしれない。新しいサービスが追加されれば新しい弱点ができる。——防衛線は完成しない。でも、今日の5枚は、昨日より確実に厚い」

ロックさんが私を見た。数秒の沈黙。

「——いい答えだ」

そしてロックさんは、私を指して言った。

テックリード

職業名ではなかった。今日の私の態度全体に対する——何か。

少し驚いた。ロックさんが「ワトソン君」以外で私を呼んだのは、5回通って初めてだった。

何が変わったのか。まだうまく言葉にできない。

報酬 — もう貰った

事務所のドアの前に立った。

「ロックさん。今回の報酬は何ですか」

ロックさんは砂時計を見ている。

「もう貰った」

意味がわからなかった。聞き返さない。5回の付き合いで、ロックさんが意味不明なことを言うときは、後からわかることを知っている。

ドアの前で振り返った。

「ロックさん。……ありがとうございました」

「また何か——」

「いえ。次は自分で、考えてみます」

ロックさんが一瞬黙った。それから——初めて見る表情。薄く笑って、右手を軽く挙げた。何も言わない。

事務所を出た。階段を降りながら、砂時計のことを考えた。

砂が落ちきったら上下をひっくり返す。流量制御の本質はそれだけだ。入る量と出る量のバランス。そして、バランスが崩れたときに「待て」と言える仕組み。

雑居ビルの入り口を出たとき、スマホに通知が来た。チームの安藤からのSlack。

「負荷試験の結果、まとめました。レビューお願いします」

返信を打った。

「月曜に見る。今日は休め」

——自分にも言い聞かせて。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
リクエスト流量制御なしRate Limiting (Token Bucket)リクエストレートの上限管理で過負荷を防止
グローバル制限のみクライアント別Rate Limiter1ユーザーの暴走が他を巻き添えにしない
固定ウィンドウカウンタSliding Window Counter境界バーストの防止(誤判定率0.003%)
エラーレスポンスの不備(503)HTTP 429 + Retry-Afterクライアントとの協調的流量制御

推理のステップ

  1. 防衛線の「向き」を確認する: CB/Retry/Timeout/Fallbackは「出て行くリクエスト」の制御。「入ってくるリクエスト」を制御するRate Limitingが欠けていないか確認する
  2. Token Bucketを設計する: rate(1秒あたりのトークン追加レート)でスループットの上限を、burst(バケット容量)で瞬間スパイクの許容量を決定する
  3. クライアント別に分離する: グローバルRate Limiterだけでは「誰が枠を使ったか」がわからない。認証前はIPベース、認証後はユーザーIDベースの2層で識別する
  4. Sliding Window Counterで時間窓を管理する: 固定ウィンドウの境界バースト問題を、Cloudflare方式の加重平均(rate = prev × ((window - elapsed) / window) + current)で解決する
  5. 429 + Retry-Afterで応答する: 制限超過時は503ではなく429を返し、Retry-Afterヘッダでクライアントに再試行タイミングを通知する
  6. 負荷試験のパラメータをビジネスサイドと合意する: 想定ピークの見積もりは技術判断ではなくビジネス判断。マーケティング施策の規模を事前にすり合わせる

ロックより

5つの防衛線を積んだワトソン君。CB、Retry、Timeout、Fallback、そしてRate Limiting。——4つは内側を向いていた。最後の1つだけが、外を向いている。

防衛線は完成しない。君自身がそう言った。新しいサービスが増えれば新しい弱点が生まれる。トラフィックの波は毎年形を変える。——しかし防衛線が「完成しない」ことは、弱さではない。システムが生きている証だ。

砂時計の砂はいつか落ちきる。そのとき上下をひっくり返す。流量制御とは、止めることではない。流れを制御し、再び流すことだ。429は拒絶ではない。「少し待ってくれ」という、サーバーからクライアントへの礼儀作法だ。

——テックリード。次は自分で考えると言ったな。そのとおりにするがいい。

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