mercariengineering
ハイパフォーマンスngx_lua

Site Reliability Engineering(SRE) Teamの@cubicdaiyaです。

今回は数あるnginxのサードパーティモジュールの中でも一際強力で、メルカリでも活用しているngx_luaの便利な活用方法や最適化集について紹介します。

ngx_lua

ngx_luaは軽量スクリプト言語のLuaでnginxを拡張できるモジュールです。
nginxの設定ファイル内にLuaのコードを埋め込んだり、nginxの拡張モジュールをCではなくLuaで開発することができます。以下はngx_luaにおける「Hello, World!」です。

location / {
content_by_lua 'ngx.say("Hello, World!")';
}

上記のロケーションにHTTPでアクセスするとnginxはボディが「Hello, World!」のレスポンスを返します。

なお、先月末にリリースされた0.9.17から、Luaのコードをヒアドキュメントのようにnginxの設定ファイル内に埋め込むことができるxxx_by_lua_blockディレクティブ(e.g. content_by_lua_block)が導入されています。xxx_by_luaディレクティブと違ってシングルクオートやダブルクオートのエスケープを気にしなくていいのでとても便利そうです。

location / {
content_by_lua_block {
ngx.say("Hello, World!")
}
}

ngx_luaは上記のようにレスポンスボディを生成するフェーズを含むさまざまなリクエスト処理フェーズにフックしてLuaのコードを実行できるほか、nginxの内部データをLuaから参照・更新するためのAPIが数多く用意されているので、それなりに制限はあるもののLuaで可能なあらゆるプログラムロジックをnginx内で記述することが可能です。

ngx_luaとメルカリ

メルカリではngx_luaをベースにしたアプリケーションフレームワークであるOpenRestyをログ分析基盤(通称:Pascal)のフロントエンドや検索サーバ群のためのロードバランサーとして活用しています。 前者は比較的大きな(とは言っても数百行程度)アプリケーションですが、後者は普通のHTTPサーバでは難しいちょっと気の利いた処理を実現するのにngx_luaのノンブロッキングAPIを利用した30行程度のLuaのコードをnginx.confに埋め込んでいます。

ハイパフォーマンスngx_lua

ngx_luaは高速でメモリの空間効率が良く、イベント駆動や非同期、ノンブロッキングといったアーキテクチャ特性をもつnginxと非常に親和性がよいのが大きな特徴です。中でもサブリクエストをノンブロッキングに処理できるngx.location.capture()やノンブロッキングで外部サーバと通信できるngx.socket.tcpといったAPIはそれを体現している機能と言えるでしょう。

一方で、ngx_luaを使いこなすにはLuaをはじめnginxのアーキテクチャ特性やngx_luaのAPIが持つ独特の癖を理解することが重要になってきます。今回はそのへんのノウハウをいくつか紹介したいと思います。

LuaJITを使う

ngx_luaは動作環境としてLua5.1とLuaJIT2.0/2.1をサポートしています。LuaJITはLuaよりもかなり高速に動作するので特に理由がなければngx_luaはLuaJITで動かすのが良いでしょう。また、OpenRestyであればデフォルトでLuaJITが有効になるようになっています。

lua-resty-coreを使う

さて、ngx_luaをLuaJITで動かしたとしても実はngx_luaのAPI実行部分はJIT化されません。元々ngx_luaのAPIはLuaのC APIで書かれたものなのですが、実はLuaのC APIを利用したプログラムはLuaJITによるJITの恩恵を受けることができないためです。LuaJITでJITの恩恵を授かるにはLuaで実装されたプログラムである必要があります。

lua-resty-coreは従来のLuaのC APIで書かれていたngx_luaのコアAPIをLua(とFFI)でリライトしたものです。

Luaで書かれたlua-resty-coreならばJITの恩恵を受けることができます。OpenRestyであれば以下の1行を書くだけでngx_luaの一部APIをlua-resty-coreで再実装されたAPIに置き換えることができます。

require 'resty.core'

os.time()とngx.time()

os.time()はLua本体が持つAPIで要はtimeシステムコールのラッパーです。一方ngx.time()はngx_luaのAPIで、nginx自身が持つタイマーのキャッシュからタイムスタンプを取得します。そのため、ngx.time()を利用すればtimeシステムコールのオーバヘッドを回避することができます。特に理由がなければos.time()ではなくngx.time()を使いましょう。

ngx.var.VARIABLEは何度も直接参照しない

ngx.var.VARIABLEはnginxの内部変数を参照・更新するためのAPIです。
例えばHTTPリクエスト内のUser-Agentヘッダの値をLuaの変数に代入するコードは以下のようになります。

