Hateburo: kazeburo hatenablog

SRE / 運用系小姑 / Goを書くPerl Monger

ISUCON4 予選でアプリケーションを変更せずに予選通過ラインを突破するの術

AMIが公開されたのでもう一度やってみた。

AMIについてはこちらのエントリに書かれています

ISUCON4 予選問題の解説と講評 & AMIの公開 : ISUCON公式Blog

まず ami-e3577fe2 を m3.xlargeで起動します。

CPUは

model name      : Intel(R) Xeon(R) CPU E5-2670 v2 @ 2.50GHz

でした。

とりあえず、MySQLのindexを追加する。init.shに追加

$ cat init.sh 
cat <<'EOF' | mysql -h ${myhost} -P ${myport} -u ${myuser} ${mydb}
alter table login_log add index ip (ip), add index user_id (user_id);
EOF

ベンチマークツールのhttp keepaliveが無効なので、sysctrl.conf でephemeral portを拡大したり、TIME_WAITが早く回収されるように変更する

$ cat /etc/sysctl.conf
net.ipv4.tcp_max_tw_buckets = 2000000
net.ipv4.ip_local_port_range = 10000 65000
net.core.somaxconn = 32768
net.core.netdev_max_backlog = 8192
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10

ついでに、backlogも増やしている。

適用は

sudo /sbin/sysctl -p

で行う。

セッション管理にmemcachedを使うのでいれる

sudo yum install -y wget libevent-devel perl-devel
cd /tmp
wget http://www.memcached.org/files/memcached-1.4.21.tar.gz
tar zxf memcached-1.4.21.tar.gz
cd memcached-1.4.21
./configure --prefix=/usr/local/memcached && make && sudo make install

Supervisord経由で起動

[program:memcahed]
directory=/
command=/usr/local/memcached/bin/memcached -p 11211 -U 0 -u nobody -m 256 -c 200000 -v -t 1 -C -B ascii
autostart = true

memcachedの起動オプションについてはこのエントリが詳しい

memcached おすすめ起動オプションまとめ - blog.nomadscafe.jp

次に、Perlにアプリケーションを変更し、起動方法も変更

まず、cpanfileに以下を追加してcarton install

$ cat cpanfile
requires "Kossy", 0.38;
requires "Starlet", 0.24;
requires "URL::Encode::XS";
requires "Plack::Middleware::Session::Simple";
requires "Cache::Memcached::Fast";
requires "Sereal";
$ carton install

アプリケーションはStarletをつかって起動。その際にunix domain socketをlistenする

[program:isucon_perl]
directory=/home/isucon/webapp/perl
command=/home/isucon/env.sh carton exec start_server --path /dev/shm/app.sock --backlog 16384 -- plackup -s Starlet --workers=4 --max-reqs-per-child 500000 --min-reqs-per-child 400000 -E production -a app.psgi
user=isucon
stdout_logfile=/tmp/isucon.perl.log
stderr_logfile=/tmp/isucon.perl.log
autostart=true

Starlet + unix domain socketの話はこちらのエントリ

Starlet + Server::Stater で UNIX domain socketに対応しました - Hateburo: kazeburo hatenablog

セッションまわりだけ、PlackのMiddlewareを入れ替える

use Cache::Memcached::Fast;
use Sereal;

my $decoder = Sereal::Decoder->new();
my $encoder = Sereal::Encoder->new();
my $app = Isu4Qualifier::Web->psgi($root_dir);
builder {
    enable 'ReverseProxy';
    enable 'Session::Simple',
        store => Cache::Memcached::Fast->new({
            servers => [ { address => "localhost:11211",noreply=>0} ],
            serialize_methods => [ sub { $encoder->encode($_[0])}, 
                                   sub { $decoder->decode($_[0])} ],
        }),
        httponly => 1,
        cookie_name => "isu4_session",
        keep_empty => 0;
    $app;
};

ここで使っているPlack::Middleware::Session::Simpleはこのエントリで紹介しています

Plack::Middleware::Session::Simple has been released - Hateburo: kazeburo hatenablog

そして、nginxの設定変更

$ cat /etc/nginx/nginx.conf
worker_processes  1;

events {
  worker_connections  10000;
}

http {
  include     mime.types;
  access_log  off;
  sendfile    on;
  tcp_nopush  on;
  tcp_nodelay on;
  etag        off;
  upstream app {
    server unix:/dev/shm/app.sock;
  }

  server {
    location / {
      proxy_pass http://app;
    }
    location ~ ^/(stylesheets|images)/ {
      open_file_cache max=100;
      root /home/isucon/webapp/public;
    }
  }
}

cssや画像をnginxから配信するのと、アプリケーションサーバunix domain socketになったのでその切り替え

nginxの設定はこのエントリが参考になるかな

G-WANはなぜ速いのか?をnginxと比べながら検証してみた - blog.nomadscafe.jp

最後にmysqlの設定

