2013年5月アーカイブ

追記: 1.30でsleepとretryのデフォルトが変更になりました。詳しくはドキュメントを参考にしてください

追記2: 2.00でsleepとretryがなくなりました。代わりにmax_waitが追加されました。詳しくはドキュメントを参考にしてください。blogも書いた

TCPサーバ・クライアントのテストには欠かせない、みんな大好きtokuhiromのみんな大好きTest::TCP。Plackのサーバのテストなら

use Plack::Loader;
use Test::TCP;

test_tcp(
    server => sub {
        my $loader = Plack::Loader->load(
            'Monoceros',
            port => $port,
            max_workers => 5
        );
        $loader->run($app);
        exit;
    },
    client => sub {
        my $port = shift;
        my $ua = LWP::UserAgent->new;
        my $res = $ua->get("http://localhost:$port/");
        ok($res->is_success)
    }
);

このように書くだけで、空いているTCPポートを探して、fork()した上でserver、clientのコードを実行してくれます。便利!!

内部的には

1. 空いているポートを探す
2. fork()
3. [子プロセス] serverのコードを実行
4. [親プロセス] 見つけたポートがconnectできるようになるまで待つ
5. [親プロセス] clientのコードを実行
6. [親プロセス] 子プロセスにTERMを送って、終了まで待つ

という順になってます。

ところが、最近Test::TCPを使ったテストが手元やtravis-ciでは問題ないのにcpantestersでコケるということが多くなっていて、そのほとんどが「4」でtimeoutしていました。なんでかなぁと思ってTest::TCPを見ていると1.27でポートに接続できるようになるまで待つ時間が短くなっていたのに気付きました。

https://metacpan.org/diff/release/TOKUHIROM/Test-TCP-1.26/TOKUHIROM/Test-TCP-1.27

0.1秒sleepの100回、10秒だったのが0.001秒sleepの100回retryの0.1秒になってます。テストを速く終わらせるためかなと思いますが、手元では0.1秒以内でサーバが起動しても、cpantestersではかなり重い・遅い環境もあるので、0.1秒ではforkとサーバの起動が間に合わなくなってしまっているのでしょう。

ということでとりあえず、MonocerosではTest::TCPのwait_portを上書きしてみました。

{
    no warnings 'redefine';
    *Test::TCP::wait_port = sub {
        my $port = shift;
        Net::EmptyPort::wait_port($port, 0.1, 40) 
            or die "cannot open port: $port";
    };
}

このようにしたところ、Monocerosのテストが失敗することはなくなりました。

この件をtokuhirom氏に報告したところ、オプションでsleep時間とretry回数を指定できるようにアップデートしてくれました。

use Test::TCP;

test_tcp(
    server => sub { .. },
    client => sub { .. },
    wait_port_retry => 40,
    wait_port_sleep => 0.1
);

これで安心ですね。

あとは、Test::TCPを使っているPlack::Test::Serverでも同じ問題起きるので、どうにかしたいと思う五月末

MonocerosやPlack/PSGIサーバの話で、YAPC::Asia 2013のトークに応募したので興味があればLikeボタンをぽちっとお願いします

http://yapcasia.org/2013/talk/show/1ae09aba-c8d5-11e2-a1f8-59856aeab6a4

その Monoceros ですが、少しずつアップデートしていて、0.13と0.14で 最大保持コネクション数を制限するオプションとMonocerosのステータスを表示するミドルウェアが付きました。

https://metacpan.org/release/Monoceros

まず、最大保持コネクション数ですが、—max-keepalive-connectionというオプションで指定します。デフォルトはオープンできるファイル数(POSIX::SCOPEN_MAX)の半分になってます。Monocerosのコネクション管理プロセスが保持する接続がこの数値以上になると、新規の接続からKeepAliveが無効となり、既存の接続も積極的にKeepAlive接続を切って行くようになります。そしてしばらくすると戻ります。

この機能をつけたのは、オープンできるファイル数の上限に達してしまうと、リクエストを処理するworkerから管理プロセスに対してソケットを渡す事ができなくなってしまい、動作が安定しなくなってしまうからです。安全をとって上限の半分をデフォルトとしています。もっとKeepAliveしたいんだ!という場合はulimitもご確認くださいませ。

指定の仕方は起動時に