local ua = ngx.var.http_user_agent

ngx.var.VARIABLEは参照する度に各リクエスト処理コンテキスト毎に確保されているメモリプールから必要なメモリをアロケートしてLuaの変数にコピーします。だから以下のようなコードだとループが回る度にメモリプールからアロケート、メモリコピーという流れになります。

for k, v in ipairs do
local ua = ngx.var.http_user_agent
do_something(ua)
end

上記のコードは非効率なのでこう書くべきです。

local ua = ngx.var.http_user_agent
for k, v in ipairs do
do_something(ua)
end

メモリバッファに収まらないリクエストボディの読み込み

ngx_luaでPOST等で受け取ったリクエストボディを処理する際にはいくつか注意する点があります。以下はそれに関連するディレクティブとAPIです。

  • client_body_buffer_sizeディレクティブとclient_body_temp_pathディレクティブ
  • lua_need_request_bodyディレクティブとngx.req.read_body()
  • ngx.req.get_body_data()とngx.req.get_body_file()

client_body_buffer_sizeディレクティブとclient_body_temp_pathディレクティブ

nginxは通常クライアントから受け取ったリクエストボディをメモリバッファに格納します。メモリバッファのサイズはclient_body_buffer_sizeディレクティブで変更可能ですが、動的に拡張されることはありません。ボディサイズが大きくてバッファから溢れる場合、nginxはリクエストボディをテンポラリファイルに書き出します(書き出し先はclient_body_temp_pathで制御できます)。

このテンポラリファイル書き出しによるI/Oオーバーヘッドはclient_body_buffer_sizeを大きめに設定するか、テンポラリファイルの書き出し先をtmpfs(e.g. /dev/shm)にすることである程度抑えることが可能です。

lua_need_request_bodyディレクティブとngx.req.read_body()

さて、ここからがngx_lua特有の話ですが、実はngx_luaはデフォルトの動作ではリクエストボディをメモリに読み込ません。POST等で受け取ったリクエストボディを処理する際はlua_need_request_bodyディレクティブを有効にするかngx.req.read_body()でリクエストボディを読み込む必要があります。

ngx.req.get_body_data()とngx.req.get_body_file()

さらに面倒なことにngx_luaでリクエストボディを読み込む際、リクエストボディがメモリバッファに収まっているか、メモリバッファに収まらずにテンポラリファイルとしてディスクに書き出されているかでLuaでリクエストボディの内容を取得する方法が異なります。

  • メモリバッファに収まっている場合はngx.req.get_body_data()を呼び出す
  • メモリバッファに収まらずにテンポラリファイルとしてディスクに書き出されている場合はngx.req.get_body_file()を呼び出す

といった具合です。以下は上記両方のケースに対応した関数です。

function ngx_lua_read_body()
local body = ngx.req.get_body_data()
if not body then
local path = ngx.req.get_body_file()
if not path then
return nil
end
local fh = io.open(path, "r")
if not fh then
return nil
end
body = fh:read("*all")
io.close(fh)
end
return body
end

ngx.req.get_body_data()はリクエストボディを文字列で返すのに対して、ngx.req.get_body_file()はテンポラリファイルのパスで返す点に注意しましょう。

ngx.location.capture()でサブリクエストをノンブロッキングで処理する

ngx_luaの大きな特徴の一つとしてngx.location.capture()やngx.socket.tcp等のノンブロッキングAPIの存在が挙げられます。
ngx.location.capture()は簡単に言うとnginxの特定のロケーションに対してのサブリクエストをノンブロッキングで実行するためのAPIです。また、ノンブロッキングで動作する一方でブロッキング処理的な書き方ができるというのが大きな利点でもあります。

例えば、nginxがクライアントからリクエストを受け取った後、外部のHTTPサーバとなにかしらのリソースをやりとりするようなケースを考えてみましょう。これは多種多様なネットワークと連携して動作する広告配信システムではよく見られる光景です。

location /external_service {
proxy_pass http://external_service/;
}
location / {
content_by_lua '
...
-- 外部サーバへのHTTPリクエストをノンブロッキングで処理する
res = ngx.location.capture("/external_service");
...
';
}

上記のngx.location.capture()は内部ロケーション(/external_service)へのサブリクエストを発行し、そのロケーションではproxy_passディレクティブを利用してexternal_serviceサーバへリクエストをプロキシします。そして先述の通り、この処理はノンブロッキング的な動作をします。

