diary.sorah.jp

ISUCON 13 参加記 (白金動物園)

白金動物園の sorah です。優勝した ISUCON 9 以来 Writer や Admin (アドバイザー), ポータルのメンテナ係として運営に幽閉されていて長いこと選手をやっていませんでしたが、今回ひさびさに選手として参加したのでその参加記。

結果としては 18 位で 103,838 点で終了。10 万点到達の着順ではわりと早かったチームになれたものの、その後伸ばせず、かなり悔しい。 https://isucon.net/archives/57993937.html

一方で Ruby を利用して NameError や NoMethodError による fail を高速に繰り返していたため「ベンチマーク Fail 回数の多い 3 チーム」として 53 回で TVer からスポンサー賞をもらいました。我々より Fail 数が多いチームは Go だと思うんだけど、コンパイラがそこそこ怒ってくれる Go でどうやって我々以上の Fail を出すんだ…? と疑問でならない (聞いてみたら SQL syntax error とからしい)。

コードはここ。参考用に Rust, Go のコードもチェックインしているけど、Ruby しか使ってません: https://github.com/shirokanezoo/isucon13

そして既にチームメンバーのブログも上がっているので先にリンクを張っておく:

そして、Ruby を使ったチームとしては最も点数が高かった模様 https://isucon.net/archives/57995340.html 。上位 30 チーム内唯一というのも驚きがある。

所感

問題自体は、思うところは少なくないものの楽しめた。予選と本選に分けるのをやめたことを踏まえると良かったと思う。特に DNS 権威サーバーを運用させられたのがトレンド…というか writer の怨念を感じられて良かった。次は SMTP をやりたい。

運営については… ポータルを作り直した挙句品質がだいぶ低下しているというのは少し信じがたく失望している。自分が残したポータルが Rails + React でみんな Go だったりする中メンテするのは大変…という話かもしれないけど、結局 Django だったようだし、その上で使いづらくなってる以上にベンチマークキューが壊れてるのは論外だと思う。なんで作り直した?

とはいえ自分もポータルでやらかしてるからあんまり人の事は言えない。ただ使い続けるなら設計変えればいいという遺言を残したつもりだった。それでもベンチマークキューの公平性が欠ける状態にはしなかったし、必要なら競技も延長した。そこについては守るべきポイントだと信じているので、一番残念だったところ。

この辺のお気持ちについては後述。というわけでここからは詳細に触れていきます。

チームの基本方針

縛りプレイをしているつもりはないのだけれど、実質的にはそう思われても仕方ない参加をしている。

  • 可能な限り Ruby 以外の言語を利用しない (戦えると信じているので)
  • TTL が入るようなキャッシュは可能な限り避ける ('キャッシュは麻薬' だと思っているしバグの温床になりがちなので)

今回は Redis のインストールすら断ってやっていました。MySQL で出来るじゃん! と返してしまった。

敗因

先に現状考えられる敗因を述べておくと、ベンチマーク時間中の負荷が頭打ちしてしまっていた。おそらく POST /api/register のパフォーマンスが悪く、トータルのユーザー数が伸びなかったため。その相関に気付くのがだいぶ遅れてしまった。

詳細は後述。序盤に PowerDNS を手癖で捨て、nsd + ゾーンファイルに移行したものの、ゾーンファイルへの書き込みが直列である・反映を待たないとベンチマーカーが失格にしてくることがある、という理由でその待ちで遅いという状態だった。

DNS 水責め対策が出来た時点で PowerDNS の MySQL データベースに直接書き込みにいくように戻しても問題なかったと思う。もう少し信じてあげるべきだった……。

もう一つは icons を static file に変えたけど 304 の生成で結局 Ruby にやらせることになっていて Ruby サーバープロセスの CPU 時間を奪ってしまっていたこと。これなら最初から静的ファイルに落とさず ETag をその仕様で生成した上で proxy_cache すれば良かった。

やったこと

構成

  • rproxy=1 (nginx)
  • dns=1 (nsd)
  • app=1,2
  • db=3

index の追加

主に @mirakui がやってた。下記のカウンタキャッシュによる最適化で追加されたカラムや、不要なテーブルをマージした関係で初期実装から変更されたカラムもあるけど、以下の通り

