Apache Traffic Serverとnginxで使えるLuaJIT用shared dictを作ってみた

はじめに

レポジトリはhnakamur/ats-ngx-lua-shdictです。

作ってみたといっても、0から作り上げたわけではなく、nginxとlua-nginx-moduleのngx.shared.DICTのソースをコピペして改変しただけです。私がLinuxでしか使う予定がないので、対象環境はLinuxのみです。

コミットログを見ると去年の12月11日から作り始めていたので3週間かかっています。

なお、現状はとりあえず実装と単体テストを書いただけで、実運用に耐えるレベルなのかは不明です。が、このタイミングでメモしておかないと、この後他の事をするとだんだん忘れてきて、後からだと書くのが面倒になってくるので今書いておきます。

背景として、私はNGINXopenresty/lua-nginx-moduleApache Traffic ServerLua PluginでLuaJITでスクリプトを書いて大変便利に使っています。

その中でもngx.shared.DICTという共有メモリ上のキーバリューストアのような仕組みが便利で、各種設定を登録・参照するのに使っているのですが、Traffic Serverでも似たような仕組みが欲しいなと思っていました。 nginxはワーカープロセスがマルチプロセス構成なのに対して、Traffic Serverはシングルプロセスでマルチスレッドなので、実は普通にメモリ上にデータ構造作ってmutexで排他制御すれば良いという話もあります。

ただ、Hierarchical Cachingを使って、かつ1台のサーバにnginxのサービス1組とtrafficserverのサービスを2組動かして、それを複数台並べる構成だと、1台のサーバ上の1組のnginxと2組のtrafficserverのサービス間でもデータを共有したいという思いがありました。

共有メモリにアドレスをそのまま書く方式だと複数サービス間では共有できない

LuaJIT+FFIで共有メモリを試してみた · hnakamur’s blogの次のステップとして、shm_openで開いた共有メモリをmmapでメモリにマップして、その上にngx.shared.DICTのデータ構造を作るのを試してみました。

プロセス終了後も/dev/shm/配下に共有メモリのファイル(/dev/shm/配下は疑似的なファイルシステムなので実際はメモリ上のデータです)が残るので、再度プロセスを起動した場合はそれを開いて利用するように実装したのですが、Segmentation faultが起きました。

ちょっと考えるとそれは当たり前で、ngx.shared.DICTではポインタのアドレスをそのままメモリ上に書いていて、それが/dev/shm/配下の共有メモリに書かれますが、次にプロセスが起動してそれを開いてmmapしたり、別のプロセスから開いてmmapすると、最初とは別のアドレスにマップされるので、保存されていたアドレスの値は変なところを指しているからです。

横道: nginxの共有メモリ

なお、nginxの場合は、managerプロセスで共有メモリを作成して、それを複数のworkerプロセスに渡しているので、同じアドレスのまま参照できているということのようです。そしてngx_shm_alloc関数ではLinuxなどMAP_ANONが利用できる環境ではmmap (2)flagsMAP_ANON|MAP_SHAREDを指定して共有メモリを作っています。

この方式だと/dev/shm/配下にはファイルは作られず、nginxを終了すると内容は失われ、nginxを再起動すると新たに作られることになります。

ベースアドレスからのオフセットを書くように改変して対応

そこで、共有メモリ上のデータ構造内ではポインタの代わりにmmapしたときのベースアドレスからのオフセットを持つようにしてみました。

ngx.shared.DICTはnginxの共有メモリの仕組みの上にスラブアロケータ(ngx_slab_pool_t)、赤黒木 (ngx_rbtree_t)キュー (ngx_queue_t)を使って実装されています。キューは双方向リンクトリストで共有メモリがいっぱいになったときにLeast Recently Used (LRU)アルゴリズムで古いキーを破棄するために使っています。

オフセットを表す型定義

オフセットを表す型としてtypedef uintptr mps_ptroff_t;というのを定義しました(mps_は今回作ったソフトウェアの接頭辞でmulti process sharedの略です)。

C++だとstd::ptrdiff_t - cpprefjp C++日本語リファレンスというのがあるそうなのですが、こちらはdiffでポインタ同士の差分ということで符号付き整数型となっています。

今回表したいのはベースアドレスからのオフセットなので符号無しで良いので、これとは違う名前にしてみました。Cのoff_tも符号付きなので紛らわしいかもとは思ったのですが、他に良い名前が思いつかず。

元のポインタは対応する構造体へのポインタですが、mps_ptroff_tだとどの構造体に対するオフセットだったかという情報は欠落してしまっています。RustやC++ならそれぞれの構造体に対するオフセット型を導入して違う方のオフセットを間違って使わないようにとかしそうな気がします(深く考えずにそんな感じかなと思ってるだけ)が、今回はCなのと構造体の種類も少ししかないのでこれで十分です。

オフセットとポインタの相互変換用のマクロを定義しています。

#define mps_offset(pool, ptr) (mps_ptroff_t)((u_char *)(ptr) - (u_char *)(pool))

