くりにっき

フルスタックキュアエンジニアです

ISUCON11に1人チームで参加するためにやったこと #isucon

最初に

予選落ちなので勝者エントリを読みたい人はここで回れ右を推奨。

  • 使用言語:Ruby
  • 最終スコア:0
  • 最高スコア:5822

モチベーション

ISUCONの出題範囲であれば(多少濃淡あるけど)一応1人で全部できると思ってたので腕試しのために1人チームで参加しました。

ISUCON歴

ISUCON6(2016年)とISUCON9(2019年)にそれぞれ当時の同僚と参加したので今回が3回目。(どっちも予選落ち)

事前準備

準備期間

6月中旬くらいから本番を想定した素振り(1セット8時間)をやってたのでだいたい2ヶ月くらいです。

7月は毎週末8時間素振りをやって、平日の夜に感想戦をやってました。

そしてISUCON開催週は有給で全部休んでひたすら毎日10〜18時で素振りをしてました。

やったこと

ひたすら過去問をやっていました。

古すぎると今と傾向が違うので比較的新し目のをときつつ、予選と本戦では問題の傾向が全然違う(予選は割とオーソドックスだけど本戦はトリッキーな問題が多い)ので予選を重点的にやりました。

解いた問題数だけでいうと下記。(計13セット)

  • 6予選 x 1
  • 7予選 x 2
  • 8予選 x 2
  • 9予選 x 2
  • 9本戦 x 1
  • 10予選 x 3
  • 10本戦 x 1
  • 11事前講習 x 1

7月の週末と8月第3週目に草が濃いのはISUCON素振りのためです。 *1

f:id:sue445:20210822092120p:plain

いやまぁ、2ヶ月間で100時間以上使っても予選突破できなかったのでもう少し素振りの質を上げるべきだったかなと後悔。

事前に用意したもの&やったこと

Sentryのbillingを有効化した

普段 https://sentry.io/ は無料枠で利用してるんですが、ISUCONの素振りをやったら一瞬で無料枠を使い切ったのでTeamプラン(月26ドル)にしました

SentryのSpike Protectionを無効化した

Sentryには突発的にエラーが送られてきた時にそれをブロックして課金を抑えるSpike Protectionという機能がデフォルトで有効になってます。

blog.sentry.io

通常はこれは有効でいいんですがISUCONの場合この機能を有効にしてるとベンチマーク実行時のエラーがSentryに飛んでこないということがあったので無効にしました。

ちなみにSpike Protectionはプロジェクト単位ではなくorganization全体の設定なのでISUCONのチーム専用のorganizationを作ってそこで無効化するのがいいと思います。

f:id:sue445:20210822210805p:plain

デプロイスクリプト

素振りの度にリファクタリングしつつ、実際にISUCON11の予選で使った最終版は https://github.com/sue445/isucon11-qualify/blob/main/Rakefile になります

デプロイスクリプト解説

自分がRubyで参戦するし書き慣れてるのでRakefileを使いました。Makefileと違って動的にタスクを書きやすいのが特徴。

ここまで重厚になるとさすがにcapistranoでええんちゃう?って気持ちになるんですが、スニペットリポジトリからコピペするだけで使えるの便利なんすよね、、、

ローカルで rake を叩くだけで git push -> 本番全台で git pull & 必要なデプロイ処理を全部実施しつつ、スコア記録用のissueに下記のようなコメントをつけるところまでやってます。(ベンチマーカーで出たスコアは手でissueに貼り付けてる)

f:id:sue445:20210821213901p:plain

実際にスコア記録用に使ったissueは https://github.com/sue445/isucon11-qualify/issues/1 なのでこれ見てもらうと当日の雰囲気は伝わると思います。

こだわりポイント

  • 複数台に直列でデプロイすると遅いので multitask で並列デプロイできるようにしてる
  • サーバ全台にデプロイ後に /initialize を叩いて簡単な動作確認までやってる
    • ただ、今回のISUCONだとパラメータ無しで /initialize を叩けない仕様だったので /initialize_from_local というエンドポイントをはやしてそいつをRakefileから叩いています
  • /initialize まで終わったら 20210821-183139-sue445 のような日付ベースのtagを作ってpush
    • 最初はデプロイしたリビジョンだけissueにコメントするようにしてたんですが、それだと直前のデプロイからのcompareが取りづらいのでタイムスタンプベースのtagにしています
    • 実際に直前のデプロイからのcompareリンクを作ってるのは https://github.com/sue445/isucon11-qualify/blob/main/Rakefile#L166-L171
    • issueの最新のコメントに書かれてるリビジョンをparseしてもよかったんだけどこっちの方が手軽だったので採用
    • 昔の癖でtagにデプロイした人の名前を入れたけど1人チームだったので全然ありがたみはなかったw

スニペット

https://github.com/sue445/isucon11-qualify/tree/main/ruby/confighttps://github.com/sue445/isucon11-qualify/tree/main/infra開始直後にスニペットリポジトリからコピペした もので、configは必要に応じてrequireして使ってました。

過去問を解いてる過程であると便利だったユーティリティが https://github.com/sue445/isucon11-qualify/tree/main/ruby/config に集約されています。

スニペット系は元々個人esaに置いてたんですが、数が多くなってきたしテストコード書いてCIも回したくなってきたので専用のリポジトリを作りました。

その中からいくつかピックアップ

enable_monitoring.rb

アプリケーションコードを一切修正しなくてもファイル先頭でこれをrequireするだけでNewRelic, Sentry, Stackprofを有効化できる便利モンキーパッチ

NewRelicは便利なんだけどそれなりに重いのでどうしてもスコアが下がってしまいます。(過去問解いた感じだとNewRelicほどじゃないけどSentryもちょっと重かった)

設定が複数箇所にあると無効にするのが大変なので、ISUCON終了直前に一瞬で全部オフれるようにこのような構成になってます。

class Sinatra::Baseclass Mysql2::Clientオープンクラスしてモンキーパッチ仕込んでるのが個人的なおすすめポイントです。

実際の終了直前対応は https://github.com/sue445/isucon11-qualify/commit/fd049a5834a03df9705c83c05ff2b3f86042e6ae になんですが、アプリだけでいうと enable_monitoring のrequireをやめるだけですんでいます。

nr_mysql2_client.rb

https://github.com/newrelic/newrelic-ruby-agent は標準でmysql2をサポートしていません。

そこで下記エントリを参考に色々魔改造したのがnr_mysql2_clientです

ohbarye.hatenablog.jp

元のソースからの差分は下記

  • SQLのクエリに含まれてるテーブル名を正規表現で全部parseしつつ、joinやサブクエリなどで1つのSQL文で複数のテーブルが登場する場合には reservations,users のようにカンマ区切りで連結した文字列を返却
  • 実際に実行されたSQL/tmp/sql.log に出力
    • スロークエリログとるまでもないけど雑に見たいクエリ見たい時に便利
  • mysql2経由ではなく system "mysql ~" のようにRubyのアプリから直接システムのmysqlコマンドを叩いた時にもNewRelicにメトリクスが送ることができるようにした
    • 具体的には with_newrelicのブロック の中で system "mysql ~" を実行
    • 普通に考えたらアプリから system "mysql ~" を叩くなんてまずないんですがISUCON8の予選の過去問解いてた時に取得したレコード数が多すぎ *2 てpumaがメモリを食いすぎてサーバがOOMで死ぬということがあったのでそういうことも一応想定してました *3

最初の頃は

# Mysql2Client = Mysql2::Client
Mysql2Client = NRMysql2Client

def db
  Mysql2Client.new(
    host: @host,
    port: @port,
    username: @user,
    database: @db_name,
    password: @password,
    charset: 'utf8mb4',
    database_timezone: :local,
    cast_booleans: true,
    symbolize_keys: true,
    reconnect: true,
  )
end

のようにしてコメントで NRMysql2Client を使うかどうか切り替えてたんですが、面倒くさくなってきたので最終的に前述の enable_monitoring.rbclass Mysql2::Client に直接モンキーパッチ仕込むようにしました

redis_methods.rb , memcached_methods.rb

Rails.cache.fetch が好きすぎて with_rediswith_memcached のようなメソッドを作ってます。

Rubyだとmemcachedクライアントの dalli がバイナリプロトコルをサポートしてるのでRubyのオブジェクトをそのまま保存できるんですが、redisだとそれがない *4 のでちょっと苦労しました。

でそれぞれベンチマークをとった結果、ojが一番速かったのでredisにRubyのオブジェクトを保存する時はojを使うようにしました。

ベンチマークの結果は下記です。

github.com

nginx.conf

設定はいたって普通なんですが

include /home/isucon/webapp/infra/nginx/sites-enabled/*.conf;

のように /etc/nginx/nginx.conf から直接 /home/isucon/webapp 配下のconfをincludeするのが個人的なマイブームです。

チェックリスト

ISUCON11の事前講習 で講師の人が「『nginxのworker_connectionsが1024になってるか確認』ぐらいの細かい粒度でチェックリストを作るのがいい」と言ってたのでそれにならってチェックリスト作りました。

esa-pages.io

個人esaにチェックリストのテンプレを作って過去問を解く時に利用し、ちょいちょい更新していったら最終的にこんな感じになりました。

サーバ構築用のItamae

過去問だとサーバ1台構成である程度動かせた後にAMIを作って2台目以降はそれベースで作っていたので、個人esaに構築手順のコマンドをメモってコピペで構築していました。

しかし直前に「当日はCloudFormationを使ってサーバを構築する」というアナウンスがありました。

運営の人に質問したところCloudFormation以外でサーバを作ってもいいけどサポート対象外だしあまり推奨しないという旨の回答をもらったので急遽構築手順をItamaeでコード化しました。

github.com

このItamaeでやってるのは主に下記です

  • 各サーバ毎にhostnameの設定
  • 必要そうなpackageのインストール
  • 必要に応じてserviceの有効化と無効化
    • 自分はRubyで参加のためRubyの参考実装のserviceのみItamaeで有効化してる
    • redisとmemcachedもインストールはするけど使う時まで無効にして、デプロイスクリプト側で有効化
  • /etc/security/limits.confsoft nofilehard nofile を65536に設定
  • /etc/newrelic-infra.ymlenable_process_metrics (起動してるプロセスのメトリクスを送信)を有効化
  • /lib/systemd/system/mysql.serviceLimitNOFILE=65535 を設定
    • これをやらないと max_connections が増えなかったので脳死で設定
  • /home/isuconssh秘密鍵を作ったり自分のdotfilesリポジトリからtigやtmuxの設定ファイルをダウンロード
  • 最新のRubyをインストール
    • 今回の予選は実行環境に3.0.2がインストール済だったんですが、過去問のAMIだと当然Rubyのバージョンが古いです。素振りの時にRactorが使えないかの検証もやりたかったので過去問も常にRuby 3系でやってました

過去問素振り用のTerraform

個人のAWSアカウントで素振りするために下記のようなTerraformを書きました。

gist.github.com

Terraform 1.0.1, Terraform Provider for AWS 3.25.0で動作確認してますが多少古くても大丈夫なはず。

VPC, Subnet, SecurityGroup辺り全部入りなのでこれを既存のTerraformのリポジトリにコピペすればだいたい動くと思います。

過去問毎に想定スペックがまちまちだし前述の通り当初は1台ずつサーバ起動する想定だったのでEC2はTerraform化していません。

【おまけ】stackprof-webnavをRuby 3.0対応した

github.com

Ruby 3.0で素振りをしてた時にstackprof-webnavが動かなくて困ったのでいくつかパッチを投げました。

github.com

github.com

github.com

無事マージされてISUCON直前にRuby 3.0対応版がリリースされています。

github.com

余談ですがgemリリースが間に合わなかった場合を想定して自分のPR全部入りの状態で手元でビルドしてISUCON当日に gem install --local して使う手順も一応用意してました

当日やったこと

予選落ちなので多くは語りません。

リポジトリは公開してるのでコードの意図を知りたい場合はブログのコメントやTwitterとかで聞いてください。

github.com

敗因は下記

  • sinatraで返してる静的ファイルをnginxで返すのに苦労した
  • 途中から急に context deadline exceeded (Client.Timeout exceeded while awaiting headers) が出始めて原因調査に時間を取られた
    • 原因はstackprofだったんですが、まさか初手で入れてるstackprofのせいで途中から動かなくなるとは思わなかった。(過去問の素振りでも一切問題なかったので余計にハマった)

1人チームで参加した感想

過去に複数人チームで参加した時と比べて下記のようなメリデメがありました

1人チームのメリット

  • 他のメンバーとの共有作業が不要
    • 複数人だとぱっと思いつくだけでリポジトリAWS、NewRelic、Sentry、作業時のチェックリストやスニペット集あたりの共有が必要だと思う
  • コードレビュー不要
    • 普通のチームだとトピックブランチ作ってpushしてPR出してレビュー後にマージという流れになるが、1人チームだとレビューしようがないしブランチ作るのも面倒なので必然的にmainブランチでガンガンコミットしていくことになる
  • 複数人での認識共有や意思疎通などのコミュニケーション作業が不要
  • 他メンバーの予定を気にせずに好きな時間に好きなだけ素振りができる
  • 実行環境を壊しても誰にも迷惑かけない
    • ローカルでアプリを動かす必要がないのでガンガンデプロイしてガンガンベンチまわせる

1人チームのデメリット

  • 1人

おまけ:素振りで使ったAWSの費用

ISUCON終わってAWSの個人アカウントの予算アラートの設定値も元に戻すのでその前に記念スクショ

f:id:sue445:20210823094917p:plain

f:id:sue445:20210823094932p:plain

*1:https://github.com/sue445

*2:https://github.com/isucon/isucon8-qualify/blob/master/webapp/ruby/lib/torb/web.rb#L423-L459

*3: https://isucon.net/archives/52520045.html によるとmysql_use_resultって選択肢もあったんだけどmysql2でmysql_use_resultを使う方法がよく分からなかったのでアプリから SELECT ... INTO OUTFILE を使ってた

*4:redisに保存すると基本的に全部stringになる

*5:後述のstackprof-webnavで必要だった