ISUCON12本選のベンチマーカーをGoで実装した話
この記事は Go Advent Calendar 2022 3日目の記事です。
今年は ISUCON12 の本選の作問に参加させていただきました。このとき、ほぼはじめて Go を使ってアプリケーションを実装することになりました(筆者は普段は Rust や Scala のエンジニアをしています)。この記事では、そのときに何をやったか、どういった実装の工夫を取り入れたかについて思い出せる限りで紹介したいと思います。また、ISUCON13 以降のベンチマーカーの実装者の参考になれば幸いです。
前提とお断り
この記事では、ISUCON [1] の競技性やルールについては既知のものとします。
ならびに、とくに運営や他のチームメンバーに確認することなく筆者が当時のことを思い出しながら書いています。非公式の思い出記事だと思っていただければ幸いです。
Go 周りの解説は初心者ゆえ使い方が間違っている可能性があります。一歩引いてご覧ください。
今年のお題
時間経過で椅子が増えるソシャゲです。ユーザーはログインすると、いくつかのプレゼントを受け取り、そのプレゼントの効能に応じて画面上の椅子が増えていく仕組みです。詳しくは下記の記事をご覧ください。
ベンチマーカーの概要
ISUCON のアプリケーションの概要
今回は2つのアプリケーションを取り上げます。
- 「webapp」: 競技中にチューニング対象となるサーバーです。Go で参考実装が提供されているのと、Rust や Perl など別の言語に移植されたものも同時に提供されます。
- 「ベンチマーカー」: 今回筆者が一部担当したアプリケーションです。上述の webapp に対してリクエストを送信し、負荷試験を実施します。
ベンチマーカーの処理フェーズ
今回私はベンチマーカーを担当したので、ベンチマーカーの処理フェーズについてさらに少し深掘りをします。まず、ベンチマーカーはさらに細かく分けると下記のステップをもちます。
- データ初期化フェーズ(prepare): ベンチマーカーのシナリオを走らせるために必要なデータをデータベースなどに投入するフェーズです。具体的には
/initialize
というエンドポイントが参考実装サーバーに用意されており、そこにリクエストを送って処理を走らせることが多いようです。 - シナリオテストフェーズ(load): アプリケーションに負荷をかける際に使用するシナリオを実行していくフェーズです。実際には、所定の順序で HTTP リクエストを送信して webapp 側のエンドポイントを叩き、レスポンスのレスポンスヘッダーやステータスコードが正しいかや、期待している JSON の形式でパースできるかを確かめるまでを含みます。
- データ整合性チェックフェーズ(validate): シナリオを実行後などに、期待したデータがきちんと入っているか確かめるフェーズです。
私が担当したのは、ベンチマーカーのアーキテクチャ全般と、シナリオテストのフェーズです。データ初期化のデータの準備やデータ整合性のチェックについては別の方に担当してもらいました。
ベンチマーカーの実装の特徴
ISUCON の参考実装、ならびにベンチマーカーは Go で実装されることが多いです。というわけでベンチマーカーもまず Go で実装されます。今回本選チームも Go で実装しました。
ベンチマーカーのリクエスト送信部分などの実装には「isucandar」というライブラリを使うととても便利です。このライブラリを使うと、ブラウザのように振る舞うエージェント、スタックトレースを複数階層持たせられるエラー、スコア計算機能、並列でリクエストを投げる際に利用できるワーカーなどの便利な機能を利用できます。とくにスコア計算周りや並列リクエスト周りなどは、自前で実装すると圧倒的にミスが増える箇所だと思うので、ライブラリでこの実装ミスを防げるのは大きいです。isucandar は使った方がよいと思います。
ワーカーを使ってリクエストを投げると書きましたが、この部分で goroutine
や context.Context
をはじめとした Go の並行処理周りのコンポーネントをたくさん使用することになるのもひとつの特徴です。そういう意味では、ベンチマーカーはある程度並行処理や並列処理について理解のあるエンジニアが書く必要があるという点で、実装が少し難しめといえると思います。ちなみに並行処理や並列処理そのものへの理解があれば、Go そのものや Go のそうしたパーツの利用経験がなくとも、Go の並行処理パーツそのものの設計のよさと isucandar の助けでちゃんと動くものが書けます。サンプルは私。
リポジトリ
ISUCON 12 本選のコードはこちらのリポジトリにあります。
「webapp」にはいわゆる参考実装が入っており、「benchmarker」にベンチマーカーの実装が入っています。
実装の構成
まず前提として、実装の構成は予選のベンチマーカーのものを参考にしてベースを実装しています。したがっていくつかの実装は予選のものと似通っています。が、たとえばスコア周りのデータ構造などは予選と仕様が異なったため改めて実装し直しました。
ベンチマーカーのルートディレクトリに入るとわかるのですが、ほとんどすべてのファイルがディレクトリを切られることなく羅列されています。Go のパッケージ周りは若干癖があるというのに序盤で気づいて、フラットにとりあえずファイルを作っておいて、あとから必要に応じて整理し直せばいいかと思ってそのままになった残骸です。結局整理し直す時は来なかったので、ある程度想定できるものはディレクトリを適切に切るなどパッケージ構成をあらかじめ考えてから作業を始めるのがよさそうに思いました。ちなみに全部 main
パッケージに入っています。
実装は大きく分けて3つにカテゴライズできます。
- prepare: webapp の
/initialize
エンドポイントにリクエストを送りつつ、ベンチマーカーのシナリオで利用するデータをメモリに読み込ませます。 - scenario 系: ベンチマーカーが実施するシナリオテストの手順を実装します。
- validation 系: シナリオテストを行ったのち、データベース上にあるデータが期待された値であるかどうかをチェックする処理を実装します。
その他、ユーティリティをまとめたものとして下記を用意しました。とくに代表的なものを紹介します。
- action 系: 各エンドポイントに HTTP リクエストを送る関数を集めています。
- request/response: action で使用するリクエストとレスポンスのデータを Go の構造体でもたせています。
- model: ベンチマーカー内でオンメモリにデータをいくつかもたせるのですが、そのデータを Go の構造体でもたせています。
エントリーポイント
ベンチマーカーの実行そのものについては、main.go
のメイン関数から実行されます。この辺りは Go を普通に利用するケースとそんなに変わらないはずです。実行時引数のパースなどの処理を経た後に、いよいよベンチマーカーの実行をします。
ベンチマーカーの実行自体は「runBenchmark
」という関数内で行うように実装しました。具体的には関数内にて、isucandar がもつ Benchmark という構造体のメソッド「Start
」から実行されます。
func runBenchmark(ctx context.Context, option Option) (*isucandar.BenchmarkResult, *Scenario) {
scenario := &Scenario{
Option: option,
ConsumedUserIDs: data.NewLightSet(),
}
benchmark, err := isucandar.NewBenchmark(
isucandar.WithoutPanicRecover(),
isucandar.WithLoadTimeout(LoadingDuration),
)
if err != nil {
AdminLogger.Fatal(err)
}
benchmark.AddScenario(scenario)
return benchmark.Start(ctx), scenario
}
「Prepare」「Load」「Validation」
仕組みを深く理解しているわけではないので間違ったことを書いているかもしれませんが、isucandar には次のようなインターフェースが定義されており、これらを満たすように実装すると「prepare」「load」「validation」[2]のそれぞれのフェーズが実行されるようになっています。
本選の実装では、この3つのベンチマークシナリオの実装は「scenario.go」というファイルがエントリーポイントになっています。このファイルに「Prepare」、「Load」そして「Validation」という関数がありますが、これらがインターフェースの定義を満たしています。つまり裏で isucandar が実行してくれます。
各シナリオの実行
「prepare」と「validation」については他のチームメンバーにほとんど実装してもらったため、私のメイン担当だった「load(負荷)」部分について解説を加えておきたいと思います。
シナリオの流れを記述する
負荷シナリオは大きく分けて4つのシナリオが用意されていました。各シナリオの詳しい狙いなどは本選講評のブログ記事を参照してください。ここでは概略を説明します。
- 既存ユーザーのログインシナリオ
- 新規ユーザーのログインシナリオ
- バンされたユーザーのログインシナリオ
- マスターバージョンの更新シナリオ: このシナリオだけは特殊で、ベンチマーカーの処理開始20秒後に発火します。発火後はマスターバージョンの更新がすべてのエンドポイントで必要になるので、新しいマスターバージョンを含むベンチマークのリクエストを再度送り直すことになります。
このあたりの実装は比較的単純で、上から順にシナリオのワーカーを生成する関数を叩いていき、最後に isucandar の worker.Worker
に詰め込むだけです。
// func (s *Scenario) Load(ctx context.Context, step *isucandar.BenchmarkStep) error {
// ...
// 各シナリオを走らせる。
loginSuccess, err := s.NewLoginSuccessScenarioWorker(step, 1)
if err != nil {
return err
}
userRegistration, err := s.NewUserRegistrationScenarioWorker(step, 1)
if err != nil {
return err
}
banUserLogin, err := s.NewBanUserLoginScenarioWorker(step, 1)
if err != nil {
return err
}
masterRefresh, err := s.FireRefreshingMasterVersion(step)
if err != nil {
return err
}
workers := []*worker.Worker{
loginSuccess,
userRegistration,
banUserLogin,
masterRefresh,
}
// ...続く
既存ユーザーログイン成功シナリオを例にとって
既存ユーザーのログイン成功シナリオを例にとって、実際のシナリオの流れを見ていきましょう。既存ユーザーログイン成功シナリオの中身は、さらにいくつかのパートに分かれています。
- ログイン処理をする
- ホーム画面表示
- インゲーム報酬受け取り
- プレゼントの受け取り
- ガチャを引く
- (開始20秒後)マスターバージョンの更新のシナリオが走る
これらのパートでは、対応する webapp のエンドポイントに HTTP リクエストを送る処理が走ります。その際 isucandar の Agent を使ってリクエストが送信されます。
以上が今回実装したベンチマーカーの実装の構成の概要です。大半のベースは予選チームのベンチマーカーを参考にしつつ、本選用にカスタマイズが必要なところは自分で考えたり、あるいは過去のベンチマーカーの実装で参考にできそうなものを参考にして実装しました。
マスターバージョンの更新というシナリオが20秒後に走りますが、ここで Go の並行処理周りのパーツがいくつか役に立ちました。次の節で、それらを一通り見ていくことにします。
ちょっと工夫してみたところ
初心者なりに Go の機能をいろいろ調べて工夫してみたところを紹介します。
iota
私がそもそも Rust や Scala のエンジニアなこともあり、いわゆるパターンマッチ的な機能を使いたいと思うケースがありました。具体的にはベンチマーカーを実行した際に発生するエラーのカテゴリ分けの機能の部分です。こうしたカテゴリ分けは enum を利用すると非常にすっきり実装できるのですが、Go には一見するといわゆる enum はないように見えます。
が、定数と定数生成器である iota
を利用すると Go でも enum に近いものを利用できるようになります。今回は uint をタイプエイリアスした ErrCategory
という型を作っておいて、iota
で自動的に値が導出されるようにしました。enum は言語によってはただの int 値に変換されたりするので当然といえば当然ですが、Go も同様の思想で enum に近いことを実現できます。
type ErrCategory uint
const (
UnexpectedErr ErrCategory = iota + 1
InitializeErr
ScenarioErr
ValidationErr
InternalErr
IsucandarMarked
)
各値の参照時も、参照したい先が何の値だったかを覚えておく必要はありません。InitializeErr
と参照すればあとは裏で等値比較をしてくれます。Go にも switch 文はあるので、これを利用してたとえば下記のように記述することができました。
for _, err := range result.Errors.All() {
category := ClassifyError(err)
switch category {
case InitializeErr:
initializeError = append(initializeError, err)
case ScenarioErr:
scenarioError = append(scenarioError, err)
case ValidationErr:
validationError = append(validationError, err)
case InternalErr:
internalError = append(internalError, err)
case IsucandarMarked:
continue
default:
unexpectedError = append(unexpectedError, err)
}
}
iota
は奥が深く、たとえばビット演算と組み合わせると、いわゆる複合状態を表現できたりします。たとえば権限で読み込み権限、書き込み権限があったとして、iota
をビット値でもたせるようにすると、ビット演算で両方の状態をもつ権限を表現できたりします。このあたりの小ネタは下記のスライドに詳しく載っていて、一度参照してみるとよいと思います。
リフレクションを使って少し整合性チェックを楽にする
整合性チェックの際、構造体のフィールドを比較して値が期待通りかをチェックする処理があります。チェックした後に、そのフィールドが違ったことだけをベンチマーカー専用のログに出力する必要がありました。このとき、フィールド同士を逐一比較する処理を書くと大変です。
構造体の diff をとるライブラリは一応いくつか存在していてそれを利用することも検討したのですが、ログを吐く先を上手に調整できず要件に合いませんでした。
いい方法なのかあまりわかりませんでしたが、Go のリフレクションを使って実装することを提案してみました。リフレクションで構造体の全フィールドを取り出して比較すればよいことは Java などでの経験からわかっていました。なのでこれを使えばいけるだろうと思い提案しました。提案、といっているのは、実装者は別だったためです。
実装は下記のようになったようです。このとき最近 Go に入ったジェネリクスを利用してみました。ちなみに今実装を読み返したら any
でもよさそうなのと、T
型同士を比較する処理はないはずなので comparable
インターフェースを満たしている必要はなさそうです。が、この関数を利用することで大幅に実装を削減することができました。
func Diff[T comparable](expected, actual T, b func() ValidationHint, ignoreWhats ...string) error {
expectedRefl := reflect.ValueOf(expected).Elem()
actualRefl := reflect.ValueOf(actual).Elem()
expectedStruct := reflect.TypeOf(expected).Elem()
for i := 0; i < expectedRefl.NumField(); i++ {
expectedField := expectedRefl.Field(i)
actualField := actualRefl.Field(i)
expectedWhat := strings.Split(expectedStruct.Field(i).Tag.Get("json"), ",")[0] //,omitemptyを除外した文字列
expectedKind := expectedStruct.Field(i).Type.Kind()
isCheck := true
for _, v := range ignoreWhats {
if v == expectedWhat {
isCheck = false
break
}
}
// チェックする必要があるstruct fieldか確認
if !isCheck {
continue
}
nextB := Hint(b().endpoint, b().what+expectedWhat)
if err := IsuTypeAssert(expectedKind, expectedField, actualField, nextB); err != nil {
// エラーをすぐに返したくない場合は、リストを外から渡すなどする。
return err
}
}
return nil
}
実は当初、この整合性チェック周りの実装はフィールドの比較忘れやメッセージの間違いなど結構不具合が多かったです。原因は内輪で問題の解き合いをするために実装を急いでいたのもあって、コードを不用意にコピーアンドペーストしていた箇所が大量に発生していたためでした。こうした問題は関数の共通化とメッセージそのものは自動生成させることで解消できるはずと思っていたので、結果この提案が刺さって単純なミスが大幅に減ったように思います。
並行処理を利用した箇所
sync.WaitGroup: 複数処理の待ち合わせとワーカーの起動
isucandar の worker.Worker
がもつ Process
という関数を呼び出すと、ワーカーを実行することができます。Worker にはどのくらいの数並列実行するかや、何回繰り返すかなどを設定できます。本選では本番では繰り返し回数は無限にしつつ、最初の並列数は確か3とかにしておいた気がします。ちなみに並列数は webapp 側にかけられそうな負荷の量を見て、どんどん増やしていく処理が裏で走ります。これは後ほど解説します。
ワーカーの起動には sync.WaitGroup
を使用します。WaitGroup は複数の処理を待ち合わせるのに利用できる構造体です。待ち合わせる処理の数だけ WaitGroup の Add 関数を呼び出し、処理が終わるタイミングで Done 関数を呼び出します。そして、待ち合わせしたい場所で Wait 関数を呼び出しておきます。この Wait 関数を呼び出した箇所で、すべての処理待ちをするブロッキングが行われます。
下記はシナリオ用のワーカーを実際に起動している箇所です。最初に流したいシナリオのワーカーを配列に登録しておき、その配列を1つ1つ回していくイメージです。1回のループで、WaitGroup への Add 、goroutine の起動、その中で isucandar の Worker の Process 関数の呼び出し、最後に WaitGroup の Done が呼び出されて、処理終了が通知されます。
workers := []*worker.Worker{
loginSuccess,
userRegistration,
banUserLogin,
masterRefresh,
}
for _, w := range workers {
wg.Add(1)
worker := w
go func() {
defer wg.Done()
worker.Process(ctx)
}()
}
最後の待ち合わせは下記で行っています。
負荷走行中のワーカー数の調整
先ほども説明した通し、isucandar が管理するワーカーの数を調整すると、webapp にかかる負荷を調整することができます。この処理は、webapp 側からのエラーの返却量や所定のシナリオの成功回数が秒間いくつかなどを見ておいて、さらに並列数を追加するといったものです。ちなみにこの箇所は fujiwara さんの助言で予選の当初の実装をわりとそのまま移して実装されています。
処理の手順としては、
- 指定した時間単位(本選の場合、10秒)で下記を確認する
- ログインに成功した回数
- ユーザー登録に成功した回数
- エラーが増えた数
- 事前に定義されたカウント数の閾値に応じて並列数を増やす
この10秒単位のティッキングの処理に Go の goroutine をいかしたパターンを使うことができます。time.NewTicker
で10秒おきにティックするティッカーを用意した後、ループを回して10秒おきに先ほどの条件を確認する処理を走らせます。
func (s *Scenario) loadAdjustor(ctx context.Context, step *isucandar.BenchmarkStep, loginWorker, userRegistrationWorker, banUserLoginWorker *worker.Worker) {
tk := time.NewTicker(time.Second * 10)
var prevErrors int64
totalLogin := 0
totalRegister := 0
for {
select {
case <-ctx.Done():
return
case <-tk.C:
}
// ...以降、Context が Done でない限り条件のチェックが走る
この loadAdjuster
関数自体は別の WaitGroup を1つ追加して、その中で goroutine を起動して走らせておくイメージです。
wg.Add(1)
go func() {
defer wg.Done()
s.loadAdjustor(ctx, step, loginSuccess, userRegistration, banUserLogin)
}()
context.Context: 時間経過でシナリオを発火させる
本選のベンチマーカーには、「ベンチマーカーの開始20秒経ってからマスター更新を発火する」というシナリオがあります。このシナリオは一見すると実装が難しそうに見えるのですが、Go の提供する context.Context
という涙が出るくらい(?)すばらしい構造体を使うと簡単に実装できてしまいます。
タイムアウト用に Context を用意します。この時、親の Context をワーカーの引数としてすでにもらっているので、それを使って子 Context を作ります。作った子 Context は MasterRefreshStartTime
分(本選の初期設定では20秒)だけ処理待ちします。20秒待って Context が Done になったら、マスター更新シナリオが走り始めます。
func (s *Scenario) FireRefreshingMasterVersion(step *isucandar.BenchmarkStep) (*worker.Worker, error) {
worker, err := worker.NewWorker(func(ctx context.Context, _ int) {
// `MasterRefreshStartTime` 分だけ待つ。
timeout, cancel := context.WithTimeout(ctx, MasterRefreshStartTime)
defer cancel()
<-timeout.Done()
// 終わったらマスター更新シナリオを開始。
refreshCtx, cancel := context.WithCancel(ctx)
defer cancel()
err := s.RefreshMasterDataScenario(refreshCtx, step, cancel)
// ... 続く
context.Context は正直よかった点を語り出すとキリがないです。特にキャンセル処理をかなり楽にできるのは大きく、これは他のプログラミング言語にもぜひ欲しい仕組みだなと思いました。実装時にはこちらの Zenn の本を参考にしました。
sync.Mutex: 負荷走行中のデータの扱い
ベンチマーカーを実装しているとシナリオ間でデータを共有したいと思うことがあるかもしれません。その際、複数スレッドから値が書き換えされることになるので、注意しなければならないのはデータの整合性です。これを担保するために、sync.Mutex
や sync/atomic
などのパッケージに入っている関数を活用します。この辺りは他のプログラミング言語にも同様の機能はあるので、利用経験がある方はすぐに追いつけると思われます。
Go の Mutex は、Lock 関数でロックをかけた後、defer (を書き忘れなければ)で Unlock 関数を呼び出すだけでロック/アンロックを制御できるので非常に楽でした。下記はマスターバージョンの更新時に呼び出される関数の例です。Scenario
という構造体に専用の Mutex とマスターバージョンを管理する変数が入っており、これらを順繰りに呼び出すだけで排他制御を実現できます。
func (s *Scenario) UpdateMasterVersion(masterVersion string) {
s.mu.Lock()
defer s.mu.Unlock()
s.MasterVersion = masterVersion
}
コード上の話で、本当はやりたかったけどできなかったこと
1. パッケージ構成をどうにかしたかった
本格的に作業に取り掛かる前にきちんと設計しておきたかったなとは思いました。ちなみにこの程度の規模のコードであれば、雑に main
パッケージに全部詰め込んだまま突っ切ったとしてもそこまで実害はなかったように思います。強いていうなら、雑に作りすぎて VSCode の suggestion が大変なことになっているくらいです。
2. Rewind のラベル不要だった
既存ユーザーログインシナリオなどでは、シナリオを実行している最中にマスターデータの更新があった場合、webapp から「マスターデータ更新があった旨のステータスコード」が返却されるので、それを検知してもう一度最初からシナリオを回し直す必要がありました。たとえば、インゲーム報酬受け取り部分まで来ていたものの、処理の最中にマスターデータの更新が走った場合には、もう一度ログイン処理(つまり先頭)からシナリオを回し直す必要がある、ということです。
このとき Rewind のラベルを使っていますがこれは不要というか、普通にループを回しておいてシナリオの最後に到達した場合だけリターンさせるでも十分だったかなと思いました。
// こういうやつ。
Rewind:
// ワーカー開始時点での最新のマスターバージョンを取得する。
masterVersion := s.LatestMasterVersion()
// 1. ログイン処理
result, login := s.LoginSuccessScenario(ctx, step, user, masterVersion)
if login == nil {
return
}
if result.Rewind {
goto Rewind
}
s.RecordLoginSuccessCount(1)
// 2. ホーム画面表示
result = s.ShowHomeViewSuccessScenario(ctx, step, user, masterVersion, login)
if result.Rewind {
goto Rewind
}
// 3. インゲーム報酬受け取り
result = s.RedeemRewardSuccessScenario(ctx, step, user, masterVersion, login)
if result.Rewind {
goto Rewind
}
// 4. プレゼントの受け取り
result = s.AcceptGiftSuccessScenario(ctx, step, user, masterVersion, login)
if result.Rewind {
goto Rewind
}
// 5. ガチャを引く
result = s.RedeemGachaSuccessScenario(ctx, step, user, masterVersion, login)
if result.Rewind {
goto Rewind
}
3. webapp 側とデータを共有するべきだった
作問が佳境になったり、試し解きを何度かすると問題が見つかり、webapp 側の返すレスポンスに変更を加えたいケースが多発します。このとき webapp 側を対応するとベンチマーカー側も対応が必要になることが多いです。普通に実装の負担が純増してしまうので、何かしらの方法で参考実装のレスポンスのデータは共通で利用できるようにしておくとよいと思いました。
このときの手順は Go に何か定石があるのであればそれでよいと思います。私が思いつく単純な例だと、IDL みたいなものがあって、それに沿ってコードが自動生成され、そのディレクトリを webapp とベンチマーカー間で共有する、などでしょうか。
ベンチマーカー実装をされる方へ
早めに「ベンチマーカーを起動して webapp に向けてリクエストを投げる」 CI パイプラインを構築しておくことをお勧めします。
ならびに、下記の本にベンチマーカーの実装方法についてかなり詳しく解説されているので、一度読んでおくとすばやくキャッチアップできると思います。sync パッケージや context の解説も充実しています。
まとめ
ベンチマーカーの実装では Go の並行処理周りのツールをたくさん利用することになります。私は Go の実務経験はなかったので最初は1つ1つのツールを理解するのに時間がかかりましたが、使いこなし始めるととても楽しくコードを書くことができます。ならびに、ベンチマーカーの実装は並行処理、並列処理の理解を深めたい方におすすめできるのかなと思いました。
Discussion