APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説

kento_morota 27分で読めます

APIが突然大量のリクエストを受けてサーバーがダウンした、特定のユーザーが大量アクセスして他のユーザーに影響が出た——APIを公開・運用する上で、レート制限(Rate Limiting)は避けて通れないテーマです。

本記事では、レート制限の基本概念から主要なアルゴリズムの仕組み、Node.jsとRedisを使った実装例、レスポンスヘッダーの設計、分散環境での考慮点まで体系的に解説します。

レート制限とは何か

レート制限とは、一定期間内にクライアントが送信できるリクエスト数に上限を設ける仕組みです。たとえば「1分間に100リクエストまで」というルールを設定し、超過したリクエストは拒否(HTTPステータス429)します。

レート制限が必要な理由

サーバーの保護
悪意のある攻撃(DDoS攻撃)や、バグのあるクライアントからの大量リクエストによるサーバーダウンを防ぎます。

公平性の確保
特定のユーザーがリソースを独占し、他のユーザーのサービス品質が低下する「ノイジーネイバー問題」を防ぎます。

コストの管理
クラウド環境では、リクエスト数に応じてコストが発生します。意図しない大量リクエストによるコスト増加を防止します。

下流サービスの保護
外部APIやデータベースへの過度なアクセスを制限し、依存サービスに負荷が集中するのを防ぎます。

API利用プランの制御
無料プランは「100リクエスト/時」、有料プランは「10,000リクエスト/時」のように、プランごとに異なる制限を設けてビジネスモデルを実現します。

レート制限の識別キー

「誰に対して」制限を適用するかを決める識別キーの設計も重要です。

IPアドレスベース
最もシンプルですが、同じIPアドレスを共有するユーザー(企業のプロキシ経由など)に対して不公平になる場合があります。

APIキーベース
APIキーごとに制限を適用します。ユーザー単位の制御が可能ですが、APIキーの発行・管理が必要です。

ユーザーIDベース
認証済みユーザーに対して制限を適用します。最も正確なユーザー単位の制御が可能です。

複合キー
エンドポイント + ユーザーIDなど、複数の要素を組み合わせて細かな制御を行います。「ユーザーAは /api/search に1分間に10回まで」のような設定が可能です。

レート制限の主要アルゴリズム

レート制限を実現するアルゴリズムにはいくつかの種類があり、それぞれ特性が異なります。

固定ウィンドウカウンター

最もシンプルなアルゴリズムです。一定の時間枠(たとえば1分間)を設定し、その枠内のリクエスト数をカウントします。

// 固定ウィンドウの概念図
// 制限: 100リクエスト / 1分

// 10:00:00 - 10:00:59 のウィンドウ
// [リクエスト1] [リクエスト2] ... [リクエスト100] → 10:00:45で上限到達
// [リクエスト101] → 429 Too Many Requests

// 10:01:00 - 10:01:59 のウィンドウ → カウンターリセット
// [リクエスト1] ... → また100リクエストまで許可

メリット:実装がシンプルで、メモリ消費が少ない。

デメリット:ウィンドウの境界付近でバースト的なアクセスが発生する「バウンダリ問題」があります。10:00:50に90リクエスト、10:01:10に90リクエストが発生すると、20秒間に180リクエストが処理されてしまい、期待した制限の1.8倍のトラフィックが集中します。

スライディングウィンドウログ

各リクエストのタイムスタンプを記録し、現在時刻から一定期間内のリクエスト数をカウントするアルゴリズムです。

// スライディングウィンドウログの概念
// 制限: 100リクエスト / 1分

// 現在時刻: 10:01:15
// 10:00:15 - 10:01:15 の1分間に含まれるリクエストをカウント
// → 古いタイムスタンプ (10:00:15より前) は削除

// リクエストのタイムスタンプログ:
// [10:00:20, 10:00:25, 10:00:30, ..., 10:01:10, 10:01:14]
// → このウィンドウ内のリクエスト数でカウント

メリット:バウンダリ問題が発生しない。正確な制限が可能。

デメリット:全リクエストのタイムスタンプを保持するため、メモリ消費が大きい。

