patorashのブログ

方向性はまだない

VagrantでポートフォワードしながらActiveStorageのファイルを表示する

今月初頭に社内ISUCONを開いたという記事を書きました。

patorash.hatenablog.com

その環境を仮想環境で構築してローカルでも試せるようにとVagrantを使って準備したのですが、自分がそれを使っていてハマッたのでメモ。

Vagrant上では、nginxを経由して表示するようにしているので、VMの80番ポートを20080番ポートにポートフォワーディングするようにしました。

ちなみに、10080番ポートはChromeがデフォルトで閉じている模様…。参考記事はこちら。

www.netassist.ne.jp

urlで出力するとポート番号が消える

ActiveStorageのstorage.ymlでserviceをDiskにしている場合で、Userモデルが画像avatorをActiveStorageで登録しているとしたら、Viewで表示するときは、通常はこう。

<%= image_tag user.avator %>

しかし、これだとデフォルトでURLが発行されてしまいます。RailsアプリはVagrantでポートフォワーディングされているということは知りませんから、http://localhost/rails/active_storage/blob/...のようなURLが発行されていて、表示することができません。

pathで出力すると問題なし

ということなので、pathで出力しましょう。Rails 6.1からですが、恒久URLを発行できるようになったので、これでOK。

<%= image_tag rails_storage_proxy_path(user.avator) %>

こうすると、/rails/active_storage/blob/proxy/...というパスが発行されるため、めでたく画像が表示されます👍

サムネイルもpathで出力

vipsを使って大きな画像のサムネイルをオンデマンドで作成するようにしていたら、同様に表示できず…。そりゃそうだ…。

<%= image_tag user.avator.variant(resize_to_limit: [200, 200]).processed %>

これもまた、同様にrails_storage_proxy_pathを使います。

<%= image_tag rails_storage_proxy_path(user.avator.variant(resize_to_limit: [200, 200]).processed) %>

以上!

HerokuのアドオンのSearchBoxが最新のElasticsearchクライアント(7.14.0)でUnsupportedProductErrorになる件

昨日発生したのですが、テストも通ってステージング環境に反映したら、表題の件が起きました。

Elasticsearch::UnsupportedProductError
The client noticed that the server is not a supported distribution of Elasticsearch.

これはもしや、アレでは…。最近読んだ記事のことを思い出しました。

www.publickey1.jp

「Elasticsearch純正のクライアントだとAWSへのOpenSearchへの接続ができなくなるんかぁ、大変やなぁ〜😲」とやや他人事に捉えていたのですが、まさか自分達の身に降りかかってこようとは。

弊プロダクトは全文検索エンジンにElasticsearchを使っているのですが、HerokuのアドオンではElastic社が提供するアドオンではなく、Searchly社が提供するSearchBoxというやつを使っています。理由は、安いから!

elements.heroku.com

elements.heroku.com

本家Elastic社は最安で月額$67なので、かなり高いんですが、Seachly社は無料から始められるし、弊プロダクトの現状だと$19のSmallプランで十分なのです。

解決方法

恐らくElasticsearchのクライアントを最新に更新していたことが原因なので、暫定措置として一旦、下げることに…。

Gemfileを更新。

# Elasticsearchを使うためのgem
# 7.14以上にしたらSearchBoxに接続できなくなった
# 暫定で7.14未満を使うように変更
# https://www.publickey1.jp/blog/21/elasticaws_opensearchaws.html
# https://stackoverflow.com/questions/68762774/elasticsearchunsupportedproducterror-the-client-noticed-that-the-server-is-no
gem 'elasticsearch-dsl'
gem 'elasticsearch', '< 7.14'
gem 'elasticsearch-api', '< 7.14'
gem 'elasticsearch-transport', '< 7.14'
gem 'elasticsearch-model', '~> 7.1.1'
gem 'elasticsearch-rails', '~> 7.1.1'

これでCIを通した後にステージングに反映したところ、問題なく動作した模様👍

まとめ

Elastic社とAWSの騒動は、AWSに限らず、Elasticsearchをサービスとして提供しているところ全体に影響しそうなので悩ましいところです🥺🥺🥺

system spec実行時に事前にwebpacker:compileさせる

これは、以下の記事を参考にしていたときの副産物ですが、ちょいちょいハマったところがあるので記事にしておきます。

techracho.bpsinc.jp

