OpenRestyの特別な点は何か
API7.ai
October 14, 2022
以前の記事では、OpenRestyの2つの基盤であるNGINXとLuaJITについて学びました。これで、OpenRestyが提供するAPIについて学ぶ準備が整ったことでしょう。
しかし、急ぐ必要はありません。その前に、OpenRestyの原則と基本概念についてもう少し時間をかけて理解を深める必要があります。
原則
OpenRestyのMaster
とWorker
プロセスはどちらもLuaJIT VMを含んでおり、同じプロセス内のすべてのコルーチンによって共有され、その中でLuaコードが実行されます。
そして、ある時点では、各Worker
プロセスは1人のユーザーからのリクエストしか処理できません。つまり、実行されているコルーチンは1つだけです。ここで疑問が浮かぶかもしれません。NGINXはC10K(1万の同時接続)をサポートしているのに、1万のリクエストを同時に処理する必要はないのでしょうか?
もちろん、ありません。NGINXはepoll
を使用してイベントを駆動し、待機やアイドル時間を減らすことで、できるだけ多くのCPUリソースをユーザーリクエストの処理に使用します。結局のところ、個々のリクエストが十分に速く処理されなければ、全体として高いパフォーマンスを達成することはできません。もしマルチスレッドモードを使用して、1つのリクエストに対応する1つのスレッドを割り当てると、C10Kの場合、リソースが簡単に枯渇してしまいます。
OpenRestyのレベルでは、LuaのコルーチンはNGINXのイベントメカニズムと連携して動作します。もしLuaコード内でMySQLデータベースのクエリのようなI/O操作が発生した場合、まずLuaコルーチンのyield
を呼び出して自身を中断し、その後NGINXにコールバックを登録します。I/O操作が完了した後(タイムアウトやエラーの場合も含む)、NGINXのコールバックresume
がLuaコルーチンを再開します。これにより、Luaの並行処理とNGINXのイベント駆動の連携が完了し、Luaコード内でコールバックを書く必要がなくなります。
以下の図は、このプロセス全体を説明しています。lua_yield
とlua_resume
はどちらもLuaが提供するlua_CFunction
の一部です。
一方、Luaコード内にI/O操作やsleep
操作がない場合、例えばすべての暗号化・復号化操作が集中している場合、LuaJIT VMはLuaコルーチンによって占有され、リクエスト全体が処理されるまで解放されません。
以下に、ngx.sleep
のソースコードの一部を提供します。これにより、この仕組みをより明確に理解できるでしょう。このコードはlua-nginx-module
プロジェクトのsrc
ディレクトリにあるngx_http_lua_sleep.c
にあります。
ngx_http_lua_sleep.c
では、sleep
関数の具体的な実装を見ることができます。まず、Lua API ngx.sleep
をC関数ngx_http_lua_ngx_sleep
に登録する必要があります。
void ngx_http_lua_inject_sleep_api(lua_State *L)
{
lua_pushcfunction(L, ngx_http_lua_ngx_sleep);
lua_setfield(L, -2, "sleep");
}
以下はsleep
のメイン関数で、ここでは主要なコードの数行だけを抜粋しています。
static int ngx_http_lua_ngx_sleep(lua_State *L)
{
coctx->sleep.handler = ngx_http_lua_sleep_handler;
ngx_add_timer(&coctx->sleep, (ngx_msec_t) delay);
return lua_yield(L, 0);
}
ここでわかることは以下の通りです:
- まずコールバック関数
ngx_http_lua_sleep_handler
が追加されます。 - 次に、NGINXが提供するインターフェース
ngx_add_timer
を呼び出して、NGINXのイベントループにタイマーを追加します。 - 最後に、
lua_yield
を使用してLuaの並行処理を中断し、制御をNGINXのイベントループに渡します。
sleep
操作が完了すると、ngx_http_lua_sleep_handler
コールバック関数がトリガーされます。この関数はngx_http_lua_sleep_resume
を呼び出し、最終的にlua_resume
を使用してLuaコルーチンを再開します。詳細はコード内で確認できるので、ここでは詳しく説明しません。
ngx.sleep
は最もシンプルな例ですが、これを解剖することで、lua-nginx-module
モジュールの基本的な原則を理解することができます。
基本概念
原則を分析した後、OpenRestyの2つの重要な概念である「フェーズ」と「非ブロッキング」について復習しましょう。
OpenRestyはNGINXと同様にフェーズの概念を持ち、各フェーズには独自の役割があります:
set_by_lua
:変数を設定するために使用されます。rewrite_by_lua
:転送、リダイレクトなどに使用されます。access_by_lua
:アクセス、権限などに使用されます。content_by_lua
:返却コンテンツを生成するために使用されます。header_filter_by_lua
:レスポンスヘッダのフィルタ処理に使用されます。body_filter_by_lua
:レスポンスボディのフィルタリングに使用されます。log_by_lua
:ロギングに使用されます。
もちろん、コードのロジックがそれほど複雑でない場合、すべてをrewrite
フェーズやcontent
フェーズで実行することも可能です。
ただし、OpenRestyのAPIにはフェーズの使用制限があることに注意してください。各APIには使用可能なフェーズのリストがあり、範囲外で使用するとエラーが発生します。これは他の開発言語とは大きく異なります。
例として、ngx.sleep
を使用します。ドキュメントから、このAPIは以下のコンテキストでのみ使用可能であり、log
フェーズは含まれていないことがわかります。
context: rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
もしこれを知らずに、サポートされていないlog
フェーズでsleep
を使用すると:
location / {
log_by_lua_block {
ngx.sleep(1)
}
}
NGINXのエラーログには、error
レベルの表示が記録されます。
[error] 62666#0: *6 failed to run log_by_lua*: log_by_lua(nginx.conf:14):2: API disabled in the context of log_by_lua*
stack traceback:
[C]: in function 'sleep'
したがって、APIを使用する前に、必ずドキュメントを確認して、そのAPIが現在のコードのコンテキストで使用可能かどうかを確認してください。
フェーズの概念を復習した後、非ブロッキングについても復習しましょう。まず、OpenRestyが提供するすべてのAPIは非ブロッキングであることを明確にします。
引き続き、1秒間スリープする要件を例に挙げます。もしこれをLuaで実装したい場合、以下のようにする必要があります。
function sleep(s)
local ntime = os.time() + s
repeat until os.time() > ntime
end
標準のLuaにはsleep
関数がないため、ここではループを使用して指定された時間に達したかどうかを判断しています。この実装はブロッキングであり、sleep
が実行されている間、Luaは何もせず、処理が必要な他のリクエストはただ待機しているだけです。
しかし、ngx.sleep(1)
に切り替えると、上記で分析したソースコードに従って、OpenRestyはこの1秒間も他のリクエスト(例えばrequest B
)を処理できます。現在のリクエスト(request A
と呼びます)のコンテキストは保存され、NGINXのイベントメカニズムによって再開され、request A
に戻ります。これにより、CPUは常に自然な作業状態に保たれます。
変数とライフサイクル
これら2つの重要な概念に加えて、変数のライフサイクルもOpenResty開発で間違いやすい領域です。
以前にも述べたように、OpenRestyでは、すべての変数をローカル変数として宣言し、luacheck
やlua-releng
などのツールを使用してグローバル変数を検出することをお勧めします。これはモジュールにも同様に適用されます。例えば以下のように:
local ngx_re = require "ngx.re"
OpenRestyでは、init_by_lua
とinit_worker_by_lua
の2つのフェーズを除いて、すべてのフェーズに対して独立したグローバル変数のテーブルが設定されており、処理中に他のリクエストを汚染しないようになっています。これらの2つのフェーズでグローバル変数を定義できる場合でも、できるだけ避けるべきです。
原則として、グローバル変数で解決しようとする問題は、モジュール内の変数でより適切に解決でき、より明確になります。以下はモジュール内の変数の例です。
local _M = {}
_M.color = {
red = 1,
blue = 2,
green = 3
}
return _M
hello.lua
というファイルにモジュールを定義し、その中にcolor
テーブルを含めました。その後、nginx.conf
に以下の設定を追加します。
location / {
content_by_lua_block {
local hello = require "hello"
ngx.say(hello.color.green)
}
}
この設定は、content
フェーズでモジュールを要求し、green
の値をHTTPレスポンスボディとして出力します。
なぜモジュール変数がこれほど素晴らしいのでしょうか?
モジュールは同じWorker
プロセス内で1回だけロードされます。その後、そのWorker
が処理するすべてのリクエストは、モジュール内のデータを共有します。OpenRestyのWorker
は互いに完全に独立しているため、各Worker
はモジュールを独立してロードし、モジュールのデータはWorker
を跨ぐことはできません。
Worker
間で共有する必要があるデータの処理については、後の章で説明するので、ここでは深く掘り下げる必要はありません。
ただし、ここで1つ注意点があります。モジュール変数にアクセスする際は、読み取り専用に保ち、変更を試みない方が良いです。そうしないと、高並列性の場合にrace
が発生し、ユニットテストでは検出できないバグが発生し、オンラインで時々発生して特定が困難になります。
例えば、モジュール変数green
の現在の値が3
で、コード内でplus 1
操作を行うと、green
の値は4
になるでしょうか?必ずしもそうではありません。4
、5
、または6
になる可能性があります。なぜなら、OpenRestyはモジュール変数への書き込み時にロックを行わないため、複数のリクエストが同時にモジュール変数の値を更新する競合が発生するからです。
グローバル変数、ローカル変数、モジュール変数について説明した後、次にフェーズを跨ぐ変数について議論しましょう。
フェーズを跨ぎ、読み書き可能な変数が必要な場合があります。NGINXでおなじみの$host
、$scheme
などの変数は、フェーズを跨ぐ条件を満たしていますが、動的に作成することはできず、設定ファイルで定義する必要があります。例えば、以下のように書きます。
location /foo {
set $my_var ; # まず$my_var変数を作成する必要があります
content_by_lua_block {
ngx.var.my_var = 123
}
}
OpenRestyは、このような問題を解決するためにngx.ctx
を提供しています。これはLuaテーブルであり、リクエストベースのLuaデータを保存するために使用でき、現在のリクエストと同じライフタイムを持ちます。公式ドキュメントの例を見てみましょう。
location /test {
rewrite_by_lua_block {
ngx.ctx.foo = 76
}
access_by_lua_block {
ngx.ctx.foo = ngx.ctx.foo + 3
}
content_by_lua_block {
ngx.say(ngx.ctx.foo)
}
}
ここでは、ngx.ctx
に保存される変数foo
を定義しています。この変数はrewrite
、access
、content
フェーズを跨ぎ、最終的にcontent
フェーズで値を出力します。期待通り、79
が出力されます。
もちろん、ngx.ctx
にも制限があります。
例えば、ngx.location.capture
で作成された子リクエストは、親リクエストのngx.ctx
とは独立した独自のngx.ctx
データを持ちます。
また、ngx.exec
で作成された内部リダイレクトは、元のリクエストのngx.ctx
を破棄し、空白のngx.ctx
で再生成します。
これらの制限については、公式ドキュメントに詳細なコード例があるので、興味があれば自分で確認してください。
まとめ
最後に、いくつか補足します。私たちはOpenRestyの原則といくつかの重要な概念を学んでいますが、これらを暗記する必要はありません。結局のところ、これらは実際の要件やコードと組み合わせることで意味を持ち、生きてきます。
どのように理解しましたか?コメントを残して議論に参加してください。また、この記事を同僚や友人と共有することも歓迎します。一緒にコミュニケーションを取り、共に進歩しましょう。