【実務・中級編】 レートリミットの設計:トークンバケットアルゴリズム – Web APIアーキテクチャ・データ連携実践ガイド

はじめに:なぜAPIの「流量制限」で現場はいつも燃えるのか

こんにちは、シニアネットワークエンジニアの私です。

Web APIの設計やインフラ運用を担当していると、必ずと言っていいほど頭を悩ませる問題があります。それが「レートリミット(流量制限)」です。

新サービスのリリース直後、マーケティング部門が仕掛けた起死回生のプロモーション、あるいは身勝手なクローラーによるスクレイピング。想定外のトラフィックが押し寄せた瞬間、バックエンドのデータベースは悲鳴を上げ、CPU使用率は天井知らずで急上昇。そして、APIサーバーは共倒れ――。そんな修羅場をくぐり抜けてきたエンジニアなら、一度や二度はトラuma(トラウマ)を抱えていることでしょう。

だからこそ、堅牢なWeb APIアーキテクチャには適切なレートリミットが不可欠です。「単純に1秒間に1回までのアクセスに制限すればいいや」と考えて固定的なインターバルを設けると、今度は正当なユーザーの快適な操作性(UX)を損ねるというジレンマに直面します。

そこで登場するのが、ネットワークの世界ではQoS(Quality of Service)やトラフィックシェーピングの文脈でもおなじみの「トークンバケットアルゴリズム(Token Bucket Algorithm)」です。この手法を理解し、正しく実装できるようになれば、「システムを守る堅牢性」と「ユーザー体験(バーストの許容)」という、一見相反する要件を美しく両立させることができます。

今回は、パケットの動きやRFCの思想を愛するインフラの視点から、このトークンバケットの深淵へ皆さんをご案内しましょう。

—

1. トークンバケットアルゴリズムの基本概念と通信フロー

なぜ「トークンバケット」なのか?

愚直なレートリミット方式として「固定ウィンドウカウンター(Fixed Window Counter)」がありますが、これには致命的な欠点があります。例えば「1分間に60回まで」という制限の場合、59秒目に60回叩き、続く60秒目にまた60回叩くと、実質1秒間に120回のリクエストが集中し、バックエンドを直撃します(これをウィンドウ境界の問題と呼びます)。

一方、トークンバケットアルゴリズムは、次のようなメンタルモデルで動作します。

1. 容量(Capacity)が決められた「バケット(バケツ)」が存在する。
2. 一定の補充レート(Refill Rate)で、バケットに「トークン」がポタポタと追加されていく。
3. バケットが満杯のとき、それ以上追加されたトークンは溢れて消滅する。
4. クライアントがリクエストを送る際、バケットから必要分のトークンを「消費(Dequeue)」する。
5. トークンが足りなければ、リクエストは拒否(HTTP 429 Too Many Requests)される。

この仕組みの最大の妙味は、「バースト(突発的なトラフィック)を許容する」点にあります。バケットの容量さえ許せば、短時間に連続してリクエストを通し、平均レートが規定値以内に収まるようにバケットが自動で調整(シェーピング)してくれるのです。

クライアントとAPIサーバーのインタラクション(シーケンス)

実際の通信がどのように行われるのか、シーケンスを見てみましょう。

[Client]                          [API Gateway / Redis]
   |                                        |
   |---- 1. GET /api/v1/resources --------->|
   |                                        |-- トークン残量チェック (容量: 10, 補充: 2/秒)
   |                                        |-- トークンが十分にあるため消費 (残り 9)
   |<--- 2. HTTP 200 OK + RateLimit Headers-|
   |                                        |
   |---- 3. GET /api/v1/resources (x5) ---->| (一気にリクエストを送信:バースト)
   |                                        |-- 残りトークンを順次消費 (残り 4)
   |<--- 4. HTTP 200 OK + RateLimit Headers-|
   |                                        |
   |---- 5. GET /api/v1/resources (x10) --->| (トークン枯渇!)
   |                                        |-- トークン不足 (残り 0)
   |<--- 6. HTTP 429 Too Many Requests -----|