alter table users add index idx1 (score, name);
alter table icons add index idx1 (user_id); -- テーブルはその後廃止
alter table themes add index idx1 (user_id); -- テーブルはその後廃止
alter table livestreams add index idx1 (user_id);
alter table livestreams add index idx2 (score asc, id asc);
alter table reservation_slots add index idx1 (start_at, end_at);
alter table livestream_tags add index idx1 (livestream_id);
alter table livecomments add index idx1 (livestream_id, tip);
alter table livecomments add index idx2 (livestream_id, created_at desc);
alter table ng_words add index idx1 (user_id, livestream_id);
alter table reactions add index idx1 (livestream_id);
alter table reactions add index idx2 (livestream_id, created_at desc);

カウンタキャッシュ

主に @rosylilly がやってた。

  • users に total_tips, total_reactions, score を追加
  • livestreams に total_tips, total_reactions, score を追加

ランキングで sorted set つかいたいと言われたけどわたしが Redis まだ不要! MySQL でやっていける! と蹴りました。

N+1 Elimination

N+1 あまりにも多すぎるだろ……。@sorah と @rosylilly で手分けしながら極力排除した。INNER JOIN するか WHERE IN 句で preload、いずれの場合もある。

icons の静的化

icons をファイルシステムに書き出すようにして nginx から static file として返せるようにした。

ETag については独自仕様だったため if-none-match: ヘッダがある場合は nginx ではなく app に回して 304 もしくは 200 + x-accel-redirect を返却するエンドポイントにルーティングした。

これによって 304 が正しく返るようになりスコアが微増したものの、あくまでも微増に留まったのは if-none-match の処理で結局 app サーバープロセス (puma) の時間を取られてしまったから。

ここに関しては正直 ETag を sha256 の仕様で生成して proxy_cache した方が賢かったなと思っている。

PowerDNS 廃止

https://speakerdeck.com/kazeburo/dns-water-torture-attack-monitoring-and-slo で PowerDNS は MySQL バックエンドでも辛さが残ったので zone file になったと書かれていた記憶もあり、初手で PowerDNS をやめて慣れた nsd に入れ替えた。

@sorah は Unbound, nsd, PowerDNS 全部運用経験があるけど、PowerDNS はそこまで慣れてない…という理由もあって一旦やってみたというところ。

ただゾーンファイルの更新 (追記) で flock、また nsd-control reload の完了を待ち合わせたりした結果 /api/register が 0.1-0.2s とあまり速くはない状態になってしまった。その点では PowerDNS のままでも良かったかもしれない、と今になっては思う、が、/api/register が負荷レベルにどう繋がるのかの理解が遅れたという点が根本的には致命的なミスだったのだろう。

DNS 水責め対策

dnsdist を nsd のフロントに置いてフィルタリングを実装した。実際のクエリを tcpdump して眺めたところ、望まれないクエリについては下記の特徴があった:

  • QName に含まれるラベルの数が長い ({x}.{y}.u.isucon.dev.)
  • そもそも username 部が長い ({username}.u.isucon.dev.)

そのため、 dnsdist.conf を https://github.com/shirokanezoo/isucon13/blob/main/dnsdist.conf のように設定した

  • QName にラベルが 5 以上含まれている → パケット破棄
    • FQDN としてラベルが 5 以上含んでいるレコードを作ることはないため
  • QName 総長が 33 バイト以上 → 全体で 2 qps に制限
    • select max(length(name)) from users で判断した

この対策を実装する場合はそもそもクライアントにエラーすら返してはいけない、また NXDOMAIN かどうかで判定させるためにクエリを転送するのも微妙、ということでこのようなルールに。

今回のベンチマーカーの挙動からして、クエリ結果が返ってこなければ水責め攻撃の負荷レベルが上昇しなかったので、これで十分問題なかったと思う。逆に言うと NXDOMAIN ですら返してしまうと負荷レベルが上がります。遅延させる必要すらなくて、出来るだけ破棄するのが良く効いた。

感情

以下、良い感想と悪い体験の感想。全体として、運営は大変だとは思ってるんだけどそれは品質に対する言い訳にはならないと思っているので、率直に思ったことを書いています。

ポータル・ベンチマークキュー

最初にも書いたけど、なんで作り直した?