ローカルでついうっかりsystem specを行うと、初回アクセス時にwebpacker:compileが実行され、最初のテストがほぼ、タイムアウトに引っかかって失敗するという現象に悩んでいました。 それが上記の記事の途中で出てくる、system specを検出したらテスト実行前にwebpacker:compileが行われるコードを導入しました。

github.com

このprecompile_assets.rbを丸っとコピーして取り込んだところ、初回テストも失敗しないようなりました。快適!!😀 早速PRを作ってpushしたところ、CIでrake taskのテストが落ちました😩どうして…どうして…🐱

現象

標準出力に出てくる文字列の検証を行なっている箇所で二重に出力が行われていました。

テストが落ちる原因

原因は、先ほどのprecompile_assets.rbで行っているRails.application.load_tasksであることは突き止めましたが、これを行わないとRake::Task["webpacker:compile"].executeができないので、これはなんとしてもやりたい…。ちなみにRails.application.load_tasksを何度も呼んでみたところ、標準出力に出てくる文字列が読み込んだ回数だけ増えたので、どうもテストしているrake taskをどこかで重複して読み込んでいるな、というところまでは想像できました。

rake_shared_contextが怪しそう

rake_shared_contextという、rake taskのテストを簡単に書けるようになるgemがあります。

github.com

これを読むと、before(:suite)でプロジェクトのrake taskのファイルを読み込んでいるので、当たってた模様。まぁこれはrake_shared_contextが悪いわけではなくて、私がRails.application.load_tasksを読んでいるのが原因です。ならば、どうすればいいのか?

解決策

Rails.application.load_tasksを行う前に、Rake.Task.clearを呼びます。こうすることで、rake_shared_contextによって登録されたプロジェクトのタスクを一旦破棄します。その後のRails.application.load_tasksで再びrake taskとして登録されるので問題ありません👍

Rake::Task.clear
Rails.application.load_tasks
Rake::Task["webpacker:compile"].execute

まだCIで落ちる

今度は、CIで不可解な現象が起きました。テストは全部通っているのに、終了コードが1になって落ちていました😢しかも、parallel_testsを使っているはずなのに、並列で行われていない…。

以下はCircleCIのログ。

🐢  Precompiling assets.
Finished in 0.27 seconds
..........


🐢  Precompiling assets.
Finished in 0.25 seconds
..........


🐢  Precompiling assets.
Finished in 0.25 seconds
...........


🐢  Precompiling assets.
Finished in 0.26 seconds
......

// 省略。何度も呼ばれる🐢…

🐢  Precompiling assets.
Finished in 0.26 seconds
...................

Finished in 18 minutes 31 seconds
842 examples, 0 failures, 2 pending
--------------------After Queue Hook - run after test suite--------------------
Coverage report generated for rspec_ci_node_1 to /home/circleci/project/tmp/coverage. 6624 / 13479 LOC (49.14%) covered.

Exited with code exit status 1
CircleCI received exit code 1

なんで何度もbefore(:suite)が呼ばれてるのかも不可解だし、2並列で動いてないのもおかしい。

原因

原因は、CIで2並列でprecompile_assets.rbの処理が行われて、webpacker:compileが同時に2並列で動いて、片方がなんらかの原因で落ちていたせいでした。

何度も呼ばれる🐢は、knapsack_proを使っている影響でテストケースを取得しにいってテストを実行するたびにbefore(:suite)が実行されているからでした。そうだったのか…。

対策

CIでは、元々事前にassets:precompileを実行させていたので、そもそも並列でこの処理実行させたくなかったので、CIの時はprecompile_assets.rbの処理をスキップさせるようにしました。

RSpec.configure do |config|
  config.before(:suite) do
    unless ENV['CI']
      # 元々のprecompile_assets.rbの処理をこの中に移動させる
    end
  end
end

これで、ローカルでsystem specを実行する場合は事前にwebpacker:compileを実行してくれるようになり、CIでは何もしないようになりました。

社内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をすることができました。あんまりコード的にコントリビュートできるレベルではないので、こういう、使ってみた系の記事を公開することで、貢献できたらなと思います。もちろん、ツッコミもありそうなので、おかしなところがあったらご指摘お願いします!😀

社内ISUCONとしてRails ISUCONを開催した

ようやくやりたかった社内イベントであるRails ISUCONを開催できたので、そのことについて書いていく。

