TL; DR
- 実力不足を痛感した
- なんとなく計測した気になってはいけない
- 予め用意したものはとても役に立った
- 更にブラッシュアップしていきたい
はじめに
この記事はチームツナ缶 としてISUCON12本戦の記録です。
予選同様、下記の様な分担で参加していました。
もし興味があれば、ぜひ予選のときの記事も読んでみてください。
参加前の準備
ISUCON12予選ではデプロイの自動化などはうまく回っていましたが、反面計測周りに関しての体験が良くありませんでした。具体的には以下の様なフローになっていました。
- アプリやインフラへの変更をデプロイ(自動化されておりコマンド一つ)
- 全てのログをローテート(自動化されておりコマンド一つ)
- ベンチを回す
- 手元でpprofのコマンドを用意してポートフォワード越しに実行(もちろん実行者しか見れない)
- 終わったらサーバに入ってIssueに貼っておいた解析コマンドを実行してコピー
- まとめてIssueなりPRなりSlackで報告
問題は3つあります。
- 手作業が多い
- 実行者しかpprofの結果をみれない
- 結果が一箇所にまとまっておらず、毎回どこにあるか探すことになる
これらを一挙に解決すべく、Ansibleでベンチ前後の処理を自動化しました。make bench
すると以下の様な処理を実行するようになっています。
- スロークエリログを有効化
- 全てのログをローテート
- ユーザのインタラクションを待つ(ベンチリクエスト待ち)
- Enterが押されたらアプリケーションホスト上でpprofでプロファイリング
- ユーザのインタラクションを待つ(ベンチ終了まで待ち)
- Ansibleのinventoryに従いDBグループならpt-query-digestの結果を、nginxグループならalpの結果を取得し、レポートのMarkdownを生成しローカルにダウンロード
- pprofのダンプファイルをローカルにダウンロード
- ダウンロードしたレポートを一つのMarkdownにマージし、ローカルホストからghコマンドでIssueに投稿
これにより以下の様なレポートがIssueに投稿されます。本文が長すぎて投稿出来なかった場合でも、ローカルに同じ内容のMarkdownのファイルが残るのでそれを見ることが出来ます。
https://github.com/pddg/isu12f/issues/1#issuecomment-1229147907
結果としてベンチ前後の処理が大幅に楽になりました。
(pushしていなかったりしてcommitがない場合もあるので必ずでは有りませんが)現在のHEADを指すコミットハッシュをレポートに入れているのでどの時点の結果なのかよくわかるというのも良い点でした。
またpprofの実行結果を全ホストからダウンロードする方法も1コマンドで用意し、他のメンバーの実行したpprofでも簡単に見ることができるようになった点も大きかったです。
今回の出題内容
クッキーの代わりに椅子を生産するクッキークリッカーみたいなゲームに関する問題でした。
ユーザは椅子生産を効率化するアイテムでより大量の椅子を生産し、ログインボーナスを獲得してガチャを回してより強いアイテムを……みたいなサイクルを回すゲームになっていました。
アプリケーションの実態としては単なるWebアプリですが、フロントエンドのアプリケーションが運営が用意したCloudFrontから配信されていたのが少し驚かされる点でした。過去問再現が難しそう……
また、サーバが5台用意されていたところもこれまでとは違う点でした。幸いAnsibleではinventoryに2つホストを足すだけで良かったので、対応は難しくありませんでした。
やったこと
自分はインフラ担当なので主にその立場でやったことになります。
2022/8/28 20:14 追記
一時的にコードを非公開にしました。実行中のインスタンスが停止次第、再度公開する予定です。
10:00~11:00 初期セットアップ
nginx・MySQL・Go製のアプリケーションとほぼいつも通りの構成であり、迷うところはありませんでした。
alpやpt-query-digest、netdataなどを入れて、nginxやMySQLの設定をバックアップ、nginxのログをLTSVにしたりMySQLのbinlogを無効にしたりしていました。
あとはmitigations=offを入れたりしていました。ちなみに/proc/cpuinfoを見たらAMD EPYC 7763だったのでちょっとびっくりしました*1。
初期スコアは600くらいでした。
11:00~12:00 作戦会議とか
アプリの仕様の共有をして、取得されたalpの結果やスロークエリログからとりあえず方針を決めました。 まずサーバが5台あり、1人1台割り当てても2台余ることから、初手でDBを別ホストに分けました。 後はインデックス貼ったりN+1潰したりをやっていこうという方針で行きました。
- DBサーバの分割
- generateIDという関数が単一のテーブルからあらゆるオブジェクトのIDを採番している部分を適当に
time.Now().UnixNano()
に変える- Fix generate ID by pddg · Pull Request #15 · pddg/isu12f · GitHub
- 結果として後からUnixNanoでは重複が出てだめだったので乱数に切り替えています。
このあたりは何かやるだけスコアが上がるのでとても楽しかったです。
12:00~13:00 obtainItemの闇に触れる
obtainItemという関数があるのですが、こいつはobtainという名前に反して内部では状態の更新をしており返り値はどこからも利用されていませんでした。
ヤバすぎる関数なのでなんとかしたかったのですが、これの調査をしているだけでかなり時間を持って行かれたのが痛かったです。同時期の他の変更とのコンフリクトを避けるため、ひとまず内部の処理を関数に分けるリファクタを加えました。
13:00~14:00 admin関連のテーブルを別DBに分離しようとしてベンチが落ちる
DBの負荷が相変わらずなので、一部のデータだけでも分離できないかと考え、ひとまず他から参照されて無さそうなadmin_usersとadmin_sessionsのみ別DBに分離しようとしました。
use isu4 as Admin DB host by pddg · Pull Request #19 · pddg/isu12f · GitHub
が、ベンチがコケるので諦めました。この時点で20000点くらいは出ておりこのままいければそれなりに順調だった(と思う)のですが、このあたりから何をしてもスコアが伸びなくなり、焦り始めます。
14:00~15:00 generateIDで重複する
単に現在の時間をナノ秒単位で返していただけの実装がduplicate entryと言われるようになってしまったので乱数に変えられないか調べていました。
ORDER BY id ASC
しているテーブルは自分たちで採番していなかったので乱数でイケると判断してrand.Int63()
に変えました。
- generateIDを乱数化 by pddg · Pull Request #23 · pddg/isu12f · GitHub
- 間違っている。焦っていて冷静になれていない……
- fix generate random id by taxio · Pull Request #25 · pddg/isu12f · GitHub
- これが正しい。
このあたりでRedis欲しいかもと言われたので、余っていた4台目にRedisを入れ設定し、環境変数にホストやポートなどを入れました。
Introduce redis by pddg · Pull Request #24 · pddg/isu12f · GitHub
15:00~16:00 alpの結果がおかしい
行き詰まってきてしまい、色々見直そうということでプロファイリング結果を見ていたのですが、叩かれているはずのエンドポイントがalpで観測出来ていませんでした。
明らかに今回最大のミスで、alpのオプションを初期のログから目grepで組み立てて運用していたのが良くないポイントでした。
- fix alp option by pddg · Pull Request #28 · pddg/isu12f · GitHub
- fix by pddg · Pull Request #31 · pddg/isu12f · GitHub
明らかにこれによって解像度は上がりました(が、結局スコアに繋がる改善ができず……)。
16:00~17:00 泥沼にはまる
- user_cardsテーブルへのinsertがとにかく遅いので、UNIQUE制約に入っている
deleted_at
という全く参照されていないカラムを外そうとしたら初期データにはあるっぽくてエラーで落ちた。 - 苦し紛れにカバリングインデックスになるようにするがスコア改善はない
- masterデータのオンメモリキャッシュを作るも、全ての場所を入れ替える前に時間が尽きそうで途中で諦める
スコアがこの時点で14時辺りから変わっておらず、打開策を見つけられなくなっていました。
17:00~18:00 とにかく再起動試験を通す
スコアは残したいので再起動周りに手を入れ始めました。 DBが起きてくるまでアプリ側は無限ループしたり、pprof切ってログ切ってnetdataをアンインストールして……みたいな感じで計測系をひたすら外していました。
再起動して何回かベンチ回してコケないことを確認して、最高スコアの22000あたり(うろ覚えだった)が出たところで終了にしました。
結果
チームツナ缶はスコアを残すことに成功し、最終スコアは21710点でした。得点のあるチームの中では上から15番目(精一杯好意的な解釈)でした*2。
失格するチームも多い中得点を残せたのは、flakyなfailを潰すために何度もベンチを回したり*3、丁寧に再起動周りをケアして、自分で再起動回してベンチがfailしないことを確認したおかげかなと思います。
振り返り
alpのオプションミス
明らかにこれは痛かったです。正規表現が間違っており、分離されるべきパスが全て集約されてしまったことで誤ったエンドポイントが遅いように見えていました。
アプリを見る前にログを目grepしてオプションを組み立てていたのですが、アプリのコードがそこにあるのだからそれを見て組み立てるべきでした。
シャーディングの観点の欠如
今回はユーザ同士のインタラクションがなく、完全にそれぞれ独立しているためシャーディングをすれば余っているサーバを活用できました*4。IDの採番のロジックも握っているのでどうにでもなったはずです。
が、そもそもこの発想に至っていませんでした。予選がSQLiteで複数サーバにシャーディングできなくて困った~~って話をやったばかりなのに、一体何を学んでいたんでしょうか……?
アプリケーションログの軽視
あまり良い手立てを思い浮かんでいなかったのでアプリのログは愚直にjournalctlで見ていました。
が、他のメンバーはあまり慣れておらず効率的なログの閲覧ができていませんでした。これによりデッドロックに気付くのが遅れたり、作業が遅くなったりしており、大いに改善の余地があります。
nonyleneくんがやっていたように、ログとトレーシングの基盤を用意するのがいいのかな……と考えていますが、来年の僕に期待します。 nonylene.hatenablog.jp
デッドロックの解消の知見が乏しい
/login
という重いエンドポイントがありました。ここではログイン処理(パスワードチェックとかsession周りとか)に加えてログインボーナスの処理などをやっており、非常に複雑なエンドポイントになっていました。
alpで見ると全アクセス中このエンドポイントは500のケースがかなり多く、ベンチの結果にも出ていましたがそこまで気にしていませんでした。
が、実はデッドロックを引き起こしておりこれを解消しなければそもそも遊んでくれるユーザが入って来れないという状況になっていたようです。
終盤はtaxioがずっとこれを見ていてくれたのですが、何がデッドロックしているのか、どうすれば解消できるのかが掴めず最後までどうにもなりませんでした。ここがスコアを伸ばすポイントだったのに、みんな目先のN+1やスロークエリにとらわれてしまっていました。
また、自分がDBのデッドロックに詳しくなく、あまり手が出せなかったのも良くなかったです。とはいえギャップロックとかネクストキーロックくらいの知見はあるのだから、分離レベルをREAD COMMITTEDにしてみるとか*5それくらいは出せたはずでしたが、競技終了後に思い出しました。
来年に向けて
他のチームのスコアを見ると、自分たちのチームと見えている世界が違いすぎ、壁を感じました。ただし「また新しい知識を吸収して来年こそは本戦でも爪痕残すぞ」という気持ちになってきたのは良かったと思います。
幸い数日間、感想戦としてポータルやインスタンスを提供して頂けるとのことなので、冷静になった頭で色々ためしてみたいなと思います。
運営の皆さん、ありがとうございました!優勝のNaruseJunチームはおめでとうございます!
来年また対戦よろしくお願いします。