GoアプリのCI/CDを4倍高速化した汎用的手法まとめ【txdb】

2025/01/31に公開

はじめに

こんにちは!J-CAT 株式会社でエンジニアをしている田中です。

弊社では、魅力あふれる日本の姿をここでしかできない感動体験として届ける予約サイトOtonami」というサービスを運営しており、このサービスのバックエンドとして、Go言語を採用しております。

これまで課題として認識していながらも、中々着手できずにいたバックエンドのCI/CD改善に本格的に取り組み、その処理速度を劇的に向上させることができました

この記事では、そのために実施したアプローチを汎用的に活用できる手法として整理し、紹介していきます。

結論として、ざっくり以下の対応を行いました。

  1. ビルドタグを利用したジョブの並列化
  2. デプロイ先の変更(不本意)
  3. txdbを利用したトランザクション → ロールバックのテスト
  4. t.Parallelを用いたテストの並列化
  5. テストをdockerコンテナ実行 → プリセットアクション実行に切り替える

どれくらいCI/CDが改善したのか

今回の手法を紹介する前に、まずはこの取り組みによってどれくらい改善したのかを紹介します。

最終結果としては、CIは当初の約6倍程高速化し、CDは約4倍程高速化させました。

今回の改善は、2回に分けて行ったため、それらをPhase1, Phase2とし、それぞれでどれくらい変わったのかを紹介します。

ちなみに2回に分かれた理由は、Phase1で若干短縮はできたものの、時間とコード量の増加とともに、CI/CD時間がかなり長くなってしまったためです。

Phase1(改善実施前 → 部分的に改善) 20%短縮

Before 15m 32s

After 13m8s

この時点では正直あまり改善はできなかったのですが、少し短縮されたのもあり、一旦放置してました。

それから数ヶ月が経ち、CI/CD時間がまた悪化してきたため、再度改善を試みました。

Phase2(部分的に改善 → 全体的に改善) 75%短縮

Before 21m18s

After 5m6s

改善に取り組む前は、テスト処理自体がとても重くなっていたため、ローカルで他の作業と並行してテストを走らせた時は、ファンが回るほど負荷が重かったのですが、Phase2を終えた時点で、テスト実行自体もかなり軽くなり、サクサクテストをすることができるようになりました。

次項でその具体的な手法を紹介していきます。

具体的な改善手法

1. ビルドタグを利用してCI上でテストを分割し、並列化する

Phase1で行った手法です。

//go:build test || db_test_controller , //go:build test || db_test_application といった具合に、レイヤー固有のビルドタグを付与することで、github actions内で実行ジョブを分けることができ、並列にテストを処理できるようにしました

go testオプションの-pタグを使ってパッケージ単位で並列に処理させることも可能だったのですが、プライベートリポジトリ配下にあるgithub actionsのジョブは2vCPUしか積んでいないため(参考: Standard GitHub-hosted runners for private repositories)
、今回のように2つ以上のレイヤーを並列で走らせる場合、1つのjobで各レイヤーを走らせるよりもjob単位でテストを走らせた方が良いと判断し、job単位でテストを並列処理させるようにしました。

2. デプロイ先を変えることで、CDを短縮する

こちらは、不本意ではあったのですが、バックエンドのデプロイ先をApp Engine → Cloud Runに変えることで、デプロイ時間が4分の1ほどに短縮されました。

Phase2での基盤を変える以前はデプロイに12分も要していたのですが、Cloud Runに切り替えたタイミングで3分台になりました。

デプロイの時間を短くするためだけに、アプリケーション基盤を変更するのだと、あまり気が進まないかと思いますが、何らかのタイミングで基盤を変えるとなった時は、デプロイ時間も気にかけると良さそうです。

今回のように大幅にデプロイ時間を短縮できるかもしれません。(逆も然り)

3. txdbを利用して、トランザクション→ロールバック方式のテストにする

前提として、弊社のテストではインテグレーションテストも行っており、docker上でtest用のコンテナ、dbコンテナを立ててテスト実行をしていました。

