プロジェクトのCIを20分弱早くした

はじめに

この時期だと ○○ Advent Calendar の N 日目です となりそうなんですが特に何もないです

担当しているプロジェクトの CI を早くする事が出来たので自慢したい備忘録の記事になります

どのぐらい早くなったのかというと

before

before

after

after

20 分程度早くなりました

CI が遅いと push してからテスト結果出るまで 20 分待たないといけないストレスはもちろんなんですが、
本番にリリースする際も CI を実行し、テストが成功してからコンテナのイメージを作成していたため、
本番で何か問題があった場合に修正してからリリースするまで 最低 20 分掛かかってしまうという状況でした
(幸いにも急いでリリースしないといけない状況にはなりませんでしたが)

やったこと の前に

どんな事をして早くしたのを紹介する前に、開発/CI を行っている環境を説明します

担当しているアプリケーションでは Google Cloud Spanner (以降 spanner)を DB に使用しています
spanner はマネージドサービスのためローカルでの開発/テストではhandy-spannerを使用し、
CI では 本番と同様の環境で確認したいので、テスト実行前に共用のインスタンスに DB を 1 つ作成し実行後に削除するという形をとっています

CI 環境は CircleCI になります

ということで、どのような対応をしたのかを紹介したいと思います

やったこと

勘が鋭い人の中には複数 DB を用意して並列に実行したんでしょ?と思う人がいるかもしれませんが、
今回は複数 DB 使用する方法は採用しませんでした

やったこととしては

になります
それぞれ説明していきます

テストケース終了時のデータ初期化方法の変更

前述の通り、1 つの DB をテストケースごとに使いまわしています
そのため各テストケース終了後に後続のテストに影響が無いよう各テーブルのデータをリセットする必要があります

修正する前のリセット方法がこちらです

for _, table := range TestTables {
  stmt := spanner.NewStatement(fmt.Sprintf("DELETE FROM %s WHERE true", table))
  if _, err := c.spanner.PartitionedUpdate(ctx, stmt); err != nil {
    return fmt.Errorf("error partition update %w", err)
  }
}

(一部抜粋)

テーブルをパーティション化 DML で 1 個 1 個 テーブル分リセットしていました

パーティション化 DMLである理由を確認しましたが、テスト内で大量のデータを使用したテストケースも特になく不要であることが分かりました

なので、呼び出しを 1 回にまとめるよう修正しました

ms := []*spanner.Mutation{}
for _, table := range TestTables {
  ms = append(ms, spanner.Delete(ctx, spanner.AllKeys()))
  if _, err := c.spanner.Apply(ctx, ms); err != nil {
    return fmt.Errorf("error apply %w", err)
  }
}

(一部抜粋)

この修正で 15 分程テストが高速化しました

CI に必要なツールの cache

修正前は CI で使用する DB の migration ツール等を都度 go get でインストールするようになっていました
0 からインストールすると時間もかかるうえ、ダウンロードする度にバージョンが変わる可能性もあります
そのため、こちらを参考に使用している go 製のツールを go module で管理するように修正しました

併せて vendor および インストール済みのバイナリを cache することでインストールする時間を短縮しました

この修正で 2 分程早くなりました

確率チェックをしているテストケースの並列化

テストケースに確率をチェックするテストケースがありました

実際に数千回施行して結果をチェックしていましたが、DB に依存していなかったためt.Parallelを使用して並列で実行するように修正しました

この修正で 1 分程 CI が早くなりました

さいごに

で 20 分程 CI を高速化することができました

修正してみると遅い原因は まぁそうだよねという物がほとんどですが、少しでも参考になればと思います

おまけ

DB のデータのリセット方法、loop があるとはいえ使う API でこんなに差が出るもんなのかなと疑問だったのでベンチマークをとってみました

手元の MacBook Pro から 共用インスタンスの特定の DB に対して行いました

検証用のコードはこちらで削除対象テーブルを 1,3,5,10 と増やしてベンチを取ってみました

検証用コード
type client struct {
	spanner *spanner.Client
}

func New(ctx context.Context, database string, opts ...option.ClientOption) (*client, error) {
	cli, err := spanner.NewClient(ctx, database, opts...)
	if err != nil {
		return nil, fmt.Errorf("error new spanner client %w", err)
	}
	return &client{
		spanner: cli,
	}, nil
}

func (c *client) ResetEach(ctx context.Context) error {
	for _, table := range TestTables {
		stmt := spanner.NewStatement(fmt.Sprintf("DELETE FROM %s WHERE true", table))
		if _, err := c.spanner.PartitionedUpdate(ctx, stmt); err != nil {
			return fmt.Errorf("error partition update %w", err)
		}
	}
	return nil
}

func (c *client) ResetMulti(ctx context.Context) error {
	ms := []*spanner.Mutation{}
	for _, table := range TestTables {
		ms = append(ms, spanner.Delete(table, spanner.AllKeys()))
	}

	if _, err := c.spanner.Apply(ctx, ms); err != nil {
		return fmt.Errorf("error apply %w", err)
	}
	return nil
}

...

func BenchmarkResetEach(b *testing.B) {
	b.ResetTimer()
	ctx := context.Background()
	cli := new(ctx, b)
	for i := 0; i < b.N; i++ {
		if err := cli.ResetEach(ctx); err != nil {
			b.Error("error reset each", err)
		}
	}
}

func BenchmarkResetMulti(b *testing.B) {
	b.ResetTimer()
	ctx := context.Background()
	cli := new(ctx, b)
	for i := 0; i < b.N; i++ {
		if err := cli.ResetMulti(ctx); err != nil {
			b.Error("error reset multi", err)
		}
	}
}

(一部抜粋)


テーブル数: 1
goos: darwin
goarch: amd64
pkg: xxx
BenchmarkResetEach-16                 68        1014858045 ns/op
BenchmarkResetMulti-16              6736          10033684 ns/op
PASS
ok      xxx    207.788s

テーブル数: 3
goos: darwin
goarch: amd64
pkg: xxx
BenchmarkResetEach-16                 22        3026885868 ns/op
BenchmarkResetMulti-16              6650          11231347 ns/op
PASS
ok      xxx    246.167s

テーブル数: 5
goos: darwin
goarch: amd64
pkg: xxx
BenchmarkResetEach-16                 12        5007657921 ns/op
BenchmarkResetMulti-16              4845          18559626 ns/op
PASS
ok      xxx    200.698s

テーブル数: 10
goos: darwin
goarch: amd64
pkg: xxx
BenchmarkResetEach-16                  6        10029466758 ns/op
BenchmarkResetMulti-16              1868          34271077 ns/op
PASS
ok      xxx    140.096s

テーブル数が 1 つの場合でも 約 100 倍遅い
テーブル数が増えるとパーティション化 DML でないクエリでも遅くなるけど、100 倍近く遅いクエリがループクエリが増えていくので…

テーブル数が 10 の場合は 300 倍程遅くなってることが分かりますね

今回のケースのプロジェクトはテーブルが 10 ちょいあるから そりゃ 15 分も早くなるのも頷ける…という感じですね