読者です 読者をやめる 読者になる 読者になる

ISUCON4 予選の参戦記録と延長戦

まさに今 ISUCON 本戦が開催中で今更感満載ですが、まだ書けていなかった予選の感想を記しておきます。

参加申し込みの期限前日にカープエンジニアの @chiastolite さんに声をかけて参加しました。チーム名は赤道直下です。

tl;dr;

やったこと

  • 静的ファイル(JS, CSS, png)を Nginx で返す
  • 全て Redis に乗せて MySQL を使わなくする(時間切れ)

思いついたけどやらなかったこと

  • ドキュメントルート(/)を静的ファイルにして返す(挑戦するも失敗)
  • DB 書き込みを非同期にして /report で sleep 20 とかする

思いつかなかったこと

  • erb を使わない
  • ユーザー情報をソース内にベタ書き

お一人様について

来たれ!おひとり様! ISUCON4 参加メンバー募集エリアを用意しました : ISUCON公式Blog

まずは予選当日の前に、こちらのメンバー募集企画について。

Twitter では「ISUCON 一緒に出る人いないかなー」みたいなつぶやきがぼちぼち観測されたので、運営側がそんな人たちのために企画したんだろうなと思われます。ただ、Github での募集は残念ながら機能してなかった……。

ハッカソンでの即席チームとかはうまく行ってる気がするので、おひとり様向け夏期講習みたいなイベント兼スカウト会みたいなのしたら面白いかもしれないですね。ただ、今回もかなり応募数多かったので、来年公式でやる必要も動機もないと思いますが。

当日まで

事前予想

特にメンバー間で意識合わせをしてなかったけど、前回の予選や ISUCON という競技の性質から、こんな感じ問題になるのではと想定はしていました。

  • いかにレスポンスを事前にキャッシュするかの Varnish 合戦
  • DB (おそらく MySQL)にアクセスしたら負け

予習

とりあえず最初の1時間は計測するしかないと思っていたので、アクセスログMySQL 周りのいつものやつをまとめておきました。

言語は Ruby で行くことに決まってたので*1、プロファイリング用に rack-lineprof を使ってみたんですが、これがとてもわかりやすくて本番でも重宝しました。 ただ、line-prof 便利すぎるんですが、無効化するの忘れてベンチ走らせて 500 エラー連発して混乱するってことを繰り返してました。

ISUCON4 予選前の予習結果

当日

予選の出題

予選の内容と、問題に使用された AMI はこちらで公開されてます。 ISUCON4 予選問題の解説と講評 & AMIの公開 : ISUCON公式Blog

最初の1時間

@chiastolite さんにインスタンスを立ててもらって、まず最初にアプリを触って感じたのは、画面数少なすぎる(2枚)上に /mypage は動的に変わりそうなのでキャッシュは難しいということです。ドキュメントルートくらいしかキャッシュ化できなさそうでした。。

アクセスログのチェック

なにわともあれアクセスログのチェックをしましたが、ドキュメントルートへのアクセスが結構あるくらいしかわからなかったのであまり深く見てません。

MySQL 周り

次は3306 を開けてもらって、Sequel Pro で MySQL につなぎます。

スロークエリログ出す

set global slow_query_log_file='mysql-slow.log';
set global long_query_time=0.1
set global slow_query_log = 1;

全然クエリが引っかからなくてマジかーってなってました。

アプリ・Nginx 周り

アプリと Nginx 周りのチューニングは @chiastolite さんお任せでした。

ルートを静的ファイルで返す

ドキュメントルートへのアクセスが結構なウエイトを占めてるのと、そこくらいしかキャッシュ化できそうになかったので、ルートを静的に返そうという提案をしました。

以下、やったこと

  1. ドキュメントルートのレスポンスをコピペして .html ファイルを作り、それを静的に返すようにする
  2. ログイン失敗時にアラートメッセージの表示で引っかかる (view の実装を見てなかった >< )
  3. エラー内容毎に .html ファイルを作成し、パスを変えてエラー毎にリダイレクト先を振り分けようとする
  4. URL チェックに引っかかる

URL変えちゃダメならクエリパラメータ付与してって手もあるけど、それも含めてベンツマークツールがチェックしてると思ったのであきらめました。*2

全Redis化への道

初期データインポート

MySQL の初期化ようファイルから変換するのが面倒だったので、一度 MySQL に入れてから1行ずつ処理をトレースして Redis に突っ込むという形にしました。

ただ、毎回1から作ってるので時間がかかり、終了間際とかに繰り返しベンチを取るときにストレスが溜まりました。早めに Redis 用のダンプファイルを作るなどしておくべきでした。

db.xquery('SELECT * FROM login_log').each do |log|
  if log['succeeded'] == 1
    redis.pipelined do
      redis.set "#user_id_{log['user_id']}_locked", 0
      redis.set "ip_#{log['ip']}_locked", 0
    end
  else
     redis.incr "user_id_#{log['user_id']}_locked"
     redis.incr "ip_#{log['ip']}_locked"
  end