$ carton exec -- plackup -E production --max-keepalive-connection=10000 \
    --port=5000 --max-workers=10 -s Monoceros --max-reqs-per-child=500 -a app.psgi

という感じです。daemontoolsを使っている場合は softlimit コマンドを使うといいですね

もうひとつのステータスを表示するミドルウェアですが、現在の接続数をみたり、—max-keepalive-connectionに達していないかを確認するのに便利です。Plack::Middleware::MonocerosStatusがMonocerosのパッケージに含まれているので、こんな風に使えます。

use Plack::Builder;

builder {
    enable "MonocerosStatus",
        path => '/monoceros-status',
        allow => [ '127.0.0.1', '192.168.0.0/16' ],
    $app;
};

Plack::Middleware::ServerStatusと似たインターフェイスとなっています。

表示は

% curl http://server:port/monoceros-status
Processing: 2
Waiting: 98
Queued: 0

となります。各項目についてですが、

Processing: workerで処理中のソケット数
Waiting: KeepAliveなど次のリクエストの到着をまっているソケット数
Queued: workerで処理されるのを待っているソケット数

Queuedが多いようであればworker数を増やすなどの対応が必要になりそうですね。

さて、どこかサービスで使えないかなー

Chunked Transferとは

一般にHTTP KeepAliveを利用するには、レスポンスのボディがどこで終わり、次のレスポンスがどこから始まるかをクライアントが知る必要があります、そのためHTTP/1.0ではKeepAliveを行う為にボディの長さをContent-Lengthをヘッダに入れなければなりませんでしたが、サイズを測るためにデータをすべてメモリに読み込むなどの処理が必要になり、レスポンス開始までの時間もかかります。(一般的なアプリケーションにはあまり影響がありませんが)

そこでHTTP/1.1ではChunked Transferという仕組みが入っていて、事前に全体のレスポンスの長さが分からなくても、chunk=固まり毎にサイズを記してレスポンスを返していき、最後に0byteと送信することで、コンテンツの切れ目がわかるようになっています。

HTTP/1.1 200 OK
Date: Fri, 24 May 2013 05:39:21 GMT
Server: Plack::Handler::Monoceros
Content-Type: text/plain
Transfer-Encoding: chunked

78
aaaaa......aaaaa
1e
bbb....bbbbbbb
9
ccccccccc
0

上の「78」「1e」「9」「0」がChunkのサイズになります。16進数で書かれていて、それぞれ「120byte」「30byte」「9byte」「0byte」となってます

MonocerosもContent-Lengthが指定されていない場合、Chunked Transferを使う事でKeepAlive通信ができるようになっています。ちなみに、アプリケーションからのレスポンスボディを複数の要素が入っている配列にするとそれぞれ別のchunkとして送り出します。Starmanも同様です。

sub {
    [200, ['Content-Type'=>'text/plain'],[
        'a'x120,
        'b'x30,
        'c'x9
    ]]
}

Chunkのビジュアライズ

前置きはこれくらいにして、実際のサーバからのレスポンスがどのように分割されて送られて来ているのか調べるツールを書いてみました。

github https://github.com/kazeburo/chunkview

中身はだいぶFurlからのコピペです。

使い方

 $ git clone https://github.com/kazeburo/chunkview
 $ cd chunkview
 $ carton install
 $ carton exec -- ./chunkview.pl [url]

上のMonocerosサーバに向けて実行すると

$ carton exec -- ./chunkview.pl http://localhost:5000/
* Chunk View
** Headers
 transfer-encoding: chunked
 content-encoding: 
 content-length: 
 server: Plack::Handler::Monoceros
** chunk table
.------------------------------------------.
| chunk size | byte | content              |
+------------+------+----------------------+
|         78 |  120 | aaaaaaaaaaaaaaaaaaaa |
|         1e |   30 | bbbbbbbbbbbbbbbbbbbb |
|          9 |    9 | ccccccccc            |
|          0 |    0 |                      |
'------------+------+----------------------'

と取れます。ふむふむ。

調子乗って他のサーバにも実行してみます。まずYahoo!

$ ./chunkview.pl  http://www.yahoo.co.jp/
* Chunk View
** Headers
 transfer-encoding: chunked
 content-encoding: gzip
 content-length: 
 server: 
