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

APIの「身を守る盾」:固定ウィンドウによるレートリミット設計と、境界付近に潜むトラップ

こんにちは。日夜パケットの流れと格闘しているインフラアーキテクトの私です。

Web APIを公開する際、あなたが最初に実装すべき「防衛線」は何でしょうか? 認証や認可はもちろん重要ですが、それと同等、あるいはそれ以上にインフラの命運を握るのがレートリミット(流量制限)です。

どれほど洗練されたREST APIを設計し、美しいリソース指向のURLを並べようとも、DDoS攻撃や、暴走したバッチ処理、あるいは行儀の悪いクライアントからのリクエストの嵐に晒されれば、バックエンドのデータベースは一瞬で息絶えます。APIサーバーを守るこの「盾」をどう設計するかは、インフラエンジニアおよびバックエンドエンジニアにとって永遠の課題です。

今回は、レートリミットのアルゴリズムの中で最もシンプルかつ直感的な「固定ウィンドウアルゴリズム(Fixed Window Algorithm)」に焦点を当てます。その仕組み、通信フロー、実務で絶対に避けて通れない「境界の罠」、そして具体的な実装例まで、現場の知見を交えて徹底的に解説していきましょう。

—

固定ウィンドウアルゴリズムの基本メカニズム

固定ウィンドウアルゴリズムのコンセプトは極めて明快です。タイムラインを一定の時間幅(ウィンドウ、例えば「1分間」や「1時間」など)で区切り、そのウィンドウ内におけるリクエスト数をカウントします。

例えば、「1分間に最大60リクエスト」という制限を設けた場合、挙動は以下のようになります。

1. ウィンドウの開始時刻(例: 12:00:00)にカウンターが 0 でリセットされる。
2. クライアントからのリクエストが届くたびに、カウンターが +1 される。
3. カウンターが 60 に達した以降、そのウィンドウが終了する(例: 12:00:59 が過ぎる)までのリクエストはすべて拒否(429 Too Many Requests)される。
4. 次のウィンドウ(12:01:00)が始まった瞬間、カウンターは再び 0 にリセットされ、リクエストの受付が再開される。

この方式の最大のメリットは、実装の圧倒的なシンプルさです。Redisなどのインクリメント操作(INCRコマンド)と、キーごとの有効期限(EXPIREコマンド)を組み合わせるだけで、数行のコードで実装できてしまいます。メモリ消費量も少なく、高負荷な環境でもオーバーヘッドが少ないのが特徴です。

—

標準化されつつあるHTTPヘッダー仕様(IETFドラフト)

レートリミットを実装する際、サーバー側が現在の制限状態をクライアントにどう伝えるかは非常に重要です。かつては独自のヘッダー名(X-RateLimit-Limitなど)が乱立していましたが、現在はIETF(Internet Engineering Task Force)において標準化の議論が進められています(draft-ietf-httpapi-ratelimit-headers)。

実務では、以下の標準的なレスポンスヘッダーを返すように設計することを強く推奨します。

  • RateLimit-Limit: ウィンドウ内で許可されている最大リクエスト数
  • RateLimit-Remaining: 現在のウィンドウ内で残りの許可されているリクエスト数
  • RateLimit-Reset: 現在のウィンドウがリセットされるまでの残り時間(秒数、またはUnixタイムスタンプ)
  • Retry-After: リクエストが拒否(429)された際、次にリクエストを試行してもよいまでの秒数

—

現場のエンジニアを悩ませる「境界バースト問題」の罠

さて、ここからが本題です。固定ウィンドウはシンプルで素晴らしいのですが、実務で運用する際には致命的な弱点が存在します。それが「境界付近のバーストトラフィック(Thundering Herd / Window Boundary Burst)」です。

どんな現象が起きるのか?

「1分間に60リクエスト」の制限があるシステムを想像してください。

  • クライアントAが、あるウィンドウの最後の1秒間(例: 12:00:59)に60リクエストをすべて消化しました。
  • ウィンドウが切り替わり、次の瞬間(例: 12:01:00)になりました。この瞬間、カウンターは 0 にリセットされます。
  • クライアントAは、切り替わった直後の1秒間(例: 12:01:01)に、また新たに60リクエストを送り込みました。

サーバー側からこの挙動を俯瞰してみるとどうなるでしょうか。
12:00:59 から 12:01:01 までのわずか2秒間の間に、合計120リクエストが処理されていることになります。ルール上は「1分間に60リクエスト」という制限をどちらのウィンドウでも満たしていますが、瞬間的な負荷は許容値の2倍に達しています。

もしこれが何千ものクライアントで同時に発生したらどうなるか……想像に難くないでしょう。ウィンドウの切り替わりタイミングでサーバーやDBに負荷が集中し、一時的なレイテンシの悪化やダウンを引き起こす原因になります。これが、固定ウィンドウにおける最大の悪夢です。

—

実装例:Python(Flask + Redis)による固定ウィンドウの構築

それでは、この固定ウィンドウアルゴリズムを、実務で使えるレベルのコードで実装してみましょう。ここでは、軽量Webフレームワークの Flask と、インメモリデータストアの Redis を使用します。

以下のコードは、IPアドレスをベースに1分間あたりのリクエスト数を制限し、前述の標準的なレートリミットヘッダーを付与するミドルウェア的な処理のサンプルです。

