🦀

ISUCON12予選参加記 Rustで予選突破した

2022/07/26に公開

2022年7月23日(土) に開催された ISUCON 12 に Rust で参加して20位で予選突破しました。

チーム

チームO

事前準備

基本的には前年までに用意したツールを活用する

  • cargo-make で3台の EC2 への deploy や再起動やログの集計をコマンド一発で実行可能にする
  • isuconf で現地と手元の設定ファイルの同期
    • omu の自作コマンド

どちらも年に1回しか登場しないので、事前の練習でコマンドを手に馴染ませておく。

Rust のためのトレースツールの整備

https://github.com/hinohi/rust-opentelemetry-auto-span を作った。

計測したい関数に対して #[auto_span] とアノテーションするだけで、その関数そのものと、関数内部で .await している箇所を計測してくれる。
特に sqlx を利用している場合は実行した SQL 文字列もキャプチャする。

use rust_opentelemetry_auto_span::auto_span;

#[get("/hello")]
#[auto_span]
async fn greet(pool: web::Data<sqlx::SqlitePool>) -> actix_web::Result<String> {
    let r: Vec<i32> = sqlx::query_scalar("SELECT id")
        .fetch_all(pool.as_ref())
        .await
        .map_err(SqlxError)?;
    Ok(format!("Hello {:?}!", r))
}

一般論として

  • APM(Application Performance Management) は便利。特にトレース
  • Webサービスを APM に対応させる場合、それ用のライブラリが存在するかどうかが全て
  • Python のような動的言語は APM 対応が簡単で、Rust は難しいと思われている

可視化するプラットフォームとしては

  • NewRelic
    • ログイベント発生からデータが見えるまでが絶望的に遅くて ISUCON との相性が悪い
    • Rust の SDK 開発が止まっていそう
  • DataDog
    • 知らない
  • Splunk
    • 好きだが、ある意味普段使いし過ぎているのでたまには別のも使いたい
  • AWS X-ray
    • 知らない

あたりが有名かと思う。

今回は opentelemetry で Jaeger を採用した。

Rust のトレースを良さげにしてくれるライブラリを探したが、sqlx の SQL 文字列を含めて送ってくれるものは見つからなかった。(あるなら教えてほしい)
仕方がないので自作した。

実際に Rust のトレースライブラリを作ってみると、Rust のブロック式が万能で感動する。
例えば以下のような置換が行われる。

sqlx::query("INSERT INTO `isu_association_config` (`name`, `url`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `url` = VALUES(`url`)")
    .bind("jia_service_url")
    .bind(&request.jia_service_url)
    .execute(pool.as_ref())
    .await
    .map_err(SqlxError)?

{
    let mut __span = __tracer.start(concat! ("db:", line! ()));
    {
        __span.set_attribute(opentelemetry::KeyValue::new("sql", "INSERT INTO `isu_association_config` (`name`, `url`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `url` = VALUES(`url`)"));
        sqlx::query("INSERT INTO `isu_association_config` (`name`, `url`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `url` = VALUES(`url`)")
    }
    .bind("jia_service_url")
    .bind(&request.jia_service_url)
    .execute(pool.as_ref())
    .await
}.map_err(SqlxError)?;

メソッドチェーンの途中だろうがなんだろうがブロック式に置き換えて元の処理を完全に維持したまま任意の処理を挟むことが簡単にできる。

その他監視ツール

  • kataribe
  • pt-query-digest
  • netdata
  • bottom

動き方

  • 序盤は典型
  • 中盤は EC2 の1台目+2台目を本番環境、3台目を開発環境のように使おうとなる

当日

10:00~11:00

  • ルールの確認
  • 現地の確認
  • isuconf pull (EC2 の設定ファイルを git 管理下に置く作業)
  • アプリ実行から docker を剥がす
  • ローカルで mysql を立てる

11:00~12:00

  • APM 対応 (auto_span の付与)
  • nginx ログの kataribe 対応
  • MySQL スロークエリ対応
  • MySQL ネットワーク設定

12:00~14:00

  • pem ファイルをプログラム起動時に読み込む
  • visit_historyMIN(created_at) があれば十分だと気づき visit_history2 として初期化時に別テーブルを作成。アプリからは visit_history2 だけを利用するできるようにロジック修正
  • competition_ranking_handler の N+1 を修正
  • SQLite から MySQL へ切り替え(後述)

13:00~16:00

  • SQLite から MySQL に移行したテーブルにPKなど作成
  • dispense_id を UUID v4 に変更
  • player_score.row_num の削除
  • env ファイルで DB を指定できるようにする
  • player_handler の N+1 を解消
  • player_score を bulk insert
  • MySQL と App を分割
  • actix-web の worker 数を 2,3,4, と試して 3 を採用
  • Redis を VPC 内に公開 (結局使わない)
  • Redus でマルチホスト間のロックを実装しようとするが、Rust で Redis を素振りしておらず少し時間がかかりそう + 今のままで予選突破にはスコアが足りそうと判断してこの方針は捨てる
  • EC2 の3台目のデータが腐ってしまい、その環境だけではいくら初期化してもベンチの初期整合性チェックで落ちるようになる。直そうと奮闘するが、結局1台目の MySQL から dump & import するのが最も早かった。これによるタイムロスが痛い