** chunk table
.---------------------------------------------------.
| chunk size | byte  | content                      |
+------------+-------+------------------------------+
|    00084c9 | 33993 | <!DOCTYPE HTML PUBLI(128637) |
|          0 |     0 |                              |
'------------+-------+------------------------------'

gzipが使われた上で、1つのchunkに収まっています。contentのカッコ内は圧縮展開後のサイズです

次、はてなブックマーク

$ ./chunkview.pl  http://b.hatena.ne.jp/         
* Chunk View
** Headers
 transfer-encoding: chunked
 content-encoding: gzip
 content-length: 
 server: nginx/0.8.52
** chunk table
.------------------------------------------------------------------.
| chunk size | byte  | content                                     |
+------------+-------+---------------------------------------------+
|        56f |  1391 | <!DOCTYPE html>%0A<htm(3533)                |
|        47e |  1150 |  + '?&login_date=' +(2896)                  |
|        6b7 |  1719 |  name="q" type="text(5792)                  |
|        223 |   547 |           </script>%0A(2896)                |
|        9be |  2494 | %E7%94%98%E3%81%84%E7.....%9D%E3%81(14480)  |
|        7cd |  1997 | ass="date">2013/05/2(11584)                 |
|       4256 | 16982 | %81%8C%E5%87%BA%E3%82%8B%..E3%81%AB(121682) |
|          0 |     0 |                                             |
'------------+-------+---------------------------------------------'

細かく分かれて転送されてきますね。

次、livedoorblog

$ ./chunkview.pl  http://blog.livedoor.jp/staff/
* Chunk View
** Headers
 transfer-encoding: chunked
 content-encoding: gzip
 content-length: 
 server: Plack::Handler::Starlet
** chunk table
.-------------------------------------------------.
| chunk size | byte | content                     |
+------------+------+-----------------------------+
|         10 |   16 | (0)                         |
|       20cd | 8397 | <!DOCTYPE html PUBLI(33005) |
|        26a |  618 | <div class="plugin-r(2896)  |
|        706 | 1798 | f/archives/51801785.(8235)  |
|          0 |    0 |                             |
'------------+------+-----------------------------'

なにやら最初の16byteがあやしいです。gzip展開すると0byteです。

実はここにはRFC 1952で仕様化されてるgzipのヘッダだけが収まっています。なんとなく無駄っぽい動作。Naverまとめだと

./chunkview.pl  http://matome.naver.jp/       
* Chunk View
** Headers
 transfer-encoding: chunked
 content-encoding: gzip
 content-length: 
 server: Apache
** chunk table
.----------------------------------------------------------------.
| chunk size | byte | content                                    |
+------------+------+--------------------------------------------+
|         10 |   16 | (0)                                        |
|        b60 | 2912 | <!DOCTYPE html>%0D%0A<ht(8184)             |
           <   略   >
|        4dc | 1244 | "NL:matomeimage" ><i(8184)                 |
|        3d7 |  983 | %0D%0A<p class="mdSubMTM(4231)             |
|          a |   10 | (0)                                        |
|          0 |    0 |                                            |
'------------+------+--------------------------------------------'

最後のフッタまで別chunkになってる。Apacheかその下のアプリケーションサーバの仕様なんですかね。

まとめとアップデートのお知らせ

ちなみにこの 16byteのchunkはPlack::Middleware::Deflaterでも生成されることが分かったのでバッファして次のchunkと一緒書き出されるようにして先ほどリリースしました。

どうぞご利用ください

https://metacpan.org/release/Plack-Middleware-Deflater

C10K対応Prefork型高速PSGI/Plackサーバの Monoceros をHTTP/1.1に対応させました。

https://metacpan.org/release/Monoceros
https://github.com/kazeburo/Monoceros

MonocerosではHTTPのKeepAliveに対応して、大量の接続を捌く事ができますが、リリース時点ではHTTP/1.0 KeepAliveにしか対応していませんでした。しかし、nginxのupsream などでは、keepaliveを有効にしてHTTPセッションを使い回したい場合にHTTP/1.1が求められます。

monoceros-keepalive.png

以前このあたりの事をしらべてblog書いています

nginx-1.1.x で httpなupstreamにもkeepaliveができるようになったので検証してみた
http://blog.nomadscafe.jp/2012/02/nginx-11x-httpupstreamkeepalive.html