Rails ISUCONの目的

目的は、主に3つ。

  • 自身の知見をチームメンバーに拡げる(チームメンバーから知見を得る)
  • コミュニケーション
  • 様々な角度からRailsアプリのパフォーマンス改善を学ぶ

うちのグループは全てRailsで作られた製品を扱っているので、Railsに特化したISUCONをやった方が効果があるし、製品へのフィードバックもしやすいという目論見がありました。

チーム構成

特に重視していたのは、1番目のチームメート同士での知見を交換することでした。そのため、1人チームや2人チームを作ることはやめました。1人だとコミュニケーションも生まれないし、知見の共有ができません。2人だと1人が一方的に作業をして、残された1人がただただ眺めてるようなことにもなりかねません。そこで、メンバーの技術力・コミュニケーション力のバランスを考えて、主催者側でチームとそのメンバーを決めて、3人の2チーム構成にしました。

アプリについて

Rails ISUCONなので、もちろんRailsで作っています。アプリは私一人で作りました。Railsでよくあるパフォーマンスが劣化しやすいポイントなどを最初に考え、それを盛り込みながらも、大きなアプリにならないようにと、画像投稿アプリを作りました。

パフォーマンスが低い要因

だいたいこんなところを仕込みました。

  • 古いバージョンのRubyミドルウェア
  • 適切なインデックスが設定されていない
  • N+1問題を含んでいるActiveRecordのクエリ
  • 何度も呼ばれるcount
  • 必要だけど重いSQLで作られたView
  • 最適化されていない画像サイズ
  • Railsが全部のリクエストを捌いている

ベンチマーカーについて

ベンチマーカーを作る知見を全く持っていなかったので、当初は「Gatlingで適当にアクセスさせて、いくら捌けたかで得点にする」程度に考えていました(Gatlingの知見も持っていないにも関わらず😅)。しかし、他の業務も忙しくなったり、チームの育成に注力したりなどしていたため、なかなかベンチマーカー作成に着手できず、いよいよベンチマーカーなしでもとりあえず開催するか…という感じになってきていました。

isucandar(イスカンダル)の存在を知る

7月上旬に、fukabori.fmのISUCONの回を聞いて、ベンチマーカーフレームワークのisucandarの存在を知りました。これを使えば、知見のない自分でもベンチマーカー作れるかもしれない!と思い、とりあえず使ってみることに。ちなみにisucandarはGo言語で作られているのですが、この時点で私はGo言語を使ったことはありませんでした…😅

github.com

READMEを読んで、適当に作ってみていたら、Railsのログがバーッと流れ出したので、いけそうだなと思って、そのままやろうとしたのですが、Goでの関数化やメソッド化やエラー処理の仕方とかが全然わからなくて厳しくなってきたので、ここから急遽Go言語を学ぶことにしました。オリンピックの開会の連休を使って家にあった「プログラミング言語Go」を読んでました(本は買ってたけど読んでなかった)。

この本のときのGoのバージョンが1.6で、現在の最新バージョンが1.16なので随分と違いがあったりもしました。

文法や並行処理で気をつけることをなんとなく把握したところで、とりあえず関数化やメソッド化したり、作ってはリファクタリングしてを繰り返して、なんとか完成させました。その際には、本家ISUCONの10回目のベンチマーカーのコードを参考にさせてもらいました(正直まだ全然わからんところとか大量にあったけど)。

github.com

こちらのコードを読んで、isucandarのREADMEで書かれていなかったBenchmark, BenchmarkStep, BenchmarkResultなどの構造体の存在に気づき、それらを使うようにしました。ベンチマーカーをどう実装したかは、また別の記事で書こうと思います。

負荷試験のやり方を学ぶ

順番が前後しますが、負荷試験のやり方については、こちらのスライドで勉強させてもらいました。まずはlocalhostでうまくいくことが重要で、クラウドの場合は下手するとクラウドサービスに対する負荷試験になってしまいかねないので、それは避けるということがわかってよかったです。確かにやってしまいそう…。

負荷試験入門公開資料 201611

これで学んだことで、とりあえずベンチマーカーはRailsが動いているサーバーに同梱してしまおうと考えました。Railsアプリとベンチマーカーがリソースを食い合うので、正確なベンチマークにはならないけれど、ネットワークの負荷をかけずに負荷試験できるので、これはこれでアリだと判断しました。

