ISUCON10 予選参加記(予選通過しました)

ISUCON10にチーム「勉強不足の分は有り余る才能でカバーでカバーしようかなと思っております」で@kenkooooさんと@GolDDranksさんと参加して、予選通過しました。
一昨年は、違うメンバーとですが出場したものの予選敗退、昨年は一人でNode.jsで参加するもののGoの参考実装のスコアすら超えられない悲惨な結果でした。
今回はRustでの参考実装が提供されそうだったというのと、Go言語を今更勉強するのもなあという気持ちもあったところに、
@kenkooooさんがRustを使うチームメイトを募集していたので誘いにのり、Rustにしました。

事前準備

事前練習としてISUCON9の予選問題をGoからRustに切り替えつつ最適化する、という練習をしていました。
ここでactixやsqlxの使い方を勉強しつつ、ISUCONで必要になる計測やチューニング方法の確認をしました

計測ツールとしてはnginxのログをalpで解析する方法と、NewRelicの使い方を確認しました。
また、CPU使用率やメモリ使用率などをリアルタイムで監視できるnetdataも用意しておきました。

デプロイの手順も確認しておいて、チームメンバー間でのコードを共有はgithubのレポジトリを介してやりましたが、サーバーへのデプロイは手元のビルド生成物をrsyncでデプロイする形式をとりました。
理由としてはコンパイルがそれなりにサーバーに負荷をかけること、サーバーに入ってgit pullしたりするのはいろいろと面倒な場面があることが上げられます。

/etc以下のファイルはetckeeperで管理しておきます。サーバーごとに変えたいとか権限の問題もあるので、特にリモートレポジトリでの管理はせず、ダイレクトに編集します。

事前にサーバーの環境はUbuntuであるとわかっていたので、例年通り自前のansibleスクリプトで各種ツールの導入や基本的なbashrcやvimrcの導入もできるようにしておきました。

当日の流れ

自分の視点から、当日の流れはこんな感じになった。

8時位:起床。入眠失敗してかなりつらい

9時位:PCを起動して支度をしていると、開始が2時間延期になったことを知る

12時位まで:リングフィットしたり、AtCoderの問題といたり、トイレの便座カバーを洗濯したり、お昼の出前をとったり

12時20分:競技開始。しかし、ポータルの不具合によりすぐにはベンチマーカーは走らせられない状態とのこと。

事前に用意しておいたansibleスクリプトを走らせて基本的な設定は済ませつつ、ゆっくりとコードを読んだりマニュアル読んだり、開発環境を整えたり。
各サーバーにsshできること、データベースにも接続できることなどを確認。
@kenkooooさんがgithubにウェブアプリのコードを共有し、各エンドポイント用の関数を別ファイルに切り出すことで編集がしやすいように手配

13時15分頃:ベンチマークが走らせられるようになる。

初回実行時のスコアは537
ベンチマークを走らせた結果をnginxのログからalpでどのエンドポイントがボトルネックになるかを観察。
/api/estate/searchなどは回数が多いが、一回の実行あたりの時間が長そうなのは/api/estate/nazotteだな、などとあたりをつける。

自分は/api/estate/searchのコードを見るものの、例年みたいにN+1問題のような感じでもなく、純粋にSQLクエリが重いだけっぽい。
ということで、インデックスを適当に張ってみたり、MySQLの設定を見直すということに注目する。

14時35分頃:@kenkooooさんの手によって、server2をMySQL専用にして、server1をウェブアプリ専用にする設定が導入される。server3はこの段階では使ってない。

ポータルが不安定だったり、nginxのログ設定がうまくいってなかったり、アプリケーションの挙動の確認などで、ここらへんはわりとあたふたしている。
@GolDDranksさんがボットフィルター入れたりなどの変更はあるものの、大きなスコアの変化はなし。

15時45分頃:nginxの設定を見直してみる。

静的ファイル配信周りに最適化の余地がありそうだったが、よくよくレギュレーションを見ると、/api以下のパフォーマンスしか見られないらしいのでスルーする。
gzipとかが有効になってなさそうなので、Rustアプリ側で圧縮を有効化してみたり、ワーカーの数を4にしてみたり。
ワーカー数はデフォルトだとCPUの数に等しくなり、今回は1コアだったのでワーカーが1つのみになるので、これだとIO待ちとかが有効活用されないのでは、と思って4にしてみた。
が、この辺の変更でもスコアは大きくは変わらず。500点台が続く

16時10分頃:@kenkooooさんのnazotteの変更が入る。

この辺からスコアが伸び始める。インデックスを張ったりすることで700点台に入る。
この段階でベンチマークを回すときにnetdataのダッシュボードを見るとMySQLサーバーのCPU使用率が非常に高いことに気がつく。一方でアプリ側は余裕がある。
メモリの使用率も全体としてはまだ余裕がある。この辺に注目すればスコアが伸びるのでは、と予想をつける。

16時50分頃:DB2台体制の準備に取り掛かる

MySQLサーバーのCPU使用率がボトルネックになってそう、ということは、サーバー数を増やせばスコアが伸びるのでは、と考えた。
今回のクエリはテーブル間でJOINすることでスコアが伸びることはなさそうだったのと、テーブル数はたったの2つのみ。
ということは、各テーブルごとに専用のサーバーを立てれば、比較的容易に負荷分散ができそうだ、ということに気がついて実装に取り掛かる。

その間に他のチームメイトはアプリケーション側でデータをキャッシュすることなどで高速化を測り、スコアが1000点台に乗り始める。

18時半頃:使ってなかったserver3を追加してDB2台体制が整う。

この時点でのスコアは1775。一気に伸び始める。