これではMonocerosが活かせないということで既に1.1に対応しているStarmanを参考にしつつ、HTTP/1.1対応しました。対応したのは次の機能

  • KeepAlive
  • Transfer-Encoding: chunked (Request & Response)
  • HTTP Pipelining
  • Expect

これらに対応しつつ、今までのHTTP/1.0のKeepAliveも使えるようになっています。

ほとんど使われない機能ですが、HTTP Pipeliningにも対応していて

my $body = 'OK 'x10;
sub {
    [200, ['Content-Type'=>'text/plain'],[$body]]
}

こんなpsgiを書いてMonocerosを起動してncを使って2つのリクエストを続けて投げてみると、

$ cat reqs.txt 
GET /1 HTTP/1.1
Host: foo

GET /2 HTTP/1.1
Host: foo
Connection: close

$ cat reqs.txt | nc 10.xx.xx.xx 5000
HTTP/1.1 200 OK
Date: Fri, 17 May 2013 06:08:35 GMT
Server: Plack::Handler::Monoceros
Content-Type: text/plain
Transfer-Encoding: chunked

1e
OK OK OK OK OK OK OK OK OK OK 
0

HTTP/1.1 200 OK
Date: Fri, 17 May 2013 06:08:35 GMT
Server: Plack::Handler::Monoceros
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: close

1e
OK OK OK OK OK OK OK OK OK OK 
0

と、レスポンスも2つ得られました。Transfer-Encodingがchunkedにもなってますね。

Monocerosをnginx のバックエンドに設置してのベンチマーク

HTTP/1.1対応できたので、早速nginxのバックエンドにしてベンチマークしてみました。

monoceros-keepalive-bench.png

Upstream KeepAliveを有効にすると、大体倍の性能になるようですね。ベンチマークに使ったのはab、参考としてabもkeepalive on/off切り替えてデータを取得しました。なお、直接 Monocerosにアクセスすると、2万強のreq/secがでます。この差がReverse Proxyする分のオーバーヘッドですね

ベンチマークに使った環境は前回と同じ、Xeon L5630 2.13GHz 4コア/8スレッド を2つ積んだサーバです。nginxとMonocerosを同じサーバに導入し、abは別のサーバから実行しました。

ベンチマークに使ったアプリケーション

my $body = 'OK 'x10;
sub {
    [200, ['Content-Type'=>'text/plain','Content-Length'=>length($body)],[$body]]
}

残念ながら ab が Transfer-Encoding: chunked による KeepAliveに対応していないので、Content-Lengthを付けています。

Monocerosの起動は、

$ carton exec -- plackup -E production --port 5000 --max-workers=10 -s Monoceros --max-reqs-per-child=50000 -a app.psgi

nginx.confは主なところを抜き出すと以下。

worker_processes 1;
events {
    use epoll;
    worker_connections  10000;
}
http {
    keepalive_timeout      5 3;
    keepalive_requests     50000;
    upstream backend {
        server 127.0.0.1:5000;
        keepalive 100;
    }
    server {
        listen       8080;
        server_name  localhost;
        location / {
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_pass http://backend;
        }
    }
}

ab は -c 100 で実行しました。

$ ab -k -c 100 -n 50000 'http://10.xx.xx.xx:8080/

リクエストの数や接続数次第ではnginxのworker_processes/keepaliveなどの設定を変更する必要がありそうです。

Monoceros というPSGI/Plackサーバ書きました

https://metacpan.org/release/Monoceros
https://github.com/kazeburo/Monoceros

StarmanやStarletのようなPreforkなアプリケーションサーバでは、コネクションの維持イコールプロセスの占有なので、HTTPのKeepAliveは無効にするのが一般的ですが、負荷の高いサービスではTIME_WAIT状態のソケットが溜まったり、SYN-ACKの再送問題などあり、KeepAliveを使いたいという欲求があったりなかったりします。

Monoceros はリクエストを処理するworkerの他に、イベントドリブンで動くコネクション管理プロセスを立てて、クライアントからの接続ソケットをunix domain socketを使いプロセス間でやりとりします。待機中の接続をPreforkなworkerではなくイベントドリブンのプロセスで管理することで、プロセスを占有することなく大量のコネクションを捌く事ができます。適切に設定すれば10000接続もいけると思います。

