Tech Learning Daily

2026-08-18 (Tue) — 第 33 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Arch 🌿 基礎 ⏱ 約 8 分

「1分100リクエストまで」のはずが、なぜ一瞬で200件通ってしまうのか?

あなたも昨日、rack-attackexpress-rate-limit のようなミドルウェアを API に差し込み、「1分あたり100リクエストまで」と設定したはずだ。だがそのカウンタが「毎分ゼロにリセットされる」実装だったなら、送信タイミング次第で数秒のうちに定義の2倍近いリクエストが通り抜けてしまう。数を数えて弾くだけの単純な仕組みに、なぜそんな抜け穴が生まれるのか。

🎯 3 行まとめ
  • 固定ウィンドウカウンタは実装が最も簡単だが、ウィンドウの区切り目をまたぐと短時間に定義の最大2倍のリクエストを通してしまう構造的な弱点を持つ
  • トークンバケットはバーストを許しながら平均レートだけを制限し、リーキーバケットは出力そのものを一定レートに均す。「溜め込みを許すか」で使い分けが決まる
  • 複数サーバーが同じカウンタを共有する分散環境では、カウントの確認と加算を1つの操作としてアトミックに行わないと、レースコンディションで制限がすり抜けられる

固定ウィンドウカウンタと、区切り目の落とし穴

最も素朴なレート制限の実装は、時間を60秒などの固定区間(ウィンドウ)に区切り、その区間内のリクエスト数を数えるだけのものだ。Redis を使うなら、キーに対して INCR で1増やし、初回だけ EXPIRE 60 を設定してウィンドウの終わりに自動でカウンタを消す、という実装が典型的だ。カウントが上限を超えたら以降のリクエストを拒否する。

この方式には見落としやすい弱点がある。ウィンドウは「0秒から60秒まで」のように固定の区切りでリセットされるため、区切りの直前と直後にリクエストを集中させると、2つの独立したウィンドウそれぞれで上限いっぱいまで通ってしまう。1分100件の制限のつもりが、59秒目に100件、61秒目にさらに100件送れば、実質2秒間に200件が通る計算になる。制限が守っているのは「各ウィンドウ単体のカウント」であって、「任意の連続した1分間のリクエスト数」ではないということだ。

0s          60s          120s
├─ window A ─┼─ window B ─┤
         ●100件│●100件
         └─ 実質2秒で200件 ─┘
🍱 たとえるなら

閉店間際のスーパーが「お一人様、1時間に3個まで」の目玉商品を売っているとする。レジのカウンタを毎時00分にゼロへリセットする運用にすると、12時59分に3個買った客が13時01分にまた3個買える。「1時間に3個」というルールの文言は守っているのに、実質2分間で6個持ち帰られてしまう。固定ウィンドウの弱点は、この「リセットのタイミングを区切りにした瞬間、その前後の連続性を見なくなる」という構造そのものにある。

トークンバケットとリーキーバケット — 溜め込みを許すか、出力を均すか

ウィンドウの区切りに頼らない代表的な方式がトークンバケットリーキーバケットだ。トークンバケットは、一定レートで補充される「トークン」をバケットに溜めておき、リクエストが来るたびに1個消費する。アイドル中にトークンが上限まで貯まっていれば、その分だけ短時間にまとめて処理できる(バースト許容)。AWS API Gateway の throttling はこの方式で、スループットの上限を決める rate(トークンの補充速度)と、瞬間的な同時実行の上限を決める burst(バケット容量)を別々に設定できる。既定ではアカウント単位でリージョンごとに rate 10,000 req/秒・burst 5,000 が設定されており、超えると 429 Too Many Requests が返る。

一方リーキーバケットは、リクエストをいったんキューに溜め、一定レートで一定間隔ずつ処理する。バケットの水がどれだけ勢いよく注がれても、底の穴から漏れる速度は変わらない、という発想だ。バーストがあっても出力側は完全に平坦になる。nginx の limit_req モジュールはこの方式を採用しており、次のように設定する。

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
  location / {
    limit_req zone=one burst=5 nodelay;
  }
}

nodelay を付けないと、rate を超えた分のリクエストは burst の枠内でキューに溜められ、レートに合わせて遅延しながら処理される。nodelay を付けると、burst 分のスロットがあるうちは即座に処理するが、その枠を使い切ると 503 で即座に拒否する。同じリーキーバケットでも「遅らせて均す」か「即応するが枠を超えたら弾く」かを、この1フラグで選べる。

固定ウィンドウの弱点を、安いコストで直す — スライディングウィンドウカウンタ

区切り目の問題を厳密に解くなら、各リクエストのタイムスタンプを全部保持し、常に「直近60秒」を数え直せばよい。しかしこれはリクエストのたびにメモリと計算量が増え続け、大量アクセスを捌く現場では現実的でない。Cloudflare が採用しているスライディングウィンドウカウンタは、直前のウィンドウと現在のウィンドウという2つのカウンタだけを保持し、直前ウィンドウの重なり具合を加重して近似する方式だ。

推定値は次の式で求める。直前ウィンドウのカウント ×(ウィンドウ長 − 現在ウィンドウの経過時間)/ ウィンドウ長 + 現在ウィンドウのカウント。たとえば1分ウィンドウで直前ウィンドウが42件、現在ウィンドウが15秒経過して18件なら、42 × (60-15)/60 + 18 = 49.5 件という推定になる。Cloudflare が4億リクエストで検証した結果では、この近似によって誤って許可・拒否されたリクエストは0.003%にとどまったという。カウンタ2個分のメモリで、タイムスタンプを全件保持する方式にかなり近い精度を得られる、というのがこの方式の要点だ。