ルールなどのドキュメント

  • ストーリー
  • ルール
  • 禁止事項
    • ヒントになるようなことを他のチームに教えない等
  • 各種操作方法
    • Railsアプリケーションの起動方法
    • ベンチマーカーの起動方法

などをWordで書いて、共有しました。チームのコミュニケーション方法は、MS Teamsのプライベートチャネルを作り、そこでやってもらうようにしました。当日はそのチャネルにパフォーマンス改善になりそうなブログのURLなどが貼られたりしてました。

社内ISUCON開催!

当日

8/6(金) 10:00〜18:00の8時間で取り組んでもらいました。 こちらの準備不足や考慮漏れもあって、多少ゴタゴタしてしまったりもありました(申し訳ない…😢)

ベンチマーカーのスコアを、どちらのチームも見られるTeamsのチャネルに投稿してもらったりして、結構盛り上がったんじゃないかと思います。終わった後は皆、かなり疲れてたようでした(わかる)

初期状態で2800程度のスコアが、最終的にはどちらのチームも7000以上になっていました👏

翌日

8/7(土)の朝だけちょっと皆に時間をとってもらい、1時間程度で振り返りを行ってもらい、その後、チーム毎に発表してもらいました。

  • 何をやったか(それでどれくらい改善したか等)
    • 時系列だとわかりやすいかも
  • やろうと思っていたができなかったこと(時間が足りないと判断して後回しにしたとか)
  • 感想
    • イベント的に良かった点
    • イベント的によくなかった点

チーム毎にどういうところに取り組んだかがわかって、面白かったです。また、イベントとしての反省点も見えてきました。インフラにあまり強くないと、改善用のローカル環境の構築自体がなかなか進まず、改善に取り組む前に時間がかかりすぎるとか…。まぁそれも含めてISUCONかなぁとは当初考えていたのだけれど、ちょっとこだわりすぎたかなと思ってしまいました。後で素振り用の環境は準備しようと考えていたので、そちらではすぐに取り組めるようにdocker-compose.ymlとかを同梱しようと思います。

あとは、想定していた改善点のポイントの残りなどで、こういうものが考えられるよっていうのを伝えました。無駄にでかくて最適化されていないトップ画像とかは、両チームとも全く気づいてませんでした😇プログラムだけ見ていたのではあかんのよ、リソースにも気を配らないといかんのよ…👻

どちらのチームからも上がった良かった点として、「ベンチマーカーのスコアが良くなるのが楽しかった」というのがあったので、やはりベンチマーカーを作っといたのはよかったです。

主催者としての感想

主催する側は相談するとネタバレになるので孤独で大変っていうのをfukabori.fmでも聴いてたんですが、それは本当にそうで、かなりシンドかったです。正直終わってかなり肩の荷が降りた気分です。でも問題を考えたり、ベンチマーカーを作ったりするのは楽しかったし、勉強にもなりました。当日のベンチマークスコアが良くなっていくのを眺めるのも、楽しかったです。

あとは皆が素振りできる環境を整えるというタスクが残っているのと、自分が思いつく全部の改善をした際のスコアを取っておくというのがあるので、休み明けも頑張ります👊

追記:isucandarの記事を書きました。

patorash.hatenablog.com

7月にやったこと

もう8月になってしまったけど。 6月のやつは、これ。 patorash.hatenablog.com

継続して取り組んでいることは、以下の通り。

  • プリンシプル オブ プログラミング読書会(Effective Rubyを読み終わったので)
  • モブレビュー
  • スプリントプランニング
  • レビューをする曜日を決める等

Effective Rubyは7月上旬に読み終わったのですが、学びが多くて本当によかったです。本自体はだいぶ古くなっているので、新しいバージョン出ないかな…。あと、そろそろRuby3.0に対応したパーフェクトRubyとか出てほしいなぁとか勝手に思っています😅

プリンシプル オブ プログラミング読書会については、過去に書いたやつがあります。

patorash.hatenablog.com

1つ1つのボリュームが少ないので、輪読しながら進めていますが、抽象的な話が多いので、これはうちの会社やプロジェクトではこれが当てはまる、とか、あのときのコードレビューの指摘がこれに当てはまるとか、話をしながらやっています。ベテランは確実にいい本だとわかるんだけど、経験が浅いとなかなかピンとこないっぽいので、やはり読書会形式で伝承していくのがいい本かなと思います。

