
フレームワーク側で全リクエストに一律の制限を掛ければ、問題を起こしていないユーザーまで巻き込む。反対に何もしなければ、外部API提供元から一時的なブロックを受け、アプリケーション全体の応答が遅くなる。外部APIを利用する機能では、誰が、どのエンドポイントを、どれくらいの頻度で呼んだかを分けて管理しなければならない。
今回の対策では、LaravelのRateLimiterファサードを使い、認証済みユーザーはユーザーID、未認証リクエストはIPアドレスを識別子にした。さらに、複数のアプリケーションサーバーで同じ制限を共有するため、キャッシュストアにはRedisを利用した。
単にthrottleミドルウェアを追加するだけでは、外部APIの制限事情に合わないことがある。どこに制限を置き、何を単位にカウントし、429を受けたクライアントへ何を返すか。実装で問題になった部分を順番に整理する。
外部API呼び出しで429エラーを回避する設計思想
外部APIには、呼び出し回数や同時実行数について何らかの上限がある。上限の表現はサービスごとに異なるが、利用者側のアプリケーションが無制限に呼び出してよいAPIはほとんどない。
問題は、外部APIが429を返した後に起きやすい。失敗したリクエストをそのまま再試行すると、最初の呼び出しに加えてリトライも発生する。複数のリクエストが同時に失敗していれば、再試行が重なり、さらに制限へ近づく。いわゆるリトライの雪だるま状態である。
この状態になると、原因を作った一部のユーザーだけでなく、正規の利用者にも影響が出る。外部APIへの接続待ちが長くなり、アプリケーション側のワーカーやキューも詰まりやすい。外部APIの障害ではないのに、こちら側で障害を拡大させてしまう。
今回の設計では、次のように責任範囲を分けた。
- Laravel側で、外部APIを呼び出す入口へのリクエスト数を制限する
- 認証済みユーザーはユーザーID単位でカウントする
- 未認証リクエストはIPアドレス単位でカウントする
- APIの種類やエンドポイントに応じて、上限を分けられるようにする
- 複数サーバーから見ても同じカウントになるようにする
- 上限を超えた場合は、
Retry-Afterを返して再試行の目安を伝える
ここで大事なのは、RateLimiterを外部APIそのものの制限を完全に再現する仕組みとして扱わないことだ。外部APIの上限が「分あたりの呼び出し数」なのか、「秒あたりの呼び出し数」なのか、「同時実行数」なのかによって、必要な制御は変わる。
LaravelのRateLimiterが得意なのは、アプリケーションの入口で一定時間あたりの試行回数を数えることだ。外部APIの利用枠を厳密に予約したり、複数種類のクォータを一つのアルゴリズムで管理したりする仕組みではない。したがって、外部API側の仕様を確認したうえで、Laravel側では安全側に余裕を持った値を設定する必要がある。
全体制限ではなく、利用者ごとの制限にする
アプリケーション全体で1分あたり60回という制限を設定した場合、あるユーザーが大量に呼び出すだけで、他のユーザーが利用できなくなる。これは実装としては簡単だが、障害の影響範囲が広い。
一方、ユーザーごとに60回としておけば、あるユーザーのバーストが別のユーザーの枠を奪うことはない。もちろん、外部API全体の契約上限があるなら、ユーザー単位の制限だけでは足りない。ユーザーごとの制限に加えて、アプリケーション全体の上限やキューによる調整が必要になる場合もある。
今回のように「特定のユーザーが短時間に呼び出し続ける」問題では、まずユーザー単位のバケットを作るのが自然だった。全員を同じ箱に入れるのではなく、問題を起こしている単位に合わせてカウントする。
全体一律の制限は実装しやすい。しかし、原因が一部のユーザーにあるなら、全員を同じ制限に入れる理由はない。
RateLimiterファサードによる動的な制限ロジックの構築
Laravelでは、RateLimiter::for()を使って名前付きのリミッターを定義できる。リミッターの定義とルートへの適用を分離できるため、ルートファイルに条件分岐を散らばらせずに済む。
基本的な構成は、アプリケーションのサービスプロバイダーでリミッターを登録し、ルート側ではthrottle:external-apiのように名前を指定する形になる。Laravelのバージョンによって登録場所の標準構成は異なるが、考え方は変わらない。
主に使う要素は次の通りだ。
| 要素 | 役割 |
|---|---|
RateLimiter::for() | 名前付きのリミッターを定義する |
Limit::perMinute() | 1分あたりの上限を作る |
Limit::perSeconds() | 秒単位の短いウィンドウを作る |
Limit::by() | カウントの単位となる識別子を指定する |
RateLimiter::tooManyAttempts() | 上限を超えたか判定する |
RateLimiter::hit() | 試行回数を加算する |
RateLimiter::availableIn() | 制限解除までの残り時間を取得する |
たとえば、外部API用のリミッターを登録する場合は、認証済みユーザーと未認証リクエストを分けて定義する。
RateLimiter::for('external-api', function (Request $request) { $key = $request->user()?->id ? 'user:' . $request->user()->id : 'ip:' . $request->ip(); return Limit::perMinute(60)->by($key); });
この例で、->by()に渡した値がカウントの識別子になる。ユーザーIDだけをそのまま渡すより、user:やip:のような接頭辞を付けておいた方がよい。数値のユーザーIDと、文字列として似た形式の値が同じ名前空間に入るのを防げるためである。
$request->user()?->id ?: $request->ip()のように短く書くこともできる。ただし、実際の運用では識別子の種類を明示した方が、ログを調べるときに分かりやすい。Redis内のキーや429の調査ログを見たとき、ユーザー単位なのかIP単位なのかをすぐ判別できるからだ。
ユーザー属性で上限を切り替える
すべての利用者に同じ上限を設定する必要がない場合は、リクエストの属性からLimitを切り替えられる。
たとえば、認証済みユーザーと未認証ユーザーで上限を分ける構成は次のようになる。
RateLimiter::for('openai-api', function (Request $request) { if ($request->user()) { return Limit::perMinute(20)->by('user:' . $request->user()->id); } return Limit::perMinute(5)->by('ip:' . $request->ip()); });
ここでは、認証済みユーザーには1分あたり20回、未認証リクエストには1分あたり5回を設定している。数値そのものは外部APIの契約、処理コスト、ユーザー体験を見ながら決めるもので、Laravelの標準値があるわけではない。
ユーザーのプランや権限で分岐することもできる。ただし、リミッターの中にビジネスルールを大量に書き始めると、制限ロジックが読みづらくなる。無料ユーザー、管理者、特定の契約プランなど、分岐が増えてきたら、上限を返す専用メソッドやポリシーへ切り出した方が保守しやすい。
また、外部APIの種類ごとにリミッターを分ける方法もある。テキスト生成系のAPIとSNS投稿系のAPIでは、提供元の上限も、1回の呼び出しに掛かるコストも違う。同じ60回という数字を両方に適用するのは合理的ではない。
ユーザーIDとIPアドレスを組み合わせた識別子の最適化
RateLimiterの実装で最も慎重に決めるべきなのは、上限の数字より識別子である。どの値を同じ利用者として扱うかを間違えると、制限が強すぎたり、逆にほとんど機能しなかったりする。
認証済みと未認証を同じバケットに入れない
認証済みユーザーはユーザーID、未認証リクエストはIPアドレスという優先順位にした。ここで単純にユーザーIDだけを使うと、未認証リクエストがすべて同じnull相当のキーに集まる可能性がある。
反対に、常にIPアドレスだけを使うと、企業ネットワークや携帯通信網のように、多数の利用者が一つのグローバルIPを共有する環境で問題が起きる。正規ユーザー同士が同じ制限枠を奪い合うためだ。
そのため、次のような優先順位にした。
1. 認証済みならユーザーIDを使う
2. 未認証ならIPアドレスを使う
3. 識別子には種類を表す接頭辞を付ける
4. エンドポイント単位で分ける必要があれば、末尾に名前を追加する
実際のキーは、たとえばuser:123:external-apiやip:203.0.113.10:external-apiのような形になる。キーの形式を最初に決めておくと、後で別の制限を加えるときも衝突しにくい。
プロキシ配下ではIPアドレスをそのまま信用しない
$request->ip()は便利だが、アプリケーションがロードバランサーやリバースプロキシの背後にある場合、設定を誤ると正しいクライアントIPを取得できない。
CloudflareやAWSのロードバランサーなどを経由する構成では、プロキシが付与するヘッダーをLaravelがどのように信頼するかを設定する必要がある。信頼するプロキシを無制限にすると、クライアントが送った偽の転送先IPを信じてしまう危険がある。逆に、プロキシをまったく信頼しなければ、すべてのリクエストがロードバランサーのIPとして扱われることがある。
TrustProxiesでは、実際に自分たちが管理するロードバランサーやプロキシだけを信頼対象にする。インフラを変更したときにこの設定が古いままだと、レート制限の精度も変わる。IPアドレスを識別子に使うなら、アプリケーションコードだけでなく、ネットワーク構成も確認対象になる。
さらに、IPv4とIPv6の表記揺れ、プロキシチェーン、CDNの経路変更なども考慮したい。IPアドレスをセキュリティ上の本人確認に使うのではなく、あくまで未認証リクエストをまとめるための補助的な識別子として扱うのが安全である。
バーストをどの時間単位で見るか
1分あたりの回数だけを制限すると、ウィンドウの境界付近で短時間にリクエストが集中することがある。たとえば、ある1分の終わりに上限近くまで呼び出し、次の1分が始まった直後に同じ回数を呼び出すケースだ。暦上の1分をまたいでいるため制限上は通っても、外部APIから見れば短い時間に大きなバーストになる。
外部APIが秒単位の上限を設けている場合は、秒単位または短い時間窓の制限を別に追加する必要がある。Laravelのバージョンによって利用できるメソッドが異なるため、プロジェクトで使っているフレームワークの仕様を確認しておく。
Limit::perSeconds(10, 3)のような制限を追加すれば、短時間の連続呼び出しを抑えられる。1分単位の制限と併用する場合は、同じ識別子に対して複数のLimitを返す構成にする。長時間の総量と短時間のバーストを別々に見る考え方である。
ただし、短い時間窓を増やしすぎると、制限に引っ掛かった理由が分かりにくくなる。ログには、リミッター名、識別子、適用した時間窓、残り秒数を記録しておくと調査しやすい。
エンドポイントごとにバケットを分ける
同じユーザーが複数の外部APIを使う場合、すべてを一つのカウントにまとめると、軽い処理が重い処理の枠を消費することがある。
識別子の末尾にエンドポイント名を追加すれば、カウントを分離できる。
return Limit::perMinute(60)->by('user:' . $request->user()->id . ':twitter');
この場合、同じユーザーでもtwitter用のバケットと、別の外部API用のバケットは別々に管理される。サービス単位で上限が違う場合には有効だ。
一方で、エンドポイント別の制限だけを設定すると、全体の呼び出し量を見失う。ユーザーが複数のエンドポイントを並行して使う可能性があるなら、次の二層を検討する。
- エンドポイント単位の細かい制限
- ユーザー単位またはアプリケーション単位の総量制限
制限を細かくするほど、どの制限が先に適用されたのかを説明しにくくなる。目的が「特定APIのバーストを防ぐこと」なら、最初からすべてを分離するのではなく、問題が起きた呼び出しから切り分ける方が現実的だった。
レート制限の精度は、上限の数字ではなく識別子の設計で決まる。誰を同じ利用者として扱うかを先に決めるべきだ。
Redisを活用したマルチサーバー環境での制限共有
ローカル開発では、ファイルキャッシュでもRateLimiterは動作する。しかし、複数台のアプリケーションサーバーを並べた本番環境では事情が変わる。
サーバーAでユーザーの試行回数を記録しても、次のリクエストがサーバーBへ振り分けられたとき、Bが別のキャッシュを見ていればカウントは共有されない。各サーバーが独立して上限を持つことになり、実質的な制限値が台数分に膨らむ。
RateLimiterが利用するキャッシュストアを、すべてのアプリケーションサーバーから参照できるRedisへ切り替えると、同じキーを共有できる。設定は、Laravelのバージョンや構成に応じてキャッシュ設定を確認したうえで、レート制限用のストアをRedisに指定する。
'limiter' => env('CACHE_STORE', 'redis'),
環境変数でCACHE_STOREをredisにする構成なら、アプリケーション全体のキャッシュストアとレート制限用ストアが同じRedisを利用することになる。セッションやキューなど、別の用途で同じRedisを使っている場合は、障害時の影響範囲も確認しておきたい。
専用のキャッシュ接続を分ける構成では、レート制限用のストアだけをRedisにすることもできる。どちらがよいかは、Redisの運用単位と障害分離の方針による。
Redis共有で変わる実際の挙動
APPサーバーが3台あり、ユーザーごとの上限を1分あたり60回に設定したとする。
共有キャッシュがない場合、各サーバーはそれぞれ60回までリクエストを通す可能性がある。ロードバランサーの振り分け次第では、同じユーザーからの呼び出しが合計180回まで通ることになる。
Redisを共有すれば、3台のどこで処理されても同じキーのカウントが更新される。合計で60回に達した時点で、他のサーバーでも制限が適用される。サーバー台数が増えても、制限値がそのまま維持されるのが共有の効果である。
ただし、これはRateLimiterを使えば自動的に正確な分散トークンバケットになるという意味ではない。カウントの更新処理、Redisの接続状態、設定の反映タイミングなどに依存する。外部APIの契約上限を絶対に超えてはいけない場合は、RateLimiterだけでなく、呼び出しを一つのキューへ集約するなど別の設計が必要になる。
Redis運用で確認したこと
Redisを使えば設定が終わるわけではない。今回の構成では、次の点を確認した。
- アプリケーションサーバーから同じRedisへ接続できること
- Redisの接続先、ポート、認証情報が環境ごとに正しいこと
- APPサーバーの台数に対して、Redisの接続数に余裕があること
- レート制限キーの有効期限が想定どおり設定されること
- Redisの障害やネットワーク遅延がアプリケーションへ与える影響
- 通常のキャッシュやセッションと、制限キーが互いに圧迫しないこと
制限カウントは長期保存するデータではない。そのため、永続化設定をどこまで強くするかは、通常の業務データとは分けて考えられる。再起動で一部のカウントが失われてもよいのか、制限が一時的に緩むことを許容するのかを、要件として決めておく。
キーの残量を調べるために、運用中のRedisへ直接大量の走査を掛けるのは避けたい。調査用には、アプリケーション側でリミッター名や識別子をログへ出す方が安全である。Redisの内部キー形式はLaravelのバージョンや設定によって変わる可能性もあるため、内部実装に依存した監視は長期的には壊れやすい。
throttleミドルウェアによるルーティングへの適用と制御
定義したリミッターをルートへ適用するには、throttleミドルウェアを使う。
Route::middleware(['auth:api', 'throttle:external-api'])->group(function () { Route::post('/tweets', [TweetController::class, 'store']); });
throttle:external-apiのexternal-apiが、RateLimiter::for()で登録した名前と一致している。これにより、対象ルートへ到達したリクエストがリミッターを通り、上限を超えた場合は429が返る。
ここで、外部APIを呼び出すすべてのルートに同じミドルウェアを付ければよいとは限らない。次のような処理は、呼び出し頻度や処理時間が異なるため、分けた方がよい。
- 検索候補を取得する軽量な呼び出し
- 外部サービスへデータを書き込む呼び出し
- 生成処理のように1回のコストが大きい呼び出し
- 非同期ジョブを登録するだけのリクエスト
- 管理者だけが使う再同期処理
HTTPリクエストに対する制限と、キューに積まれたジョブの制限も分けて考える必要がある。入口で1分あたり60回に制限しても、1回のリクエストが同じ外部APIを複数回呼べば、提供元から見た呼び出し数は増える。ルートのリクエスト数ではなく、外部APIの実呼び出し回数を制限したいなら、サービス層やジョブ層にも制御を置くべきだ。
429レスポンスの形式を整える
Laravelの標準的なスロットリング処理は、上限超過時に429を返し、Retry-Afterヘッダーを付ける。これはHTTPクライアントに再試行までの目安を伝えるための重要な情報だ。
ただし、JSON APIでは、レスポンスボディの形式も統一したいことがある。標準の429レスポンスが、フロントエンドやモバイルアプリの想定するエラー形式と合わない場合があるためだ。
Limitにレスポンスを指定する構成では、次のようにエラーコードと再試行までの秒数をJSONへ含められる。
return Limit::perMinute(60)->by($key)->response(function (Request $request, array $headers) { return response()->json(['error' => 'rate_limit_exceeded', 'retry_after' => $headers['Retry-After'] ?? null], 429, $headers); });
ここで気を付けたいのは、Retry-Afterが必ず存在すると決め付けないことだ。カスタム処理やLaravelのバージョン差によって、ヘッダーの扱いが異なる場合がある。値がない場合のフォールバックを用意しておくと、エラー処理自体が別のエラーを起こすのを防げる。
クライアント側では、429を通常の入力エラーと同じように扱わない。すぐに同じリクエストを再送すると、制限をさらに長引かせる可能性がある。Retry-Afterを読み取り、必要なら指数バックオフやジッターを加えて再試行する。
ただし、同じリクエストを再試行して安全かどうかは、エンドポイントの性質によって違う。外部サービスへの書き込み処理では、タイムアウト後に相手側で成功している可能性がある。再試行による二重登録を防ぐため、冪等性キーや処理IDを使う設計が必要になることもある。
ルートだけで制限が足りないケース
今回の問題は、認証済みユーザーが画面操作を通じて外部APIを連続呼び出しするケースだったため、ルートへのthrottle適用で大きな効果があった。
しかし、次のような構成では、ルート制限だけでは不十分になる。
- 1回のHTTPリクエストから複数回の外部API呼び出しが発生する
- キューがリトライされ、利用者の操作とは別に呼び出しが増える
- 定期実行ジョブが複数サーバーで重複起動する
- 管理画面から一括処理が実行される
- WebSocketやイベント処理を通じて外部APIを呼び出す
この場合は、外部APIを呼び出すサービスクラスの直前でRateLimiterを使うか、ジョブの実行前に制限を掛ける。ルートでの制限は利用者体験を守るための入口であり、外部APIの利用量を守る最後の防波堤とは限らない。
実装後の挙動と残った課題
ユーザー単位のリミッターを適用した後、ログに記録される429エラーは大きく減少した。特定ユーザーによる短時間の連続呼び出しは、ユーザーIDをキーにしたバケットで抑えられるようになった。
ただし、429が完全にゼロになったわけではない。残った429にも理由があった。企業プロキシを経由するアクセスでは、複数の正規ユーザーが同じIPアドレスを共有する。未認証リクエストや、認証情報を正しく取得できなかったリクエストが同じIPバケットへ集まると、ユーザー同士が制限枠を共有してしまう。
認証済みユーザーについてはユーザーIDで分離できるため、この問題は主に未認証アクセスや認証判定が揺れる経路で発生する。IPアドレスは便利なフォールバックだが、個人を完全に識別する値ではない。ここを過信すると、共有ネットワークの利用者へ過剰な制限を掛けることになる。
最終的な構成は次のようになった。
- 認証済みリクエストはユーザーIDを優先する
- 未認証リクエストはIPアドレスを使う
- 識別子に
user:やip:の接頭辞を付ける - 複数のアプリケーションサーバーでRedisを共有する
- 外部APIごとに名前付きリミッターを分ける
- ルートには
throttle:external-apiを適用する - 429では
Retry-Afterを返す - 必要なエンドポイントでは短い時間窓の制限も追加する
- キューや一括処理では、ルートとは別に外部API呼び出し数を管理する
fail-openをどう扱うか
Redisが一時的に利用できない場合に、レート制限をどうするかは設計判断になる。
制限できないときにリクエストを通すfail-openなら、アプリケーションの通常機能は止まりにくい。一方で、Redis障害中に外部APIの利用量が増え、提供元の制限を超える危険がある。
反対に、Redisへ接続できないときにリクエストを拒否するfail-closedなら、外部APIの保護は強くなる。しかし、外部APIもアプリケーションも正常なのに、レート制限用のRedisだけが原因で機能停止になる可能性がある。
今回の実装では、外部APIの停止よりもアプリケーション側の全面停止を避けるため、fail-openを許容した。その代わり、Redisの死活監視と接続エラーの監視を別に用意する方針にした。
これは、どのシステムでも同じ答えになる話ではない。課金API、決済処理、送信系APIなど、呼び出し超過の影響が大きい場合はfail-closedやキューによる直列化を検討する。検索や候補表示のように、一時的に呼び出しを許しても業務データが壊れない処理なら、可用性を優先する余地がある。
Laravelのレートリミットを運用で育てる
RateLimiterはコードを書いて終わりではない。制限に掛かったリクエストが、本当に想定した利用者や処理なのかを確認できなければ、上限の調整もできない。
最低限、次の情報はログやメトリクスで追えるようにしておきたい。
- リミッター名
- 識別子の種類
- 対象ルートまたは外部API名
- 429を返した回数
Retry-Afterの値- 認証済みか未認証か
- 外部API側で429が発生した回数
- リクエストから外部API呼び出しまでの回数
ただし、ユーザーIDやIPアドレスをそのまま大量にログへ出す場合は、ログの保管期間やアクセス権限にも注意が必要だ。調査に必要な範囲でマスキングやハッシュ化を検討する。レート制限のために取得した情報を、別の目的で無制限に利用しないことも重要である。
上限値を決めるときは、正常系の利用パターンだけでなく、画面の二重送信、ブラウザの自動再送、モバイル回線の再接続、フロントエンドのポーリング設定も確認する。意図的な攻撃でなくても、クライアント実装の不具合で呼び出しが集中することはある。
また、制限に掛かった回数だけでなく、制限に掛からなかったリクエストの分布も見る必要がある。上限を低くしすぎると、429は増えるが外部APIの保護という意味では成功しているように見える。実際には正規ユーザーが機能を使えなくなっているだけかもしれない。
逆に、429が少ないからといって安全とは限らない。Laravel側の制限が効く前に外部APIへ呼び出しが流れている、複数サーバー間でキーが共有されていない、ジョブ側が別の経路で呼び出している、といった可能性もある。
まとめ
今回の対策で効果があったのは、RateLimiterを導入したことだけではない。外部APIの呼び出しを誰の操作として数えるかを決め、ユーザー単位のバケットへ分け、複数サーバーから同じカウントを参照できるようにしたことが大きい。
LaravelのRateLimiterを使う場合、実装の要点は次の三つに集約できる。
1. 全体一律ではなく、ユーザーIDやIPアドレスなど実際の問題に合った単位で制限する
2. エンドポイントやAPIの性質に応じて、時間窓と上限を使い分ける
3. マルチサーバー環境では共有キャッシュを使い、429とRetry-Afterをクライアントへ正しく返す
IPアドレスは万能な識別子ではなく、Redisも万能な障害対策ではない。キューからの呼び出しや、1リクエスト内で複数回発生する外部APIアクセスまで守るには、サービス層やジョブ層の制御が必要になる。
それでも、Laravel標準のRateLimiterは、外部API依存のアプリケーションにとって扱いやすい土台になる。自前でカウント処理や有効期限の管理を実装するより、フレームワークの仕組みに乗せた方が、設定の共有、ルートへの適用、429の返却、Redisへの切り替えまで一貫して管理できる。
外部APIを使う機能で「たまに429が出る」状態を放置すると、原因はすぐに特定のユーザー、再試行処理、複数サーバー間の不整合へ広がる。Laravelのレートリミットは、そのすべてを解決する銀の弾丸ではない。しかし、識別子と共有ストアを正しく設計すれば、特定ユーザーのバーストをシステム全体の障害へ発展させないための、堅実な一手になる。
Related reading: LaravelのAPI連携におけるRESTとGraphQLの長所と個人開発での最適解 and Axiosインターセプターによるトークンリフレッシュ:非同期リクエストが並行処理される内部挙動.