コンテンツにスキップ

レートリミット

[ratelimit]
enabled = true

これだけで、呼び出し元ごとに 1 分あたりのリクエスト予算が付きます。認証済みユーザーは 600、匿名アドレスは 300。予算を超えたリクエストは 429Retry-AfterX-RateLimit-* ヘッダーで拒否され、ブラウザにはアプリケーションの 429 ページが、API クライアントには problem ドキュメントが返ります。数えるのはプロセス自身で、何も追加せずに使える memory バックエンドがそれを担当します。

デフォルトで無効なのは、普通のデプロイでは手前のプロキシや API ゲートウェイがすでにアドレス単位の制限を持っているからです。誰もサイズを考えていない二重の制限は、防御ではなくノイズです。エッジの制限で足りているなら、このスイッチは使わないでください。ではいつ有効にするのか。エッジには計算できない予算 — つまり認証済みユーザー単位の制限が欲しくなったときです。セッションを解決できるのはアプリケーションだけなので、この予算だけはエッジに任せられません。

このセクションが取る設定は、以下がすべてです。

キー 既定値 何を決めるか
enabled false ここに書かれたものを動かすかどうか
backend "memory" カウントの置き場所。memory または redis
window "1m" 以下のカウントを測る期間。X-RateLimit-Reset が伝えるのもこれ
per_subject 600 認証済みサブジェクト 1 つがウィンドウ内に出せるリクエスト数。0 でこのバケットを無効化
per_address 300 セッションを持たない呼び出し元 1 つがウィンドウ内に出せるリクエスト数。正の値のみ
process 0 ウィンドウ内の総到着数の上限。キーを持たない。0 なら上の 2 バケットだけ
redis.dsn (空) redis:// または rediss:// のカウンターサーバー
redis.key_prefix "pw:ratelimit:" このリミッターが占めるキー空間
redis.connect_timeout "5s" 起動時の ping と、コマンドごとの期限

環境変数とコマンドラインでの名前は規則で導かれるもので、アプリケーション設定一覧に一覧があります。このページの残りは、これらの数字が何を意味し、どう決めるかです。

バケットは 2 つ、ルールエンジンではない

Section titled “バケットは 2 つ、ルールエンジンではない”

カウント対象のリクエストは、必ずちょうど 1 つのバケットに落ちます。検証済みの身元を持っていればそのサブジェクトの予算(per_subject)から、持っていなければクライアントアドレスの予算(per_address)から消費します。ルートごとのルールもパターン記法もありません。呼び出し元 1 つにつき予算 1 つ、それがアプリケーション全体に効きます。操作単位のクォータが必要なら、その語彙を最初から持っている API ゲートウェイの仕事です。

2 つの数字は、別々の集団に向けてサイズを決めます。認証済みの呼び出し元には説明責任があります — 誰なのか分かっていて、いざとなれば失効させられる — ので per_subject は寛大でよく、0 にすればこのバケットごと無効にできます。一方、アドレスバケットにはオフがありません。認証されていない洪水が最初にぶつかるのはここだけで、同時に企業 NAT の背後にいる大勢の実在ユーザーが共有するのもここだからです。per_address = 0 は「無制限」ではなく起動エラーになります。このバケットに限っては、無制限とは寛容な設定ではなく、制御の不在です。

[ratelimit]
enabled = true
window = "1m"
per_subject = 600
per_address = 300

アルゴリズムは固定ウィンドウです。ウィンドウが切り替わるとカウントはリセットされ、その時刻を X-RateLimit-Reset がクライアントに伝えます。境界をまたげば 2 ウィンドウ分の予算を続けて使えますが、それは予算が倍になるだけで洪水ではありません。流量そのものへの防御はエッジの仕事のままです。

プロキシの背後では server.trusted_proxies を設定してください。設定しないと匿名の呼び出し元が全員プロキシのアドレスに解決され、1 つのバケットを共有します。制限が障害に変わる瞬間です。

process はキーを持たない 3 つ目のカウントです。プロセス全体で、ウィンドウあたりの総到着数を数えます。

[ratelimit]
enabled = true
process = 30000

これが存在する理由は、分散型の洪水はどのアドレス単位の値にも引っかからないからです — 多数のアドレスが、それぞれ行儀よく予算内に収まる。作りからしてそうなります。キー付きのバケットには見えないこの攻撃を見られるのは、キーなしの上限だけです。

デフォルトが 0(無効)で、推測値ではないのには理由があります。このセクションで一番危険な数字だからです。攻撃に対してではなく、そのデプロイが実際に捌ける量に対してサイズを決めてください。実能力より低い上限は、正当なトラフィックを全体で拒否します。また、正の process より大きい per_addressper_subject は起動時に拒否されます。1 人に全体より多くを許す設定は、決して効かない制限を記述しているからです。

ここまでの数字はどれもカウントで、そのカウントをどこに置くかを決めるのが backend です。答えは 2 つあり、選ぶ基準はレプリカの台数です。

既定の memory は 1 プロセスの中で数えます。 追加で用意するものは何もなく、数える経路にネットワークが挟まらず、落ちうるストアも存在しません。レプリカ 1 台なら、これが正解です。

N 台になると、ちょうど N 倍だけ間違いになります。各プロセスが自分のカウンターを持ち、互いを見られないので、4 台に分散した呼び出し元はファイルに書いた予算の 4 倍を使えます。しかもこれを報告するものはありません。どのプロセスも、設定された数字をそのとおりに守っているからです。本番で気づくのではなく、ここに書いておくのはそのためです。

redis はデプロイ全体で 1 つのカウントを持ちます。 Redis でも Valkey でもよく、設定はキー 2 つと import 1 行です。

[ratelimit]
enabled = true
backend = "redis"
redis.dsn = "${RATELIMIT_REDIS_DSN}"
import _ "github.com/shibukawa/popcornweb/ratelimitstore/redis"

ブランク import がバックエンドをバイナリに繋ぎます。登録の時点では接続せず、backend = "redis" が選ばれたときにダイヤルします。引き受けるのは、数える経路に乗る依存 — カウント 1 回につき往復 1 回 — と、起動時に到達できていなければならないストアです。到達できなければ起動を拒否します。全リクエストが黙ってフェイルオープンするリミッターを出荷するくらいなら、起動しない方がましだからです。なお、動き出したあとに到達できなくなった場合の挙動は別で、ストアが落ちたときにあります。

中途半端な形は 2 つとも、無視ではなく起動時拒否です。memory バックエンドのまま書かれた redis.dsn と、DSN の無い backend = "redis"。どちらも誰かが意図した設定ではありえません。

見落としやすい帰結が 1 つあります。process は固定のキー 1 つで数えるので、バックエンドがその上限の意味を変えます。memory では 1 プロセスへの到着を、redis ではデプロイ全体への到着を縛ります。いま自分がどちらにいるかに合わせてサイズを決め、切り替えるときは決め直してください。

まとめると、レプリカが1台なら memory を使い、スケールアウトするときに redis へ 切り替えます。必要な変更は設定と import 1行だけなので、まずは構成の単純な memory から 始めるのがよいでしょう。

フレームワーク自身の運用エンドポイント — ヘルスチェック、レディネス、OpenAPI ドキュメントとそのビューア — と公開アセットのマウントは、アイデンティティ別のバケットでは対象外で、この除外は設定できません。レディネスプローブはプロキシから届くので、匿名の呼び出し元全員と同じアドレスです。数えればそれだけでバケットを使い切ります。ページを 1 回表示すれば多数のアセットを取りに来ます。どちらを数えても、最初のデプロイで制限が障害に変わります。

process の天井だけは、この除外が届かない層です。除外パスも含めて、すべての到着を数えます。多数のアドレスに分散したフラッドを見られる唯一の防御であり、誰にも数えられない固定の判別可能なプレフィックスは、そのフラッドの通り道になってしまいます。だから process は、アセットのトラフィックを含めた数字でサイズを決めてください。

カウンターストアに到達できないとき、リクエストは通します。そして「数えずに通した」ことをエラーレベルでログに残します。これは意図した動作です。エッジには自前の制限が残っているので、フェイルオープンはエッジのみの防御への劣化で済みます。逆にフェイルクローズは、Redis の一瞬の不調を、ログインを含む全対象ルートの障害に変換してしまいます。この挙動にスイッチはありません。注意を向けるべきはあのログ行です。黙って制限しなくなったリミッターは、3 つの状態の中で最悪のものだからです。