$ cat /etc/my.cnf
innodb_buffer_pool_size = 1G
innodb_flush_log_at_trx_commit = 0
innodb_flush_method=O_DIRECT

この3つだけ追加

全ての設定ができたので、mysql、nginxとSupervisordを再起動

$ sudo service mysqld restart
$ sudo /usr/bin/supervisorctl reload
$ sudo service nginx restart

これで準備完了。

いよいよ、benchmarkを動かしてみる

$ ./benchmarker bench --workload 8
07:51:41 type:info      message:launch benchmarker
07:51:41 type:warning   message:Result not sent to server because API key is not set
07:51:41 type:info      message:init environment
07:51:50 type:info      message:run benchmark workload: 8
07:52:50 type:info      message:finish benchmark workload: 8
07:52:55 type:info      message:check banned ips and locked users report
07:52:57 type:report    count:banned ips        value:605
07:52:57 type:report    count:locked users      value:4384
07:52:57 type:info      message:Result not sent to server because API key is not set
07:52:57 type:score     success:188930  fail:0  score:40812

見事予選突破ラインの37,808を、アプリケーションのコードを変更せずに超える事に成功しました。ぱちぱちぱち

// 追記

Run any Perl applications on Heroku

I made a simple heroku-buildpack for perl. With using this buildpack you can run any perl application from Procfile.

github: https://github.com/kazeburo/heroku-buildpack-perl-procfile

Sample and Usage