スライディングウィンドウカウンター

固定ウィンドウカウンターとスライディングウィンドウログのハイブリッドです。前のウィンドウと現在のウィンドウのカウントを加重平均で計算します。

// スライディングウィンドウカウンターの計算例
// 制限: 100リクエスト / 1分

// 前のウィンドウ(10:00:00-10:00:59): 84リクエスト
// 現在のウィンドウ(10:01:00-10:01:59): 36リクエスト

// 現在時刻: 10:01:15(現在ウィンドウの25%が経過)
// 推定リクエスト数 = 84 × (1 - 0.25) + 36 = 84 × 0.75 + 36 = 63 + 36 = 99

// → 残り1リクエストで上限

メリット:メモリ効率が良く、バウンダリ問題を緩和できる。多くの本番環境で採用されている。

デメリット:完全に正確ではなく、近似値による制御。

トークンバケット

一定のレートでトークンが補充されるバケツ(容器)を持ち、リクエストごとにトークンを1つ消費するアルゴリズムです。バケツが空になるとリクエストは拒否されます。

// トークンバケットの概念
// バケットサイズ: 10トークン(最大値)
// 補充レート: 1トークン/秒

// 初期状態: バケット満タン (10トークン)
// t=0: リクエスト → 9トークン
// t=0: リクエスト → 8トークン
// t=0: リクエスト → 7トークン(バースト的な利用が可能)
// ...
// t=0: 10リクエスト処理 → 0トークン
// t=0: リクエスト → 拒否(トークンなし)
// t=1: 1トークン補充 → 1トークン
// t=1: リクエスト → 0トークン

メリット:バーストトラフィック(一時的な大量リクエスト)を許容しつつ、平均レートを制限できる。AWSやStripeなど多くのAPIで採用されている。

デメリット:パラメータ(バケットサイズと補充レート)のチューニングが必要。

リーキーバケット

トークンバケットと似ていますが、リクエストの処理レートが一定に保たれる点が異なります。バケットにリクエストがキューイングされ、一定レートで処理されます。

メリット:アウトプットのレートが完全に均一になるため、下流サービスへの負荷が安定する。

デメリット:バーストトラフィックを処理できない。キューが溢れるとリクエストが破棄される。

Node.jsでレート制限を実装する

実際にNode.jsでレート制限を実装する方法を紹介します。

インメモリ実装(シンプル版)

まずは小規模なアプリケーション向けのインメモリ実装です。

// rateLimiter.ts - スライディングウィンドウカウンターの実装
interface WindowData {
  count: number;
  startTime: number;
}

class SlidingWindowRateLimiter {
  private windowSize: number;  // ウィンドウサイズ(ミリ秒)
  private maxRequests: number; // ウィンドウ内の最大リクエスト数
  private previousWindows: Map<string, WindowData> = new Map();
  private currentWindows: Map<string, WindowData> = new Map();

  constructor(windowSizeMs: number, maxRequests: number) {
    this.windowSize = windowSizeMs;
    this.maxRequests = maxRequests;
  }

  isAllowed(key: string): { allowed: boolean; remaining: number; resetAt: number } {
    const now = Date.now();
    const currentWindowStart = Math.floor(now / this.windowSize) * this.windowSize;
    const windowProgress = (now - currentWindowStart) / this.windowSize;

    // 現在のウィンドウのカウントを取得
    let current = this.currentWindows.get(key);
    if (!current || current.startTime !== currentWindowStart) {
      // 前のウィンドウを保存
      if (current) {
        this.previousWindows.set(key, current);
      }
      current = { count: 0, startTime: currentWindowStart };
      this.currentWindows.set(key, current);
    }

    // 前のウィンドウのカウントを取得
    const previous = this.previousWindows.get(key);
    const previousCount = previous && previous.startTime === currentWindowStart - this.windowSize
      ? previous.count
      : 0;

    // 加重平均で推定リクエスト数を計算
    const estimatedCount = previousCount * (1 - windowProgress) + current.count;
    const remaining = Math.max(0, this.maxRequests - Math.ceil(estimatedCount) - 1);
    const resetAt = currentWindowStart + this.windowSize;

    if (estimatedCount >= this.maxRequests) {
      return { allowed: false, remaining: 0, resetAt };
    }

    current.count++;
    return { allowed: true, remaining, resetAt };
  }
}