しかし、この方法ではテストごとにディスクI/Oが発生し、処理が重く、実行時間が非常に長いという問題がありました。

なので、テスト実行時間短縮に関してはgo-txdb が1番貢献してくれました。

go-txdb は、テスト専用のSQLドライバで、単一のトランザクション内でSQL操作を行い、接続を閉じると自動でロールバックします。この仕組みにより、テストごとにDB状態をリセットできるため、他のテストケースに影響を与えることなく効率的に実行できます

txdbが貢献してくれたポイントは大きく2つあって

  1. 各インテグレーションテストでI/O処理がなくなるため、各テスト時間が大幅に短縮でき、処理も軽くなる。
  2. 最終的にロールバックされるため、並列にテスト実行ができ、さらなる高速化が見込める。

といった点になります。

従来のテストがあまりにも長かったため、txdbを使ったとしてもそこまで変わるのかと思ってたのですが、実際に導入して実行してみると、1番重いテスト実行でさえも0.x秒で終わったので、とても感動しました。

txdbの導入手順

  1. go-testfixtures/testfixtures などのinitデータを投入できるライブラリでDBのセットアップ
  2. 各テストケースでtxdbを走らせる

とても簡単に導入できます。

https://zenn.dev/rinchsan/articles/83cf6f3b5d70c4d9b5d4
の記事がとても参考になりました。

ただ、「testfixturesを使ったDBセットアップを各テストケース毎に行いたい」といった時には、txdbを使うのが難しくなってしまいます。

その理由と、それを解消するための対応は次の手法で解説します。

4. go-testfixtures/testfixturesのソースコードを書き換えて、テスト毎にtestfixtures × txdbを走らせる。

3での手法は、testfixturesでモックデータを投入してから各テストケースをtxdbで走らせる分には有効でした。

しかし、各テストケースでモックデータの投入して実行したい場合は、内部でトランザクションを張っているテストでは下記のようなエラーを吐いて失敗してしまいます。

Error 1305: SAVEPOINT tx_1 does not exist

これは、txdb が 1 つの接続で 1 つのトランザクションを管理しているため、testfixtures が内部でトランザクションを開始すると、その時点で txdb のトランザクションが使い切られてしまうのが原因です。(該当コード: go-testfixtures該当コード)

そのため、両者を併用するには、testfixturesのトランザクションを張らない実装を再現する必要がありました。

こちらの対応として、testfixturesのコードを参考にしつつ、YAMLファイルからデータのinsert、トランザクションを張らない実装をしました。

トランザクションを挟まずにyamlデータをdbに投入する

test_util.go
import (
	"database/sql"
	"encoding/hex"
	"fmt"
	"io/fs"
	"log"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"
	"time"

	"gopkg.in/yaml.v2"
)

var (
	_, file, _, _ = runtime.Caller(0)
	rootPath = filepath.Join(filepath.Dir(file), "./")
)

// DBに挿入するデータの構造体
type Data map[string]interface{}

