OpenResty 中的动态速率限制
API7.ai
January 6, 2023
前回の記事では、バーストトラフィックを扱うための一般的なアルゴリズムであるleaky bucket
とtoken bucket
を紹介し、NGINX設定を使用してリクエストのレートリミットを行う方法を学びました。しかし、NGINX設定を使用するのはあくまで使用可能なレベルであり、実用的になるまでにはまだ長い道のりがあります。
最初の問題は、レートリミットのキーがNGINX変数の範囲に限定されており、柔軟に設定できないことです。例えば、異なる地域や異なるクライアントチャネルに対して異なる速度制限の閾値を設定する方法はありませんが、これはNGINXでは一般的な要件です。
もう一つの大きな問題は、レートを動的に調整できないことであり、変更のたびにNGINXサービスを再読み込みする必要があります。その結果、異なる期間に基づいて速度を制限することは、外部スクリプトを通じて不格好に実装するしかありません。
技術はビジネスに奉仕し、同時にビジネスが技術を推進することを理解することが重要です。NGINXが誕生した当時、設定を動的に調整する必要性はほとんどなく、リバースプロキシ、ロードバランシング、低メモリ使用量などのニーズがNGINXの成長を牽引していました。技術アーキテクチャと実装の観点から、モバイルインターネット、IoT、マイクロサービスなどのシナリオで動的かつ細かい制御の需要が爆発的に増加することを誰も予測できませんでした。
OpenRestyのLuaスクリプトの使用は、NGINXのこの分野での不足を補い、効果的な補完となります。これが、OpenRestyがNGINXの代替として広く使用されている理由です。次のいくつかの記事では、OpenRestyでのより動的なシナリオと例を紹介し続けます。まず、OpenRestyを使用して動的なレートリミットを実装する方法を見てみましょう。
OpenRestyでは、lua-resty-limit-trafficを使用してトラフィックを制限することを推奨しています。これにはlimit-req
(リクエストレートの制限)、limit-count
(リクエスト数の制限)、limit-conn
(同時接続数の制限)が含まれており、limit.traffic
を提供してこれら3つの方法を集約します。
リクエストレートの制限
まず、limit-req
を見てみましょう。これは、リクエストのレートを制限するためにリーキーバケットアルゴリズムを使用します。
前回のセクションでは、このrestyライブラリのリーキーバケットアルゴリズムの主要な実装コードを簡単に紹介しましたが、今回はこのライブラリの使用方法を学びます。まず、以下のサンプルコードを見てみましょう。
resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
local delay, err = lim:incoming("key", true)
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
return ngx.exit(500)
end
if delay >= 0.001 then
ngx.sleep(delay)
end'
lua-resty-limit-traffic
はshared dict
を使用してキーを保存およびカウントするため、limit-req
を使用する前にmy_limit_req_store
の100m
スペースを宣言する必要があります。これはlimit-conn
とlimit-count
でも同様で、それぞれ別のshared dict
スペースを区別する必要があります。
limit_req.new("my_limit_req_store", 200, 100)
上記のコード行は最も重要なコード行の1つです。これは、my_limit_req_store
というshared dict
を使用して統計を保存し、1秒あたりのレートを200
に設定することを意味します。これにより、200
を超えるが300
未満(この値は200 + 100
から計算されます)の場合、キューに入れられ、300
を超える場合は拒否されます。
設定が完了したら、クライアントからのリクエストを処理する必要があります。lim: incoming("key", true)
はこれを行うためのものです。incoming
には2つのパラメータがあり、詳細に読む必要があります。
最初のパラメータは、レートリミットのためのユーザー指定のキーで、上記の例では文字列定数であり、すべてのクライアントに対して均一にレートリミットを行うことを意味します。異なる地域やチャネルに応じてレートを制限したい場合は、両方の情報をキーとして使用するだけで簡単に実現できます。以下は、この要件を実現するための疑似コードです。
local province = get_ province(ngx.var.binary_remote_addr)
local channel = ngx.req.get_headers()["channel"]
local key = province .. channel
lim:incoming(key, true)
もちろん、キーの意味やincoming
を呼び出す条件をカスタマイズすることもできるため、非常に柔軟なレートリミット効果を得ることができます。
incoming
関数の2番目のパラメータを見てみましょう。これはブール値で、デフォルトはfalse
であり、リクエストがshared dict
に記録されず、統計に含まれないことを意味します。これは単なる演習です。true
に設定すると、実際の効果があります。したがって、ほとんどの場合、明示的にtrue
に設定する必要があります。
このパラメータが存在する理由について疑問に思うかもしれません。ホスト名をキーとする1つのlimit-req
インスタンスと、クライアントのIPアドレスをキーとする別のlimit-req
インスタンスを設定するシナリオを考えてみましょう。その後、クライアントリクエストが処理されると、これらの2つのインスタンスのincoming
メソッドが順番に呼び出されます。以下の疑似コードで示されています。
local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100)
local limiter_two, err = limit_req.new("my_limit_req_store", 20, 10)
limiter_one :incoming(ngx.var.host, true)
limiter_two:incoming(ngx.var.binary_remote_addr, true)
ユーザーのリクエストがlimiter_one
の閾値検出を通過しても、limiter_two
の検出で拒否された場合、limiter_one:incoming
関数呼び出しは演習と見なすべきであり、カウントする必要はありません。
この場合、上記のコードロジックは十分に厳密ではありません。すべてのリミッターを事前に演習する必要があり、クライアントリクエストを拒否できるリミッターの閾値がトリガーされた場合、直接返すことができます。
for i = 1, n do
local lim = limiters[i]
local delay, err = lim:incoming(keys[i], i == n)
if not delay then
return nil, err
end
end
これがincoming
関数の2番目の引数の目的です。このコードはlimit.traffic
モジュールのコアコードであり、複数のレートリミッターを組み合わせるために使用されます。
リクエスト数の制限
次に、リクエスト数を制限するlimit.count
ライブラリを見てみましょう。これはGitHub API Rate Limitingのように、固定時間ウィンドウ内でのユーザーリクエスト数を制限します。いつものように、サンプルコードから始めましょう。
local limit_count = require "resty.limit.count"
local lim, err = limit_count.new("my_limit_count_store", 5000, 3600)
local key = ngx.req.get_headers()["Authorization"]
local delay, remaining = lim:incoming(key, true)
limit.count
とlimit.req
は同様に使用されます。まず、nginx.conf
でshared dict
を定義します。
lua_shared_dict my_limit_count_store 100m;
次に、リミッターオブジェクトをnew
し、最後にincoming
関数を使用して判断および処理します。
ただし、limit-count
のincoming
関数の2番目の戻り値は残りの呼び出し回数を表し、これに応じてレスポンスヘッダーにフィールドを追加して、クライアントに適切な指示を与えることができます。
ngx.header["X-RateLimit-Limit"] = "5000"
ngx.header["X-RateLimit-Remaining"] = remaining
同時接続数の制限
limit.conn
は、同時接続数を制限するためのライブラリです。前述の2つのライブラリとは異なり、特別なleaving API
を持っています。ここで簡単に説明します。
リクエストレートとリクエスト数の制限は、前述のようにaccess
フェーズで直接行うことができます。一方、同時接続数の制限は、access
フェーズで閾値を超えているかどうかを判断するだけでなく、log
フェーズでleaving
APIを呼び出す必要があります。
log_by_lua_block {
local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
local key = ctx.limit_conn_key
local conn, err = lim:leaving(key, latency)
}
ただし、このAPIのコアコードは非常にシンプルで、以下のコード行で接続数を1つ減らします。log
フェーズでクリーンアップしないと、接続数が増え続け、すぐに同時接続数の閾値に達します。
local conn, err = dict:incr(key, -1)
レートリミッターの組み合わせ
これで、これら3つの方法の紹介は終わりです。最後に、limit.rate
、limit.conn
、limit.count
を組み合わせる方法を見てみましょう。ここでは、limit.traffic
のcombine
関数を使用する必要があります。
local lim1, err = limit_req.new("my_req_store", 300, 200)
local lim2, err = limit_req.new("my_req_store", 200, 100)
local lim3, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5)
local limiters = {lim1, lim2, lim3}
local host = ngx.var.host
local client = ngx.var.binary_remote_addr
local keys = {host, client, client}
local delay, err = limit_traffic.combine(limiters, keys, states)
このコードは、今までに得た知識で簡単に理解できるはずです。combine
関数のコアコードは、limit.rate
の分析で既に述べたように、主にdrill
関数とuncommit
関数の助けを借りて実装されています。この組み合わせにより、複数のリミッターに対して異なる閾値とキーを設定し、より複雑なビジネス要件を実現できます。
まとめ
limit.traffic
は、今日紹介した3つのレートリミッターだけでなく、incoming
とuncommit
APIを持つレートリミッターであれば、limit.traffic
のcombine
関数で管理できます。
最後に、宿題を出します。以前紹介したトークンとバケットのレートリミッターを組み合わせた例を書くことができますか?コメント欄に答えを書いて私と議論してください。また、この記事を同僚や友人と共有して、一緒に学び、交流することも歓迎します。