あとは社内勉強会で、「exists?で起きるN+1問題に対処するためにSetを使った話」をスライドにして発表しました。

社内勉強会は毎月1回行っているのですが、7月は後輩くんがサーバー構築の話をしてくれました。数ヶ月前の1on1のときに、「インフラ系の知識が浅いのが気になっている」という話だったので、サーバ/インフラエンジニアの基本がこれ1冊でしっかり身につく本を読むことを薦めてみたら、ちゃんと読んでくれて、その上でVirtualBoxで環境構築を試していたようでした。苦労したポイント等も取り上げていて、スライドをまとめるのもだいぶ慣れてきて成長してきているなと感じました。

あと、個人的にGo言語の勉強をしています。fukabori.fmのisuconの回を聴いていたら、ベンチマークフレームワークisucandar(イスカンダル)の紹介がされていたので、それを使えるようになりたいというのが動機です。

fukabori.fm

最初はGoを勉強しなくても作りたいものは実現できるかな?と思いながら試していたのですが、やはり文法がわからないと辛いものがあったので、とりあえず家にあって全然読めてなかったプログラミング言語Goを1回読みました(リフレクションの章は飛ばしたけど)。おかげで、だいぶ元のコードの読み方がわかってきました。今も並行処理のところとかは見返しながらやっています。普段はRubyばっかりやっているので、他の言語でしかも静的型付なやつは学びが多いです。ちゃんと使えるようになりたい!

twitterでGoのわからんこととかを呟いていると、諸先輩方から、色々アドバイスをいただけるのでありたがい🙏現在のGoのバージョンが1.16で、本のときのバージョンが1.6だったので、モジュール管理のところがだいぶ変わっているみたいで、悩んでいたら、教えていただけました。

おかげさまで理解が深まりました!👍

exists?で起きるN+1問題に対処するためにSetを使った話

課題:データを弾くためにN+1問題が発生していた

数年前に実装した、CSVデータをDBにインポートするためのプログラムがありました。 単にインポートするだけならいいのですが、除外リストに登録済のデータは弾いてほしい、という要望があり、そのように実装していました。

以下、ダミーのプログラムです。モデル名やCSVの内容は変えてあります。

require 'csv'

csv = CSV.read('list.csv', { headers: true, return_headers: false, skip_blanks: true, encoding: 'UTF-8' })
import_data = csv.reject do |row|
  name = row['名前']
  age = row['年齢']
  hobby = row['趣味']

  # 除外リストに存在するデータは弾く
  IgnoreUser.exists?(name: name, age: age, hobby: hobby)
end

users = import_data.map do
  name = row['名前']
  age = row['年齢']
  hobby = row['趣味']
  User.new(name: name, age: age, hobby: hobby)
end
User.import(users) # activerecord-importでバルクインサートする

しかし、この実装だと、CSVファイルの行数だけIgnoreUser.exists?が実行されてしまいます。が、それも仕方ないかな…と考えていました。

EffectiveRuby読書会で学んだことを活かす

ここで、5〜7月で実施していたEffectiveRuby読書会で学んだことで、このN+1問題を解決する方法を思いつきました。 それが、StructとSetを使う方法です。

Structは、データクラスを作るのを簡単にするための組み込みライブラリで、SetはRubyで集合を扱うための標準ライブラリです。

class Struct (Ruby 3.0.0 リファレンスマニュアル)

library set (Ruby 3.0.0 リファレンスマニュアル)

Structで定義すると、サブクラスが作られます。そのサブクラスのオブジェクトの配列をSetに入れるというアイデアです。Setは内部記憶としてHashを使うため、include?メソッドを使ったときの探索がO(1)になるという特長があります。ちなみにArrayでinclude?を呼ぶと、線形探索なので最悪の場合は計算量がO(N)になり、Nが大きくなればなるほど遅くなります。

Structは設定される値が全部同じだと、==がtrueになります。

IgnoreUserStruct = Struct.new(:name, :age, :hobby)
a = IgnoreUserStruct.new("a", 1, "a")
b = IgnoreUserStruct.new("a", 1, "a")
a == b # => true

Setを使って修正する

そこで、修正したコードがこちら。

require 'csv'
require 'set'

# 構造体を定義
IgnoreUserStruct = Struct.new(:name, :age, :hobby)

