patorashのブログ

方向性はまだない

社内ISUCONのベンチマーカーをisucandarで作った話

前回の記事で、社内ISUCONをしたという話を書きました。

patorash.hatenablog.com

そのときにベンチマーカーを作るのに、isucandarを使ったので、あとでまた記事を書く!と宣言していたのですが、なかなか書けず…。でも忘れないうちに書く!

isucandarとは?

isucandarとは、ISUCON用のベンチマーカーフレームワークです。

github.com

@catatsuy さんが作ったISUCON9のベンチマーカーに感銘を受けた @rosylilly さんが作成したそうです。

@catatsuyさんがisucandarについて書いたZennの記事があります。

zenn.dev

私も実装前はこの記事読みながらも「わからんな?😇」と思ってましたが、今読むとなんとなくわかるくらいにはなりました(なんとなく、かい!)

私なりの雑な説明

以下、私なりの雑な説明をしていきます。(GitHubのREADMEを読んだ方がいいと思われるが…)

agent

実際にリクエストを扱うパッケージ。ブラウザのような挙動を行う。セッションも使えるし、アクセス後に取得したレスポンスを解析してまた何かアクセスさせたりもできる。

failure

エラーを扱うパッケージ。独自のエラーを扱ったりできる。エラー数を数えたりもできるので、例えば100件以上エラーになったら失格扱いや減点、とかの処理に使える。

score

スコア集計のためのパッケージ。複数の処理用にタグを定義しておく。タグ毎に得点を付けられるので、難しい処理をしたら10点、簡単な処理をしたら1点などの設定が可能。

worker

同じ処理を複数回実行したり、並列数を抑えながら無限に実行したりする処理の制御を提供する。

基本的には、このworkerの中でAgentを動かして色々処理するのを繰り返させる。

parallel

同時実行数を制御しつつ、複数のジョブを実行させる処理を提供する。

私的には使いどころがわからず、使わなかった(が、workerで並列数を指定しているので、自動的に使ってたと思う)。

とまぁ、ここまでは、isucandarのREADMEに書いてあります。しかし、他にも便利なやつがあります。

READMEに書いてなかったその他の使い方

ベンチマーカーの実装の参考にしようと、ISUCON10-finalのベンチマーカー実装を読んでいたら、上記以外の構造体が使われていたので、それを読み解きながら作りました。

github.com

先に、私が実装したやつの簡易版を出します。

package main

// importは省略…

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

         // 実行時間は60秒
    b, err := isucandar.NewBenchmark(isucandar.WithLoadTimeout(60 * time.Second))
    if err != nil {
        panic(err)
    }

    b.OnError(func(err error, step *isucandar.BenchmarkStep) {
        // エラーを検知した場合の処理を行う
        critical, _, _ := checkError(err)

        if critical {
            // 実行中のベンチマークを止める
            step.Cancel()
        }
    })

         // 私が作ったシナリオの構造体
    s := &scenario.Scenario{}

         // targetAddressは引数で指定する。
    s.BaseURL = fmt.Sprintf("http://%s/", targetAddress)

         // シナリオをベンチマーカーにセット
    b.AddScenario(s)
    // シナリオを実行する
    result := b.Start(ctx)

    result.Score.Set(score.ScoreTag(scenario.SuccessGet), 5)
    result.Score.Set(score.ScoreTag(scenario.SuccessPost), 10)
    result.Score.Set(score.ScoreTag(scenario.SuccessAsset), 1)
    fmt.Printf("Score: %d\n", result.Score.Total())
}

これを踏まえて、書いていきます。

benchmark.go

isucandar/benchmark.go at master · isucon/isucandar · GitHub

ベンチマーカーの構造体が定義してあります。

ベンチマークオブジェクトを作る

NewBenchmarkメソッドで作ります。タイムアウト時間を指定できるので60秒にしました。(本当は定数で定義してますが、わかりやすくするために直書きに)