monoceros1.png

コネクション管理プロセスではAnyEventを使って接続をイベントドリブンで処理して、クライアントからの接続ののち最初のリクエストが読み取れる段階になったらIO::FDPassを使ってWorkerにソケットを受け渡します。Workerは受け取ったソケットからリクエストを読み取ってクライアントにレスポンスを直接返します。KeepAliveが有効であれば、その接続を維持して再度読み込み待ちにするようにコネクション管理プロセスに情報を送信し、Workerは次のリクエストに備えます。

実際にはDEFER_ACCEPTの処理やmax_keepalive_requestに達した際の処理があるのでもう少し複雑です。興味のある方はソースコードを参考にしてもらえたらと思います。

Workerは、高速で安定しているStarletを継承してほぼそのまま利用しています。なので現状HTTP/1.0のKeepAliveだけをサポートしています。

ベンチマーク

さっそくベンチマークです。MonocerosとStarman/Starletを比べてみます。

今回ベンチマークに使った各ソフトウェアのバージョンは以下になります

Plack (1.0023)
EV (4.15)
Guard (1.022)
Starlet (0.18)
HTTP::Parser::XS (0.16)
Monoceros (0.08)
Starman (0.3011)

サーバは Xeon L5630 2.13GHz 4コア/8スレッド を2つ積んだサーバです

それぞれ以下のオプションで起動します。MonocerosとStarmanは1接続あたりのKeepAliveリクエスト数の上限設定がありません。

$ carton exec -- plackup -E production --port 5000 --max-workers=15 -s Starlet --max-keepalive-reqs=50000 --max-reqs-per-child=50000 -a app.psgi
$ carton exec -- plackup -E production --port 5000 --max-workers=15 -s Monoceros --max-reqs-per-child=50000 -a app.psgi
$ carton exec -- starman --preload-app --workers=15 --max-requests=50000 -a app.psgi

app.psgiの中身は

use Plack::Builder;
my $length = 12;
my $body = 'x'x$length;
builder {
    enable 'AccessLog', logger => sub { };
    sub {
        #select undef,undef,undef,0.01;
        my $env = shift;
        my $req = Plack::Request->new($env);
        my @params = $req->param('foo');   
        [200, ['Content-Type'=>'text/plain','Content-Length'=>length($body)],[$body]]
    }
};

になっています。

ベンチマークに使った ab のオプションは

KeepAlive無効時 $ ab -c 100 -n 50110 http://../foo?foo=bar&bar=baz&baz=hoge&hoge=foo
KeepAlive有効時 $ ab -k -c 100 -n 50110 http://../foo?foo=bar&bar=baz&baz=hoge&hoge=foo

ベンチマーク結果

monoceros2.png

まず、MonocerosはKeepAlive無効時にStarletとほぼ同等の性能がでます。内部の動作でもselect(2)が一回多いだけでほぼ同じ動きをしています。

そしてKeepAliveを有効にした場合、StarmanとStarletが大きく数値を伸ばしているのに対して、Monocerosは少し増えただけのように思えます。

これにはいくつか理由があります。まずabは接続できたコネクションを優先して使ってリクエストを行います。-c 100と同時接続数を100に指定しても実際には100回の接続を均等に使いません。最初に接続できたコネクションを中心にリクエストを行ってしまいます。Starman/Starletでは —workers/—max-workers に指定された値、ここでは15接続に偏ってアクセスします。

Starman/Starletでは1つの接続が1つのプロセスを占有して処理されるのでKeepAlive有効時に高速になりますが、逆にMonocerosではAnyEventのプロセスとworker間のソケットのやりとりをして多くの接続を受け付け、接続とプロセスを分離して扱えるように設計されているので、その分多少のオーバーヘッドがあります。それでもKeepAlive有効時に高速になるようにkazuho氏のアドバイスの参考に様々な工夫をしています。

Monoceros雑感
http://d.hatena.ne.jp/kazuhooku/20130425/1366851011

kazuho++

KeepAlive Timeoutを考慮したベンチマーク

