LayerX エンジニアブログ

LayerX の エンジニアブログです。

マルチテナントSaaSにおけるGoのテスト高速化

この記事はLayerX Tech Advent Calendar 2022 の14日目の記事です。


LayerXのバクラク事業部でエンジニアをしている @upamune です。現在はバクラク申請・経費精算チームの開発に携わっています。

今回の記事では、Goで書かれているバックエンドアプリケーションのテストが遅かったのを、どのようにして改善したかについてご紹介します。

背景

LayerXでのサービスのバックエンドはGo言語で記述されています。マルチテナント構成で一つのDBに複数のテナント(会社)のデータが入っている状態です。 入社してバックエンド開発をしていると、テストを走らせてみると結構時間がかかることに気づきました。その時のテストケースの数を考えても遅いと感じ、今後テストを拡充していくことを考慮すると今のうちに対処しておかないとテストの時間の増加に繋がってしまうため、この問題を解消するべく調査を始めました。

テストが遅い理由

Go言語では go test-v オプションを付けるとテストケースごとのかかった時間を出してくれるので遅いテストケースの特定は容易でした。 そのテストは権限周りのテストで、DBアクセスを伴う膨大なパターン数(300パターンほど)のテストを直列で実行していたため遅いようでした。その時のメモを見てみると、このテストだけで、3分45秒 ほどかかっていたようです。他の層のテストではモックを利用してDBアクセスしない部分もありますが、ここではDBにアクセスして挙動を確かめるテストが記述されていました。

このテストではテストケースごとに、テストに必要となるリソースを作成・削除していたため遅くなっていましたが、うまくやれば並列化して高速化できるなと思いカイゼンに着手しました。

カイゼン

単に今の状態のまま並列化するだけでは、他のテストケースで作成されたリソースが邪魔をして正しくテストが動作しません。そこで以下の2つのアプローチを考えました。

  1. MySQLのデータベースをいくつかプールしておいて、テストケースごとに別のデータベースを利用する
  2. 同じデータベースを利用するが、作成するリソースを別テナントのリソースにする

1のアプローチはテストケースごとに接続するDBを変更するだけで、リソース作成の部分は変更が必要がありません。しかし、ここではテストケース数が膨大なためかなりの数のDBをプールしておく必要がありますし、他のテストケースがDBプールから空きDB待ちで速度がでないと判断したので、このアプローチは諦めました。

2のアプローチは、今まで同一テナントに作成していたリソースをテストケースごとに別テナントに作成する方法です。すでに述べたように、1つのDBに複数のテナントのデータが入っている構成になっています。そのため、リソースにはテナントのIDが必ず存在します。テストケースごとにリソースに紐づくテナントのIDを変えてしまえば、他のテストケースからそのリソースは見れなくなるため並列でテストケースを実行しても問題がありません。また前提としてテナントのID が異なるテストケースのデータ操作で、異なるテナントのテストに影響が出ることは起きてはなりません。

高速化する前の状態を表した図です。テストケースが違ってもリソースに紐づくテナントのIDはすべて同じになっています。

テナントのIDがテストケースが違っても同じ

こちらは、並列化できる理想の状態を表した図です。テストケースごとに、リソースに紐づくテナントのIDを変えています。

テナントのIDがテストケースごとに異なる

しかし、リソースの1つ1つの構造体に別々のテナントのIDを指定して行くのは面倒です。そのため、ここでは利用する側はテナントのIDのことを意識しなくても良いファクトリーを作成しました。だいたい以下のような実装になっています。

type Factory struct {
    db       *gorm.DB
    tenantID tenant.ID
}

// ①:ファクトリー初期化
func NewFactory(db *gorm.DB) *Factory {
    return &Factory{
        db:       db,
        tenantID: tenant.ID(uuid.NewString()),
    }
}

func (f *Factory) TenantID() tenant.ID {
    return f.tenantID
}

// ②:リソース作成の薄いラッパーメソッドの実装
func (f *Factory) CreateForm(t *testing.T, m *model.Form) *model.Form {
    t.Helper()
    m.TenantID = f.tenantID
    return createForm(t, f.db, m)
}
...

①のところでファクトリー初期化時に、テナントのIDをランダムで決めてしまいます。ここではUUID v4を利用してIDを振っています。そして、②のようにリソースを作成するためのメソッドを生やしますが、これは今まで叩いていたリソースを作成するための簡易なラッパーになっています。ここでやっていることは、このファクトリー初期化時に決めたテナントのIDを入れています。そのため、利用者はこのファクトリーのメソッドを利用してリソースを作成するだけで、別のテナントにリソースが作成されるようになっています。

テストケースごとのリソースの作成・削除をやめて、このファクトリーを利用してテストケースごとに別テナントでリソースを作成するようにしました。こうすることでこのテストケース全てを並列化できるようになりました。

結果

結果的には 3分45秒 かかっていたテストケースが28秒ほどで終わるようになりました。このファクトリーを導入したことで、他のテストケースでも容易に並列化できるようになり、今後テストケースが増えたとしても急激にテストの時間が増加することは無くなったため安心です。

番外編:CI上だけテストが速くならない

この高速化を実施した後でも、CI上(GitHub Actions)では相変わらずテストに時間がかかっていました。CI上では、 go test を実行してから、テストのログが出るまでが手元より明らかに遅い状態でした。

おそらくビルドに時間がかかっているのではないかと思っていたのですが go test 実行時にビルドにかかっている時間を調べるのに上手い方法が思い浮かばなかったため、Goのソースコードを手元に持ってきて、時間を計測して出力するコードを書いてビルドしました。 具体的には src/cmd/go/internal/work/exec.goL457あたりに時間を計測するコードを追加し、ざっくりした時間が分かるようにしました。この作成したバイナリを手元とCI上で利用することでパッケージごとの大体のビルド時間が go test 実行時に分かるようになりました。

以下はCI上での出力の例です。パッケージ名とビルドにかかった大体の時間が go test 実行時に出力されています。

...
package:elastic 7367ms
package:redis 4069ms
package:yaml    1197ms
package:testcert    10ms
package:httptest    195ms
package:assert  820ms
package:require 478ms
package:zip 1ms
package:sha1    26ms
package:sha256  22ms
package:deepcopy    25ms
package:sendgrid    41ms
package:writer  12ms
package:css 68ms
package:scanner 80ms
package:parser  90ms
package:atom    46ms
package:html    755ms
package:repository  5348ms
package:bluemonday  769ms
package:match   55ms
package:pretty  151ms
package:gjson   513ms

結果を比べてみると手元はほとんど0msと出力されているのに対し、CI上では最大で7秒ほどかかっているものもあるような状態でした。

手元ではキャッシュが効いているのだろうかと思い、 go clean -cache -testcache を実行した後に手元でテストを再実行したところ、手元でも遅くなりました。

原因がキャッシュと分かったところで、注意深くGitHub Actionsの定義を見てみると、キャッシュするべきパスが一つ抜けていたのが原因でした。 ~/.cache/go-build をキャッシュしていなかったのです。

- uses: actions/cache@v3
  with:
    path: |
      ~/.cache/go-build # ここがなかった
      ~/go/pkg/mod
    key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
    restore-keys: |
      ${{ runner.os }}-go-

これを指定し、一旦キャッシュされたあとは、CI上でも手元と変わらない時間でテストが実行されるようになりましたとさ、めでたしめでたし。