APIゲートウェイの堅牢化:NginxとRedisによる分散型レート制限の実装

Security tutorial - IT technology blog
Security tutorial - IT technology blog

午前2時の緊急呼び出し:デフォルト設定が通用しない理由

午前2時14分、ナイトテーブルの上で携帯電話が激しく震え始めました。私たちの本番環境のAPIゲートウェイが悲鳴を上げていたのです。ボットネットが認証なしの検索エンドポイントを標的にし、秒間5,000件以上のリクエストを浴びせかけていました。バックエンドのデータベースは複雑なJOIN処理でパンク寸前。APIノードのCPU使用率は95%に急上昇し、正当なユーザーの画面には「504 Gateway Timeout」が表示されていました。

これが公開APIの現実です。トラフィックを制限せず、呼び出し元を特定しないのであれば、それは本番システムを運用しているのではなく、時限爆弾を管理しているようなものです。私はその夜、ファイアウォールでIPレンジを手動でブラックリストに登録し続けました。これはまさに、実戦で鍛えられたLinuxインシデントレスポンスの現場そのものでした。教訓は明確でした。一意のAPIキーに紐付いた「分散型レート制限システム」が必要だったのです。

アプローチの比較:ローカル vs. 分散型レート制限

当初、私はNginxの要塞化の基本である limit_req_zone に頼っていました。これはシンプルですが、現代のオートスケーリング環境ではうまく機能しません。ロードバランサーの背後に5つのNginxインスタンスがある場合、各インスタンスは独自の独立したカウンターを保持します。つまり、攻撃者は単一のノードがブロックを開始する前に、理論上、許可された制限の5倍のリクエストをシステムに送り込めてしまうのです。

現実的な環境における一般的な戦略の比較は以下の通りです:

  • 標準のNginx (ngx_http_limit_req_module): 高速かつシンプルで、オーバーヘッドはミリ秒未満です。共有メモリゾーンを使用します。最適: シングルサーバー構成、またはエッジでの基本的なDDoS対策。
  • アプリケーションレベルでの検証: PythonやNode.jsでロジックを記述することで、複雑なビジネスルールを適用できます。しかし、コストが高くなります。コードが実行される頃には、リクエストはすでにワーカーのスレッドとかなりのメモリを消費してしまっています。
  • Nginx + Lua + Redis: これが業界のゴールドスタンダードです。Nginxが接続を処理し、Luaがロジックを実行し、Redisがグローバルな状態を保存します。最適: 10台、あるいは100台のノード間で一貫した制限が必要な高トラフィックのクラスター。

Redisベースのセキュリティにおけるトレードオフ

エンジニアリングとは、トレードオフを管理することです。状態をRedisに移行すると、インフラに新たな変数が導入されます。

メリット

  • 絶対的な一貫性: ユーザー의制限が1時間あたり1,000リクエストであれば、どのゲートウェイノードにアクセスしても、正確に1,000リクエストで制限されます。
  • リアルタイム更新: Nginxの設定をリロードしたりサービスを再起動したりすることなく、Redis内の制限値を即座に変更できます。
  • ティア別アクセス: 「無料プラン」のキーには秒間10リクエスト、「エンタープライズ」のキーには秒間500リクエストといった割り当てが簡単にできます。

課題

  • レイテンシのオーバーヘッド: すべてのリクエストでRedisへのネットワークラウンドトリップが発生します。Redisは高速ですが、通常、リクエストごとに0.5msから2msのレイテンシが加算されます。
  • 運用の複雑化: 監視対象にRedisクラスターが加わります。Redisがダウンした場合、ゲートウェイに「フェイルオープン(制限なしで通す)」または「フェイルクローズ(すべて遮断する)」の戦略が必要になります。

アーキテクチャ:OpenResty + Redis

私はこれに OpenResty を使用することを好みます。これはLuaJITがバンドルされた堅牢なNginxの派生版です。リクエストライフサイクルの access フェーズにフックすることができます。これは、Apache/Nginx Webアプリケーション保護の基本戦略の一つであり、リクエストがゲートウェイに到達すると、APIキーを抽出し、Redisでクォータを確認し、許可するか、あるいは 429 Too Many Requests ステータスで破棄するかを決定します。

セキュリティは基本から始まります。このゲートウェイ用にRedisインスタンスをセットアップした際、強力なパスワードの生成方法を実践するため、管理用パスワードの作成には toolcraft.app/ja/tools/security/password-generator のツールを使用しました。これは完全にブラウザ上で動作するため、設定にデプロイされる前にキーがリモートサーバーに送信されることがなく安心です。

