Hateburo: kazeburo hatenablog

Operations Engineer / Site Reliability / 運用系小姑 / Perl Monger

ISUCON4 予選アプリケーションの復習した結果

本選までの間に地道に復習をした結果です。

repositoryはここで公開されています

https://github.com/kazeburo/isucon4-elimination-myhack

最終スコア

派手な点ではありませんが、63000弱となりました

$ ~/benchmarker bench --workload 8
07:26:29 type:info      message:launch benchmarker
07:26:29 type:warning   message:Result not sent to server because API key is not set
07:26:29 type:info      message:init environment
07:26:44 type:info      message:run benchmark workload: 8
07:27:44 type:info      message:finish benchmark workload: 8
07:27:49 type:info      message:check banned ips and locked users report
07:27:52 type:report    count:banned ips        value:1048
07:27:52 type:report    count:locked users      value:5478
07:27:53 type:info      message:Result not sent to server because API key is not set
07:27:53 type:score     success:291580  fail:0  score:62985

構成

nginx、Perl、Redisを使った構成です。ユーザデータは変化しないので、最初に読み込んでしまいますが、他はRedisを使っています。集計は実装が面倒だったのでfujiwara組と同じようにRedisからMySQLに書き出して集計しています。

cookieがない場合だけ、Nginxから静的にindex.htmlをnginxから吐き出していますが、この設定をしなくても6万点でました。

新しく作ったもの/アップデートしたもの

性能をあげるため、いくつかの新しいモジュールをつくったり、バージョンアップをしました。

新作

  • Redis::Jet
  • Chobi

アップデート

  • Kossy@0.39開発版

Redis::Jet

Redisのクライアント。たぶんPerlで一番速い。既にCPANにリリースしています。

https://metacpan.org/pod/Redis::Jet

Redisのプロトコルのパーサも含めてすべてXSで実装。業務でRedisを使った事ないので実際の仕事で役に立つモジュールかどうか全くわからない。

他のモジュールと違って getsetといったメソッドは(まだ)ない。単純にRedisと通信するためのモジュールです

use Redis::Jet;
 
my $jet = Redis::Jet->new( server => 'localhost:6379' );
# GET
my $ret = $jet->command(qw/set redis data-server/); # $ret eq 'OK'
my $value = $jet->command(qw/get redis/); # $value eq 'data-server'
# SET
my $ret = $jet->command(qw/set memcached cache-server/);

複数の値を返すコマンドは配列のリファレンスを返す

my $values = $jet->command(qw/mget redis memcached mysql/);
# $values eq ['data-server','memcached',undef]

エラーも取れる

($values,$error) = $jet->command(qw/get redis memcached mysql/);
# $error eq q!ERR wrong number of arguments for 'get' command!

複数のコマンドを一気におくる

my @values = $jet->pipeline([qw/get redis/],[qw/get memcached/]);
# \@values = [['data-server'],['cache-server']]

my @values = $jet->pipeline([qw/get redis/],[qw/get memcached mysql/]);
# \@values = [['data-server'],[undef,q!ERR wrong...!]]

Redis::Jetのnoreplyモード

memcachedのnoreplyと同じような感じで、レスポンスを待たないモード。ただし、必要ないかもしれないけど、1回だけread(2)を発行している。memcachedのnoreplyはプロトコルにもある正規の動作だけど、Redisにはないのですね。

ベンチマークすると通常で1.5倍ぐらい速い

single get =======
            Rate   redis    fast hiredis     jet
redis    46494/s      --    -58%    -70%    -75%
fast    109506/s    136%      --    -30%    -41%
hiredis 156052/s    236%     43%      --    -16%
jet     185561/s    299%     69%     19%      --
single incr =======
                Rate       redis        fast     hiredis         jet jet_noreply
redis        49723/s          --        -56%        -70%        -73%        -82%
fast        111858/s        125%          --        -33%        -39%        -61%
hiredis     165731/s        233%         48%          --        -10%        -42%
jet         184785/s        272%         65%         11%          --        -35%
jet_noreply 283305/s        470%        153%         71%         53%          --
pipeline =======
                Rate       redis        fast         jet jet_noreply
redis        15794/s          --        -73%        -87%        -91%
fast         57769/s        266%          --        -54%        -67%
jet         124368/s        687%        115%          --        -28%
jet_noreply 172577/s        993%        199%         39%          --

noreplyを使うときは通常のインスタンスとは分けて使うのがお勧め。

sub redis {
  $_[0]->{redis} ||= Redis::Jet->new(server => '127.0.0.1:6379');
}

sub redis_noreply {
  $_[0]->{redis_noreply} ||= Redis::Jet->new(server => '127.0.0.1:6379',noreply=>1);
}

実際のサンプルは

https://github.com/kazeburo/isucon4-elimination-myhack/blob/master/perl/lib/Isu4Qualifier/Model.pm#L120

このあたり

Chobi

nginxでreverse proxyするという前提でつくったPlackのサーバ。KeepAliveなしのベンチマークでStarletよりはやくなってる

