OpenRestyの特別な点は何か

API7.ai

October 14, 2022

OpenResty (NGINX + Lua)

以前の記事では、OpenRestyの2つの基盤であるNGINXとLuaJITについて学びました。これで、OpenRestyが提供するAPIについて学ぶ準備が整ったことでしょう。

しかし、急ぐ必要はありません。その前に、OpenRestyの原則と基本概念についてもう少し時間をかけて理解を深める必要があります。

原則

Diagram1

OpenRestyのMasterWorkerプロセスはどちらも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_yieldlua_resumeはどちらもLuaが提供するlua_CFunctionの一部です。

Diagram2

一方、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では、すべての変数をローカル変数として宣言し、luachecklua-relengなどのツールを使用してグローバル変数を検出することをお勧めします。これはモジュールにも同様に適用されます。例えば以下のように:

local ngx_re = require "ngx.re"

OpenRestyでは、init_by_luainit_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になるでしょうか?必ずしもそうではありません。45、または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を定義しています。この変数はrewriteaccesscontentフェーズを跨ぎ、最終的にcontentフェーズで値を出力します。期待通り、79が出力されます。

もちろん、ngx.ctxにも制限があります。

例えば、ngx.location.captureで作成された子リクエストは、親リクエストのngx.ctxとは独立した独自のngx.ctxデータを持ちます。

また、ngx.execで作成された内部リダイレクトは、元のリクエストのngx.ctxを破棄し、空白のngx.ctxで再生成します。

これらの制限については、公式ドキュメントに詳細なコード例があるので、興味があれば自分で確認してください。

まとめ

最後に、いくつか補足します。私たちはOpenRestyの原則といくつかの重要な概念を学んでいますが、これらを暗記する必要はありません。結局のところ、これらは実際の要件やコードと組み合わせることで意味を持ち、生きてきます。

どのように理解しましたか?コメントを残して議論に参加してください。また、この記事を同僚や友人と共有することも歓迎します。一緒にコミュニケーションを取り、共に進歩しましょう。