分散環境では「数える」だけでもレースコンディションが起きる

ここまでの方式はどれも「カウントを確認してから加算する」という2段階の操作を前提にしている。1台のサーバーの中だけで完結するなら順番は保証されるが、複数のアプリケーションサーバーが同じ Redis のカウンタを共有する構成では話が変わる。サーバーAとサーバーBがほぼ同時に「現在のカウントは99だから、まだ上限100未満だ」と読み取り、両方が加算を実行すれば、実際には101件目まで通ってしまう。読み取りと加算の間に他のプロセスが割り込める、典型的なレースコンディションだ。

これを避けるには、確認と加算を1つの不可分な操作にする必要がある。Redis の INCR コマンド自体はサーバー内部で単一のアトミックな操作として実行されるため、「まず加算してから、返ってきた値が上限を超えていれば取り消す」という順序にすれば競合を避けられる。より複雑な判定が要る場合は、複数コマンドをまとめて実行できる Lua スクリプト(EVAL)を使い、確認と加算をサーバー側で1つの操作として完結させる。

制限したことをクライアントにどう伝えるか

制限を超えたリクエストには 429 Too Many Requests(RFC 6585)を返す。このステータスコードのレスポンスはキャッシュしてはならないと同 RFC で定められている。あわせて Retry-After ヘッダーで「何秒後に再試行すべきか」を伝えるのが基本だ。IETF で標準化が進む RateLimit ヘッダーフィールド(draft-ietf-httpapi-ratelimit-headers)を使えば、残り枠や適用中のポリシーも構造化して伝えられる。

HTTP/1.1 429 Too Many Requests
Retry-After: 5
RateLimit: "default";r=0;t=5
RateLimit-Policy: "default";q=100;w=60

RateLimitr は残り枠、t は次に枠が回復するまでの秒数、RateLimit-Policyqw はそれぞれ「何件まで/何秒のウィンドウで」というポリシー自体を表す。両方のヘッダーが返ってきた場合、クライアントは Retry-After の値を優先すべきだとこの草案は定めている。

💼 実務でどう出会うか

Rails の rack-attack や Node の express-rate-limit でアプリ層に制限を仕込む場面、Cloudflare の WAF レート制限ルールでエッジに仕込む場面、AWS API Gateway の使用量プランで API ごとに rateburst を決める場面など、レイヤーを問わず登場する。モバイルアプリの再接続ストームのように正当な瞬間バーストを許したいならトークンバケット寄りの設計、下流の固定容量なリソースを一定速度でしか叩かせたくないならリーキーバケット寄りの設計、と目的次第でアルゴリズムを選ぶ判断軸になる。

⌨️ 手を動かす(5 分)

nginx の limit_req(リーキーバケット)を Docker で立ち上げ、burst を使い切った後に 503 へ切り替わる様子を観察する。

cat > /tmp/nginx-ratelimit.conf <<'EOF'
events {}
http {
  limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
  server {
    listen 8080;
    location / {
      limit_req zone=one burst=3 nodelay;
      return 200 "ok\n";
    }
  }
}
EOF
docker run --rm -d --name ratelimit-demo -p 8080:8080 \
  -v /tmp/nginx-ratelimit.conf:/etc/nginx/nginx.conf:ro nginx:alpine
for i in $(seq 1 8); do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/
done
docker stop ratelimit-demo

最初の数件は 200 が返るが、burst=3 の枠を使い切ったあたりから 503 に切り替わるはずだ。何件目で切り替わるかは curl を発行する間隔で多少前後するが、「一定数までは即応、それを超えると弾く」という nodelay 付きリーキーバケットの挙動を手元で確認できる。

🙅 よくある誤解
  • 「1分間に100件まで」と設定すれば、任意の1分間で100件を超えることは絶対にない — 固定ウィンドウカウンタで実装している場合、ウィンドウの境界をまたぐタイミング次第で短時間に最大2倍近くのリクエストが通り得る。「任意の連続区間」で厳密に守りたいならスライディングウィンドウ系の方式が要る。
  • トークンバケットとリーキーバケットは、呼び方が違うだけで同じもの — トークンバケットは貯めたトークン分だけ即座にバーストを許すのに対し、リーキーバケットは出力レートそのものを一定に保つ。バーストを許すか均すかという挙動が逆になる。
  • レート制限はアプリのコードにさえ実装すれば、どこからのリクエストも正しく数えられる — 複数サーバーで同じカウンタを共有する構成では、確認と加算をアトミックに行わないとレースコンディションで制限をすり抜けられる。単一プロセス内の実装をそのまま分散環境に持ち込むと壊れる。
📖 用語ミニ辞典
固定ウィンドウカウンタ
時間を固定長の区間に区切り、区間内のリクエスト数を数えて制限する方式。境界をまたぐ短時間バーストに弱い。
トークンバケット
一定レートで補充されるトークンをリクエストごとに消費する方式。溜まっている分だけバーストを許容する。
リーキーバケット
リクエストを一定レートで処理し、出力を平坦化する方式。バーストがあっても出力速度は変わらない。
スライディングウィンドウカウンタ
直前と現在の2つのウィンドウのカウントを加重平均し、少ないメモリで「直近N秒」を近似する方式。
Retry-After ヘッダー
429 などのレスポンスで、クライアントが何秒後に再試行すべきかをサーバーが伝えるヘッダー。
🔗 もっと深く