ISUCON4 予選の参戦記録と延長戦
まさに今 ISUCON 本戦が開催中で今更感満載ですが、まだ書けていなかった予選の感想を記しておきます。
参加申し込みの期限前日にカープエンジニアの @chiastolite さんに声をかけて参加しました。チーム名は赤道直下です。
tl;dr;
やったこと
思いついたけどやらなかったこと
- ドキュメントルート(/)を静的ファイルにして返す(挑戦するも失敗)
- DB 書き込みを非同期にして /report で sleep 20 とかする
思いつかなかったこと
- erb を使わない
- ユーザー情報をソース内にベタ書き
お一人様について
来たれ!おひとり様! ISUCON4 参加メンバー募集エリアを用意しました : ISUCON公式Blog
まずは予選当日の前に、こちらのメンバー募集企画について。
Twitter では「ISUCON 一緒に出る人いないかなー」みたいなつぶやきがぼちぼち観測されたので、運営側がそんな人たちのために企画したんだろうなと思われます。ただ、Github での募集は残念ながら機能してなかった……。
ハッカソンでの即席チームとかはうまく行ってる気がするので、おひとり様向け夏期講習みたいなイベント兼スカウト会みたいなのしたら面白いかもしれないですね。ただ、今回もかなり応募数多かったので、来年公式でやる必要も動機もないと思いますが。
当日まで
事前予想
特にメンバー間で意識合わせをしてなかったけど、前回の予選や ISUCON という競技の性質から、こんな感じ問題になるのではと想定はしていました。
- いかにレスポンスを事前にキャッシュするかの Varnish 合戦
- DB (おそらく MySQL)にアクセスしたら負け
予習
とりあえず最初の1時間は計測するしかないと思っていたので、アクセスログや MySQL 周りのいつものやつをまとめておきました。
言語は Ruby で行くことに決まってたので*1、プロファイリング用に rack-lineprof を使ってみたんですが、これがとてもわかりやすくて本番でも重宝しました。 ただ、line-prof 便利すぎるんですが、無効化するの忘れてベンチ走らせて 500 エラー連発して混乱するってことを繰り返してました。
当日
予選の出題
予選の内容と、問題に使用された 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 さんお任せでした。
ルートを静的ファイルで返す
ドキュメントルートへのアクセスが結構なウエイトを占めてるのと、そこくらいしかキャッシュ化できそうになかったので、ルートを静的に返そうという提案をしました。
以下、やったこと
- ドキュメントルートのレスポンスをコピペして .html ファイルを作り、それを静的に返すようにする
- ログイン失敗時にアラートメッセージの表示で引っかかる (view の実装を見てなかった >< )
- エラー内容毎に .html ファイルを作成し、パスを変えてエラー毎にリダイレクト先を振り分けようとする
- 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)
まとめと反省点
結果を残すことができなくて残念ですが、来年も開催するとのことなので、来年こそは本戦に出場できるよう精進します。
以下、来年を見越した反省点