作り直すのはいいけどそれで品質落ちてるのは判断として誤りとしか思えない。特にベンチマークキューが壊れていて公平性にも欠けているのは論外だと思う。設計のセンスも、(作り直すかどうかを含めた)技術選定・判断のセンスが無い。

個人的には毎年作り直すのはナンセンスだと思っていて、貴重な運営のリソースをそこに割くべきとは到底思えないから。

ただ、自分が残した isucon/portal は使い続けるなら (参加チーム数が多い場合に) leaderboard のパフォーマンスがよくないというのはあって修正の余地があるけど、そんな根本的に壊れている訳じゃないはず。Rails と React が分からないから作り直したとしか思えないんだけど、結局でも Go じゃなくて Django だし、Django なら今後運営に入る人間もめっちゃメンテできますみたいな感じにはならないと思うのでモチベーションも理解できない。

ベンチマークキューの公平性がないというのは、特にベンチマーカー < チーム数の場合に大きな問題を起こすと思う。作り直すとしても過去の仕様がなぜそうなっているか質問する機会も調査する機会もあったはずなのに、それを怠って品質を落とした、という解釈しかできない。残念です。

追記: ブログ公開前に https://twitter.com/chibiegg/status/1729370754465345776 が発言されていたのを今 (12/6) 知った。キューの設計に問題がなかったら 16 時前後に enqueue をやめてくださいってアナウンスをする事にはならなかったでしょ。これは portal と supervisor を分けて開発してるという点で難しいところだけど、両方の設計ミスであることは明らかだと思う (↓を見てね)。使いやすい道具を使ったほうがベターというのは同意するけど (その上で、品質を担保できないなら作り直しは自分なら選ばず Python でも Go でも PHP でも書くけど… IOI 2018 のときはそうしたし)。なお、作り直しではなく ISUCON 9 からの改造と言っても, 8 以前のポータルで考慮されていた運用が無い、という時点で結果としてデグレードしてる事実に変わりはないかな。

キューが壊れていたことに対する考察

  • なぜか supervisor が SQS からの受信を別 goroutine でバックグラウンドで行っている。なぜ?
    • chan に送った後、そのジョブの処理が進んでいるかいないかにもかかわらず chan が埋まるまで無限に sqs:ReceiveMessage, sqs:DeleteMessage を行う挙動になりそう。 https://github.com/isucon/isucon13/blob/d33a72acdb4029f1ca53ccbe90ff5f2348c8e5cc/bench/cmd/bench/sqs.go#L143
    • これは公平性のない動作になるため本当に良くない。どのベンチマーカーのタスクで受信されたかに依存する。そのタスクの上で chan から pop されて実行中のジョブが、完走するのか早期に終了するのかで待ち時間が変わってしまう。
    • たとえば、後に Enqueue されたベンチマークジョブが仮に chan の中が即座に Fail するようなチームのジョブで埋まっていた場合、先に Enqueue されたベンチマークジョブより先に完了する可能性がある。
    • Availability Zone 分散している以上、ベンチマーカータスクレベルでこの問題を回避したとしても完全には取り除けないけれど、スケールアウトもちゃんと可能になるしこの挙動はどのみち無いでしょ、という感想になってしまう…。
  • というか、即座に sqs:DeleteMessage するような使い方をするなら AWS SQS である必要がないと思う
    • 実際、SQS を意味もなく利用した結果キューの中身がコントロールできず、「ベンチマーカーデプロイ時に運が悪いとタイムアウトするまで何もできなくなります」というアナウンスが流れたのはこの設計ミスのせいだろう。
  • 即座に着手しないジョブを ReceiveMessage して DeleteMessage を実行、その上でメモリ上のみに保持するという仕様のため「ベンチマーカーデプロイ時に (プロセス再起動するので) 運が悪いとタイムアウトまで何もできなくなる」になったのだろう。ひどい話だ…。
    • この仕様の場合でもポータルの RDBMS 上にジョブのデータは登録されているはずなので、一括キャンセルはできたように思うが、キャンセル済みのジョブも実行されてしまうような仕様も相俟って運用不可能になってたと推察している
    • そもそも supervisor をデプロイのために再起動・停止する必要がある仕様がおかしくない? 競技中のベンチマーカーの更新は想定できるはずだし、過去の実装でもそうなってるのに、どうして…。
  • というか良く見ると実行結果に関しても通信に失敗すると結果もログも破棄する処理になっているように見えるし (supervise.go)、これ本当に何も問題なくて良かったですね
    • というか個人的には競技終了前にログを意図的に破棄する挙動のほうがだいぶありえないんだけど、ECS で動作してるならある程度はしょうがないか
    • せめて stdout/err に吐いてログドライバに拾ってもらってないと不安にはなるかな