// LoadYAMLToDBは、テストファイル(ex: hoge_test.go)で呼ばれる
// 大まかな処理の流れとしては
// 1. hoge_test.goがあるディレクトリからhoge_testディレクトリを探し、その中にあるyamlファイルを抽出する
// 2. テーブル名.yamlから挿入したいレコードを取り出し、sql用にデータを整形
// 3. dbの既存データをリフレッシュし、2で整形したレコードを挿入
func LoadYAMLToDB(t *testing.T, db *sql.DB, path string, filenames []string) error {
	t.Helper()

  // こちらの、yamlデータをdbへ投入する処理では、
  // 以下のようなツリー構造を想定して実装されている。
	// hoge_layer/
	// ├── hoge_test/
	// │   ├── companies.yaml
	// │   └── users.yaml
	// └── hoge_test.go
	if path == "" {
		t.Fatal("pathは必須パラメータです")
	}
	
	// hoge_layer/hoge_testディレクトリを抽出
	fileSystem := os.DirFS(getTestDataDir(path))
	
	
	for _, file := range filenames {
		// ファイルパスを組み立てる
		filePath := filepath.Join(path, file)
		// YAMLファイルを読み込み (fsパッケージを使用)
		yamlData, err := fs.ReadFile(fileSystem, file)
		if err != nil {
			return fmt.Errorf("failed to read file %s: %v", filePath, err)
		}
		tableName := file[:len(file)-len(filepath.Ext(file))] // 拡張子を除去

		// YAMLデータをパース
		var records []Data
		if err := yaml.Unmarshal(yamlData, &records); err != nil {
			return fmt.Errorf("failed to unmarshal YAML file %s: %v", file, err)
		}

		// データベースに挿入
		if err := insertDataIntoTable(db, tableName, records); err != nil {
			return fmt.Errorf("failed to insert data into table %s: %v", tableName, err)
		}

	}
	return nil
}
func getTestDataDir(path string) string {
	return path[:len(path)-len(filepath.Ext(path))]
}

// "RAW=" のチェックと整形処理
func processRawString(s string) (string, error) {
	if strings.HasPrefix(s, "RAW=") {
		rawValue := strings.TrimPrefix(s, "RAW=")
		rawValue = strings.Trim(rawValue, `"`) // 前後の " を削除
		return rawValue, nil

	}
	return "", fmt.Errorf("string does not start with 'RAW='")
}
func tryHexStringToBytes(s string) ([]byte, error) {
	if !strings.HasPrefix(s, "0x") {
		return nil, fmt.Errorf("not a hexadecimal string, must be prefix 0x")
	}
	return hex.DecodeString(strings.TrimPrefix(s, "0x"))
}
// 下記のフォーマットで挿入されるデータはDATE型に変える
var timeFormats = [...]string{
	"2006-01-02 15:04",
	"2006-01-02 15:04:05",
	"20060102",
	"20060102 15:04",
	"20060102 15:04:05",
	"2006-01-02 15:04:05",
	"02/01/2006",
	"02/01/2006 15:04",
	"02/01/2006 15:04:05",
	"2006-01-02T15:04-07:00",
	"2006-01-02T15:04:05-07:00",
	"2006-01-02T15:04:05Z07:00",
	"2006-01-02 15:04:05Z07:00",
	"2006-01-02T15:04:05Z0700",
	"2006-01-02 15:04:05Z0700",
	"2006-01-02T15:04:05Z07",
	"2006-01-02 15:04:05Z07",
	"2006-01-02 15:04:05 MST",
}

// tryStrToDate converts a string into time.Time using multiple formats.
func tryStrToDate(s string) (time.Time, error) {

	for _, f := range timeFormats {
		t, err := time.ParseInLocation(f, s, time.Local)
		if err != nil {
			continue
		}
		return t, nil
	}
	return time.Time{}, fmt.Errorf(`could not convert string "%s" to time`, s)
}

func insertDataIntoTable(db *sql.DB, tableName string, records []Data) error {
	if len(records) == 0 {
		return nil
	}

	// モックデータを投入する際は、一旦外部キーを無視することで、挿入順序を気にしなくて良いようにする。
	if err := toggleForeignKeyChecks(db, false); err != nil {
		return err
	}
	defer func() {
		if err := toggleForeignKeyChecks(db, true); err != nil {
			log.Printf("failed to enable foreign key checks: %v", err)
		}
	}()
	// 初期データがあれば、削除
	if err := deleteTableData(db, tableName); err != nil {
		return err
	}

	for _, record := range records {
		if err := insertRecord(db, tableName, record); err != nil {
			return err
		}
	}

	return nil
}

func toggleForeignKeyChecks(db *sql.DB, enable bool) error {
	value := 0
	if enable {
		value = 1
	}
	_, err := db.Exec(fmt.Sprintf("SET FOREIGN_KEY_CHECKS = %d", value))
	if err != nil {
		return fmt.Errorf("failed to toggle foreign key checks: %v", err)
	}
	return nil
}