#define mps_ptr(pool, offset) ((u_char *)(pool) + (offset))

スラブアロケータのmps_slab_pool_t *poolが共有メモリのベースアドレスです。 また、NULLポインタに対応するオフセットは #define mps_nulloff 0 と定義しています。これはpoolの先頭にはスラブアロケータの管理データがあり、スラブアロケータで割り当てられるメモリ領域は絶対にpoolとは異なる値になるため、オフセットが0になることはないからです。

オフセット用の型を用意したら、あとは以下の方針で書き換えていきました。

文字列キーのハッシュ関数はngx_crc32_shortからngx_murmur_hash2に変更

ngx_crc32_shortuint32_t ngx_crc32_table16[]をCPUのキャッシュラインのサイズに応じてngx_crc32_table_initでアラインしなおすという処理が必要でちょっと面倒なのと、マイクロベンチマークをしてみた感じではngx_murmur_hash2のほうが約10倍速かったので、こちらに変更しました。

どちらも、入力は長さ指定の文字列で、出力はuint32_tと同じです。

nginxとtrafficserverのログ出力関数の利用

ngx.shared.DICTはnginxのモジュールとして実装されていますが、今回作ったのは単なる共有ライブラリでLuaJITからffi.loadで読み込んで使う方式としています。実際はFFIの関数をラップするLuaJITのスクリプトファイルを提供していて、それをrequireして使います。

nginx用とtrafficserver用で別々の共有ライブラリを作るようにしていて、nginx用では ngx_log_errorngx_log_debugを、trafficserver用では TSStatus, TSNote, TSWarning, TSErrorTSDebugを使うようにしています。

nginxのngx_log_error用のログレベル定義とtrafficserverのTSNote~TSEmergencyは数が同じなので、当初は全て使おうかと思ったのですが、TSFatal, TSAlert, TSEmergencyはログ出力後プロセスを終了するようになっていて、一方nginxのほうは全レベルで終了しないという違いがあって、運用時に紛らわしいことになりそうと思ったので、TSFatal, TSAlert, TSEmergencyのレベルはこのライブラリでは使わないことにしました。

ということでnginxとlua-nginx-module内でNGX_LOG_CRIT以上のレベルでログ出力していた箇所もNGX_LOG_ERR相当に変更しました。

mps_log.hmps_log_debugmps_log_statusmps_log_notemps_log_warningmps_log_errorを定義してビルド時に#ifdefでnginx用のログ関数を使うか、trafficserver用のログ関数を使うか、単体テスト用に標準エラー出力に出力する関数を使うかを切り替えるようにしています。

なお、メッセージのフォーマット文字列内の%での指定方法に違いがあるので、真っ当にはnginxかtrafficserverのどちらかに合わせるような関数を書いてラップするほうが良いかもしれません。

nginxのほうは最終的にはngx_vslprintfに行きついてこちらはsrc/core/ngx_string.c#L90-L119のフォーマットをサポートしています。

一方trafficserverのほうは最終的にはDiags::print_vaに行きついてこれはvfprintf (3)を呼んでいます。

最大長さを指定した文字列出力はnginxでは%*s、trafficserverでは%.*sと違う指定が必要なので、mps_log.hLogLenStrというマクロを定義してとりあえずしのいでいます。

2023-01-02 ログ書式をvsnprintfに統一しました

e14b6798cdd4beのコミットで統一しました。

"%" PRId64みたいに書くのは割と面倒なので、ngx_vslprintfをコピペ改変しようかと一度は思ったのですが、書式と引数が一致しないときにコンパイル時に警告が出るのは便利だなと思い直して、nginx用のログ出力をvsnprintfを使って文字列を作ってからnginxのログ出力関数に渡すように改修しました。

また、tslog.hで使用されていたClang format attributeを、nginxと標準エラー出力用のログ関数にも付けました。

これで上に書いたLogLenStrのマクロは不要になったので直接"%.*s"と書くようにしました。 なお、テストでは面倒だったので"%" PRId64ではなく"%ld"のように書いています。

このへんを試していて気づいたのですが、フォーマット文字列と値の間で改行が入っていると、以下のように警告メッセージにフォーマット文字列が出力されないんですね。

src/mps_slab.c:108:9: warning: format specifies type 'int' but the argument has type 'ngx_uint_t' (aka 'unsigned long') [-Wformat]
        mps_pagesize, mps_slab_max_size, mps_slab_exact_size);
        ^~~~~~~~~~~~
src/mps_log.h:11:48: note: expanded from macro 'mps_log_debug'
#define mps_log_debug(tag, ...) TSDebug((tag), __VA_ARGS__)
                                               ^~~~~~~~~~~
1 warning generated.

フォーマット文字列と値の間に改行が入っていない場合は、フォーマット文字列と修正後のフォーマットも出力されます。

src/mps_slab.c:105:51: warning: format specifies type 'int' but the argument has type 'ngx_uint_t' (aka 'unsigned long') [-Wformat]
    mps_log_debug(MPS_LOG_TAG, "mps_pagesize=%d", mps_pagesize);
                                             ~~   ^~~~~~~~~~~~
                                             %lu