クライアントは、レスポンスヘッダーに含まれるメタデータを見て、自らの送信ペースを自発的に制御することが求められます。

—

2. 標準化の動向:IETF I-Dとレスポンスヘッダーのベストプラクティス

レートリミットのレスポンス仕様は、長らく各社バラバラの独自ヘッダー(X-RateLimit-Limitなど)で実装されてきました。しかし、IETF(Internet Engineering Task Force)において、RateLimit Fields for HTTP という標準化ドラフト(インターネットドラフト)の議論が進められており、現在のモダンなAPI設計ではこの標準に準拠するのが定石となっています。

最低限実装すべき、主要なレスポンスヘッダーの顔ぶれは以下の通りです。

  • RateLimit-Limit: バケットの最大容量(許容される最大リクエスト数)
  • RateLimit-Remaining: 現時点でバケットに残っているトークン数
  • RateLimit-Reset: トークンが完全に満杯(あるいはリセット)されるまでの残り時間(秒単位)
  • Retry-After: 429エラー時に、クライアントが次にリクエストを送るまでの待機秒数(RFC 9110準拠)

—

3. 実装の現場:Redisを用いたアトミックなトークンバケットの構築

実務で数万〜数百万リクエストを捌くAPIサーバー群において、レートリミットの状態(誰が何個トークンを持っているか)をインメモリだけで保持するのはロードバランサーの負荷分散の観点から御法度です。通常は、超高速なKVSであるRedisをバックエンドに据えて状態を共有します。

ここでは、実務でそのまま使える、RedisのLuaスクリプトを用いたアトミック(不可分)なトークンバケットのPython(FastAPI / Redis)実装サンプルを紹介します。

Pythonによる実装例(Redis + Luaスクリプト)

競合状態(Race Condition)を防ぐため、トークンの計算と消費はRedis上でLuaスクリプトを用いて一原子的に処理するのがインフラ設計の鉄則です。

import redis
import time
from fastapi import FastAPI, HTTPException, Response

app = FastAPI()
# Redisクライアントの初期化(本番ではコネクションプール等を適切に設定)
r = redis.Redis(host="localhost", port=6379, db=0)

# Luaスクリプト:Redis側でアトミックにトークンを計算・消費する
# KEYS[1]: ユーザーのキー (例: ratelimit:user123)
# ARGV[1]: バケットの最大容量 (Capacity)
# ARGV[2]: 1秒あたりの補充レート (Refill Rate)
# ARGV[3]: 現在のタイムスタンプ (Epoch time)
# ARGV[4]: 今回消費するトークン数 (通常は 1)
lua_token_bucket = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- Redisから現在の状態(最後の更新時刻とトークン残量)を取得
local data = redis.call('hmget', key, 'last_updated', 'tokens')
local last_updated = tonumber(data[1])
local tokens = tonumber(data[2])

if not last_updated then
    -- 初回アクセスの場合はバケット満杯の状態からスタート
    tokens = capacity
    last_updated = now
else
    -- 経過時間に基づいてトークンを補充
    local delta = math.max(0, now - last_updated)
    local tokens_to_add = delta * refill_rate
    tokens = math.min(capacity, tokens + tokens_to_add)
    last_updated = now
end

local allowed = 0
if tokens >= requested then
    tokens = tokens - requested
    allowed = 1
end

-- 状態をRedisに保存(TTLは容量回復に必要な時間を計算して設定)
local ttl = math.ceil(capacity / refill_rate)
redis.call('hmset', key, 'last_updated', last_updated, 'tokens', tokens)
redis.call('expire', key, ttl)