func deleteTableData(db *sql.DB, tableName string) error {
	query := fmt.Sprintf("DELETE FROM %s", tableName) 
	_, err := db.Exec(query)
	if err != nil {
		return fmt.Errorf("failed to delete records from table %s: %v", tableName, err)
	}
	return nil
}

func insertRecord(db *sql.DB, tableName string, record Data) error {
	columns, placeholders, rowValues, err := prepareInsertData(record)
	if err != nil {
		return err
	}

	query := fmt.Sprintf(
		"INSERT INTO %s (%s) VALUES (%s)",
		tableName,
		strings.Join(columns, ", "),
		strings.Join(placeholders, ", "),
	)
	_, err = db.Exec(query, rowValues...)
	if err != nil {
		return fmt.Errorf("failed to execute query: %v", err)
	}
	return nil
}

func prepareInsertData(record Data) ([]string, []string, []interface{}, error) {
	var columns []string
	var rowValues []interface{}
	var placeholders []string

	for col := range record {
		columns = append(columns, col)
	}

	// geoのような特殊な型だとプレースホルダーで値を入れられなかったため、直接書き込んでinsertするようにしてる。
	sort.Slice(columns, func(i, j int) bool {
		if columns[i] == "geo" {
			return true
		}
		if columns[j] == "geo" {
			return false
		}
		return columns[i] < columns[j]
	})

	for _, col := range columns {
		value, err := processColumnValue(record[col])
		if err != nil {
			return nil, nil, nil, err
		}
		rowValues = append(rowValues, value)
		placeholders = append(placeholders, "?")
	}

	placeholders, rowValues = adjustPlaceholdersForGeo(rowValues, placeholders)
	return columns, placeholders, rowValues, nil
}

func processColumnValue(value interface{}) (interface{}, error) {
	if strValue, ok := value.(string); ok {
		if rawValue, err := processRawString(strValue); err == nil {
			return rawValue, nil
		} else if strings.HasPrefix(strValue, "0x") {
			decodedBytes, err := tryHexStringToBytes(strValue)
			if err != nil {
				return nil, fmt.Errorf("failed to decode hex string: %v", err)
			}
			return string(decodedBytes), nil
		} else if parsedTime, err := tryStrToDate(strValue); err == nil {
			return parsedTime.Format("2006-01-02 15:04:05"), nil
		}
	}
	return value, nil
}

func adjustPlaceholdersForGeo(values []interface{}, placeholders []string) ([]string, []interface{}) {
	var newValues []interface{}
	for i, value := range values {
		if strValue, ok := value.(string); ok && strings.Contains(strValue, "ST_GeomFromText") {
			placeholders[i] = strValue
		} else {
			newValues = append(newValues, value)
		}
	}
	return placeholders, newValues
}

上記を用意して、テスト実行の際は以下のように呼び出します。

テスト用DBのセットアップ

test_init_db.go
func init() {
	txdb.Register("txdb", "mysql", readTestDSN())
}

func InitTestDB(t *testing.T) *sql.DB {
	// テスト用DB名を生成 コネクション名はランダム文字列で生成する。
	connectionName, gErr := generateRandomDBName("test_db_", 16)
	if gErr != nil {
		t.Fatal("failed to generate random database name: %w", gErr)
	}

	db, err := sql.Open("txdb", connectionName)
	if err != nil {
		t.Fatal("db setup is failed:%w", err)
	}
	t.Cleanup(func() {
		cErr := db.Close()
		if cErr != nil {
			t.Fatal("failed to close db: %w", cErr)
		}
	})
	return db
}

テストケース作成