// Expressミドルウェアとして使用
import { Request, Response, NextFunction } from 'express';

const limiter = new SlidingWindowRateLimiter(60000, 100); // 1分間に100リクエスト

function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
  // APIキーまたはIPアドレスを識別キーとして使用
  const key = req.headers['x-api-key'] as string || req.ip;

  const result = limiter.isAllowed(key);

  // レート制限のレスポンスヘッダーを設定
  res.set('X-RateLimit-Limit', '100');
  res.set('X-RateLimit-Remaining', String(result.remaining));
  res.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));

  if (!result.allowed) {
    res.set('Retry-After', String(Math.ceil((result.resetAt - Date.now()) / 1000)));
    return res.status(429).json({
      error: 'Too Many Requests',
      message: 'レート制限を超過しました。しばらく待ってから再試行してください。',
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }

  next();
}

export { rateLimitMiddleware };

Redis実装(本番環境向け)

分散環境では、複数のサーバーインスタンス間でレート制限のカウンターを共有する必要があります。Redisを使った実装が一般的です。

// redisRateLimiter.ts - トークンバケットのRedis実装
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: 6379,
});

// Luaスクリプトでアトミックにトークンバケットを操作
const TOKEN_BUCKET_SCRIPT = `
  local key = KEYS[1]
  local capacity = tonumber(ARGV[1])
  local refillRate = tonumber(ARGV[2])
  local now = tonumber(ARGV[3])
  local requested = tonumber(ARGV[4])

  -- 現在のバケット情報を取得
  local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
  local tokens = tonumber(bucket[1])
  local lastRefill = tonumber(bucket[2])

  -- 初回アクセスの場合
  if tokens == nil then
    tokens = capacity
    lastRefill = now
  end

  -- トークンの補充
  local elapsed = now - lastRefill
  local newTokens = elapsed * refillRate / 1000  -- ミリ秒単位
  tokens = math.min(capacity, tokens + newTokens)

  -- トークンの消費
  local allowed = false
  local remaining = tokens

  if tokens >= requested then
    tokens = tokens - requested
    allowed = true
    remaining = tokens
  end

  -- バケット情報を更新
  redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
  redis.call('PEXPIRE', key, capacity / refillRate * 1000 * 2)  -- TTL設定

  return {allowed and 1 or 0, math.floor(remaining)}
`;

class RedisTokenBucket {
  private capacity: number;
  private refillRate: number; // トークン/秒

  constructor(capacity: number, refillRate: number) {
    this.capacity = capacity;
    this.refillRate = refillRate;
  }

  async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number }> {
    const result = await redis.eval(
      TOKEN_BUCKET_SCRIPT,
      1,
      `ratelimit:${key}`,
      this.capacity,
      this.refillRate,
      Date.now(),
      1 // 1トークンを消費
    ) as [number, number];

    return {
      allowed: result[0] === 1,
      remaining: result[1],
    };
  }
}

// 使用例
const bucket = new RedisTokenBucket(100, 10); // 容量100、毎秒10トークン補充

async function rateLimitMiddleware(req, res, next) {
  const key = req.headers['x-api-key'] || req.ip;
  const result = await bucket.isAllowed(key);

  res.set('X-RateLimit-Limit', '100');
  res.set('X-RateLimit-Remaining', String(result.remaining));

  if (!result.allowed) {
    return res.status(429).json({
      error: 'Too Many Requests',
      message: 'レート制限を超過しました',
    });
  }

  next();
}

レスポンスヘッダーの設計

クライアントがレート制限の状態を把握できるよう、適切なレスポンスヘッダーを返すことが重要です。

標準的なヘッダー

IETF RFC 6585で定義された429ステータスコードと、広く使われているヘッダーの慣例があります。

