レートリミットの「固定ウィンドウ」はなぜ現場を焼き尽くすのか?――プロトコルレベルで紐解く設計の深淵
ネットワークエンジニアやアーキテクトがAPI設計を語る時、RESTの原則論ばかりに目を奪われがちだが、現場の現実はもっと冷徹だ。我々が守るべきは美しいリソース定義だけではなく、その裏側で呼吸するTCP/TLSセッションの整合性と、サーバーを死から守るための「流量制御」という泥臭い防波堤である。
今回は、最も単純にして最も危険なトラップを秘めた「固定ウィンドウ(Fixed Window)」アルゴリズムを、インフラの深淵から解剖していく。
固定ウィンドウの「境界問題」と物理的な代償
固定ウィンドウとは、「1分間に100リクエスト」という制約を、時計の針に合わせて機械的にリセットする手法だ。実装は極めて容易で、RedisのINCRとEXPIREだけで書ける。しかし、この「単純さ」がネットワークの平穏を脅かす。
バースト・パケットの衝撃
固定ウィンドウの最大の脆弱性は、ウィンドウの切り替わり境界で発生する「2倍のバースト」だ。
例えば、1分間の終了直前(59秒目)に50リクエストが押し寄せ、次の1分が始まった直後(0秒目)にまた50リクエストが押し寄せる。システム全体で見れば「1分間で100」という制約を守っているように見えるが、サーバー側から見れば、ミリ秒単位の極小時間で100リクエストを捌くことを強いられる。
これは、ロードバランサーの接続キューや、カーネルのbacklogキューを一気に溢れさせ、SYNパケットに対するRST(Reset)の嵐を招く。結果、APIは「429 Too Many Requests」を返す以前に、TCPハンドシェイクすら完了できない「接続タイムアウト」の地獄に陥るのだ。
パケットが語る「最適化」の真実
レートリミットを設計する際、単に「リクエスト数」を数えるだけでは不十分だ。パケットレベルの挙動を最適化しなければ、限られた帯域を有効に使えない。
TLSハンドシェイクとRTTの削減
APIがHTTPSである以上、TLSハンドシェイクのコストは避けられない。レートリミットに引っかかるようなリクエストを、ハンドシェイク完了後に「429」で弾くのはあまりにコストが高い。
- TCP Fast Open (TFO): クライアントが過去に接続済みであれば、ハンドシェイクの初期段階でデータを送れる。これによりRTT(Round Trip Time)を削減し、リミット判定を早める。
- TLS 1.3 0-RTT: 最初のパケットにアプリケーションデータを乗せる。ただし、リプレイ攻撃には十分な注意が必要だ。
これらの最適化を行う場合、レートリミットの判定は「アプリケーション層」ではなく、可能な限り「エッジ(WAFやNginxのlimit_reqモジュール)」で実行すべきである。
実装:Nginxで「境界」を制御する現実解
単なる固定ウィンドウで終わらせないためには、burstオプションとnodelayの制御が肝となる。これらはカーネルのTCPバッファと密接に関わっている。
# Nginxの設定例: 境界付近のバーストを物理的に抑制する
http {
# ゾーンを定義(1分間に100リクエスト)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m;
server {
location /api/ {
# burst=20: 溢れたリクエストを20個までキューイング
# nodelay: キューを待たずに即時処理(バーストを許容)
# ここを調整しないと、バックエンドのCPUが瞬時に飽和する
limit_req zone=api_limit burst=20 nodelay;
# TCPバッファの最適化(低レイテンシ環境向け)
tcp_nodelay on;
tcp_nopush on;
}
}
}
なぜこの設定が「設計」なのか
burst=20は、固定ウィンドウの「切り替わり際」のバーストを物理的に吸収するためのバッファである。nodelayを指定しないと、Nginxはキューイングされたリクエストを律儀に待たせる。これはUXを低下させるが、サーバー負荷を抑えるための「意図的な減速」としては有効だ。
セキュリティスペシャリストとしての警鐘
レートリミットは、単なる負荷制御ではない。DoS攻撃に対する第一線だ。
もしあなたが「固定ウィンドウ」の境界問題を放置すれば、攻撃者はウィンドウの切り替わりタイミングを正確に狙い撃ちし、あなたのAPIをピンポイントで麻痺させることができる。
プロダクション環境への提言
1. スライディングウィンドウへの移行: 固定ウィンドウではなく、スライディングウィンドウログやトークンバケットアルゴリズムを採用せよ。これらは時間の「境界」を滑らかにし、バーストの衝撃を分散させる。
2. ヘッダー圧縮の影響を考慮: HTTP/2以降ではHPACKが使われる。頻繁にレートリミット情報を返す場合、カスタムヘッダー(X-RateLimit-Remaining等)の動的な生成がCPUに与える負荷も無視できない。
3. TCPバッファの監視: ss -nt コマンドで、送信待ちキューが常に溢れていないか監視せよ。レートリミットが機能しているはずなのに接続が切れる場合、それはリミットの問題ではなく、OSレベルのTCPパラメーター(net.ipv4.tcp_max_syn_backlog 等)の限界である可能性が高い。
結局のところ、優れたAPI設計とは「コードの美しさ」ではなく、「ネットワークという不安定な媒体の上で、いかにして壊れないシステムを構築するか」という執念の産物なのだ。次回のデプロイ前には、ぜひ tcpdump を走らせ、パケットたちがどのようにしてあなたの「リミット」にぶつかっているのか、その目で確かめてみてほしい。
コメント