hoge_test.go
func Test_Hoge(t *testing.T) {
  var _, filePath, _, _ = runtime.Caller(0)
	// TDDでテストケースを準備
	type args struct {
			ctx context.Context
			req *Request
		}
	tests := []struct {
		name                          string
		args                          args
		want                          *Response
		wantTransactionErr            bool
	}{
		{
			name: "TEST 1",
			args: args{
				ctx: context.Background(),
				req: argReq,
			},
			want: &Response{
				Hoge: hoge,
			},
			wantTransactionErr: false,
		},
		{
			name: "TEST 2",
			args: args{
				ctx: context.Background(),
				req: argReq,
			},
			want:               nil,
			wantTransactionErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			db := InitTestDB(t)
		// セットしたいテーブル名をファイル名としたyamlのデータを投入する。
			dErr := testutil.LoadYAMLToDB(t, db, filePath, []string{"users.yaml", "companies.yaml"})
			if dErr != nil {
				t.Errorf("Init DB error = %v", dErr)
			}
			// テストしたい処理に上で用意したdbを注入する。
			got, err := u(db).Create(tt.args.ctx, tt.args.req)

			if (err != nil) != tt.wantTransactionErr {
				t.Errorf("Create() error = %v, wantTransactionErr %v", err, tt.wantTransactionErr)
			}
			if (err == nil) && (!tt.wantTransactionErr) {
				// 各アサーション
				...
			}
		})
	}
}

テストの流れとしては、

  1. yamlのモックデータを作成(ファイル名 = テーブル名)
  2. LoadYAMLToDBでトランザクションを張らずにモックデータを投入する
  3. テストしたいメソッドにtxdbで作成したdbを注入して実行する
  4. 各アサーションの実行

になります。

これにより、テスト毎にデータのセットアップが可能になり、txdbによりディスクI/Oを避けられるので処理もかなり高速化します。

余談ですが、「これなら、各テストの並列処理も可能なのでは?」と思ったのですが、この手法を導入しているパッケージを並列化してもうまくいきませんでした、、(細かい原因はわかってないです、、)

とはいえ、順序実行だとしても処理はかなり高速化できました。

5. 並列にテストできるパッケージは、t.Parallelを挿入して並列化

以前まではDBへのI/O処理で競合が起きてしまうため、テスト全体を順次実行していたのですが、txdbを入れたことで、並列実行を導入しました。

t.Parallelに関しては

https://engineering.mercari.com/blog/entry/how_to_use_t_parallel/
こちらを参考にし、各テストケースにセットしました。

6. Github Actions上で、「Dockerを用いたテスト」ではなく、「actions/setup-go」を使う

今までは、github actionsでアプリケーションコンテナとDBコンテナをDocker上で起動させてテストを実行してました。

そのため、ImageのPull ~ ビルド実行までに時間を要していたため、こちらも改善する必要がありました。

こちらへのアプローチとして、アプリケーション側はコンテナを立てて動かすのではなく、actions/setup-go上で動かすことで、テスト実行までの時間を短縮することができました。

また、actions/setup-goは、goモジュールとビルドファイルをキャッシュするため、2回目以降のテスト実行までの時間はさらに速くなります。

.github/workflows/deploy.yml
...
test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Go
        uses: actions/setup-go@v5
        id: setup-go
        with:
          go-version-file: "go.mod"
      - name: Download Go modules
        shell: bash
        if: ${{ steps.setup-go.outputs.cache-hit != 'true' }}
        run: go mod download
      - name: Start Integration Test
        run: |
          // dbコンテナを立てる
	        make up-db 
          go test ./pkg/... -tags=\"hoge_test\"

さいごに: テストが速いと副次的なメリットがたくさんある

CI/CDを短縮することは直接的なビジネスインパクトがないため、よく後回しにされがちですが、

今回この改善に取り組んだことで、ローカルでのテスト実行回数が上がり、その分PDCAを早く回せるようになったため、開発生産性はとても上がったように感じました。

また、テスト処理もかなり軽量になったため、テスト実行が全く億劫に感じなくなったのも、集中力を維持でき、とても良かったなと思ってます。

今回の施策を通して、開発者エクスペリエンスを上げることの重要性を身に染みて理解できたので、開発生産性周りの課題はこれからも定期的取り組んで改善していきたいです。

J-CATテックブログ

Discussion