RedisおよびSpring Cloud GatewayでAPIゲートウェイ用のレートリミッターを設計

1. 概要

複数のクライアントが同じAPIを呼び出すとき、障害のあるクライアントまたは意図的なスパムが、誰もが利用するバックエンドを落とすことができます。APIゲートウェイのレイヤーにあるレートリミッターは、リクエストが後ろのサービスに到達する前に、制限を超えたリクエストをブロックします。

この記事は完全なケーススタディを通じて説明します。要件を述べることから、アルゴリズムの選択、データとAPIの設計、Redis Luaスクリプトを使ったSpring Cloud Gatewayでの実装まで、各ステップを順に進めます。最後まで読むと、分散レートリミッターを構築でき、競合状態、クロックスキュー、ホットキー、Redisの障害への対処方法を理解することができます。

2. 要件定義

2.1. 機能要件

  • テナントのAPIキーとルートごとに制限を設定します。例えば、テナントshop-aのPOST /ordersは最大100リクエスト/秒、バースト時は最大200まで許可。
  • 制限を超えたリクエストは429 Too Many RequestsとRetry-Afterヘッダーで応答します。
  • すべてのレスポンスにX-RateLimit-Remainingヘッダーを付けて、クライアントが自動調整できるようにします。
  • ルールはゲートウェイを再デプロイせずに実行時に変更できる必要があります。

2.2. スケールと制約

  • ピーク時に約50,000 RPS、20インスタンスのゲートウェイ、100,000テナント。
  • 各リクエストに追加される遅延はp99で2ms未満である必要があります。
  • 制限はクラスタ全体で正確である必要があり、各インスタンスで個別に計算してはいけません。制限が100/sの場合、クラスタ全体で約100/sしか許可されません。
  • レートリミッターは単一の障害点になってはいけません。Redisに問題が発生した場合でも、ゲートウェイは引き続きサービスを提供する必要があります。
  • パフォーマンスと引き換えに、小さなエラーマージン(数パーセント)を許容します。

3番目の制約は、各インスタンスの在メモリカウントを除外します。共有状態ストレージが必要です。Redisが自然な選択です。低遅延で、Luaを通じた原子的な操作が可能だからです。

3. アルゴリズムの選択

アルゴリズム 長所 短所
Fixed window シンプル、1つのINCR ウィンドウの境界でバースト2倍
Sliding log 完全に正確 メモリ集約的、タイムスタンプを保存
Sliding window counter ほぼ正確、軽量 バースト表現が難しい
Token bucket バーストを自然にサポート、2フィールドのみ トークン補充計算が必要

トークンバケットを選択します。各バケットにはcapacity(バースト容量)とrefillPerSecondの補充レートがあります。各リクエストは1トークンを取得します。トークンが尽きたらリクエストは拒否されます。状態はtokensとts(最終更新時刻)の2つの数値だけです。100,000テナント×複数ルートでも、総容量は数十MB程度です。

4. データモデル

4.1. SQLのルール設定

ルールはほぼ変わらず、管理が必要なため、PostgreSQLに保存します。

CREATE TABLE rate_limit_rule (
    id                 BIGSERIAL PRIMARY KEY,
    tenant_id          VARCHAR(64)  NOT NULL,
    route_id           VARCHAR(64)  NOT NULL,
    capacity           INT          NOT NULL CHECK (capacity > 0),
    refill_per_second  INT          NOT NULL CHECK (refill_per_second > 0),
    updated_at         TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, route_id)
);

INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);

ゲートウェイは、リクエストのたびにこのテーブルを読みません。各インスタンスはすべてのルールをメモリにロードし、定期的(例えば30秒ごと)に更新します。別の方法は、Kafkaを通じてrule-changedイベントをリッスンすることです。ホットパスはデータベースに決して触れません。

4.2. Redis内のバケット状態

各テナント–ルートペアはRedisハッシュです。

Key:    rl:{shop-a}:create-order
Fields: tokens = 153.4
        ts     = 1760256000123   (ms、Redisクロック)
TTL:    バケットが満杯で長時間使用されない場合は自動期限切れ

{shop-a}部分はハッシュタグです。Redis Clusterでは、同じテナントのすべてのキーが同じスロットに落ちます。後でスクリプト内で複数のバケットをチェックする必要がある場合、それらは同じノードに配置されます。

5. APIと処理フロー

クライアント側では、2つのタイプのレスポンスが見えます。

HTTP/1.1 200 OK
X-RateLimit-Remaining: 152

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0

ゲートウェイ内の処理フロー:

  1. X-Api-Keyを取得してテナントを特定し、マッチしたルートからルートIDを取得します。
  2. メモリキャッシュでルールを検索します。ルールがない場合はデフォルトルールを使用します。
  3. Redisでluaスクリプトを呼び出して、トークンを補充し、1回のラウンドトリップの原子的な操作で取得します。
  4. 許可された場合はヘッダーを付けて転送します。拒否された場合は429を返します。
  5. Redisがエラーまたはタイムアウトの場合は、ローカルリミッターに切り替えます(セクション7.3)。