// 実行時間は60秒
b, err := isucandar.NewBenchmark(isucandar.WithLoadTimeout(60 * time.Second))
if err != nil {
    panic(err)
}
エラー制御を定義する

OnErrorメソッドでエラーが起きた場合のコールバックを定義しておきます。 ここで、致命的なエラーとかが起きたらCancelで止めます。BenchmarkStepの説明は後で…。 checkErrorメソッドはエラーの種類を検証して返すメソッドですが、ここは適当に(ISUCON10のをそのまま採用した)。

b.OnError(func(err error, step *isucandar.BenchmarkStep) {
    // エラーを検知した場合の処理を行う
    critical, _, _ := checkError(err)

    if critical {
        // 実行中のベンチマークを止める
        step.Cancel()
    }
})
シナリオを設定する

シナリオの構造体はagent, worker, failure, scoreを使って作ったシナリオです。これも後ほど。 ベンチマークオブジェクトに対して、AddScenarioメソッドでシナリオを登録します。 複数登録できそうでもあるんですが、1つのほうがよさそう。

// 私が作ったシナリオの構造体
s := &scenario.Scenario{}

// targetAddressは引数で指定する。
s.BaseURL = fmt.Sprintf("http://%s/", targetAddress)
// シナリオをベンチマーカーにセット
b.AddScenario(s)
ベンチマークを実行する

実際にベンチマークを実行します。戻り値は、BenchmarkResultオブジェクトです。

// シナリオを実行する
result := b.Start(ctx)
スコア集計する

最後に、BenchmarkResultオブジェクトに設定されているScoreオブジェクトに得点ルールを書きます。 得点ルールは先に定義しなくても、最後に定義すればいいです。 最後に、スコアの合計を出力しました。

result.Score.Set(score.ScoreTag(scenario.SuccessGet), 5)
result.Score.Set(score.ScoreTag(scenario.SuccessPost), 10)
result.Score.Set(score.ScoreTag(scenario.SuccessAsset), 1)
fmt.Printf("Score: %d\n", result.Score.Total())

benchmark_result.go

isucandar/benchmark_result.go at master · isucon/isucandar · GitHub

ベンチマーク結果オブジェクトは、scoreとfailureのオブジェクトを持ってます。 なので、最後にスコア集計ができるわけですが、このオブジェクトは BenchmarkStep に入っています。

benchmark_step.go

isucandar/benchmark_step.go at master · isucon/isucandar · GitHub

BenchmarkStep構造体はBenchmarkResultを持っているので、BenchmarkStepのAddScoreメソッドなどを呼び出すと、BenchmarkResultにあるScoreオブジェクトを介してスコアが登録できます。AddErrorメソッドも同様。

benchmark_scenario.go

isucandar/benchmark_scenario.go at master · isucon/isucandar · GitHub

インターフェースが定義されています。

  • PrepareScenario
  • LoadScenario
  • ValidationScenario

これらのどれか1つでも定義されているものが、シナリオとして認識されます。なので、これらのインターフェースを含むシナリオを定義します。

isucandarのシナリオの作り方

どれか1つでもあればいいので、LoadScenarioを持つシナリオを構造体で定義しました。

package scenario

// importは省略

type Scenario struct {
    BaseURL string
    isucandar.LoadScenario
}

LoadScenarioを持たせているので、これにLoadメソッドを定義します。 ざっくりと、ログインしていないユーザー用のシナリオと、ログインしているユーザー用のシナリオを並列に実行させました。

func (s *Scenario) Load(ctx context.Context, step *isucandar.BenchmarkStep) error {
    wg := sync.WaitGroup{}

    wg.Add(1)
    go func() {
        defer wg.Done()
        // ログインしていないユーザー用のシナリオを実行する
        if err := s.loadNoSignInUserBenchmark(ctx, step); err != nil {
            step.AddError(err)
        }
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        // ログインするユーザー用のシナリオを実行する
        if err := s.loadSignInUserBenchmark(ctx, step); err != nil {
            step.AddError(err)
        }
    }()
    wg.Wait()

    return nil
}

