lua-nginx-module を導入した話 (1) JSON-RPC 2.0 batch request編
nginxにluaを組み込む openresty/lua-nginx-module · GitHub というのが非常に便利で、単なるreverse proxyだったnginxに、あらゆる役割を持たせられるようになります。
nginxの設定を動的にしたり、nginxからDBやmecached, redisなどへのアクセスも可能になります。HTTP requestを発行することさえできます。
これにより、例えばnginxの設定をDB化したり(NginxとLuaを用いた動的なリバースプロキシでデプロイを 100 倍速くした)、ちょっとした認証機能を入れたり、アクセスをmemcachedやredisなどに記録して攻撃を動的に判定してブロックしたりとか、nginxのレイヤーで色んなことができるようになります。(バックエンドのアプリケーションでも勿論同様のことは可能ですが、前段でブロックできるのが嬉しいですね)
あまりにも色んなことができるので、極論をいえばluaだけでサービスの構築も可能で、バックエンドにアプリケーションを用意する必要さえなくなります。聞く話によると、性能・速度が求められるアドテク企業にてお金がないところだと、本当にnginx+luaだけでサービスを構築しているところもあるそうです。(やりすぎだとは思いますが…)
この記事では応用例の一つとして、JSON-RPC 2.0 batch requestというものをnginx+luaで実装・ライブラリ化した話をします。
目次
JSON-RPC
JSON-RPC はとってもシンプルなRPCプロトコルで、リクエストもレスポンスも全部指定のJSON形式でやろうぜ!というものです。多数の言語でサーバー・クライアント共に実装されフレームワーク化やライブラリ化されています。
JSON-RPCですが、個人的には外部に公開するAPIでもない限り、URI設計や冪等性とか色々気にしないといけないRestFulよりも扱いやすいなーと思っています。大抵のネイティブアプリとサーバーAPI間の通信などはこれで十分ではないでしょうか。
以下がサンプルとなります。
sample
request
{"jsonrpc": "2.0", "method": "subtract", "params": [5, 2], "id": 1}
response
{"jsonrpc": "2.0", "result": 3, "id": 1}
"subtract"という名の引き算するmethodに2つパラメーターを渡しています。とってもシンプルですね。 key "id" の意味は後述します。
request
{"jsonrpc": "2.0", "method": "getUser", "params": { "user_id": 1 }, "id": 1}
response
{"jsonrpc": "2.0", "result": {"name": "mosa", "email": "mosa@hogefuga.com"}, "id": 1}
ユーザー情報を取得するAPIの例です。
どちらも特定のmethodにパラメータつきで1つの単純なリクエストを送って、1つのレスポンスをもらっています。
(後述のbatch requestと区別して、これを便宜上「single request」と呼ぶことにします)
JSON-RPC 2.0 batch request
JSON-RPC 2.0 にはbatch requestという仕様があります。簡単にいうと「複数のリクエストを一気に送って、一気にレスポンスもらおうぜ」というものです。以下がサンプルです。
request
[ {"jsonrpc": "2.0", "method": "subtract", "params": [5, 2], "id": 1}, {"jsonrpc": "2.0", "method": "getUser", "params": { "user_id": 1 }, "id": 2} ]
response
[ {"jsonrpc": "2.0", "result": 3, "id": 1}, {"jsonrpc": "2.0", "result": {"name": "mosa", "email": "mosa@hogefuga.com"}, "id": 2} ]
JSON Arrayで送ってJSON Arrayで返る、とてもシンプルなプロトコルです。 ただし仕様上、送ったArrayの順番通りにレスポンスが並ぶことは保証されません。なので識別子として "id"のkeyをclientは見ないといけません。
batch requestのメリット
batch requestはとても便利です。 複数のAPIのレスポンスをもとにクライアントの画面が描画されることは良くあり、その際batch requestでまとめてしまうことでリクエスト数を減らすことができます。(まるでCSS spriteのようですね) また、クライアントでの「2つのAPIの結果を待たないと描画してはいけない」「どれかのAPIがこけた場合は全体もエラーにしないといけない」などの、非同期では面倒だったハンドリングもシンプルになるケースがあります。
batch request導入前は、リクエスト数を減らすために「1画面に必要な情報はだいたい1つのAPIで返す」ような設計にしていたこともありました。しかしAPIのインターフェースがクライアントのUI変更に引きずられて変更を余儀なくされたりと、設計的に非常に気持ち悪いことになっていました。batch request導入後は「クライアントでどう描画されるか」はあまり気にすることなく、リソース単位でAPIのインターフェースを決めることができるようになりました。
batch requestのデメリット
重い処理に引きずられてしまいます。つまり、処理に10秒かかるリクエストAと、1秒かかるリクエストBの2つをbatch requestにした場合、A, Bのレスポンスをもらうために最低10秒かかることになります。(※サーバーの実装次第では11秒です)
ですので、重いmethodを叩く場合は注意する必要があります。
batch requestのサーバー実装
これだけ便利なbatch requestをどう実装するのが良いでしょうか。 以下、JSON-RPC APIをサーバーに立て、nginxを通してHTTP通信で利用することを考えます。
実装1. フレームワーク側で行う
既存のJSON-RPC 2.0のサーバーフレームワーク実装をみると、対応していないものも多く、対応していても以下のような単純な実装が見られました。
- リクエストで来たJSONをdecodeする
- Arrayではなかったら通常のリクエストとして処理する
- Arrayの場合(batch request)、for loop でそれぞれ順番に処理し、全て終わったらまとめてレスポンスを生成する
この場合、とてもシンプルではありますが、10個分のbatch requestを投げたら処理に10倍時間がかかることになります。あんまりイケてるとはいえません。並列にそれぞれのプロセス(スレッド)が処理してくれるのが理想です。 for loopではなく並列で処理すれば良いのですが、言語によっては複雑な実装になるでしょう。
実装2. proxy serverをたてる
上記の1-3の処理をするだけのproxy コンポーネントを立てるのは、処理を並列化する方法の1つでしょう。 batch requestはまずproxyが処理することにし、proxyがJSON Arrayのそれぞれに対し、非同期で並列にバックエンドのJSON-RPCサーバーにリクエストを投げ、全て返ってきたら1つにまとめてレスポンスを生成する形です。 nodeなどで書けばおそらくシンプルな実装になるでしょう。
しかし管理コンポーネントが増えることになり、下手したら client => nginx1 => proxy => nginx2 => app の経路を辿ることになり、運用の複雑さは増すでしょう。異常が起きた場合、どの経路で死んだのか多数のログを漁ることになります。proxy serverの台数管理も考えないといけません。
(なぜ間にnginx2が必要かは、複雑ですが以下の理由によります。まず、single requestの場合はproxyを挟まない client => nginx2 => app の経路にするとします。このときbatch requestで proxy => app の経路にしてしまうと、appへのreverse proxy設定をnginx2とproxyで二重管理することになります。proxy => nginx2 => app とすれば二重管理を防ぐことができます)
実装3. nginxで行う
という訳で今回提案したいのが、nginx-lua-moduleを用いてnginxで処理をする形です。 nginx => app の経路はそのままで、nginxのレイヤーでbatch requestはバラしちゃおうぜというものです。前項のproxy server の役割をluaが担当することになります。
backendのJSON-RPC APIはsingle requestさえ捌ければ良く、サーバー言語に依らずこの構成が取ることができ、管理コンポーネントも増えないのがメリットです。
イベントドリブンなnginxの性質を活かし、ノンブロッキング(=backendの処理を待たなくてよい)で並列なbatch requestを簡単に実装することができるのがポイントです。
nginx-lua-moduleにはlocaltion.capture
という機能があり、nginx内でノンブロッキングな内部リクエスト(subrequest)を発行することが簡単にできます。また、location.capture_multi
では複数のsubrequestを並列で投げられます。これを用いて、single requestを捌けるlocationへ並列にsubrequestを発行することで、簡単にbatch requestを実装することができます。
(また、内部リクエストなので、single requestのreverse proxy設定はそのまま流用することができ、前述した二重管理問題もおきません)
ややこしいことを書きましたが、今回batch requestに必要な機能をluaのライブラリ化したため、これを用いるだけで簡単に使うことができます。
lua-resty-jsonrpc-batch
という訳で以下はライブラリの紹介です。
使い方
以下のような設定をnginxに書くだけで、batch requestが並列に、ノンブロッキングに実装できます。
server { location /jsonrpc { # jsonrpc single request endpoint } location /jsonrpc/batch { lua_need_request_body on; content_by_lua ' local jsonrpc_batch = require "resty.jsonrpc.batch" client = jsonrpc_batch:new() local res, err = client:batch_request({ path = "/jsonrpc", request = ngx.var.request_body, }) if err then ngx.exit(500) end ngx.say(res) '; } }
/jsonrpc
はjsonrpcのsingle requestを処理できるendpointとします。
/jsonrpc/batch
のエンドポイントは、request bodyにJSON Arrayが来た場合は、バラして並列に /jsonrpc
のエンドポイントにsubrequestを投げます。
シンプルですね!
以下はもう少し凝った使い方がしたい人のための例です。
http { init_by_lua ' local jsonrpc_batch = require "resty.jsonrpc.batch" client = jsonrpc_batch.new({ -- バッチリクエストのArray sizeの制限 max_batch_array_size = 10, -- バックエンドの処理時間をロギングするためにnginx変数に入れる before_subrequest = function(self, ctx, req) ctx.start_at = ngx.now() end, after_subrequest = function(self, ctx, resps, req) ngx.var.jsonrpc_upstream_response_time = ngx.now() - ctx.start_at end, }) '; server { set $jsonrpc_upstream_response_time -; location ~ /jsonrpc/method/.* { # jsonrpc endpoint } location /jsonrpc/batch { lua_need_request_body on; content_by_lua ' local res, err = client:batch_request({ -- リクエストごとにendpointを動的に選択できる path = function(self, ctx, req) return "/jsonrpc/method/" .. req.method end, request = ngx.var.request_body, }); if err then ngx.log(ngx.CRIT, err); ngx.exit(500); end ngx.say(res); '; } } }
この例は以下のような機能を実装しています。
- lua-resty-jsonrpc-batch オブジェクトをリクエストのたびに作るのは馬鹿らしいので、nginxのinit時に作るようにしている
- でかいArrayが来ると攻撃になるので、最大サイズを10としている
- subrequestの処理時間をnginx access logに入れるために、nginx変数に値を入れている
- before_subrequest, after_subrequestのフックポイントを利用している
- 「jsonrpc "substract" methodは
/jsonrpc/method/substract
のパスで受けたい」など、動的なlocationに対応している
この例でわかるように、lua-resty-jsonrpc-batchには、ロギングしたりするためのフックポイントをいくつか用意しています。 とはいえ本質はnginxのsubrequestを利用しているだけなので非常にシンプルです。 詳しくはmosasiru/lua-resty-jsonrpc-batch · GitHubをご覧ください。
インストール方法
まずはnginx-lua-moduleをnginxに入れないといけません。 インストール方法はLua | NGINX にありますが、書いてある通りopenresty (luaやら色々はいった拡張版nginx)を使うことが強く推奨されています。
brew install ngx_openresty
自力でビルドする場合は以下です。
curl -O http://openresty.org/download/ngx_openresty-1.9.3.1.tar.gz tar xzvf ngx_openresty-1.9.3.1.tar.gz cd ngx_openresty-1.9.3.1/ ./configure make make install
lua-resty-jsonrpc-batchのインストール方法は、以下の2通りです。
(1) lua-rocksで入れる
luarocks --local install lua-resty-jsonrpc-batch
(2)ファイルを読み込む
あるいはlua-resty-jsonrpc-batchをcloneして、libディレクトリを適当なところにおき、以下のようにlua_package_pathを指定することでライブラリを読み込むことができます。
http { lua_package_path '/hogehoge/lua-resty-jsonrpc-batch/lib/?.lua;;';
最後に
長々と紹介してきましたが、まとめると、以下の3点になります。
- jsonrpc 便利! batch requestも便利!
- batch requestには色んなサーバー実装が考えられるけど、nginx + luaでやると良さそう!
- lua-resty-jsonrpc-batch っていうライブラリ作ったから使ってね
です。
lua-resty-jsonrpc-batchはまだ色々いじる余地があると思うので、どんどん利用していただけるとありがたいです。
最後に。nginx + luaには本当にデメリットはないのでしょうか?実運用に耐えられるモジュールなのでしょうか?パフォーマンスは?などの疑問が残ります。 これらは、実際に技術調査したり運用して地雷を踏んだ結果をまとめて、次回の記事にしたいと思います。 先に結論だけいうと、少し気をつけるところはあるけど普通に使えるぜ!!です!