6. 実装

6.1. 依存関係

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>
</dependencies>

6.2. Luaスクリプトトークンバケット

ファイルをsrc/main/resources/scripts/token_bucket.luaに配置します。

local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0   -- ms当たりのトークン

local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts     = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)

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

redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)

return { allowed, math.floor(tokens) }

主な点:

  • redis.call('TIME')はゲートウェイの時刻ではなくRedisの時刻を取得します。20インスタンスは数十ミリ秒のクロックスキューを持つため、各インスタンスが独自のnowを送信すると、トークンが誤って補充されます。Redis 7以降、スクリプトはデフォルトで効果レプリケーション(effects replication)を使用するため、書き込み前にTIMEを呼び出すのは安全です。
  • 読み取り、計算、書き込みの全シーケンスは原子的に実行されます。Redisはスクリプトをシングルスレッドで実行するため、これがインスタンス間の競合に対する解決策です。
  • PEXPIREはバケットを満杯にするのにかかる時間の2倍です。使用されないバケットは自動的に消えます。再度表示されると、満杯から始まり、期待される動作になります。
  • スクリプトは[allowed, remaining]を返します。Luaは返却時に浮動小数点数を整数に変換するため、明確にするために前にfloorを行います。

6.3. JavaからのスクリプトCall

public record RateLimitRule(int capacity, int refillPerSecond) {}

public record Decision(boolean allowed, long remaining) {}

@Component
public class RedisTokenBucket {

    private final ReactiveStringRedisTemplate redis;
    private final RedisScript<List> script =
            RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);

    public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
        this.redis = redis;
    }

    @SuppressWarnings("unchecked")
    public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
        String key = "rl:{" + tenant + "}:" + route;
        return redis.execute(script, List.of(key),
                        List.of(String.valueOf(rule.capacity()),
                                String.valueOf(rule.refillPerSecond())))
                .next()
                .map(r -> {
                    List<Long> res = (List<Long>) r;
                    return new Decision(res.get(0) == 1L, res.get(1));
                });
    }
}

Springは自動的にスクリプトのSHA1を計算してEVALSHAを使用します。Redisがまだスクリプトをキャッシュしていない場合、SpringはEVALにフォールバックします。したがって、最初の呼び出しだけがネットワーク経由でスクリプト全体を送信します。

6.4. ゲートウェイのグローバルフィルター

@Component
public class RateLimitFilter implements GlobalFilter, Ordered {

    private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);

    private final RedisTokenBucket redisBucket;
    private final LocalFallbackLimiter fallback;
    private final RuleCache rules; // Map<String, RateLimitRule> DBから定期的にロード

    public RateLimitFilter(RedisTokenBucket redisBucket,
                           LocalFallbackLimiter fallback, RuleCache rules) {
        this.redisBucket = redisBucket;
        this.fallback = fallback;
        this.rules = rules;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
        if (tenant == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
        String routeId = route != null ? route.getId() : "default";
        RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);

        return redisBucket.tryAcquire(tenant, routeId, rule)
                .timeout(Duration.ofMillis(5))
                .onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
                .flatMap(decision -> {
                    var response = exchange.getResponse();
                    response.getHeaders().set("X-RateLimit-Remaining",
                            String.valueOf(decision.remaining()));
                    if (decision.allowed()) {
                        return chain.filter(exchange);
                    }
                    response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
                    response.getHeaders().set("Retry-After",
                            String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
                    return response.setComplete();
                });
    }

    @Override
    public int getOrder() {
        return -100; // ルートマッチ後、ルーティングフィルター前に実行
    }
}

フィルターはすべてのルートに対して実行されます。これは遅いRedisノードがゲートウェイ全体の遅延を引きずらないようにtimeout(5ms)を設定します。Redisがエラーになると、onErrorResumeはローカルリミッターに切り替わります。ルールcapacity=2でcurlをすばやく試してみます。

for i in 1 2 3; do
  curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429

7. 難しい点と解決策

7.1. インスタンス間の競合

素朴なアプローチは、HGETを呼び出し、Javaで計算し、HSETを呼び出すことです。2つのインスタンスが同じtokens=1を読み取り、両方ともリクエストを許可する可能性があります。Luaスクリプトはこのレースコンディションを完全に排除します。WATCH/MULTIアプローチも正しいですが、競合時に再試行する必要があり、より多くのラウンドトリップが必要です。

7.2. ホットキーとバースト負荷

大きなテナントは1つのキーに20,000 RPSを送信する可能性があります。1つのRedisノードは約100,000 ops/sを処理できますが、そのキーはホットスポットになります。2つの解決方法があります。

  • トークンをバッチで割り当てる(ローカルプリフェッチ): 各インスタンスは事前にトークンのバッチ(例:50トークン/回)を要求し、メモリで徐々に消費します。Redisコールの数は約50倍減少します。その代わり、短時間で総制限がインスタンス数×バッチサイズを超える可能性があります。セクション2.2の「数パーセントのエラーマージン」要件を考えると、これは許容範囲です。
  • バケットのシャーディング: 20,000/sの制限を10個のサブバケットrl:{shop-a}:create-order:0..9に分割し、各2,000/s。リクエストはサブバケットをランダムに選択します。これらのキーのハッシュタグを削除して、複数のノードに分散させます。