このシナリオの内容は普通に実装の話になってしまうので、省略します。やってることは、workerで無限にアクセスさせるようなもので、成功したらスコアを追加、失敗したらエラーに追加をするくらいです。

トップページにアクセスして成功したら得点GET!を書くとしたら、こんな感じだろうか?ベンチマークで指定された時間だけ6並列で、延々とトップページにアクセスさせます。時間を迎えたら自動的に終わります。

func (s *Scenario) loadNoSignInUserBenchmark(ctx context.Context, step *isucandar.BenchmarkStep) error {
    w, err := worker.NewWorker(func(ctx context.Context, _ int) {
        for ctx.Err() == nil {
            agent, err := agent.NewAgent(WithBaseURL(s.BaseURL))
            if err != nil {
                step.AddError(err)
                continue
            }
            req, err := agent.GET("/")
            if err != nil {
                step.AddError(err)
                continue
            }
            res, err := agent.Do(ctx, req)
            if err != nil {
                step.AddError(err)
                continue
            }
            defer res.Body.Close()

            if res.StatusCode == 200 {
                step.AddScore(score.ScoreTag("GET"))
            }    
        }
    }, worker.WithInfinityLoop(), worker.WithMaxParallelism(6))

    if err != {
        return err
    }
    w.Process(ctx)

    return nil
}

ざっと、こんなところでしょうか。今はこれが精一杯😇

Railsアプリに対してPOSTするために工夫したこと

Railsアプリに対してPOSTしようとすると、CSRFトークンが必要になります。アクセスしたページを解析してCSRFトークンを取得するのに、goqueryを使いました。

github.com

以下が、その実装例です。

// ログイン状態を保持したいので、AgentとContextを持つ構造体を定義
type Action struct {
    Agent *agent.Agent
    Ctx   context.Context
}

var (
    ErrAuthenticityToken failure.StringCode = "authenticity-token-not-found"
)

// goqueryを使ってCSRFトークンを取得する
func getAuthenticityToken(responseBody io.ReadCloser, index int) (string, error) {
    doc, err := goquery.NewDocumentFromReader(responseBody)
    if err != nil {
        return "", err
    }
    selection := doc.Find("input[name='authenticity_token']").Eq(index)
    authenticityToken, exists := selection.Attr("value")
    if exists == false {
        return "", failure.NewError(ErrAuthenticityToken, fmt.Errorf("authenticity_tokenが見つかりませんでした。"))
    }
    // fmt.Println("authenticityToken = " + authenticityToken)
    return authenticityToken, nil
}

func (a *Action) SignIn(email, password string) (*http.Response, error) {
    req, err := a.Agent.GET("/users/sign_in")
    if err != nil {
        return nil, err
    }
    res, err := a.Agent.Do(a.Ctx, req)
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()
    // ログイン情報入力時間
    time.Sleep(2 * time.Second)

    authenticityToken, err := getAuthenticityToken(res.Body, 0)
    if err != nil {
        return nil, err
    }

    // ログインする
    values := url.Values{}
    values.Set("user[email]", email)
    values.Set("user[password]", password)
    values.Set("authenticity_token", authenticityToken)
    req, err := a.Agent.POST("/users/sign_in", strings.NewReader(values.Encode()))
    if err != nil {
        return nil, err
    }

    res, err := a.Agent.Do(a.Ctx, req)
    if err != nil {
        return nil, err
    }
    return res, nil
}

以上になります。

まとめ

初めてのベンチマーカー作り、初めてのGO言語、初めての社内ISUCON開催と、初めて尽くしで準備も大変でしたが、isucandarのおかげで、ワイワイと社内ISUCONをすることができました。あんまりコード的にコントリビュートできるレベルではないので、こういう、使ってみた系の記事を公開することで、貢献できたらなと思います。もちろん、ツッコミもありそうなので、おかしなところがあったらご指摘お願いします!😀