kazeburo/Chobi · GitHub

特徴

  • HTTP/1.0だけをサポート。KeepAliveはサポートなし
  • 一部XSで実装
  • HTTPのパースはHTTP::Parser::XSのコードをもってきて、ChobiのXS内部で処理
  • Dateヘッダを吐かないオプションあり
  • accept4(2) を使う。accept4(2)が使えない場合はacceptになる
  • writevをつかってレスポンスを書き出す

最後のwritevが大きな特徴で、PSGIのレスポンス

[200,[..],['foo','bar','barz']]

このbodyの部分を文字列連結なしにそのまま書き出せます。

4コア8スレッドのマシンで参考ベンチマークplackとabは同じサーバ。

Chobi

$ plackup -I./lib -Iblib/arch -s Chobi --port 5003 --max-workers 8 --max-reqs-per-child 500000 --max-keepalive-reqs 500  -E prod --disable-date-header -e 'sub{[200,[],["foo","bar","\n"]]}'
$ ab -c 100 -n 80000 http://localhost:5003/
Requests per second:    61631.61 [#/sec] (mean)
$ plackup -I./lib -Iblib/arch -s Chobi --port 5003 --max-workers 8 --max-reqs-per-child 500000 --max-keepalive-reqs 500  -E prod --disable-date-header -e 'sub{[200,[],["foobar\n"]]}'
$ ab -c 8 -n 80000 http://localhost:5003/
Requests per second:    62545.11 [#/sec] (mean)

Starlet

$ plackup -I./lib -Iblib/arch -s Starlet --port 5003 --max-workers 8 --max-reqs-per-child 500000 --max-keepalive-reqs 500  -E prod --disable-date-header -e 'sub{[200,[],["foo","bar","\n"]]}'
$ ab -c 8 -n 80000 http://localhost:5003/
Requests per second:    33796.43 [#/sec] (mean)
$ plackup -I./lib -Iblib/arch -s Starlet --port 5003 --max-workers 8 --max-reqs-per-child 500000 --max-keepalive-reqs 500  -E prod --disable-date-header -e 'sub{[200,[],["foobar\n"]]}'
$ ab -c 8 -n 80000 http://localhost:5003/
Requests per second:    35143.41 [#/sec] (mean)

Starletは複数個の要素が入った配列だと遅くなる。配列内の個数が増えるともっと遅くなると思う。

予選アプリのスコアで、Starlet => Chobiで2000ぐらいスコア上がる。

Kossy@0.39開発版

cpanには0.39-trialであがっている。改善点は

  • Router結果のcache
  • JSONレスポンスの高速化
  • header出力まわりの高速化
  • xslateのcacheオプション

あたり。いくつかオプションが増えてて

  • $Kossy::XSLATE_CACHE xslateのcache設定
  • $Kossy::XSLATE_CACHE_DIR
  • $Kossy::SECURITY_HEADER セキュリティ系のヘッダを出さないオプション

こんな感じで使う

local $Kossy::XSLATE_CACHE = 2;
local $Kossy::XSLATE_CACHE_DIR = tempdir(DIR=>-d "/dev/shm" ? "/dev/shm" : "/tmp");
local $Kossy::SECURITY_HEADER = 0;
my $app = Isu4Qualifier::Web->psgi($root_dir);

手元のベンチマークでは速くはなるけど、スコアはあまり変化しなかった

アプリケーションの工夫

ここからはその他のアプリケーションの変更点

Cookie Sessionの簡単な実装

cookieにセッション情報をそのまま入れてしまうやつの簡単な実装。Redis/memcachedへの通信を減らします。暗号化とかしてないので仕事でつかっっちゃだめよ。

use Cookie::Baker;
use WWW::Form::UrlEncoded::XS qw/parse_urlencoded build_urlencoded/;

my $cookie_name = 'isu4_session';

builder {
    enable sub {
        my $mapp = shift;
        sub {
            my $env = shift;
            my $cookie = crush_cookie($env->{HTTP_COOKIE} || '')->{$cookie_name};
            if ( $cookie ) {
               $env->{'psgix.session'} = +{parse_urlencoded($cookie)};
               $env->{'psgix.session.options'} = {
                   id => $cookie
               };
            }
            else {
                $cookie = '{}';
                $env->{'psgix.session'} = {};
                $env->{'psgix.session.options'} = {
                    id => '{}',
                    new_session => 1,
                };
            }

            my $res = $mapp->($env);

            my $cookie2 = build_urlencoded(%{$env->{'psgix.session'}});
            my $bake_cookie;
            if ($env->{'psgix.session.options'}->{expire}) {
                $bake_cookie = bake_cookie( $cookie_name, {
                    value => '{}',
                    path => '/',
                    expire => 'none',
                    httponly => 1 
                });
            }
            elsif ( $cookie ne $cookie2 ) {
                $bake_cookie = bake_cookie( $cookie_name, {
                    value => $cookie2,
                    path => '/',
                    expire => 'none',
                    httponly => 1 
                });
            }
            Plack::Util::header_push($res->[1], 'Set-Cookie', $bake_cookie) if $bake_cookie;
            $res;
        };
    };
    $app
}

cookieの内容が変わらない限り、set-cookieしないようになってる。今回使ってないけど、JSONcookieに入れておけばnginx-luaからも参照ができる。

テンプレートエンジン使わない

テンプレートで複雑なことをしていないので、Xslate使うより、パーツ毎にHTMLを組み立てていくので十分。

テンプレートの静的な部分は、新しくモジュールつくって、DATA に入れておくと前処理が楽

以下はHTMLのminifyもしてる

package Isu4Qualifier::Template;

use strict;
use warnings;
use Data::Section::Simple;

my $reader = Data::Section::Simple->new(__PACKAGE__)->get_data_section;
chomp($reader->{$_}) for keys %$reader;
chomp($reader->{$_}) for keys %$reader;
$reader->{$_} =~ s/^ +//gms for keys %$reader;
$reader->{$_} =~ s/\n//gms for keys %$reader;
$reader->{$_} =~ s/="([a-zA-Z0-9-_]+)"/=$1/gms for keys %$reader;
sub get {
    my $class = shift;
    $reader->{$_[0]};
}

1;

__DATA__

@@ base_before
<!DOCTYPE html>
<html>
..

@@ base_after
..
</body>
</html>

使うときは ->getを使う。

my $flash = delete $env->{'psgix.session'}->{flash};
return [200,['Content-Type'=>'text/htmlcharset=UTF-8'],[
    Isu4Qualifier::Template->get('base_before'),
    Isu4Qualifier::Template->get('index_before'),
    $flash ? q!<div id="notice-message" class="alert alert-danger" role="alert">!.$flash.q!</div>! : (),
    Isu4Qualifier::Template->get('index_after'),
    Isu4Qualifier::Template->get('base_after') 
    ]];
}

前述のChobiのwritevとの合わせ技で高速にHTMLが出せるというおまけ付き。

Kossyの速度

HelloWorldのベンチマークで、Kossyの速度は生PSGIの半分しかでません。(他のWAFと比べてわりと速いとは思うけど)

なので突き詰めるとpsgiファイルにある程度書いていった方が速くなる。

my $app = Isu4Qualifier::Web->psgi($root_dir);
builder {
    enable 'ReverseProxy';
    sub {
        my $env = shift;
        if ( $env->{PATH_INFO} eq '/' ) {
            …
            return [200,[…],[…]];
        }
        elsif ( $env->{PATH_INFO} eq '/mypage' ) {
        }
        return $app->($env)
    };
}

仕事でこんなコード書いたら怒られますが、ISUCONでは怒られない。

インデントが多くてきつい場合は、builderの外に出す。

Web.pmに書かれている $self-> でつかうメソッドは以下のように呼べる。

my $isuweb = Isu4Qualifier::Web->new($root_dir);
my $app = $web->psgi;
builder {
    enable 'ReverseProxy';
    sub {
        my $env = shift;
        if ( $env->{PATH_INFO} eq '/mypage' ) {
            my $user_id = $env->{'psgix.session'}->{user_id};
            my $user = $isuweb->user_id($user_id); #Web.pmに書かれているメソッド呼べる
            return [200,[…],[…]];
        }
        elsif ( $env->{PATH_INFO} eq '/mypage' ) {
        }
        return $app->($env)
    };
}

メソッド$cが必要になる場合は修正するしかない。

Plack::Middleware::ReverseProxy

使わなくてもいける

builder {
    sub {
        my $env = shift;
        my ( $ip, ) = $env->{HTTP_X_FORWARDED_FOR} =~ /([^,\s]+)$/;
        $env->{REMOTE_ADDR} = $ip;
        $app->run($env);
    };
};

これで十分。効果は不明

Plack::Requestを使わないPOSTデータの読み込み

application/x-www-form-urlencodedのPOSTデータなら、Plack::Request使わずに決めうちで読んじゃえ。

use WWW::Form::UrlEncoded::XS qw/parse_urlencoded/;

my $input = $env->{'psgi.input'};
$input->seek(0, 0);
$input->read(my $chunk, 8192);
my $params = +{parse_urlencoded($chunk)};
$params->{login}

WWW::Form::UrlEncoded::XSも割と高速なので、単純なフォームだったらこれで速度稼げます。

さいごに

css/img消して実行するとこんな感じでした

$ GOGC=off ~/benchmarker bench --workload 8
05:10:19 type:info      message:launch benchmarker
05:10:19 type:warning   message:Result not sent to server because API key is not set
05:10:19 type:info      message:init environment
05:10:33 type:info      message:run benchmark workload: 8
05:11:33 type:info      message:finish benchmark workload: 8
05:11:38 type:info      message:check banned ips and locked users report
05:11:53 type:report    count:banned ips        value:4792
05:11:53 type:report    count:locked users      value:14442
05:12:01 type:info      message:Result not sent to server because API key is not set
05:12:01 type:score     success:232494  fail:0  score:232494

Chobiも近いうちにCPAN公開したいと思ってます