If-None-Match

ISUCON 4 で Cache-Control まわりで散々叩かれたので Conditional GET を利用した出題についてはめちゃめちゃ気持ちがある弊チームです。isucon/isucandar にも丁寧な実装してますし……。

今回の問題ではユーザーのアイコンを取得する場合に user agent が自分で計算した値を ETag として If-None-Match ヘッダに設定して送出しますという仕様でした。現実の user agent である Web ブラウザはサーバーが送出した ETag を無視して勝手に自分で別の値を引っ張ってくることはしないので、それを問題に組み込んだのは結構センスがないなと感じています。

これを問題にするのであれば ETag の値は icons.icon_hash の値になってなければいけないという仕様にして、レスポンスヘッダで ETag が含まれている場合はその検証をするべき。そうすると現実の user agent の挙動との一貫性は保たれるし自分としては(厄介な仕様にしやがってとは思うけど)疑問は抱かないです。

壊れたフロントエンドと動作確認

今回だと新規登録が完全に壊れているのはさすがに…というところ。フロントエンドが壊れていると手元での動作確認が困難なので、簡易的なチェックについてはベンチマーク実行を通さずにできてほしいという気持ちが強くなった。どうせ最終的なチェックはベンチマーカーがするのだし。

一方で完全にローカル完結で動作テストができてしまうと結構ゲームの流れが変わると思う。そういう意味ではベンチマーカーと独立して動作チェックボタンがあったほうがやっぱりいいのかな…自分ならそうする。個人的にはこれはベンチマーカー数 < チーム数、にどうしてもなってしまう最近の ISUCON だと必須だと思っていて、結果としてベンチマークキューの伸びが抑えられると思うし、あってほしいと感じる。

特に動作チェックだけなら AWS Lambda とかで実行できるサイズ感だと思うので、同時実行数も待ちもいい感じに出来るはず。

当日結果発表

当日である必要は正直ないと思うけど、リブート後のチェックの自動化を頑張った形跡を確認してシンプルに努力がすごいと思った。この取り組みは継続できるといいですね…。

(なので、ポータルに時間割くんじゃなくてこういうベンチマーカーの細かいところや品質に力をかけるべきという気持ちなんだよね)

利用言語差

今年の利用言語比率も圧倒的に Go だった。 https://isucon.net/archives/57995340.html 特に上位 30 チームになると、我々以外全員 Go。まじかよ。

@rosylilly も書いていたけど 言語ごとの賞、悪くないと思う。いろんな言語があってその多様性が良いと思っているので、言語ごとに競ってみるのも悪くないと思う。どうしても今は勝つためなら Go だよねという空気感になりすぎていると思うけど、実際仕事で Go + ISUCON で利用するテクニックをめちゃめちゃ使うかって言ったらあまり使わないでしょ? 飛び道具を抜きに慣れた道具で戦うの、楽しいよ。そういう意味でわれわれのチームは基本的に Ruby しか使わないという縛りをしているつもり。

自分は Go 書くくらいなら Ruby 書いてる方がいいし、そうじゃないなら Rust を書くわっていうのもあるけど。どうせ nil pointer dereference で怒られるなら Ruby もたいして変わらんし、優勝できたし、また Go 以外でちゃんと優勝できる実績を残したい。

総合入賞とまではいかなくても、他の言語にももうちょっと輝いてほしいので、言語賞、たのしいんじゃないですかね。

オフサイト参加

数少ない LINE ヤフー社オフィスのオフサイト参加枠を、過去優勝チーム招待でいただいた。ちょうどリモートでやるか、どこか借りるか…みたいな話をしていたところなのでありがたい。

(弊社のオフィスは今は休日の空調が不自由だったり場所も遠かったりで候補に入れられなかった)

オフサイト参加、やっぱり終わった後すぐ他のチームと感想戦できるのがいいですね。めちゃ楽しかった。ありがとうございました。

運営

