hideden.hatenablog.com

はてなぶろぐー。URLなげー。

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。

mod_perl + memcached

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*1tcp_tw_reuse*2あたりをいじる必要があるかもしれない。


そして最近なかなか時間が取れなくて、まだproduction環境では導入してないので安定性とかその辺は未知数。暇を見つけてblogの拍手APIとかをこれに置き換えたいなぁ。




 
  

*1:ロードバランサ配下だったりするとかなりややこしい弊害もあるので注意。詳細はgoogle先生に。

*2:これも注意?