end

ip_banned?, user_locked? を Redis に移す

その後、rack-lineprof でアプリの重い箇所を探したところ、予想通り MySQL のところで時間がかかってました。とりあえず重そうな参照部分から順々に Redis に移していきました。

Ban 系の参照部分を全部 Redis に移し、残りはレポート用に login_log を書き込んでるところとユーザ情報を取っている所だけとした状態でスコアはだいたい 8,800 程でした(workload=1)。

レポート用のデータをどうするか

次に login_log の DB 書き込みをやめて、Redis のみでレポートを作り上げるところに手を出したのですが、SQL の読み間違え等でレポートのチェックが通ることなく時間切れとなりました。

延長戦

その後の懇親会でチューニングの方向性は間違ってなかったという手応えを得たので、一人延長戦をしてみました。

login_log の書き込みを無くす

レポートのエラーを修正してもスコアは 9,029 と微増に止まりました。*3

workload を 4 にあげるとスコアは 16,018 まで上がるものの、 dial tcp 127.0.0.1:80: cannot assign requested address が頻発してレポートのチェックで落ちました。

ミドルのチューニング

あまり手をつけてなかったネットワーク周りのチューニングのためにググって見つかった設定をとりあえず適応。

sysctl

vi /etc/sysctl.conf
# add
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_fin_timeout = 2
net.ipv4.tcp_rmem = 16384 131072 262144
net.ipv4.tcp_wmem = 16384 131072 262144
net.ipv4.tcp_mem  = 2048000 4096000 4096000
net.ipv4.ip_local_port_range = 10240 65000
net.core.rmem_max=16777216
net.core.wmem_max=16777216
net.core.netdev_max_backlog = 30000
net.ipv4.tcp_no_metrics_save=1
net.core.somaxconn = 262144
net.ipv4.tcp_syncookies = 0
net.ipv4.tcp_max_orphans = 262144
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 2
net.ipv4.tcp_max_tw_buckets = 56384
fs.file-max = 70000

kernel.panic = 10

反映

sudo sysctl -p

limits.conf

ファイルディスクリプタ関連

vi /etc/security/limits.conf
isucon soft nofile 1000000
isucon hard nofile 1000000
# 一度ログアウトし、再度 ssh 接続してから確認
ulimit -n

Redis

Unix socket 経由で Redis を使うようにします。

vi /etc/redis.conf
unixsocket /tmp/redis.sock
unixsocketperm 777
sudo /etc/init.d/redis restart

score: 21,680 (workload=8)

mysql からの user 情報 SELECT をなくしてすべて Redis で処理

converter.rb に以下を追加し、

db.xquery('SELECT * FROM users').each do |user|
  redis.pipelined do
    redis.set "#{user['id']}_login", user['login']
    redis.set "#{user['login']}_id", user['id']
    redis.mapped_hmset "#{user['login']}_user", user
    redis.mapped_hmset "#{user['id']}_user", user
  end
end

アプリを書き換えました。

app.rb

        # user = db.xquery('SELECT * FROM users WHERE login = ?', login).first
        user = redis.hgetall "#{login}_user"
        # @current_user = db.xquery('SELECT * FROM users WHERE id = ?', session[:user_id].to_i).first
        @current_user = redis.hgetall "#{session[:user_id].to_s}_user"

score: 22,255 (workload=16)

'/' の静的ページ化

最後に、クエリパラメータ付与すればベンチマークのチェックをくぐり抜けることができるということを懇親会で確認したので、やってみました。

app.rb

        case err
        when :locked
          # flash[:notice] = "This account is locked."
          redirect '/?err=locked'
        when :banned
          # flash[:notice] = "You're banned."
          redirect '/?err=banned'
        else
          # flash[:notice] = "Wrong username or password"
          redirect '/?err=wrong'
        end
        redirect '/'

nginx.conf

    location / {
      if ( $arg_err = "locked" ) {
        root /home/isucon/webapp/public/locked;
      }
      if ( $arg_err = "banned" ) {
        root /home/isucon/webapp/public/banned;
      }
      if ( $arg_err = "wrong" ) {
        root /home/isucon/webapp/public/wrong;
      }
      root /home/isucon/webapp/public;
    }
    location /login {
      proxy_pass http://app;
    }
    location /report {
      proxy_pass http://app;
    }
    location /mypage {
      proxy_pass http://app;
    }

sudo /etc/init.d/nginx restart

score: 39,032 (workload=16)

まとめと反省点

結果を残すことができなくて残念ですが、来年も開催するとのことなので、来年こそは本戦に出場できるよう精進します。

以下、来年を見越した反省点

  • コードの管理をしよう
  • 作業者ごとに複数インスタンスを立てよう
  • 繰り返しベンチを取ることになるので、初期化処理はサクッと終わるようにしよう

*1:去年と違い、そこそこ Ruby も読めるようになった

*2:後で聞いた話では実はチェックしてなかった!!

*3:本番中にチェックが通らなかった原因は、初期化スクリプトのタイポでした><