実装ガイド:APIの保護

今回は「固定ウィンドウ(Fixed Window)」アルゴリズムを実装します。これは最もパフォーマンスが高く、緊急時でもデバッグが容易なアプローチです。

ステップ1:Redisバックエンドの準備

キーは ratelimit:API_KEY:TIMESTAMP というパターンで保存します。キー usr_99 を持つユーザーが午前10時05分にAPIを呼び出した場合、60秒後に期限切れになるRedisキーをインクリメントします。これにより、古いデータは自動的にクリーンアップされます。

# redis-cliによる手動検証
INCR "limit:usr_99:202310271405"
EXPIRE "limit:usr_99:202310271405" 60

ステップ2:Nginx Luaロジック

OpenRestyの設定で、access_by_lua_block 内にロジックを定義します。これにより、高コストなバックエンドサービスにリクエストがプロキシされる前にチェックが行われることが保証されます。

http {
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";

    server {
        listen 80;
        server_name api.example.com;

        location /v1/ {
            access_by_lua_block {
                local redis = require "resty.redis"
                local red = redis:new()
                red:set_timeout(1000) -- 1秒のタイムアウト

                local ok, err = red:connect("127.0.0.1", 6379)
                if not ok then
                    ngx.log(ngx.ERR, "Redisへの接続に失敗しました: ", err)
                    return ngx.exit(500)
                end

                local api_key = ngx.req.get_headers()["X-API-Key"]
                if not api_key then
                    ngx.status = ngx.HTTP_UNAUTHORIZED
                    ngx.say("{\"error\": \"APIキーがありません\"}")
                    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
                end

                -- 制限: 1分間に100リクエスト
                local limit = 100
                local current_time = os.date("%Y%m%d%H%M")
                local key = "limit:" .. api_key .. ":" .. current_time

                local count, err = red:incr(key)
                if not count then
                    ngx.log(ngx.ERR, "インクリメントに失敗しました: ", err)
                    return ngx.exit(500)
                end

                if tonumber(count) == 1 then
                    red:expire(key, 60)
                end

                if tonumber(count) > limit then
                    ngx.status = 429
                    ngx.header.content_type = "application/json"
                    ngx.say("{\"error\": \"レート制限を超過しました。1分後にもう一度お試しください。\"}")
                    return ngx.exit(429)
                end
                
                -- 重要:接続をコネクションプールに戻す
                red:set_keepalive(10000, 100)
            }

            proxy_pass http://backend_cluster;
        }
    }
}

ステップ3:キーの検証

キー自体が偽物であれば、レート制限は何の意味もありません。インクリメントを確認する前に、RedisのSETをクエリして、X-API-Key が実際にシステム内に存在するか確認してください。これにより、攻撃者がランダムな存在しないキーでRedisেরメモリを埋め尽くすのを防ぐことができます。

local is_valid, err = red:sismember("active_keys", api_key)
if is_valid == 0 then
    ngx.status = ngx.HTTP_FORBIDDEN
    ngx.say("{\"error\": \"無効なAPIキーです\"}")
    return ngx.exit(ngx.HTTP_FORBIDDEN)
end

テストとモニタリング

次の攻撃が来るまで、これが機能するかどうか待つ必要はありません。hey のようなツールを使用して、200リクエストのバーストをシミュレートしてください。redis-cli monitor を使用して、リアルタイムでRedisインスタンスを監視します。これは、Wazuhによるセキュリティ監視と同様に、システムの健全性を可視化するために重要です。キーがインクリメントされ、重要な点として、制限を超えた瞬間に429エラーが発生することを確認できるはずです。

コネクションプールには細心の注意を払ってください。HTTPリクエストごとにRedisへの新しいTCP接続を開くと、パフォーマンスが極端に低下します。必ず set_keepalive を使用して既存の接続を再利用してください。この最適化だけで、レイテンシのオーバーヘッドを70%削減できることもあります。

このアーキテクチャを実装したことで、私は安眠を手に入れました。次にボットネットが私たちのデータをスクレイピングしようとした際、Nginxは何の苦もなくそれを処理しました。数千件の429レスポンスを数ミリ秒で返し、バックエンドは完全に静かなままでした。これこそが、プロアクティブなセキュリティ層の力です。

Share: