こんにちは。プラットフォーム開発部でリードエンジニアをしているchanceです。
今回は nginx のレートリミット (limit_req) の話をします。
概要
先日、APIの流量制御のために nginx をプロキシとしたレートリミットを設定しました。
アルゴリズムと設定値の関係など、自分なりに整理して図解してみたのでシェアしたいと思います。
本記事では以下について解説します。
前提 : nginx のレートリミットの種類
nginx では、流量制御に関連したモジュールが大きく3種類あります。
モジュール(略称) | 概要 |
---|---|
limit_conn | コネクション数の制御 |
limit_req | リクエスト数の制御 |
limit_rate | 帯域幅の制御 |
今回はlimit_req (ngx_http_limit_req_module) についての記事となります。詳細な仕様については公式のリファレンスをご参照ください。
基本的な書式構文は以下のようになります。
http { limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s; #zoneを定義 ... server { ... location /any-path/ { limit_req zone=one burst=5; #zoneを適用 } }
レートリミットのアルゴリズム
まずは基本的なアルゴリズムについてです。
nginxのlimit_reqはリーキーバケットというアルゴリズムを採用しています。一般的には名前の通り(といっても私には馴染みのない単語ですが、)穴の空いたバケツで例えられます。私はバケツの穴よりも回転ドアのようなイメージをしました。一定間隔で1つずつしか通れないイメージです。
nginxの具体的な設定で言うと、下に落とす頻度がrate、バケツに溜められる量がburst、rateを待たずに即座に処理する設定がnodelayになります。これを図示してみます。
burstを設定しない場合は、バケツがない状態なので上図の左のイメージです。回転ドアの間隔にうまくハマればボールは通れますが、運悪く同じタイミングで届いたボールは一つ以外はハジかれます。つまり、rate=10r/sと設定していても1秒に必ず10リクエスト捌くという意味にはならず、0.1秒間隔でリクエストが通せる、という方が正確です。
burstを設定すると、上図の真ん中のイメージです。瞬間的に通れなくても一定量まではバケツに保持されて順次処理されます。ただし、ボールが下に落ちるペースはあくまでも回転ドアの間隔です。
burstと組み合わせて使うnodelayは少し特殊です。例えが難しいのですが、上図の右のイメージです。バケツに入ったボールは即座に下に落とされますが、バケツはあたかも回転ドアのペースでしか空きません。これにより、リクエストを受け過ぎないようにしつつ、受けつけたリクエストはすぐに応答を返すことができます。
レートリミットのキー情報
limit_req を利用するには、まずキー情報を定義します。
limit_req_zone <キー情報> <zone名:サイズ> <レート>;
キー情報にはいろいろな項目が使えるのですが、公式ドキュメントやググって出てくる例のほとんどは $binary_remote_addr ばかりで、他に何が利用できるかが分かりにくかったのでいくつか例を挙げます。
キー情報 | 意味 |
---|---|
$binary_remote_addr | IPアドレス(をバイナリにしたもの) |
$server_name | ホスト名 |
$http_user_agent | ユーザーエージェント |
$http_referer | リファラ |
$request_uri | リクエストURI |
$http_{HEADER_NAME} | 任意のリクエストヘッダ(-と_は読み替え可) |
$scheme | "http", "https" |
$binary_remote_addr
だと1ユーザ毎やNATされている場合は接続元毎の制御になり、システム全体へのレートリミットを計算しにくいです。例えば $request_uri
でパス毎を対象にしたり、 $sheme
でリクエスト量全体を対象にしたりすることで、様々なユースケースに対応できます。
レートリミットの実際の挙動
レートリミットの仕様は先に示した通りですが、ここからは実際にnginxを動かしてみてさらにイメージを掴んでいきます。
各設定値の意味は改めて以下のとおりです。
検証の前提はこちらです。
- バージョン
- nginx : 1.23.4
- ngx_http_limit_req_module : 0.7.21
- nginx 設定
- rateは10r/s
- レートリミットによる応答はHTTP429(デフォルトは503)
リクエストを投げるクライアントとしては grafana k6を使用して、 20r/s (rate の2倍程度)を5分間投げ続けます。この結果をグラフで可視化して以下に貼り付けます。
検証は以下の4パターンで行います。
- burstなし(nodelayもなし)
- burst=5 nodelay; (いくらかのburst)
- burst=1000 nodelay;(十分すぎるburst)
- burst=1000;(十分すぎるburstだがdelayする)
検証1 burstなし
まずburst を指定しない場合ですが、rate で設定した値よりも実際の処理成功数は少なくなりました。これは先述の通り、rateのタイムボックス内(10 r/s なら0.1秒間)で同時にリクエストを受け付けた場合、リクエストが弾かれてしまうためです。
「10 r/s」という字面だけで判断すると、仕様の把握を誤りそうですね。(私自身、危うく誤ってburstを設定しないという選択をするところでした。)
少なくとも rate の設定値を十分に捌くためには、次の検証のように burst の設定が必要になります。
検証2 burst=5 nodelay
burstを設定すると、処理成功数がrateの設定値通りになります。グラフも処理成功数がキレイに10r/sで並んでいます。burst のバケツを用意することで、瞬間的な同時リクエストを一時的に保持しておきつつ rate のペースで応答を返せるようになっています。
このことから、burst も併用することでより正確に rate の設定を実現できると言えます。
ところで、今回nodelay 設定も入れているのですがいまいち効果が見て取れません。そこで、次の検証では burst をもっと大きくしてみて nodelay の挙動に注目してみます。
検証3 burst=1000 nodelay
burst を非常に大きく取ると、開始してしばらくは rate の設定値である 10r/s を超えて 20r/s が全て処理成功しています。これは nodelay 設定によりバケツに入ったリクエストも即時で処理が開始されているためです。
しかし、バケツに入ったリクエストの処理自体が終わってもすぐにはバケツは空きません。あたかも rate (10r/s) のペースで空いていきます。すると今回の場合、毎秒 rate 以上のリクエストが届くためバケツを空ける暇がなく、どんどん溜まっていきます。バケツが溢れたところで受け付けることができなくなり、エラーが返りはじめます。
nodelayを設定することで、受け付けた分のリクエストはクライアントになるべく早く応答を返すことができます。では、nodelayがないとどのような挙動になるのでしょうか。
検証4 burst=1000(nodelayなし)
nodelayがない場合、開始してしばらくは 20 r/s のうちの rate の設定値である 10r/s は処理成功しています。残りの10 件は応答もありませんが、これはバケツに溜められているからと考えられます。。1000burstを超えたと思われるところでエラーが返り始めます。(時間が経ちすぎていて499を返しているようです。)
バケツに溜めておく間、遅延することになります。リクエストを流すペースは変わらないため、後続のシステムを守りやすくはなります。
検証まとめ
- burst を設定しない場合
- 受けられるリクエストはrate "以下"に抑えられます。(rate未満になる可能性もあります。)
- どうしても一瞬でもrateを超えたくない場合などでの利用が考えられます。
- burst + nodelay を設定した場合
- burst のみを設定した場合
基本的にはburst+nodelayをつけておくのが良さそうですね。
補足:nginx以外の選択肢
今回は nginx を利用していますが、レートリミットの実現方法としては他にも、apacheなど別のWebサーバやコンテナのサイドカーとしてプロキシを立てたり、ベンダーマネージドなAPIゲートウェイを利用したり、といった選択肢があります。
前者は、細かい設定での制御ができる反面、冗長構成での総量の制御が複雑になったり、プロキシサーバ自体の運用コストがかかります。逆に後者は、運用のコストやリスクを抑えられる反面、柔軟な設定が難しい場合があります。コストと一言で言っても、設備上のコストもあれば、保守メンテナンスのコストもあります。
また、他の製品のレートリミットの多くはリーキーバケットではなくトークンバケットアルゴリズムを採用していることが多いです。詳しい説明は省きますが、トークンバケットは事前に定めたトークン量が許すだけはリクエストを受け切るという仕組みです。両者を端的に比較すると、リーキーバケットはペースを平準化することを重視しており、トークンバケットは許容量の上限を超えないように制御することを重視しています。
要件や環境構成などの状況に応じて、適切な技術を採用する必要があります。
まとめ
今回は nginx の limit_req のしくみと仕様、実際に実行した際のサンプルを解説しました。
この記事が何かの参考になれば幸いです。
最後に
みらい翻訳では、エンジニアを募集しています。
ご興味のある方は、ぜひ下記リンクよりご応募・お問い合わせをお待ちしております。