NGINXワーカー間のコミュニケーションの魔法:最も重要なデータ構造の1つ `shared dict`
API7.ai
October 27, 2022
前回の記事で述べたように、Luaにおける唯一のデータ構造はtable
です。これはOpenRestyプログラミングで使用できる最も重要なデータ構造であるshared dict
に対応しています。shared dict
は、データの保存、読み取り、アトミックなカウント、キュー操作をサポートしています。
shared dict
を基に、複数のWorker
間でのキャッシュや通信、レート制限、トラフィック統計などの機能を実装できます。shared dict
を簡易的なRedisとして使用することもできますが、shared dict
のデータは永続的ではないため、保存されたデータの損失を考慮する必要があります。
データ共有のいくつかの方法
OpenRestyのLuaコードを書く際には、リクエストの異なるフェーズで異なるWorker
間でデータを共有する必要がある場合があります。また、LuaとCコードの間でデータを共有する必要がある場合もあります。
そのため、shared dict
のAPIを正式に紹介する前に、まずOpenRestyで一般的なデータ共有方法を理解し、現在の状況に応じてより適切なデータ共有方法を選択する方法を学びましょう。
1つ目はNGINXの変数です。 これはNGINXのCモジュール間でデータを共有できます。当然、CモジュールとOpenRestyが提供するlua-nginx-module
間でもデータを共有できます。以下のコードのように。
location /foo {
set $my_var ''; # この行は設定時に$my_varを作成するために必要
content_by_lua_block {
ngx.var.my_var = 123;
...
}
}
ただし、NGINXの変数を使用してデータを共有するのは遅いです。なぜなら、ハッシュ検索とメモリ割り当てが関与するからです。また、この方法は文字列を保存するためにしか使用できず、複雑なLuaの型をサポートしていません。
2つ目はngx.ctx
で、同じリクエストの異なるフェーズ間でデータを共有できます。 これは通常のLuaのtable
なので高速であり、さまざまなLuaオブジェクトを保存できます。そのライフサイクルはリクエストレベルです。リクエストが終了すると、ngx.ctx
は破棄されます。
以下は典型的な使用シナリオで、ngx.ctx
を使用してNGINX変数のような高価なキャッシュ呼び出しを行い、さまざまな段階で使用します。
location /test {
rewrite_by_lua_block {
ngx.ctx.host = ngx.var.host
}
access_by_lua_block {
if (ngx.ctx.host == 'api7.ai') then
ngx.ctx.host = 'test.com'
end
}
content_by_lua_block {
ngx.say(ngx.ctx.host)
}
}
この場合、curl
でアクセスすると、
curl -i 127.0.0.1:8080/test -H 'host:api7.ai'
test.com
が出力され、ngx.ctx
が異なる段階でデータを共有していることがわかります。もちろん、上記の例を変更して、単純な文字列ではなくtable
のようなより複雑なオブジェクトを保存し、期待通りに動作するか確認することもできます。
ただし、ここで特に注意すべき点は、ngx.ctx
のライフサイクルがリクエストレベルであるため、モジュールレベルでキャッシュされないことです。例えば、foo.lua
ファイルでこのような間違いを犯しました。
local ngx_ctx = ngx.ctx
local function bar()
ngx_ctx.host = 'test.com'
end
関数レベルで呼び出してキャッシュするべきです。
local ngx = ngx
local function bar()
ngx_ctx.host = 'test.com'
end
ngx.ctx
にはまだ多くの詳細があり、パフォーマンス最適化のセクションで引き続き探求していきます。
3つ目のアプローチは、モジュールレベルの変数を使用して、同じWorker
内のすべてのリクエスト間でデータを共有します。 前のNGINX変数やngx.ctx
とは異なり、このアプローチは少し理解しにくいです。しかし、心配しないでください。概念は抽象的ですが、コードが先にあるので、例を見てモジュールレベルの変数を理解しましょう。
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.get_age(name)
return data[name]
end
return _M
nginx.conf
の設定は以下の通りです。
location /lua {
content_by_lua_block {
local mydata = require "mydata"
ngx.say(mydata.get_age("dog"))
}
}
この例では、mydata
はWorker
プロセスによって一度だけロードされるモジュールであり、その後そのWorker
が処理するすべてのリクエストはmydata
モジュールのコードとデータを共有します。
当然、mydata
モジュール内のdata
変数はモジュールレベルの変数であり、モジュールのトップレベル、つまりモジュールの最初に位置し、すべての関数からアクセス可能です。
したがって、リクエスト間で共有する必要があるデータをモジュールのトップレベルの変数に置くことができます。ただし、一般的にこの方法は読み取り専用のデータを保存するためにのみ使用します。書き込み操作が関与する場合、非常に注意が必要です。なぜなら、競合状態
が発生する可能性があり、これは特定が難しいバグだからです。
以下の最も簡略化された例でこれを体験できます。
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.incr_age(name)
data[name] = data[name] + 1
return data[name]
end
return _M
モジュール内で、data
テーブルのデータを変更するincr_age
関数を追加しました。
次に、呼び出しコードで最も重要な行ngx.sleep(5)
を追加します。ここでsleep
はyield
操作です。
location /lua {
content_by_lua_block {
local mydata = require "mydata"
ngx.say(mydata. incr_age("dog"))
ngx.sleep(5) -- yield API
ngx.say(mydata. incr_age("dog"))
}
}
このsleep
コードの行(または他の非ブロッキングIO操作、例えばRedisへのアクセスなど)がない場合、yield操作はなく、競合もなく、最終的な出力は順番通りになります。
しかし、この行のコードを追加すると、たとえ5秒間のスリープ中であっても、別のリクエストがmydata.incr_age
関数を呼び出し、変数の値を変更する可能性があり、その結果、最終的な出力番号が連続しなくなります。実際のコードではロジックがそれほど単純ではなく、バグの特定がはるかに困難です。
したがって、間にyield操作がないことが確実でない限り、モジュールレベルの変数を読み取り専用に保つことをお勧めします。
4つ目で最後のアプローチは、shared dict
を使用して、複数のワーカー間で共有できるデータを共有します。
このアプローチは赤黒木の実装に基づいており、パフォーマンスは良好ですが、制限があります。NGINX設定ファイルで共有メモリのサイズを事前に宣言する必要があり、実行時に変更することはできません。
lua_shared_dict dogs 10m;
shared dict
もstring
データのみをキャッシュし、複雑なLuaデータ型をサポートしていません。つまり、table
のような複雑なデータ型を保存する必要がある場合、JSONや他の方法を使用してシリアライズおよびデシリアライズする必要があり、これにより当然多くのパフォーマンスの損失が発生します。
いずれにせよ、ここには銀の弾丸はなく、完璧なデータ共有方法はありません。ニーズとシナリオに応じて複数の方法を組み合わせる必要があります。
Shared dict
上記のデータ共有部分について多くの時間を費やして学びましたが、一部の方は疑問に思うかもしれません。それらはshared dict
と直接関係がないように見えますが、それは話題から外れているのではないでしょうか?
実際にはそうではありません。考えてみてください。なぜOpenRestyにshared dict
があるのでしょうか? 最初の3つのデータ共有方法はすべてリクエストレベルまたは個々のWorker
レベルです。したがって、現在のOpenRestyの実装では、shared dict
のみがWorker
間でのデータ共有を実現し、Worker
間の通信を可能にします。これがその存在価値です。
私の意見では、技術が存在する理由を理解し、他の類似技術との違いや利点を把握することは、提供されるAPIを呼び出すことに熟練していることよりもはるかに重要です。この技術的視野は、ある程度の先見の明と洞察力を与え、エンジニアとアーキテクトの重要な違いと言えるでしょう。
shared dict
に戻ると、20以上のLua APIが公開されており、すべてアトミックであるため、複数のWorker
と高並列性の場合でも競合を心配する必要はありません。
これらのAPIにはすべて詳細な公式ドキュメントがあるので、すべてを説明しません。再度強調しますが、技術コースは公式ドキュメントを注意深く読むことに代わるものではありません。誰もこれらの時間のかかる愚かな手順をスキップすることはできません。
次に、shared dict
のAPIを見ていきましょう。これらは3つのカテゴリに分けられます。dictの読み書き、キュー操作、および管理です。
Dictの読み書き
まず、dictの読み書きクラスを見てみましょう。元のバージョンでは、dictの読み書きクラスのAPIしかありませんでした。これは共有辞書の最も一般的な機能です。以下は最も簡単な例です。
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
set
に加えて、OpenRestyはsafe_set
、add
、safe_add
、replace
の4つの書き込み方法を提供しています。ここでのsafe
プレフィックスの意味は、メモリが一杯になった場合、LRUに従って古いデータを削除するのではなく、書き込みが失敗し、no memory
エラーを返すことです。
get
に加えて、OpenRestyはget_stale
メソッドを提供しており、get
メソッドと比較して、期限切れのデータに対する追加の戻り値があります。
value, flags, stale = ngx.shared.DICT:get_stale(key)
また、指定されたキーを削除するためにdelete
メソッドを呼び出すこともできます。これはset(key, nil)
と同等です。
キュー操作
キュー操作に移ると、これはOpenRestyに後から追加されたもので、Redisと同様のインターフェースを提供します。キュー内の各要素はngx_http_lua_shdict_list_node_t
で記述されます。
typedef struct {
ngx_queue_t queue;
uint32_t value_len;
uint8_t value_type;
u_char data[1];
} ngx_http_lua_shdict_list_node_t;
これらのキューイングAPIのPRを記事に投稿しました。これに興味がある場合は、ドキュメント、テストケース、およびソースコードを追って具体的な実装を分析できます。
ただし、以下の5つのキューAPIに対応するコード例はドキュメントにありませんので、ここで簡単に紹介します。
lpush``/``rpush
はキューの両端に要素を追加します。lpop``/``rpop
はキューの両端から要素を取り出します。llen
はキュー内の要素数を返します。
前回の記事で議論したもう一つの便利なツールを忘れないでください。テストケースです。ドキュメントにない場合、通常はテストケースに対応するコードを見つけることができます。キュー関連のテストはまさにファイル145-shdict-list.t
にあります。
=== TEST 1: lpush & lpop
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua_block {
local dogs = ngx.shared.dogs
local len, err = dogs:lpush("foo", "bar")
if len then
ngx.say("push success")
else
ngx.say("push err: ", err)
end
local val, err = dogs:llen("foo")
ngx.say(val, " ", err)
local val, err = dogs:lpop("foo")
ngx.say(val, " ", err)
local val, err = dogs:llen("foo")
ngx.say(val, " ", err)
local val, err = dogs:lpop("foo")
ngx.say(val, " ", err)
}
}
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]
管理
最後の管理APIも後から追加されたもので、コミュニティで人気のある要件です。最も典型的な例の一つは、共有メモリの使用状況です。例えば、ユーザーがshared dict
として100M
のスペースを要求した場合、この100M
は十分でしょうか? その中に保存されているキーの数はいくつで、どのキーが保存されているのでしょうか? これらはすべて実際の問題です。
この種の問題に対して、OpenResty公式はユーザーがフレームグラフを使用して解決することを望んでいます。つまり、非侵襲的な方法でコードベースを効率的で整頓された状態に保ち、直接結果を返す侵襲的なAPIを提供しないことです。
しかし、ユーザーフレンドリーな観点から見ると、これらの管理APIは依然として必要です。結局のところ、オープンソースプロジェクトは製品要件を解決するために設計されており、技術自体を展示するためではありません。したがって、後から追加される以下の管理APIを見てみましょう。
まず、get_keys(max_count?)
で、デフォルトでは最初の1024
キーのみを返します。max_count
を0
に設定すると、すべてのキーを返します。次に、capacity
とfree_space
で、これらはどちらもlua-resty-core
リポジトリの一部であるため、使用する前にrequire
する必要があります。
require "resty.core.shdict"
local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()
これらは共有メモリのサイズ(lua_shared_dict
で設定されたサイズ)と空きページのバイト数を返します。shared dict
はページ単位で割り当てられるため、free_space
が0
を返しても、割り当てられたページ内にスペースがある可能性があります。したがって、その戻り値は共有メモリがどれだけ占有されているかを表すものではありません。
まとめ
実際には、マルチレベルキャッシュを使用することが多く、公式のOpenRestyプロジェクトにもキャッシュパッケージがあります。それらのプロジェクトがどれかわかりますか? または、キャッシュをカプセル化した他のlua-resty
ライブラリを知っていますか?
この記事を同僚や友人と共有して、コミュニケーションと改善を一緒に行いましょう。