# 正常時のレスポンスヘッダー
HTTP/1.1 200 OK
X-RateLimit-Limit: 100          # 最大リクエスト数
X-RateLimit-Remaining: 73       # 残りリクエスト数
X-RateLimit-Reset: 1711540800   # リセット時刻(UNIXタイムスタンプ)

# レート制限超過時のレスポンス
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711540800
Retry-After: 45                  # 再試行までの秒数
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "レート制限を超過しました。45秒後に再試行してください。",
    "retryAfter": 45
  }
}

RateLimit ヘッダーフィールドの新標準

IETF draft-ietf-httpapi-ratelimit-headers で新しいヘッダー名が提案されています。

# 新しい標準ヘッダー(draft)
RateLimit-Limit: 100
RateLimit-Remaining: 73
RateLimit-Reset: 45  # リセットまでの秒数(UNIXタイムスタンプではなく差分)

現時点では新旧両方のヘッダーを返しておくのが安全です。

多層レート制限の設計

実際のAPIでは、複数の粒度でレート制限を組み合わせることが一般的です。

多層レート制限の実装

// 多層レート制限の設定例
const rateLimitConfig = {
  // グローバル制限(全体の保護)
  global: {
    windowMs: 1000,      // 1秒
    maxRequests: 10000,   // 全クライアント合計で10,000リクエスト/秒
  },
  // ユーザー単位の制限
  perUser: {
    windowMs: 60000,     // 1分
    maxRequests: 100,    // 100リクエスト/分
  },
  // エンドポイント単位の制限
  perEndpoint: {
    '/api/search': {
      windowMs: 60000,
      maxRequests: 20,   // 検索は重い処理なので制限を厳しく
    },
    '/api/users': {
      windowMs: 60000,
      maxRequests: 100,
    },
  },
  // プラン別の制限
  perPlan: {
    free: { windowMs: 3600000, maxRequests: 100 },    // 100リクエスト/時
    pro: { windowMs: 3600000, maxRequests: 10000 },    // 10,000リクエスト/時
    enterprise: { windowMs: 3600000, maxRequests: 100000 },
  },
};

// 多層チェックのミドルウェア
async function multiLayerRateLimit(req, res, next) {
  const userId = req.user?.id || req.ip;
  const plan = req.user?.plan || 'free';
  const endpoint = req.path;

  // 1. グローバル制限のチェック
  const globalResult = await globalLimiter.isAllowed('global');
  if (!globalResult.allowed) {
    return res.status(503).json({ error: 'Service Temporarily Unavailable' });
  }

  // 2. プラン別制限のチェック
  const planConfig = rateLimitConfig.perPlan[plan];
  const planLimiter = new RedisTokenBucket(
    planConfig.maxRequests,
    planConfig.maxRequests / (planConfig.windowMs / 1000)
  );
  const planResult = await planLimiter.isAllowed(`plan:${userId}`);
  if (!planResult.allowed) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      upgrade: plan === 'free' ? 'Proプランにアップグレードすると制限が緩和されます' : undefined,
    });
  }

  // 3. エンドポイント別制限のチェック
  const endpointConfig = rateLimitConfig.perEndpoint[endpoint];
  if (endpointConfig) {
    const endpointResult = await endpointLimiter.isAllowed(`endpoint:${userId}:${endpoint}`);
    if (!endpointResult.allowed) {
      return res.status(429).json({ error: 'Endpoint rate limit exceeded' });
    }
  }

  next();
}

クライアント側のレート制限対応

APIを利用するクライアント側でも、レート制限を考慮した実装が必要です。

指数バックオフとリトライ

// 指数バックオフ付きリトライの実装
async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  maxRetries: number = 3
): Promise<Response> {
  let lastError: Error;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.status === 429) {
        // Retry-Afterヘッダーを確認
        const retryAfter = response.headers.get('Retry-After');
        const waitTime = retryAfter
          ? parseInt(retryAfter) * 1000
          : Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 30000);

        console.log(`レート制限超過。${waitTime / 1000}秒後にリトライします(試行${attempt + 1}/${maxRetries + 1})`);

        if (attempt < maxRetries) {
          await new Promise(resolve => setTimeout(resolve, waitTime));
          continue;
        }
      }

      if (response.status === 503) {
        // サーバー過負荷の場合もリトライ
        const waitTime = Math.min(1000 * Math.pow(2, attempt), 30000);
        if (attempt < maxRetries) {
          await new Promise(resolve => setTimeout(resolve, waitTime));
          continue;
        }
      }

      return response;
    } catch (error) {
      lastError = error as Error;
      if (attempt < maxRetries) {
        const waitTime = Math.min(1000 * Math.pow(2, attempt), 30000);
        await new Promise(resolve => setTimeout(resolve, waitTime));
      }
    }
  }

  throw lastError!;
}