通常nginxのようなイベント駆動型のWebサーバはそのアーキテクチャの特性上prefork型のWebサーバと比べて非常に少ないワーカー数で大量の同時接続を処理しますが、外部サーバと通信する際にブロッキングしてしまうと必然的にワーカー数を増やさなければならず段々prefork型のサーバに近いワークロードを示すようになります。これはnginxの本来の強みを消してしまうのでnginxをベースにしたアプリケーションサーバにおいてサブリクエストや外部サーバとの通信をノンブロッキングで処理できることは非常に重要です。

memcached、Redis、MySQLとの通信をノンブロッキングで処理する

OpenRestyであればmemcached、Redis、MySQLとの通信をノンブロッキングで行うためのモジュールが含まれているのでこちらを利用するのがよいでしょう。

これらのモジュールはngx.socket.tcpを利用しています。このAPIは比較的ローレベルなものなので手間ではありますが、利用することでさまざまなプロトコルを利用した通信処理をノンブロッキングな動作にすることが可能です。

メルカリにおけるngx.location.capture()の活用事例

話は変わりますが、メルカリではApache Solrによる検索サーバ群の前段にnginx(lb_search)を配置して検索リクエストをロードバランスしたり、レスポンスをキャッシュすることで検索APIの応答速度を高めています。

このエントリを書く少し前、メルカリでは検索APIの応答速度が日に日に悪化していくことに頭を悩ませていました。日々増え続ける大量の商品データを単一のインデックスに格納していたため、あらゆる検索リクエストに対して素早く応答するのが難しくなっていたのです。

そこで検索の応答速度を改善するため、まずは比較的件数の少ない最新のインデックスを搭載したSolrサーバ(latest cluster)に検索リクエストを発行し、ヒット件数が少ない場合だけすべてのインデックスを搭載したSolrサーバ(all index cluster)にリクエストを発行する構成に変更しました。

f:id:cubicdaiya:20151120192039p:plain

この仕組みを導入することで検索APIの応答速度を大幅に向上させることができました。

一方で、上記の多段リクエスト発行処理をPHPで実行されたAPI側で行うとかなり複雑になってしまうので検索サーバ群の前段に配置したnginx(lb_search)で行ってAPI開発者からは具体的な処理内容を隠蔽するといった形を取ることにしました。そこで登場するのがngx_luaのngx.location.capture()というわけです。

以下はそのための擬似的なLuaコードです。(エラー処理などは省いています。実際のコードはもう少し込み入っていて複雑なのと、件数はテキトーです。)

local args = ngx.var.args
local uri = "/search_latest_items"
-- サイズの小さい最新のインデックスに対して検索リクエストを発行する
local res = ngx.location.capture(uri, { args = args })
body = cjson.decode(res.body)
if #body.response.docs < 20 then
-- 検索にヒットした件数が20件に満たなかったら全インデックスを搭載したSolrに対して検索リクエストを発行する
res = ngx.location.capture("/search_all_items", { args = args })
end
-- 検索結果を返す
ngx.print(res)

Luaの正規表現とngx_luaの正規表現

ngx_luaではLua標準の正規表現とngx_luaが提供しているPCREベースのAPI(e.g. ngx.re.match()、ngx.re.find())が利用可能です。
後者の方が高機能ですが、単純性能ではLua標準の正規表現の方がシンプルなこともあってこちらが勝ることが多いです。また、性能では劣るngx_luaの正規表現APIもcompile-once mode(o)とJIT compilation(j)のオプションを付与することである程度高速化することが可能です。

-- compile-once modeとJIT compilationを有効にする
local m, err = ngx.re.match(text, regex, "oj")

両者のベンチマークについてはOpenRestyのre.matchとstring.matchの性能差が非常に参考になります。

また、JIT compilationを利用するにはPCREのバージョンが8.20以上であることとpcre_jitディレクティブを有効にする必要があります。

pcre_jit on;

さらにPCREがJIT compilationを利用可能な状態でビルドされている必要があります。nginxはPCREを静的あるいは動的に組み込む方法を提供しており、静的に組み込む際は--with-pcre=PCREのソースコードのパスと一緒に--with-pcre-jitを付加します。動的に組み込む際はPCREをビルドする際に--enable-jitを付加します。

まとめ

ngx_luaの便利な活用方法や最適化集について紹介しました。nginxはHTTPサーバやリバースプロキシといった用途に限らず、L7およびL4ロードバランス、コンテンツキャッシュなど様々な場面で応用が利くサーバソフトウェアですが、ngx_luaを活用することでnginxをより高度なサーバアプリケーションフレームワークとして昇華することができます。

ngx_luaはよりよいnginxライフを送るための一助となるでしょう。

参考

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追åŠ