正当なバーストはすでにcapacityで吸収されています。これがトークンバケットをfixed windowの代わりに選んだ理由です。

7.3. Redisが利用不可:制御されたフェイルオープン

フェイルクローズ(つまり、Redisが停止したときにすべてのリクエストを拒否)は、二次的なインフラストラクチャの障害を全体的なシステム障害に変えます。完全なフェイルオープンでは保護層が失われます。中間のソリューションは、ローカルリミッターで、制限をインスタンス数で均等に分割することです。

@Component
public class LocalFallbackLimiter {

    private final int instanceCount;
    private final LongSupplier clock;
    private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();

    public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
        this(instanceCount, System::currentTimeMillis);
    }

    LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
        this.instanceCount = instanceCount;
        this.clock = clock;
    }

    public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
        double cap = Math.max(1, rule.capacity() / (double) instanceCount);
        double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
        LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
                k -> new LocalBucket(cap, clock.getAsLong()));
        return b.take(cap, perMs, clock.getAsLong());
    }

    static final class LocalBucket {
        private double tokens;
        private long ts;

        LocalBucket(double tokens, long ts) {
            this.tokens = tokens;
            this.ts = ts;
        }

        synchronized Decision take(double cap, double perMs, long now) {
            tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
            ts = now;
            if (tokens >= 1) {
                tokens -= 1;
                return new Decision(true, (long) tokens);
            }
            return new Decision(false, 0);
        }
    }
}

各インスタンスは1/instanceCount制限の一部を保持します。クラスタ全体の制限はRedisなしでもほぼ正確です。本番環境では、Redisコールをサーキットブレーカー(例えばResilience4j)でラップする必要があります。Redisがダウンしている場合、サーキットブレーカーは各リクエストの5msタイムアウトを節約できます。

7.4. マルチリージョンの一貫性

ゲートウェイが2つのリージョンで実行されている場合、リージョン間のRedis同期をリクエストごとに行うと、2msの要件が破られます。実用的なアプローチは、トラフィック比率に基づいてリージョンごとに制限を分割することです(例えば70/30)。テナントはこのリージョンで早期にブロックされる可能性があります。より最適化が必要な場合、バックグラウンドジョブが実際のメトリクスに基づいて数分ごとに比率を調整できます。

8. テスト

トークンバケットロジックは実時間時計から独立してテストされるべきです。LocalFallbackLimiterがLongSupplierを受け入れるため、AtomicLongを使用して時間を進めることができます。

class LocalFallbackLimiterTest {

    @Test
    void shouldRejectWhenBucketEmptyAndRefillOverTime() {
        AtomicLong now = new AtomicLong(0);
        var limiter = new LocalFallbackLimiter(1, now::get);
        var rule = new RateLimitRule(2, 1); // バースト2、1トークン/秒で補充

        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();

        now.addAndGet(1_000); // 1秒後、追加の1トークン

        Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
        assertThat(d.allowed()).isTrue();
        assertThat(d.remaining()).isZero();
    }
}

このテストは3つの動作を確認します。バーストはcapacityまで許可され、制限を超えるリクエストは拒否され、トークンは時間に応じて正しく補充されます。Luaスクリプトを使用する場合は、Testcontainersでredis:7イメージを使用して統合テストを作成する必要があります。1,000個のリクエストを並列に送信し、許可されたリクエスト数がcapacityを超えないことを確認します。

注意: よくあるエラーのいくつか:

  • ゲートウェイのSystem.currentTimeMillis()をスクリプトのnowとして使用する。インスタンス間のクロックスキューにより、トークンが誤って補充されます。
  • キーのTTLを設定し忘れる。Redisは表示されたテナントの数に応じて徐々に拡大します。
  • NATPまたはロードバランサーの後ろのIPでクライアントを識別する。会社全体が1つのバケットを共有します。
  • ゲートウェイのレートリミッターを、サービス内の過負荷保護メカニズム(bulkhead、load shedding)の代わりに使用しないでください。ゲートウェイはリクエスト数しか認識しておらず、どのリクエストが重いかはわかりません。

9. 結論

この記事では、要件から実装まで、APIゲートウェイのレートリミッターを設計しました。トークンバケットはバーストをきれいに処理します。RedisのLuaスクリプトは、1つの原子的ラウンドトリップで競合を解決します。Redisから時刻を取得することで、インスタンス間のクロックスキューの問題を排除します。

実際の難しさはアルゴリズムを超えています。ホットキーにはトークンのバッチ割り当てまたはバケットシャーディングが必要です。Redisの障害には、ローカルリミッターによる制御されたフェイルオープンが必要です。マルチリージョンシステムには、厳密な同期ではなく制限の分割が必要です。各ポイントで、低遅延と高可用性を得るために少しの精度を交換します。これは最初に設定した制約に沿っています。

system-designrate-limitingredisspring-cloud-gatewayjava