# 除外リストのデータを構造体の集合にする
# DBへのアクセスはこの箇所のみ
ignore_users = IgnoreUser.find_each.map { |ignore_user|
  IgnoreUserStruct.new(ignore_user.name, ignore_user.age, ignore_user.hobby)
}.to_set

csv = CSV.read('list.csv', { headers: true, return_headers: false, skip_blanks: true, encoding: 'UTF-8' })
import_data = csv.reject do |row|
  name = row['名前']
  age = row['年齢']
  hobby = row['趣味']

  # 除外リストに存在するデータは弾く
  # 集合に対して除外データを確認するのでDBへのアクセスは起きない
  ignore_users.include?(IgnoreUserStruct.new(name, age, hobby))
end

# 以下略

これで、N+1問題をやっつけることができました。めでたしめでたし!

ベンチマークを取る

N+1問題は解決したものの、実際にはどれくらい速くなったのかが気になります。 そこで、benchmark-ipsを使ってベンチマークを取りました。

前提

  • ignore_usersテーブルには1万件のデータを登録済み
  • CSVファイルの行数は1万行

ignore_usersテーブルへのデータ投入は、PostgreSQLのgenerate_series関数を使いました。

bin/rails dbconsolepsqlを開きます。そこから、以下のクエリを流し込んで1万件のデータを生成しました。

truncate ignore_users;
insert into ignore_users(name, age, hobby, created_at, updated_at)
select
  format('名前%s', i), 
  i,
  format('趣味%s', i),
  clock_timestamp(),
  clock_timestamp()
from
  generate_series(1, 10000) as i;

これで準備はできました。

ベンチマークプログラム

rails runnerで実行できるように、script/benchmark.rbにファイルを作りました。 bin/rails runner script/benchmark.rb で実行します。

また、上記ではfind_each.mapで除外リストのモデルから構造体を生成していましたが、これpluckでやればモデルの生成処理が不要になるから更に速くなるんじゃないか?と考えて、それを追加してます。

  • exists?
  • Array#include?
  • Set#include?(model version)
  • Set#include?(pluck version)

なお、このベンチマークプログラムもダミーです(モデル名とかCSVの内容とかは変えてあります)

require 'csv'
require 'set'

# 構造体を定義
IgnoreUserStruct = Struct.new(:name, :age, :hobby)