冒頭に書いた通り 2020 年の ISUCON 10 からしばらく運営メンバーになっていて選手として競技参加することはなく、久々の選手としての参加になった。運営もたのしいけど、やっぱりランキングに載る形で参加するのって楽しいですね。

その一方で、先に書いたように競技として品質が低いところがどうしても目についてしまう。これは競技としての ISUCON に気持ちがあるという話でもあるけど、ICPC アジア地区大会の手伝いや、日本で finals を開催した国際情報オリンピック (IOI 2018) でコンテストシステムを含むインフラを全て面倒みていて、もっと strict な競技運営をしている経験が悪くも生きてしまっているんだと思う。悪い側面だと思うので困ってる。

そして競技としての ISUCON の側面の維持に気持ちがある人がそんなにいない…という問題もあると思う。一応賞金もあるし、気持ちよく勝負するという意味で公平な競技を運営するべきだと思っているんだけど、やっぱりお祭り感だけで運営してる人の方が多いなぁというところはちょっと悲しいです。ここはわたしがズレてるだけなのか? 間違ったこと言ってないと思うんだけど……。やっぱり今年の様子とか見てるとあんまり理解されてないな? わたしがおかしいのかな? と思ってしまうところはある。

そういう意味では運営に継続して身を置いてクオリティコントロールに努めたほうが自分のストレスに繋がらないから良いと思うんだけど、特定 1 人がやりつづけるのも健全とは思っていないので難しい。正直選手にいても運営にいてもこの点で常に何かしらのストレスになっているので、運営をやりつづけるか、選手も運営もやらないかの 2 択が自分にとっては現状楽になってしまっている。今年はチームリーダーのやる気を見て選手に戻ってみたし、また来年も選手が良いって言っているし、自分も悔しいのでまた優勝したいんだけど、本当に気持ちがありすぎて難しい。

ポータルのメンテナンスとかなら運営に直接入らなくても出来るとは思っているのだけど。そして、ドキュメンテーションも足りてないと繰り返し引き継ぎを行うなか感じてはいて、この部分はこういう仕様であるべき、理由はこう、みたいなのをうまく言語化してまとめたい…とは思っているんだけどなかなか進められない。 isucon.slack.com の #forge-feed でやろうとしてるんですけど、誰か手伝ってくれないかなあ…。いや自分がまず手を動かさないといけないんですよね、はい……。

タイムライン

  • 10:18, Git リポジトリ化。初期スコア 4,081
  • 11:07, dns=1 proxy=1 app=1,2 db=3 という構成になる。tags がハードコードされる. スコア 4,673
  • 11:27, icons (user_id), livestream_tags (livestream_id) に index が追加される スコア 8,996
  • 11:42 スコア 11,768
    • themes (user_id), reactions (livestream_id),reservation_slots (start_at,end_at), livecomments (livestream_id, tip) に index が追加
  • 12:34 スコア 20,160
    • icons テーブルが users テーブルにマージされる
    • icons が nginx から静的に返却されるようになる
  • 13:41 スコア 50,089
    • themes テーブルが users テーブルにマージされる
    • score, tips, total_reactions にカウンタキャッシュが導入される
    • statistics が ↑ を利用するようになる
    • PowerDNS (mysql) が nsd (zone file) に変更される
    • コメント投稿時の NG ワードとのマッチを MySQL の LIKE 句で実行するのをやめる
  • 14:48 スコア 88,874
    • users (score, name), livestreams (user_id), livecomments (livestream_id, created_at desc), reactions (livestream_id, created_at desc), livestreams (score asc, id asc) index が追加される
    • fill_livestream_response で livestream_tags の N+1 を解消
    • livecomment 取得で users の N+1 を解消, INNER JOIN するように
  • 15:24 スコア 92,110
    • livecomments, livecomment_reports の N+1 を解消
    • users への N+1 を fill_livestream_response, fill_livecomment_response, fill_livecomment_report_response, fill_reaction_response で解消
    • reaction 取得 API での N+1 をだいたい解消
  • 15:47 スコア 106,432
    • icon の 304 を icon_hash を元に返却できるように
  • 16:06 スコア 111,243
    • 不要なトランザクション廃止
    • 500 エラーのバグ取り
    • たしかこの辺で DNS 水責め対策も実装された

Published at