// 使用例
const response = await fetchWithRetry('https://api.example.com/users', {
  headers: { 'X-API-Key': 'my-api-key' },
});

プロアクティブなレート管理

レスポンスヘッダーのX-RateLimit-Remainingを監視し、制限に達する前にリクエスト頻度を調整する方法も有効です。

// プロアクティブなレート管理
class RateLimitAwareClient {
  private remaining: number = Infinity;
  private resetAt: number = 0;

  async request(url: string): Promise<Response> {
    // 残りが少ない場合はリクエスト間隔を空ける
    if (this.remaining <= 5 && Date.now() < this.resetAt) {
      const waitTime = (this.resetAt - Date.now()) / (this.remaining + 1);
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }

    const response = await fetch(url);

    // ヘッダーからレート制限情報を更新
    this.remaining = parseInt(response.headers.get('X-RateLimit-Remaining') || 'Infinity');
    this.resetAt = parseInt(response.headers.get('X-RateLimit-Reset') || '0') * 1000;

    return response;
  }
}

分散環境でのレート制限の考慮点

複数のサーバーインスタンスやリージョンにまたがる環境では、追加の考慮が必要です。

一貫性と性能のトレードオフ

強い一貫性
Redisの単一インスタンスで集中管理する方法です。正確な制限が可能ですが、Redisへのネットワークレイテンシがボトルネックになる場合があります。

結果整合性
各サーバーにローカルカウンターを持ち、定期的にRedisと同期する方法です。パフォーマンスは向上しますが、短期間の制限超過が発生する可能性があります。

多くの場合、レート制限には厳密な正確さよりも性能が優先されるため、結果整合性のアプローチで十分です。「100リクエスト制限で105リクエスト通過した」程度の誤差は実用上問題ありません。

APIゲートウェイでの実装

本番環境では、アプリケーションコード内ではなく、APIゲートウェイ層でレート制限を実装するのが一般的です。Kong、AWS API Gateway、Nginx、Envoyなどがレート制限機能を提供しています。

# Nginx でのレート制限設定例
http {
    # レート制限ゾーンの定義
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            # レート制限の適用(バースト5リクエストまで許容)
            limit_req zone=api burst=5 nodelay;
            limit_req_status 429;

            proxy_pass http://backend;
        }
    }
}

まとめ

APIレート制限は、安定したサービス運用とビジネスモデルの制御に不可欠な仕組みです。本記事のポイントを振り返りましょう。

目的を明確にする
サーバー保護、公平性確保、コスト管理、ビジネスモデルの実現など、レート制限の目的によって設計が変わります。

アルゴリズムの選択
トークンバケットはバーストを許容しつつ平均レートを制限でき、最も汎用的です。スライディングウィンドウカウンターはメモリ効率と精度のバランスが良好です。

レスポンスヘッダーの重要性
X-RateLimit-RemainingRetry-Afterヘッダーにより、クライアントが適切に対応できるようになります。

多層の制限設計
グローバル、ユーザー単位、エンドポイント単位、プラン単位の多層構成で、きめ細かな制御を実現します。

クライアント側の対応
指数バックオフ付きリトライとプロアクティブなレート管理で、レート制限に上手く対応するクライアントを実装しましょう。

まずは既存のAPIに最もシンプルな固定ウィンドウカウンターを導入し、必要に応じてアルゴリズムを高度化していくのが現実的なアプローチです。

#API#レート制限#設計
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

AI活用のヒントをお探しですか?お気軽にご相談ください。

まずは話だけ聞いてもらう