18時50分頃:MySQLの設定を見直していたら、実はクエリキャッシュがきいていないことに気がつく

query_cache_sizeがデフォルトで正の値が設定されていたので、てっきり有効になっていると思ってましたが、
query_cache_typeがデフォルトだと0に設定されて無効になっていました。1に変えて有効化すると、なんとスコアが2465に一気に伸びる!
キャッシュサイズが大きすぎるとCPUの負荷も上がるかもなあと思って、大きくは変更しませんでした。

19時頃:時々、ベンチマークが落ちるようになる

アプリケーション側のキャッシュロジックがかなり怪しかったので、ここを他のメンバーが見直しつつ、自分はSQLの設定をいじったり、インデックスの貼り方を見直したり。
MySQLTunerというのを使ったんですが、どうもサーバーが貧弱すぎるせいか、まずRAMを増設せよ、みたいなアドバイスが出てきたりして、あまり活用できませんでした。
インデックスの仕組みも自分があまり勉強したことがなかったため、適当な複合インデックスを貼ったりはしていたのですが、果たして効果があったかはよく検証できませんでした。

20時頃:@kenkooooさんが離脱

まだ、アプリが不安定な状態が続いていたので、アプリケーション側のキャッシュロジックを全部取り外すことに。
一時的に3000点台も記録しましたが、キャッシュロジックを取り除くと2500点付近まで落ちてしまうことに…
とはいうものの、残り時間もわずかで、凍結前のスコアボードを見るに、この点数でも十分に決勝に残れそうだったのでこのままいくことに。
本当は再起動試験を真面目にやりたかったのですが、再起動の手順を確認しておらず、万が一再起不能になると運営からの救済も厳しい時間帯だと思ったので、行わないことに。
ただ、設定はすべてファイル経由でおこなっていたし、systemdのRust側のサービスのみが有効になっていることだけを確認はしておいたので、まあ大丈夫だろうと。

netdataなどを落としたり、ログのレベルを落としたりして、スコアガチャの時間に。2684点が終了10分前あたりに出て、ここで作業ストップ。
不必要なsshコネクションを落としたりして、天命を待つことに。

24時頃:結果発表。予選通過!

正直、今までの結果もそこまで良くなかったので、まさか通過できるとは思ってませんでした。
チームも即席で、チーム内のコミュニケーションもすべてテキストチャットで不安な面もありましたが、意外となんとかなりました。
この手のプログラミングコンテストでここまでいい成績を残せたのははじめてな気がします。
本戦も引き続きがんばっていきたいです。

反省

良かった点

  • テキストチャットのみでも比較的コミュニケーションはとれる
    • ボイスチャットだと不必要なインターセプトも入る可能性があるので、テキストのみというのは意外と悪くないかも
    • 個人の好みや場面に依存はすると思う
  • エンドポイント毎にファイルを切り出すのはよかった。見通しがよくなる
  • netdata等の監視ツールは大事。ansibleなどで自動で入れられるとすごく楽
    • NewRelicも用意はしていたが、今回は活用できませんでした
  • お昼ご飯はとても大事。今回はスポンサーである出前館を利用として、出前を注文しておきました
    • 参加費と思って、ちゃんといいものを食べましょう

悪かった点

  • 再起動試験の手順はもっと早くに確認しよう
  • MySQLのインデックスについてなど、ちゃんと勉強しよう
    • 5.7では降順インデックスは存在しないらしい。8では存在する
    • Generated Columnをソート用に使うというテクニックもあるらしい
      − 他にも不要なSELECT FOR UPDATEを取り除ける、というのも見逃していた
      − 前日の睡眠はきちんととりましょう…

9/14 追記

チームメイトの@kenkooooさん視点の参加記です。

他のチームの参戦記もいろいろ読ませてもらって、自分たちのできてなかった改善点はだいたいこんなところだと思います

  • 実行結果をアプリ側でキャッシュする
    • low_pricedが主か。自分たちもやろうとしたが、バグらせてしまい断念
  • MySQL 8.xの使用
  • nginxのclient_body_buffer_sizeの調整
    − nginxのログをちゃんと見ると警告が出ていたらしい
  • MySQLの真面目な解析
    long_query_time=0として全部スロークエリとしてログに吐かせたあとpt-query-digestで解析するといいっぽい
    • EXPLAINを使うとインデックスがちゃんと効いているか確認できる
  • recommended_estateWHERE句の最適化
    • 椅子の高さ・幅・奥行きのうち下位2つのみを取り出せば、ORによる連結が減らせる。GENERATED COLUMNを活用すれば、さらに減らせる

その他、他のチームの参戦記は以下の公式のブログにまとめられるそうです。

今回、はじめて初期実装としてRust言語が追加されたことに関しては運営の皆様とそれに協力してくださったボランティアの方に深く感謝します。
初期実装がない状態だと敗戦濃厚でした。また、実装自体もちゃんとしていて不利になることがありませんでした。

一方で、今回の大会ではポータルの不調が例年に比べると目立ち、多くのチームが混乱しているように見えました。
自分たちもキャッシュロジックの追加時、ポータルの不調に出会い、アプリが悪いかベンチマークが悪いかでかなり混乱してしまいました。
アクセスができない時間帯もありましたが、僕個人としては、そういう時間は冷静に次に何ができるかを見直せる時間になったりしたので、大きくは影響しなかったのかなと思ってます。
特に今年は予選が一日に集中している関係でかかる負荷が想定よりも重かったなどの事情もあったのでしょうか。
この点は少々、残念ではありましたが、毎年多大な労力をかけて開催していただけることは非常にありがたいと思っています。
運営の皆様には改めて感謝の気持ちを伝えたいと思います。ほんとうにありがとうございます。