-- { 許可フラグ(1/0), 残りトークン数, リセットまでの秒数 } を返す
return { allowed, tokens, ttl }
"""

# スクリプトをRedisに登録(SHA1ハッシュをキャッシュ)
token_bucket_sha = r.script_load(lua_token_bucket)

@app.get("/api/v1/data")
def get_data(user_id: str, response: Response):
    key = f"ratelimit:{user_id}"
    capacity = 10.0   # 最大10トークン
    refill_rate = 2.0 # 毎秒2トークン補充
    now = time.time()
    requested = 1.0

    # Luaスクリプトの実行
    result = r.evalsha(token_bucket_sha, 1, key, capacity, refill_rate, now, requested)
    allowed, remaining, ttl = int(result[0]), float(result[1]), int(result[2])

    # 標準化されたレートリミットヘッダーの付与
    response.headers["RateLimit-Limit"] = str(int(capacity))
    response.headers["RateLimit-Remaining"] = str(int(remaining))
    response.headers["RateLimit-Reset"] = str(ttl)

    if not allowed:
        response.headers["Retry-After"] = "1"
        raise HTTPException(
            status_code=429, 
            detail="Rate limit exceeded. Too many requests."
        )

    return {"status": "success", "data": "ここに保護されたAPIレスポンスが入ります"}

このコードでは、hmgetで前回の状態を読み出し、経過時間から補充すべきトークンを計算した上で、トランザクション安全に消費処理を行っています。

—

4. 現場で役立つ実践的Tipsとトラブルシューティング

最後に、現場のインフラアーキテクトとして、実際に本番運用を始める際につまづきやすいポイントと対処法をいくつか伝授しましょう。

1. クライアント識別キーの選定を間違えるな

user_id や api_key で制限をかけるのが基本ですが、未認証のパブリックAPIエンドポイント(ログイン画面や資料請求フォームなど)では、どうしてもIPアドレス(X-Forwarded-Forなど)をキーにせざるを得ません。
しかし、大規模な社内ネットワークやプロキシ(キャリア網など)の配下では、多数の正当なユーザーが同一のIPアドレスからアクセスしてくるため、あっという間にトークンが枯渇して「巻き込み事故」が発生します。パブリックAPIのIPベース制限は、バケット容量を広めに取るか、Fingerprint(User-AgentやTLSの特性を組み合わせたハッシュ)の併用を検討してください。

2. Redisの障害耐性とフォールバック戦略

もしRedisがスローダウンしたり、ダウンタイムに陥ったりした場合、レートリミットシステムのためにAPI全体が500エラーを吐いて停止しては本末転倒です。
アプリケーション層の実装では、必ずRedisへの接続タイムアウト(例: 50ms〜100ms)を設け、万が一Redisが応答しない場合は「フェイルオープン(制限をバイパスしてリクエストを通す)」にするか、ローカルメモリキャッシュに一時退避するサーキットブレーカーのパターンを組み込んでおきましょう。「落ちるくらいなら一時的に制限を緩める」のが可用性設計の鉄則です。

3. クライアントサイドへの優しさ(Retry-Afterの徹底)

429ステータスコードを返すだけで、Retry-After ヘッダーを返さない不親切なAPIを見かけます。クライアント側のポーリング処理やバックオフアルゴリズム(指数バックオフなど)は、このヘッダーの数値を頼りに再試行間隔を決定します。インフラ屋の美学として、エラーレスポンスのボディだけでなく、ヘッダーまで美しく設計し、クライアントに正しい「背中」を見せてやりましょう。

—

おわりに

APIのレートリミット、そしてトークンバケットアルゴリズムは、単なる「悪意あるアクセスを防ぐための防壁」ではありません。それは、有限なサーバーリソースをすべてのユーザーに対して公平に配分し、システム全体の死活を守るための「優しくも力強い流量制御の芸術」です。

今回紹介したLuaスクリプトやヘッダー設計の思想を、皆さんのプロダクトのアーキテクチャにぜひ組み込んでみてください。荒ぶるトラフィックの嵐の中でも、ビクともしない堅牢で美しいAPIインフラの構築を応援しています。

コメント

タイトルとURLをコピーしました