Benchmark.ips do |x|
  csv = CSV.read('list.csv', { headers: true, return_headers: false, skip_blanks: true, encoding: 'UTF-8' })
  
  x.report('exists?') do
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      IgnoreUser.exists?(name: name, age: age, hobby: hobby)
    end
  end

  x.report('Array<Struct>.include?') do
    ignore_users = IgnoreUser.find_each.map do |ignore_user|
      IgnoreUserStruct.new(ignore_user.name, ignore_user.age, ignore_user.hobby)
    end
    
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      ignore_users.include?(IgnoreUserStruct.new(name, age, hobby)
    end
  end

  x.report('Set<Struct>.include? model version') do
    ignore_users = IgnoreUser.find_each.map { |ignore_user|
      IgnoreUserStruct.new(ignore_user.name, ignore_user.age, ignore_user.hobby)
    }.to_set
    
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      ignore_users.include?(IgnoreUserStruct.new(name, age, hobby)
    end
  end

  x.report('Set<Struct>.include? pluck version') do
    ignore_users = IgnoreUser.in_batches.flat_map { |records|
      records.pluck(:name, :age, :hobby).map { |data| IgnoreUserStruct.new(*data) }
    }.to_set
    
    csv.reject do |row|
      name = row['名前']
      age = row['年齢']
      hobby = row['趣味']

      ignore_users.include?(IgnoreUserStruct.new(name, age, hobby)
    end
  end
  x.compare!
end

ベンチマーク結果

以下が、実行結果です。

bin/rails runner script/benchmark.rb
Warming up --------------------------------------
             exists?     1.000  i/100ms
Array<Struct>.include?
                         1.000  i/100ms
Set<Struct>.include?     1.000  i/100ms
Set<Struct>.include? pluck version
                         1.000  i/100ms
Calculating -------------------------------------
             exists?      0.035  (± 0.0%) i/s -      1.000  in  28.891907s
Array<Struct>.include?
                          0.057  (± 0.0%) i/s -      1.000  in  17.419491s
Set<Struct>.include?      2.386  (± 0.0%) i/s -     12.000  in   5.045444s
Set<Struct>.include? pluck version
                          3.810  (± 0.0%) i/s -     20.000  in   5.252635s

Comparison:
Set<Struct>.include? pluck version:        3.8 i/s
Set<Struct>.include? model_version:        2.4 i/s - 1.60x  (± 0.00) slower
Array<Struct>.include?:        0.1 i/s - 66.36x  (± 0.00) slower
             exists?:        0.0 i/s - 110.07x  (± 0.00) slower

予想通り、DBからデータを取得した際にIgnoreUserモデルを生成しない分、Setを使ったpluckバージョンが最も速いという結果になりました。 Array#include?はexists?よりも速いとはいえ、66倍も遅い結果に。 exists?を使った結果は110倍も遅い結果になりました。

SetとStructを使うと超速くなりますね!

データが増えたらどうなる?

とはいえ、ignore_usersテーブルに1万件程度なので、これが10万件とか100万件とかになってきたらexists?のほうが速くなるんじゃないのー?と思って、それもベンチマークとってみました。

10万件の場合

  • ignore_usersテーブルには10万件のデータを登録済み
  • CSVファイルの行数は1万行(変わらず)

以下、結果です。

bin/rails runner script/benchmark.rb
Warming up --------------------------------------
             exists?     1.000  i/100ms
Array<Struct>.include?
                         1.000  i/100ms
Set<Struct>.include? model version
                         1.000  i/100ms
Set<Struct>.include? pluck version
                         1.000  i/100ms
Calculating -------------------------------------
             exists?      0.010  (± 0.0%) i/s -      1.000  in 102.770187s
Array<Struct>.include?
                          0.005  (± 0.0%) i/s -      1.000  in 216.871624s
Set<Struct>.include? model version
                          0.262  (± 0.0%) i/s -      2.000  in   7.638904s
Set<Struct>.include? pluck version
                          0.436  (± 0.0%) i/s -      3.000  in   6.878924s

Comparison:
Set<Struct>.include? pluck version:        0.4 i/s
Set<Struct>.include? model version:        0.3 i/s - 1.67x  (± 0.00) slower
             exists?:        0.0 i/s - 44.83x  (± 0.00) slower
Array<Struct>.include?:        0.0 i/s - 94.61x  (± 0.00) slower

1位は変わらず、Setを使ったpluckバージョンが最も速いという結果になりました。 exists?を使った結果は44倍なので、多少縮まりましたが、全然遅いです。 Array#include?は遂にexists?よりも遅くなり、94倍という結果になりました。

100万件の場合

  • ignore_usersテーブルには100万件のデータを登録済み
  • CSVファイルの行数は1万行(変わらず)
  • Array#include?は遅くなりすぎて耐えられないのでベンチマークから外す
  • Setのモデルバージョンはやめてpluckのみにする

以下、結果です。

bin/rails runner script/benchmark.rb
Warming up --------------------------------------
             exists?     1.000  i/100ms
Set<Struct>.include? pluck version
                         1.000  i/100ms
Calculating -------------------------------------
             exists?      0.002  (± 0.0%) i/s -      1.000  in 402.106370s
Set<Struct>.include? pluck version
                          0.043  (± 0.0%) i/s -      1.000  in  23.170393s

Comparison:
Set<Struct>.include? pluck version:        0.0 i/s
             exists?:        0.0 i/s - 17.35x  (± 0.00) slower

100万件でも、Setを使ったpluckバージョンのほうが速いという結果になりました。 exists?を使った結果は17倍なので、だいぶ縮まりました。

しかし、Setを使うバージョンは100万件のデータをRubyのオブジェクトとしてメモリに持っている状態なので、メモリ使用量が気になりますね。その点でいえば、exists?はメモリはあまり使わなくて済むでしょうけど、遅いは遅いですね…。

なんとなくでいいから、どれくらいメモリ使ってるかなーとMacアクティビティモニタを眺めていたら、100万件の場合は420MBくらいが上限だったのでまぁそれくらいならまだSetを使ってもいいのかな?という気がします。なんといってもまだまだ17倍も速いので。

まとめ

exists?で起きるN+1問題はSet + Structでかなり対応できそうです! Structを作るには、pluckを使うと速く、簡潔になります。

EffectiveRubyを読んでよかった!