NGINXワーカー間のコミュニケーションの魔法:最も重要なデータ構造の1つ `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

前回の記事で述べたように、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"))
     }
 }

この例では、mydataWorkerプロセスによって一度だけロードされるモジュールであり、その後その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)を追加します。ここでsleepyield操作です。

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 dictstringデータのみをキャッシュし、複雑な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_setaddsafe_addreplaceの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_count0に設定すると、すべてのキーを返します。次に、capacityfree_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_space0を返しても、割り当てられたページ内にスペースがある可能性があります。したがって、その戻り値は共有メモリがどれだけ占有されているかを表すものではありません。

まとめ

実際には、マルチレベルキャッシュを使用することが多く、公式のOpenRestyプロジェクトにもキャッシュパッケージがあります。それらのプロジェクトがどれかわかりますか? または、キャッシュをカプセル化した他のlua-restyライブラリを知っていますか?

この記事を同僚や友人と共有して、コミュニケーションと改善を一緒に行いましょう。