import time
from flask import Flask, jsonify, request
import redis

app = Flask(__name__)

# Redis接続設定(本番環境では環境変数等から取得)
r = redis.Redis(host="localhost", port=6379, db=0)

# 設定定数
WINDOW_SIZE = 60  # ウィンドウのサイズ(秒)
MAX_REQUESTS = 5  # ウィンドウあたりの最大リクエスト数(テスト用に小さく設定)


@app.route("/api/v1/resources", methods=["GET"])
def get_resource():
    # クライアントの識別(実務ではAPIキーやIPアドレスを使用)
    client_ip = request.remote_addr
    current_time = int(time.time())

    # ウィンドウの開始時刻を計算(例: 1分単位で切り捨て)
    window_key_time = current_time - (current_time % WINDOW_SIZE)
    redis_key = f"rate_limit:{client_ip}:{window_key_time}"

    # Redisパイプラインを使ってアトミックに操作
    pipe = r.pipeline()
    pipe.incr(redis_key, 1)
    # キーの有効期限をウィンドウサイズ+αに設定(自動クリーンアップ)
    pipe.expire(redis_key, WINDOW_SIZE + 5)
    result = pipe.execute()

    request_count = result[0]

    # 残り時間と残りリクエスト数の計算
    time_to_reset = (window_key_time + WINDOW_SIZE) - current_time
    remaining = max(0, MAX_REQUESTS - request_count)

    # 共通のレートリミットヘッダーを作成するヘルパー関数
    def add_rate_limit_headers(response, status_code):
        response.headers["RateLimit-Limit"] = str(MAX_REQUESTS)
        response.headers["RateLimit-Remaining"] = str(remaining)
        response.headers["RateLimit-Reset"] = str(time_to_reset)
        if status_code == 429:
            response.headers["Retry-After"] = str(time_to_reset)
        return response

    # 制限を超過した場合の処理
    if request_count > MAX_REQUESTS:
        response = jsonify(
            {
                "error": {
                    "code": 429,
                    "message": "Too Many Requests. Rate limit exceeded.",
                }
            }
        )
        response.status_code = 429
        return add_rate_limit_headers(response, 429)

    # 正常系のレスポンス
    response = jsonify(
        {"status": "success", "data": "ここにリソースのペイロードが入ります"}
    )
    response.status_code = 200
    return add_rate_limit_headers(response, 200)


if __name__ == "__main__":
    app.run(debug=True)

このコードでは、rate_limit:{client_ip}:{window_key_time} というキー構造を採用することで、時間経過とともにRedis上のキーが自然に切り替わる(そして古いキーは期限切れで消滅する)ように設計しています。

—

動作確認とデバッグの手順

実装したら、実際に curl コマンドを使って挙動を確認してみましょう。

1. 正常なリクエストの送信

curl -i http://localhost:5000/api/v1/resources

レスポンスのイメージ:

HTTP/1.1 200 OK
Content-Type: application/json
RateLimit-Limit: 5
RateLimit-Remaining: 4
RateLimit-Reset: 45

RateLimit-Remaining が正しく減算されていることがわかります。

2. 制限超過(429)の確認

制限値(今回は5回)を超えるまで連続してリクエストを投げると、以下のレスポンスが返ってきます。

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 12
Retry-After: 12

クライアント側のアプリケーションやSDKは、この Retry-After ヘッダーの秒数を読み取り、その時間だけ待機(バックオフ)してからリクエストを再送するよう実装するのが、モダンなAPI設計のエチケットです。

—

固定ウィンドウの限界を超えるために(実務での選択肢)

ここまで固定ウィンドウの仕組みと実装を見てきましたが、前述の「境界バースト問題」がどうしてもシステムの許容範囲を超える場合は、より高度なアルゴリズムへの移行を検討する必要があります。

1. スライディングウィンドウ・ログ方式 (Sliding Window Log):
すべてのリクエストのタイムスタンプを正確に記録し、過去10秒間などの移動平均で計算する方式。精度は完璧ですが、メモリ消費量と計算コストが高くなります。
2. スライディングウィンドウ・カウンター方式 (Sliding Window Counter):
固定ウィンドウの軽量さと、スライディングウィンドウの滑らかさを折衷した方式。直前のウィンドウのリクエスト数を一定割合で加算して近似値を計算します。実務では非常にコスパが良い選択肢です。
3. トークンバケット方式 (Token Bucket) / リーキーバケット方式 (Leaky Bucket):
ネットワークのQoS制御やルーターのトラフィックシェーピングでもおなじみの方式。バーストを許容しつつ平均流量を厳格に制限できます。APIゲートウェイ(KongやNginxなど)の標準機能としてもよく使われます。

—

まとめ

固定ウィンドウアルゴリズムは、その圧倒的なシンプルさと実装・運用の容易さから、多くのWeb APIの第一線で今なお採用され続けています。

しかし、「仕組みが単純だからこそ、境界付近の挙動に潜むリスクをエンジニアが理解していなければならない」ということを忘れてはなりません。システムの規模、クライアントの特性、そしてバックエンドの耐荷重を見極めながら、適切なウィンドウサイズと制限値を設定してください。

あなたの設計したAPIが、予期せぬトラフィックの波から守られ、常に美しく安定したレスポンスを返し続けることを願っています。それでは、また次のパケットの海でお会いしましょう。

コメント

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