16:00~17:00

  • player_handler の N+1 を修正 (2回目)
  • competition_score_handler の N+1 を修正
  • Appのログレベルを warn にあげる
    • ベンチマークで意図しない 500 が出ているがアプリケーションログがアクセスログで氾濫して見づらくなっていたことに対する対策
  • competition_ranking_handler でエラーハンドリングをがっつりやって 500 エラーの原因特定

17:00~18:00

  • APM のためのコード削除(簡単)
  • billing_report_by_competition で created_at による刈り込みを SQL に含める
  • 再起動試験
  • ログを OFF って良いスコアが出るまでガチャる

SQLite から MySQL への移行

12時ごろから MySQL への移行を検討し始める。
モチベーションは

  • SQLite はスロークエリが見えない
    • アプリケーション側で SQL とその実行速度を出すオプションがあることは perl を読んでいて気づき、出力してみた
    • 出てきた JSON のパースがしんどかったのでこれを使うのはやめた
  • App の CPU ネックに見えたので SQLite 処理(DB処理)を別ホストに移したい
  • なんだかんだ「自分の土俵に持ち込む」が大事だよねと思っているので、ちゃんと使ったことがない SQLite よりは MySQL で戦いたかった

途中、マルチテナントの sharding 状態を維持するか維持するならどのように初期化するかなどを考え

  • SQLite の全てのテーブルには(親切にも) tenant_id が含まれている
  • SQLite の全てのテーブルの PK は(親切にも)重複していない
  • 一番巨大な player_score テーブルは row_num による冗長を圧縮すると結構小さくなる
    • 1.db で 300万行 → 20万行 くらいだったはず

ことがわかったので、特に初期化ロジックの簡単のため sharding はやめて単一テーブルへの合体で行くことにした。

実装的には

  1. Python スクリプトで SQLite DB → MySQL の init_ でプレフィックスされたテーブルへデータをインサート(1度だけ実行)
    • このタイミングで row_num が最大のレコードにフィルタする
    • これみよがしに置かれていた sqlite-to-mysql みたいなスクリプトはチラッと見て、バルクインサートになっていないのを見て、捨てた
  2. POST /initialize
    1. CREATE TABLE player SELECT * FROM init_player
    2. 作ったテーブルに PK や KEY を追加する

となった。

競技後に Discord や参加記で知ったことで「SQLite の journal mode は Rust だけが WAL になっていたので有利かも」などあったが、僕達は結構即座に MySQL にしてしまったのでその恩恵は分からずじまいだった。

flock を剥がす試み

メタ読みとして、どうせほとんど or 全ての flock は剥がせるんだろうなと思っていたが最後までよく分からなかった。

  • チームメイトが flock 箇所を全て MySQL のトランザクションに書き換えたことろ Deadlock が頻発したので剥がした
  • ロックに Redis を使おうとしたが、正しく実装し切るには結構時間がかかりそうだと思ったので辞めた。素振り不足

その後は lock 内部の処理を十分に高速にすればまあ良いだろうと思って N+1 を直すことに集中した。

最後の方(17時くらい)に flock を利用しているエンドポイントとそれ以外のエンドポイントを nginx で別ホストへ振り分けようとしたが、ベンチマークが通らなかったし時間も残り少なかったので取りやめた。
この時には App のアクセスログを OFF にしてしまっていたので、エンドポイントの振り分けが意図通りになっていたのかすら見えておらず、もうちょっとデバッグ方法あったなと思っている。

感想など

14時ごろに MySQL への移行が完了してスコアも少し出たくらいの段階で「予選突破できそうやな」と思ってしまい、その後はプレッシャーでずっと胃が痛かった。この経験はしたことない。

去年の ISUCON 予選も Rust で出ており、積極的にインメモリキャッシュを活用する路線で行った結果よく分からないエラーで苦しんだ。
後からの追試で、どうやら sqlx で select する対象とマップ先の struct がずれている(?)みたいな気持ちになったが、結局詳細は追えていない。
この経験が少し尾を引いて、今年は結局一つもインメモリキャッシュを利用しない実装となった。
本戦までにはインメモリキャッシュ使っても良いよという気持ちに切り替えておく。

チームとしての戦略は

  • ひたすらに手が早い omu に自明を潰してもらう
  • 丁寧にいろいろなメトリクスを見てくれる dice にいろいろ見てもらう
  • 僕はなんか非自明なことする

みたいな感じになっていたようだ。
もうちょっと非自明改善を素早く通す力をつけておきたい。

Discussion