ApacheBenchの動作は上で説明した通りなのですが、実際のブラウザ/Proxyとの通信では状況が異なります。KeepAliveを使って何回かのリクエストを行った後もまたその接続を使い回す可能性あるので、ブラウザ側かサーバ側でコネクションを切断するまで何もしない状態で接続が維持されます。Starman/Starletではそういった接続でも1つのプロセスを占有してしまうので、次のリクエストを受け付けることができなくなります。

Starman/Starletにある —keepalive-timeout というオプションはKeepAliveの次のアクセスを待つまでの時間で、通信のない接続にプロセスがいつまでも占有されてしまうのを防ぎます。ApacheではKeepAliveTimeoutですね。

以下のブログも参考になります。

keep-aliveでHTTPコネクションを放置するベンチマーク(abパッチ)
http://mtl.recruit.co.jp/blog/2008/09/keepalivehttpab.html

上記のブログと同じようなベンチマークをperlのCoroとFurlを使って書いてみました。FurlはデフォルトでKeepAliveになります。

use strict;
use warnings;
use 5.10.0;
use AnyEvent;
use Coro;
use FurlX::Coro::HTTP;
use Time::HiRes;
use Statistics::Basic qw(:all);
use List::Util qw/max min/;

my $reqs_per_thread = $ARGV[0];
my $threads = $ARGV[1];
my $url = $ARGV[2];

my $cv = AE::cv;
my @coros;
my @statistics;
my $errors = 0;
my %ua;
for my $id (1..$threads) {
    $cv->begin;
    push @coros, async {

        warn "[$id] start";
        $ua{$id}  = FurlX::Coro::HTTP->new(
            timeout => 5,
        );
        for (1..$reqs_per_thread) {
            my @t = Time::HiRes::gettimeofday;
            my @res = $ua{$id}->get($url);
            my $ela = Time::HiRes::tv_interval ( \@t );
            push @statistics, $ela;
            $errors++ if $res[1] != 200;
        }
        warn "[$id] end";
        $cv->end;
    };
}

my @t = Time::HiRes::gettimeofday;
$_->join for @coros;
$cv->recv;
my $total =  Time::HiRes::tv_interval ( \@t );

say sprintf 'finished in %s sec. concurrency: %s reqs_per_thread: %s', $total, $threads, $reqs_per_thread;
say sprintf 'requests: failed %s', $errors;
say '= per-reqs statistics =';
say sprintf 'minimum: %s sec', min(@statistics);
say sprintf 'maximum: %s sec', max(@statistics);
say sprintf 'average: %s sec', average(@statistics);
say sprintf 'median: %s sec', median(@statistics);

1接続に付き250リクエストを接続数100と1000でStarletとMonocerosに対して実行してみます

まずStarlet。

■接続数100

finished in 15.043832 sec. concurrency: 100 reqs_per_thread: 250
requests: failed 80
= per-reqs statistics =
minimum: 0.000444 sec
maximum: 5.991323 sec 
average: 0.03 sec
median: 0 sec

■接続数1000

finished in 177.534857 sec. concurrency: 1000 reqs_per_thread: 250
requests: failed 14765
= per-reqs statistics =
minimum: 0.00041 sec
maximum: 6.146582 sec
average: 0.37 sec
median: 0 sec

5秒以上リクエスト(接続)に時間が掛かかることもあり、何度もリクエストに失敗しています。

次にMonoceros。

■接続数100

finished in 4.0479 sec. concurrency: 100 reqs_per_thread: 250
requests: failed 0
= per-reqs statistics =
minimum: 0.000625 sec
maximum: 0.045615 sec
average: 0.02 sec
median: 0.02 sec

■接続数1000

finished in 43.784275 sec. concurrency: 1000 reqs_per_thread: 250
requests: failed 0
= per-reqs statistics =
minimum: 0.001168 sec
maximum: 0.402207 sec
average: 0.17 sec
median: 0.17 sec

全体の掛かる時間が短くなりました。リクエスト単位でも最大で0.4秒でレスポンスが得られ、リクエストが失敗するとはありません。Starletに比べて最短のレスポンス時間とレスポンス時間の中央値が悪くなっていますが、このあたりがMonocerosのオーバーヘッドなのでしょう。

結論

アプリケーションサーバでKeepAliveを有効にして大量の接続を維持し隊!ということがあればMonocerosは十分に使えると思われます。ぜひお試しください