This sample runs a PSGI server and a worker in 1 Dyno (It's free).

$ ls
cpanfile
Procfile
server.pl
lib/

$ cat cpanfile
requires 'HTTP::Tiny','0.043';
requires 'Getopt::Long';
requires 'Proclet';
requires 'Plack';
requires 'Starlet';

$ cat Procfile
web: ./server.pl --port $PORT

$ heroku create yourappname --buildpack https://github.com/kazeburo/heroku-buildpack-perl-procfile.git

$ git push heroku master
...
-----> Heroku receiving push
-----> Fetching custom buildpack
-----> Perl/Procfile app detected
-----> Installing dependencies

And server.pl

#!/usr/bin/env perl

use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib";
use Proclet;
use Plack::Loader;
use Getopt::Long;
use HTTP::Tiny;

my $port = 5000;
Getopt::Long::Configure ("no_ignore_case");
GetOptions(
    "p|port=s" => \$port,
);

$proclet->service(
    tag => 'worker',
    code => sub {
        my $worker = MyWorker->new;
        $worker->run
    },
);

my $app = MyWeb->to_psgi;
$proclet->service(
    code => sub {
        my $loader = Plack::Loader->load(
            'Starlet',
            port => $port,
            host => 0,
            max_workers => 5,
        );
        $loader->run($app);
    },
    tag => 'web',
);

$proclet->run;

In some cases, adding a worker to access the Web server periodically might be good.

$proclet->service(
    every => '*/30 * * * *',
    tag => 'ping',
    code => sub {
        my $ua = HTTP::Tiny->new;
        $ua->get("http://${yourservicename}.herokuapp.com/");
    }
);

Proclet is minimalistic Supervisor. it also supports cron like jobs. it's very useful!

At the last, don't forget to add exec permission to server.pl

chmod +x server.pl

(解決済み) DBIx::TransactionManager + File::RotateLogsで意図せずトランザクションが終了してしまう件

DBIx::TransactionManager 1.13で子プロセスでrollbackを実行しないような変更が入っています。

https://metacpan.org/release/NEKOKAK/DBIx-TransactionManager-1.13

TengやDBIx::Sunnyなどでトランザクションを使用し、File::RotateLogsでログを書き出している場合はバージョンアップをお勧めします。

経緯など

某サービスにおいて、DBIx::TransactionManagerを使ってトランザクションを実行している箇所で9時にトランザクションが意図せず終了するという問題がありました。

コードにするとこんな感じ

my $rotatelogs = File::RotateLogs->new(
    logfile => '/path/to/access_log.%Y%m%d%H%M',
    linkname => '/path/to/access_log',
    rotationtime => 86400,
    maxage => 86400*14,
);

my $tm = DBIx::TransactionManager->new($dbh);
my $txn = $tm->txn_scope;

$dbh->do("insert into foo (id, var) values (1,'baz')");

$rotatelogs->print("log! log! log!");

$dbh->do("update foo set var='woody' where id=1");
$txn->commit;

DBIx::TransactionManager->txn_scopeすると、トランザクションを開始し、guardオブジェクトが返ります。commitせずに途中で終了した場合は、guradオブジェクトが破棄され、自動でrollbackがかかる仕組みになっています。

このトランザクション中にログを書くだけなら問題なさそうですが、File::RotateLogsはrotationtimeで設定した時間ごとにファイルを切り替え、maxageよりも古いログがあればProc::Daemonモジュール使い、削除用プロセスをデーモンとして起動して、古いログを削除します。

この際、Proc::Daemonはforkを2回して、プロセスを元のプロセスから切り離しますが、1回目にforkされた子プロセスは孫プロセスをforkしたあとすぐにexit()します。そこで、global destructionが走り、$txn も破棄されるので、なんとrollbackが走ってしまいます。

File::RotateLogsのprintとProc::Daemonを展開するとこんな感じ

$fh->print($log);
if ( 削除するファイルがある ) {
    my $pid = fork; #1回目のfork
    if ( $pid == 0 ) {
        #子プロセス
        $pid = fork; #2回目のfork
        if ( $pid == 0 ) {
            #孫プロセス
            unlink .. #削除
            POSIX::_exit(); #ここはglobal destructionを防いでいた 
        }
        exit; ここでrollback 
    }
    waitpid($pid,0);
}

トランザクション中にforkなんて普通はしないと思いますが、今回はDBIx::TransactionManagerの方で対策をいれてもらい、guardオブジェクトのDESTROYの中で、トランザクション開始時のpidと現在のpidが異なっていたらrollbackせずにそのまま終了するようになりました。

nekokakさんありがとうございます

参考

DBIとforkの関係 - heboi blog
http://nihen.hatenablog.com/entry/2011/11/17/102557

Router::BoomとRouter::Simpleの文字列エンコードまわりの動作

昨日気付いた。

Router::Simpleはいわゆるutf8 flaggedな内部文字列を渡すと、キャプチャしたテキストも内部文字列として得られるけど、Router::Boomはバイナリ列となる。

use Router::Boom;
use Router::Simple;
use Encode;
use Test::More;
use utf8;

subtest 'boom' => sub {
    my $path = '/foobarです';
    my $router = Router::Boom->new();
    $router->add('/:user', 'dispatch_user');
    my @args = $router->match($path);
    is $args[1]->{user}, 'foobarです', 'match';
    ok Encode::is_utf8($args[1]->{user}),'utf8'; 
};

subtest 'simple' => sub {
    my $path = '/foobarです';
    my $router = Router::Simple->new();
    $router->connect('/:user', {action=>'dispatch_user'});
    my $args = $router->match($path);
    is $args->{user}, 'foobarです', 'match';
    ok Encode::is_utf8($args->{user}),'utf8'; 
};

done_testing;

結果

% perl -MTest::Pretty ./boom.pl
  boom
    ✖  match
    #   Failed test 'match'
    #   at /path/to/boom.pl line 12.
    #          got: 'foobarã§ã'
    #     expected: 'foobarです'
    ✖  utf8
    #   Failed test 'utf8'
    #   at /path/to/boom.pl line 13.
  simple
    ✓  match
    ✓  utf8

KossyはRouter::Boom使っているので、得られたテキストをdecodeするようにした

通信先が明確な内部APIなどのURIを構築するときはURI.pmを使わなくても良いんじゃないかな

通信先が明確な内部APIなどのURIを構築するときはURI.pmを使わなくても良いというかURI.pmはあまり速くないので、文字列連結だけで十分だと思います

#!/usr/bin/perl

use strict;
use warnings;
use Benchmark qw/:all/;
use URI;
use URI::Escape;
use URL::Encode::XS qw/url_encode/;

my $base = q!http://api.example.com!;
my $path = q!/path/to/endpoint!;
my %param = (
    token => 'bar',
    message => 'foo bar baz hoge hogehoge hogehoghoge',
);

cmpthese(timethese(-2, {
    'uri' => sub {
        my $uri = URI->new($base . $path);
        $uri->query_form(
            s_id => 1,
            type => 'foo',
            %param
        );
        $uri->as_string;
    },
    'concat' => sub {
        my @qs = (
            s_id => 1,
            type => 'foo',
            %param
        );
        my $uri = $base . $path . '?';
        while ( @qs ) {
            $uri .= shift(@qs) . '='. uri_escape(shift(@qs)) . '&'
        }
        substr($uri,-1,1,"");
        $uri;

    },
    'concat_xs' => sub {
        my @qs = (
            s_id => 1,
            type => 'foo',
            %param
        );
        my $uri = $base . $path . '?';
        while ( @qs ) {
            $uri .= shift(@qs) . '='. url_encode(shift(@qs)) . '&'
        }
        substr($uri,-1,1,"");
        $uri;
    },
}));

__END__
Benchmark: running concat, concat_xs, uri for at least 2 CPU seconds...
    concat:  2 wallclock secs ( 2.04 usr +  0.00 sys =  2.04 CPU) @ 81818.63/s (n=166910)
 concat_xs:  2 wallclock secs ( 2.17 usr +  0.00 sys =  2.17 CPU) @ 277470.51/s (n=602111)
       uri:  2 wallclock secs ( 2.20 usr +  0.00 sys =  2.20 CPU) @ 25653.18/s (n=56437)
              Rate       uri    concat concat_xs
uri        25653/s        --      -69%      -91%
concat     81819/s      219%        --      -71%
concat_xs 277471/s      982%      239%        --