OpenResty 中的动态速率限制

API7.ai

January 6, 2023

OpenResty (NGINX + Lua)

前回の記事では、バーストトラフィックを扱うための一般的なアルゴリズムであるleaky buckettoken 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-trafficshared dictを使用してキーを保存およびカウントするため、limit-reqを使用する前にmy_limit_req_store100mスペースを宣言する必要があります。これはlimit-connlimit-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.countlimit.reqは同様に使用されます。まず、nginx.confshared dictを定義します。

lua_shared_dict my_limit_count_store 100m;

次に、リミッターオブジェクトをnewし、最後にincoming関数を使用して判断および処理します。

ただし、limit-countincoming関数の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.ratelimit.connlimit.countを組み合わせる方法を見てみましょう。ここでは、limit.trafficcombine関数を使用する必要があります。

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つのレートリミッターだけでなく、incominguncommit APIを持つレートリミッターであれば、limit.trafficcombine関数で管理できます。

最後に、宿題を出します。以前紹介したトークンとバケットのレートリミッターを組み合わせた例を書くことができますか?コメント欄に答えを書いて私と議論してください。また、この記事を同僚や友人と共有して、一緒に学び、交流することも歓迎します。