JSONPなAPIの負荷対策にngx_http_jsonp_callbackってのを書いてみた
認証が不要で、結果をJSONPで返してくれるAPI。大体は高速化の為にmemcachedを使用し、cacheが存在すればcacheから、存在しなければDB等から引いてcacheに入れ、その後結果を返す設計になってるはず。
URL: http://api.example.com/count?user_id=12345&entry_id=12345&callback=hoge response: hoge({"status":"success", "count":1000});
みたいなの。ほとんどの場合cacheにHitするので一瞬でresponseが返るけど、あまりに簡単なお仕事過ぎてそれの為にmod_perlのプロセスを使うのがもったいない。特に1日数千万回アクセスされるようなAPIだと積もり積もってすごい負荷に。
responseに使うJSONをそのままcacheに入れて、Tokyo TyrantにあるHTTPプロトコル実装やnginx + memcached_moduleで返せたらいいのになぁと思ったが、JSONならやれるがJSONPになるとcallback関数名部分は可変なので出来ず、現状callbackを付けるだけの為にmodperlを使ってる。
で、modperlを使用せずにこのcallback関数名を付与の部分だけをnginxのbody filterとして実装したngx_http_jsonp_callbackってのを書いてみた。クエリから関数名を取得してbodyの前後をcallback_func( ... );で挟むだけ。使い方は
location hoge { jsonp_callback callback; jsonp_callback_types text/javascript; # proxy_pass ... # memcached_pass ... # etc... }
こんな感じ。jsonp_callbackにcallback関数名を取得するQueryStringのパラメーター名を指定。jsonp_callback_typesにcallbackを付与するcontent-typeを指定。callback関数名には[a-zA-Z0-9_]{1,255}のみ通します。
さて、これだけじゃ使えないので他の準備。
memcachedが1台だけの場合はmemcached公式のmemcachedモジュールを使えば問題ないけど、基本的に複数台なはず。memcachedは分散がクライアント側の実装に任されている&モジュールによって実装方法がまちまちな為、互換性がある分散方法じゃないと使えない。ngx_http_consistent_hashモジュールとmemcached_passを併用した場合はPHPの分散と同じになるとかなんかドキュメントに書いてあったりした気もするけど会社が全部Perlなので駄目。
で、いろいろ探したところCache::Memcached::Fastと同じ分散をしてくれるnginx-patchedってのを見つけた。
installはgitでmaster-v0.7ブランチを取ってきてコンパイルするだけ。理由は後述するが、ngx_http_upstream_keepaliveもあった方がよさげ。
git clone git://openhack.ru/nginx-patched.git cd nginx-patched # get ngx_http_upstream_keepalive, ngx_http_jsonp_callback cd server ./configure --add-module=../ngx_http_jsonp_callback --add-module=../memcached_hash --add-module=../ngx_http_upstream_keepalive make; make install
あとはCache::Memcached::Fastとnginxのconfで設定値を合わせればperl側とnginx側が同じcacheを見るようになる。すばらしすぎる。
ベンチを取ってみた。modperl版はWAFを使うと遅すぎて話にならないのでPerlHandler。
package TestApi; use strict; use TestApiCache; use Apache2::Request; use Apache2::Const -compile => 'OK'; sub handler : method { my ($class, $r) = @_; my $req = Apache2::Request->new($r); my $key = $req->param('user_id') . '::' . $req->param('entry_id'); my $json = TestCache->instance->get($key); unless ($json) { # my $data = Data::Hoge->search(...); # $json = JSON::XS->new->latin1->encode({ ... }); # $cache->set($key, $json, $exp); } my $callback = $req->param('callback'); $callback = undef if $callback !~ /^[a-zA-Z0-9_]{1,255}$/; $r->content_type('text/javascript'); if ($callback) { $r->print($callback . '(' . $json . ');'); } else { $r->print($json); } return Apache2::Const::OK; } package TestApiCache; use strict; use base qw(Cache::Memcached::Fast Class::Singleton); sub _new_instance { my $class = shift; $class->SUPER::new({ servers => [ 'localhost:20000', 'localhost:20001', 'localhost:20002' ], namespace => 'testapp::', ketama_points => 150 }); }
nginx + memcached
# nginx.conf user www-data; worker_processes 1; events { worker_connections 2048; } http { include mime.types; default_type text/html; tcp_nopush on; upstream memcached_cluster { # ketama_pointsはCache::Memcached::Fastで指定したものと同じにする memcached_hash ketama_points=150; # serverの並び順やweightも同様 server localhost:20000; server localhost:20001; server localhost:20002; keepalive 300; } server { listen 10080; server_name localhost; location /count { default_type text/javascript; # callbackの設定 jsonp_callback callback; jsonp_callback_types text/javascript; # memcachedのkey設定。$arg_QUERY_NAMEでQueryAtringsをnginxがparseしてくれた # 結果を使えるので便利。便利すぎ。 set $memcached_key "$arg_user_id::$arg_entry_id"; set $memcached_namespace "testapp::"; memcached_pass memcached_cluster; # 存在しない時はbackendのmodperlにリクエストを送る error_page 404 = @fetch; } # backendの設定 location @fetch { proxy_pass http://localhost:8080; } } }
nginxの場合は、単体ではcacheからのread onlyなので、cacheにHitしなかった場合はbackendのmodperlに飛ばす。ただ、今回は事前にsetしておきcacheにHitしなかった場合は省略した。
結果
##### modperl $ lwp-request 'http://192.168.0.1:8080/count?user_id=100&entry_id=100&callback=cb_func_hoge' cb_func_hoge({"status":"success", "count":1000}); ##### nginx $ lwp-request 'http://192.168.0.1:10080/count?user_id=100&entry_id=100&callback=cb_func_hoge' cb_func_hoge({"status":"success", "count":1000}); # 当然取得結果は同じ
##### modperl $ ab -c 100 -n 100000 'http://192.168.0.1:8080/count?user_id=100&entry_id=100&callback=cb_func_hoge' Time taken for tests: 29.795265 seconds Requests per second: 3356.24 [#/sec] (mean) Time per request: 29.795 [ms] (mean) Time per request: 0.298 [ms] (mean, across all concurrent requests) Transfer rate: 966.93 [Kbytes/sec] received USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND www-data 21833 0.2 0.6 30020 12588 ? S 02:43 0:00 /usr/sbin/apache2 -k start ##### nginx $ ab -c 100 -n 100000 'http://192.168.0.1:10080/count?user_id=100&entry_id=100&callback=cb_func_hoge' Time taken for tests: 11.729462 seconds Requests per second: 8525.54 [#/sec] (mean) Time per request: 11.729 [ms] (mean) Time per request: 0.117 [ms] (mean, across all concurrent requests) Transfer rate: 1473.64 [Kbytes/sec] received USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND www-data 21921 1.3 0.0 4976 1904 ? S 02:45 0:01 nginx: worker process
PerlHandlerも十分早いが、nginxだと大体それの2倍くらい。それよりもメモリ消費量。modperlはAPI専用ではない事が多いと思うが、その場合1プロセスあたりメモリを数十MB食ってるケースも普通にあるはず。APIにアクセスが大量に来るとそんな巨大プロセスがmpm_preforkで複数起動するが、nginxはイベント駆動なので数MBのプロセスが数個起動するだけ。cache hitした場合はmodperlのプロセスを消費しなくなるので、だいぶうれしい。
また、普段うちの会社で業務で使う構成の場合frontのapacheとbackendのmodperlは別のサーバーなので、負荷の高いmodperlサーバーから比較的余裕があるfrontサーバーに負荷を移動できる点もうれしい。
ただいくつか注意。
modperlではmemcachedへのconnectionを都度切らずに使い回したりするが、nginxは都度切断するのでベンチマークのような大量のconnectionが一気に来るとシステムがTIME_WAITなsocketで埋まってたりする。通常1台あたり秒間数千ものアクセスが来る事はほぼあり得ないとは思うが、ngx_http_upstream_keepaliveを使ってconnectionをある程度使い回したり、kernelのtcp_tw_recycle*1やtcp_tw_reuse*2あたりをいじる必要があるかもしれない。
そして最近なかなか時間が取れなくて、まだproduction環境では導入してないので安定性とかその辺は未知数。暇を見つけてblogの拍手APIとかをこれに置き換えたいなぁ。