src/mps_log.h:20:48: note: expanded from macro 'mps_log_debug'
#define mps_log_debug(tag, ...) TSDebug((tag), __VA_ARGS__)
                                               ^~~~~~~~~~~
1 warning generated.

1回のログ出力で多数の値を出してフォーマット文字列が長くなりがちな私としてはちょっと残念ですが、警告メッセージを見れば分かるので慣れればよいかという気もしました。

リスト関連のメソッド(lpush, rpush, lpop, rpop, llen)

ngx.shared.DICTにはLuaの文字列、Number、booleanの値の設定・取得などのメソッドに加えて、リスト関連のメソッド(lpush, rpush, lpop, rpop, llen)も用意されています。

個人的には使ってないので、非対応にしようかとも思ったのですが、一旦対応してみました。getなどのメソッドはLuaJIT FFI用の関数が実装されているのですが、リスト関連のメソッドはLua用の関数しかなかったのでFFI用の関数に改変して対応しました。

LuaJIT FFI用の関数は普通のCの関数として書けばよいので楽で、これに慣れるとlua_pushlstringなどを使ってLua用の関数を書くのはかなり面倒に感じます。Luaの入出力のスタックから引数を取得したり戻り値をスタックに積むときに、インデクスを随時意識する必要があるのがかなり大変ですし、LuaJIT FFIのほうが実行時の呼び出しも速いとのことなので、個人的にはLuaJIT FFI一択です。

単体テストとカバレッジ

単体テストはThrowTheSwitch/Unity: Simple Unit Testing for Cというフレームワークを初めて使ってみました。テストの.cファイルを分けるとアサーションエラーの時にファイル名が正しく出ないようだったので、とりあえず.cファイルは1つにしました(深く調査してないです)。

カバレッジはllvm-cov - emit coverage informationで出力しています。Targeted Cache Control のライブラリをC言語で書いた · hnakamur’s blogで初めて使って、今回が2回目です。行単位ではなく式単位でカバレッジが表示されるのが便利です。

make testでテストを実行してmake covでカバレッジを出力するようにしています。端末にそのまま出力されるのでtmuxでスクロールをさかのぼってすぐに確認できるのが良いです。

関連: Handles are the better pointers

ポインタの代わりにオフセットを使うというので、似た話をどこかで聞いたようなと思ったのですが A Practical Guide to Applying Data-Oriented Design の13:49あたりからのポインタの代わりにインデクスを使うという話でした。

こちらはメモリ使用量削減のために64bitのポインタの代わりに32bitとかのインデクスの整数を使って、 構造体のサイズを削減するという文脈です。

その中でHandles are the better pointersが紹介されていました(Handles Are the Better Pointers (2018) | Hacker News)。

インデクスにするには構造体の種類ごとに別の配列に集める必要があると思うのですが、lua-nginx-moduleだと ngx_http_lua_shdict.c#L1495-L1500のようにノードの構造体とキーと値を連結したものを1回のメモリ割り当てで作成するといういかにもC言語的な手法を使っていてサイズがまちまちなので、インデクス方式にするにはさらに改変が必要になりそうです。

ということで、とりあえず現状で使ってみて問題なければこのままでも良いかなという気分です。 もし、将来さらに性能が必要となったときには検討してもよいかもしれません。

データベースではmmapは使うべきではないという話

以前CMU Database Group - YouTubeでAndy Pavloさんのデータベース講義のオンラインコースを見たのですが、その中でもデータベースではmmapを使うべきではないという話が出ていました。

検索してみるとIssues with mmap – Shekhar Gulatiに良いまとめがありました。

私の場合はLinux対応のみで良いので、Windowsでmmapが使えないというのは置いておくとして、ディスクへの書き込みが同期的でブロッキングするのが良くないというのと、ディスクへの読み書きの際にキャッシュ管理をカーネル任せにするのではなくデータベースシステムが自分で管理すべきという話です。

データベースの場合はトランザクションのコミットでディスクに確実に書き込んだことを確認する必要があるので納得です。ちなみにetcd-io/bbolt: An embedded key/value database for Go.は読み取り専用でmmapを使って書き込みは自前で行っています。

一方、私の用途は元データは別にあって、キャッシュ的に保持する用途なので、そこまで厳密に書き込みを制御できなくても大丈夫です。RAMディスク上のファイルであれば、同期的でブロッキングというのも特に問題にならないのかなと思っていたところにlinux - Using pthread mutex shared between processes correctly - Stack Overflowshm_openを使った共有メモリのほうがさらに良いと知ってこれで良さそうとなった次第です。

共有メモリに加えてメモリマップトファイルも対応してみました

一方で、たまにしか書き込みしない用途ならディスク上のファイルでも良いかも、と思ってshm_openの代わりにopenでファイルを作るまたは開くのも対応してみました。引数ではファイル名を指定するようにして/dev/shm/で始まっていればshm_openを使い、そうでなければopenを使うようにしているだけです。