Mirai Translate TECH BLOG

株式会社みらい翻訳のテックブログ

nginx の limit_req で API に流量制御(レートリミット)をかける

こんにちは。プラットフォーム開発部でリードエンジニアをしているchanceです。

今回は nginx のレートリミット (limit_req) の話をします。

概要

先日、APIの流量制御のために nginx をプロキシとしたレートリミットを設定しました。

アルゴリズムと設定値の関係など、自分なりに整理して図解してみたのでシェアしたいと思います。

本記事では以下について解説します。

前提 : nginx のレートリミットの種類

nginx では、流量制御に関連したモジュールが大きく3種類あります。

モジュール(略称) 概要
limit_conn コネクション数の制御
limit_req リクエスト数の制御
limit_rate 帯域幅の制御

今回はlimit_req (ngx_http_limit_req_module) についての記事となります。詳細な仕様については公式のリファレンスをご参照ください。

nginx.org

基本的な書式構文は以下のようになります。

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を動かしてみてさらにイメージを掴んでいきます。

各設定値の意味は改めて以下のとおりです。

  • rate:リクエストを流すペース
  • burst:バケツに溜められる量
  • nodelay:リクエストを即座に処理する

検証の前提はこちらです。

  • バージョン
    • 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秒間)で同時にリクエストを受け付けた場合、リクエストが弾かれてしまうためです。

burstなし

「10 r/s」という字面だけで判断すると、仕様の把握を誤りそうですね。(私自身、危うく誤ってburstを設定しないという選択をするところでした。)

少なくとも rate の設定値を十分に捌くためには、次の検証のように burst の設定が必要になります。

検証2 burst=5 nodelay

burstを設定すると、処理成功数がrateの設定値通りになります。グラフも処理成功数がキレイに10r/sで並んでいます。burst のバケツを用意することで、瞬間的な同時リクエストを一時的に保持しておきつつ rate のペースで応答を返せるようになっています。

burstあり、nodelayあり

このことから、burst も併用することでより正確に rate の設定を実現できると言えます。

ところで、今回nodelay 設定も入れているのですがいまいち効果が見て取れません。そこで、次の検証では burst をもっと大きくしてみて nodelay の挙動に注目してみます。

検証3 burst=1000 nodelay

burst を非常に大きく取ると、開始してしばらくは rate の設定値である 10r/s を超えて 20r/s が全て処理成功しています。これは nodelay 設定によりバケツに入ったリクエストも即時で処理が開始されているためです。

burstあり、nodelayあり

しかし、バケツに入ったリクエストの処理自体が終わってもすぐにはバケツは空きません。あたかも rate (10r/s) のペースで空いていきます。すると今回の場合、毎秒 rate 以上のリクエストが届くためバケツを空ける暇がなく、どんどん溜まっていきます。バケツが溢れたところで受け付けることができなくなり、エラーが返りはじめます。

nodelayを設定することで、受け付けた分のリクエストはクライアントになるべく早く応答を返すことができます。では、nodelayがないとどのような挙動になるのでしょうか。

検証4 burst=1000(nodelayなし)

nodelayがない場合、開始してしばらくは 20 r/s のうちの rate の設定値である 10r/s は処理成功しています。残りの10 件は応答もありませんが、これはバケツに溜められているからと考えられます。。1000burstを超えたと思われるところでエラーが返り始めます。(時間が経ちすぎていて499を返しているようです。)

burstあり、nodelayなし

バケツに溜めておく間、遅延することになります。リクエストを流すペースは変わらないため、後続のシステムを守りやすくはなります。

検証まとめ

  • burst を設定しない場合
    • 受けられるリクエストはrate "以下"に抑えられます。(rate未満になる可能性もあります。)
    • どうしても一瞬でもrateを超えたくない場合などでの利用が考えられます。
  • burst + nodelay を設定した場合
    • 一時的にはrateを超えることがありますが、全体的には平準化されます。
    • burst のサイズ設定が重要で、rateのタイムボックスで同時に届きうるリクエストに合わせると良さそうです。
    • (バックエンドのAPIが受け切れる数である前提です。)
  • burst のみを設定した場合
    • 受けられるリクエスト数がrateを超えることはありません。(断続的にリクエストがきても、rateの設定値までは受け切ります。)
    • ただし、burstに入ったリクエストは応答が遅れます。
    • あまり採用するシチュエーションが思いつきませんでした。

基本的にはburst+nodelayをつけておくのが良さそうですね。

補足:nginx以外の選択肢

今回は nginx を利用していますが、レートリミットの実現方法としては他にも、apacheなど別のWebサーバやコンテナのサイドカーとしてプロキシを立てたり、ベンダーマネージドなAPIゲートウェイを利用したり、といった選択肢があります。

前者は、細かい設定での制御ができる反面、冗長構成での総量の制御が複雑になったり、プロキシサーバ自体の運用コストがかかります。逆に後者は、運用のコストやリスクを抑えられる反面、柔軟な設定が難しい場合があります。コストと一言で言っても、設備上のコストもあれば、保守メンテナンスのコストもあります。

また、他の製品のレートリミットの多くはリーキーバケットではなくトークバケットアルゴリズムを採用していることが多いです。詳しい説明は省きますが、トークバケットは事前に定めたトークン量が許すだけはリクエストを受け切るという仕組みです。両者を端的に比較すると、リーキーバケットはペースを平準化することを重視しており、トークバケットは許容量の上限を超えないように制御することを重視しています。

要件や環境構成などの状況に応じて、適切な技術を採用する必要があります。

まとめ

今回は nginx の limit_req のしくみと仕様、実際に実行した際のサンプルを解説しました。

この記事が何かの参考になれば幸いです。

最後に

みらい翻訳では、エンジニアを募集しています。

ご興味のある方は、ぜひ下記リンクよりご応募・お問い合わせをお待ちしております。

miraitranslate.com