M1チップ搭載のMac & RancherDesktop環境下におけるTestcontainersを使用したテスト失敗の原因の考察とその解決策

# はじめに
こんにちは、[RetailAI Advent Calendar 2023](https://qiita.com/advent-calendar/2023/rai-adventurers) の 20日目の記事です。

昨日は[jito-20231110](https://qiita.com/jito-20231110)さんの[『Coqを試してみた』](https://qiita.com/jito-20231110/items/72b7e0b590087981013d)でした。

この記事では、MacOSとRancherDesktopを使用した環境での、Testcontainersでpostgres DBを立ててテストを行おうとした際に発生した不具合と、その解決策について解説します。

# Testcontainersとは？
公式だと「Dockerコンテナ上で動かせるものならなんでも動かせる、軽量で使い捨てのインスタンスを生成するオープンソースフレームワーク」と説明があります<sup>[1](https://testcontainers.com/)</sup>。

主にテストの際に利用されることが多いようで、メリットとしては以下の3点が考えられます。
- テスト実行時だけ起動するコンテナを一時的に作成し、テストが終わったら削除することで前後のテストの影響を受けることなく外部サービスとの依存関係を持つ処理のテストができる。
- 一時的とはいえ実際にコンテナを立ち上げるため、テスト用の環境を使っているプロジェクトに対してコードの変更を少なくして導入できる。
- Dockerfileなどの設定を行わずにコンテナを立ち上げることが可能なため、環境構築が簡単である。

一方でデメリットとしては、テスト用の環境を作る方法に比べると高頻度でコンテナの起動と終了を繰り返すため、うまく使わないとテストが遅くなることだと考えられます。

とはいえテスト用DBのデータを毎回リフレッシュしないといけない手間を省けたり、テスト用のコンテナを複数起動して並列でテストを実行できるのでは？という期待を込めてこのフレームワークを使用してみました。

# 実行環境
- MacBook Air (M1チップ)
- macOS Sonoma 14.0
- golang 1.21.5
- RancherDesktop 1.11.1
- Docker 24.0.6-rd

# 発生した事象
まずは公式にあるいかにもコピー&ペーストで動きそうなサンプルコード<sup>[2](https://golang.testcontainers.org/modules/postgres/)</sup>をもとに、簡単なプロジェクトを作成し、テスト実行を試みました。
 
プロジェクトのディレクトリ構造

```
my-test-project
├── go.mod
├── go.sum
├── sample
│   └── sample_test.go
└── testdata
    ├── init-user-db.sh
    └── my-postgres.conf
```
sample_test.go
```golang
package sample

import (
	"context"
	"database/sql"
	"log"
	"os"
	"testing"
	"time"

	_ "github.com/jackc/pgx/v4/stdlib"
	"github.com/stretchr/testify/assert"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

var db *sql.DB

const driver = "pgx"

func TestMain(m *testing.M) {
	ctx := context.Background()

	dbName := "users"
	dbUser := "user"
	dbPassword := "password"

	postgresContainer, err := postgres.RunContainer(ctx,
		testcontainers.WithImage("docker.io/postgres:15.2-alpine"),
		postgres.WithInitScripts("../testdata/init-user-db.sh"),
		postgres.WithConfigFile("../testdata/my-postgres.conf"),
		postgres.WithDatabase(dbName),
		postgres.WithUsername(dbUser),
		postgres.WithPassword(dbPassword),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections"). 
				WithOccurrence(2). 
				WithStartupTimeout(5*time.Second)), 
	)
	if err != nil {
		log.Fatal(err)
	}

	defer func() {
		if err := postgresContainer.Terminate(ctx); err != nil {
			log.Fatal(err)
		}
	}()

	connStr, err := postgresContainer.ConnectionString(context.Background(), "sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}

	db, err = sql.Open(driver, connStr)
	if err != nil {
		log.Fatal(err)
	}

	code := m.Run()

	os.Exit(code)
}

// 追加したテスト
func TestQueryRowContext(t *testing.T) {
	q := `SELECT * FROM testdb;`
	row := db.QueryRowContext(context.Background(), q)

	var (
		id   int
		name string
	)
	err := row.Scan(&id, &name)
	if err != nil {
		log.Fatal(err)
	}

	assert.Equal(t, 1, id)
	assert.Equal(t, "test", name)
}
```
init-user-db.sh
```bash
#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
	CREATE USER docker;
	CREATE DATABASE docker;
	GRANT ALL PRIVILEGES ON DATABASE docker TO docker;
    CREATE TABLE IF NOT EXISTS testdb (id int, name varchar(255));
    INSERT INTO testdb (id, name) VALUES (1, 'test')
EOSQL
```
※my-postgres.confはとても長く、本記事の本題と関連が薄いと判断しため、省略します。<sup>[3](https://github.com/testcontainers/testcontainers-go/blob/main/modules/postgres/testdata/my-postgres.conf)</sup>

上記のコードを準備して"test go"を実行すればテストが実行されるはずですが、コンテナに接続できずにテスト失敗となりました。
```
2023/12/19 07:38:47 failed to connect to `host=localhost user=user database=users`: dial error (dial tcp [::1]:32804: connect: connection refused)
exit status 1
FAIL    sample/sample   2.713s
```
コンテナ起動時にエラーが発生していないか調べるためにtime.Sleepでテストを止めてコンテナのログを見るも、特にエラーが発生しておらずハマってしまいました。

ところが、time.Sleepをcmd+Cで中断することなくそのまま放置してみるとテストが成功しているではないですか。
```
PASS
ok      sample/sample   6.691s
```

現状起きている現象をまとめると以下の通りです。
1. sample_test.goのwait.ForLogでコンテナのログに"database system is ready to accept connections"という文字列が出現するまで待機する。
2. 上記文字列がコンテナのログに出現したので、アプリがこれを検知してテストを実行する。
3. しかし、2.の時点ではコンテナは接続待機状態になっておらず、エラーが発生する。
4. 上記文字列が出現してから数秒後にコンテナが接続状態となり、接続が可能になる。

# 解決策
time.Sleepで単純にスリープする方法は実行環境ごとに接続可能になるまでの時間が変わる（かもしれない）ので、その度修正が必要で手間がかかります。また、テストを行う身としてはコンマ１秒でもとにかく早く結果を知りたいため、待機時間をハードコーディングする方法はお勧めしません。
上記以外の方法でこのエラーを解消できないか調べてみました。

### その1：WithWaitStrategyにForExposedPortを設定する
sample_test.goのTestMainのコードを以下のように修正することで毎回テストが通るようになります。ForExposedPortはコンテナ側のポートの公開が完了するまで待機するメソッドです。

sample_test.go
```diff_go
func TestMain(m *testing.M) {
	ctx := context.Background()

	dbName := "users"
	dbUser := "user"
	dbPassword := "password"

	postgresContainer, err := postgres.RunContainer(ctx,
		testcontainers.WithImage("docker.io/postgres:15.2-alpine"),
		postgres.WithInitScripts("../testdata/init-user-db.sh"),
		postgres.WithConfigFile("../testdata/my-postgres.conf"),
		postgres.WithDatabase(dbName),
		postgres.WithUsername(dbUser),
		postgres.WithPassword(dbPassword),
		testcontainers.WithWaitStrategy(
-			wait.ForLog("database system is ready to accept connections").
-				WithOccurrence(2).
-				WithStartupTimeout(5*time.Second)),
+  			wait.ForExposedPort(),
	))
	if err != nil {
		log.Fatal(err)
	}
 // 以下省略
```
Colimaなどのいくつかのアプリケーションではポートのフォワーディングが遅れることで上記エラーが発生する現象が確認されており<sup>[4](https://node.testcontainers.org/supported-container-runtimes/)</sup>、詳しい記載はないですがRancherDesktopも同様の問題が発生しているようです。

### その2：RancherDesktopではなくDockerDesktopを使用する
RancherDesktopの問題のようなので、代わりにDockerDesktopを使ってテストを試してみると、WithWaitStrategyの変更をしなくてもテストが通りました。
<br>※DockerDesktopを使用してから最初にTestcontainersを使用したテストを実行した際に<br>"Failed to get image auth for docker.io. Setting empty credentials for the image: docker.io/postgres:15.2-alpine. Error is:credentials not found in native keychain"<br>がログに出現しますが、しばらく放置するとイメージの取得に成功し、その後テストが成功します。 

# 考察
### RancherDesktop側の問題
RancherDesktopのアーキテクチャを調べると<sup>[5](https://docs.rancherdesktop.io/references/architecture/)</sup>、LimaというVMを使っているようです。LimaとはMacOS上でLinuxが搭載されたVMを立ち上げるアプリであり、ファイルの自動共有機能やポートのフォワーディングの機能を提供しています<sup>[6](https://github.com/lima-vm/lima)</sup>。フォワーディングの機能を提供しているということでさらに調べてみると、Limaがフォワーディングを5秒間隔で試みる処理が原因だとの考察が見つかりました<sup>[7](https://github.com/abiosoft/colima/issues/71#issuecomment-979516106)</sup>。Limaの公式サイト<sup>[8](https://lima-vm.io/)</sup>をみてみると、解決策その１で挙げたColimaもLimaを採用していることから、RancherDesktopでも同様にフォワーディングが遅いことによる問題が発生してると考えられます。

### M1チップ側の問題
M1チップ搭載のMacの場合はWindowsやLinux、Intel CPU搭載のMacと違い、Testcontainersを実行するのに環境変数の設定などの準備が必要だということで、M1チップもこの問題に関連しているようです<sup>[9](https://docs.rancherdesktop.io/how-to-guides/using-testcontainers)</sup>。こちらについては問題を特定できませんでした…が、M1チップの処理が速すぎて、ポートのフォワーディングが完了する前にテストが動いているのではないか？という考察が見つかりました<sup>[10](https://github.com/rancher-sandbox/rancher-desktop/issues/2609#issuecomment-1718480801)</sup>。

# まとめ
M1チップを搭載してるMac上でRancherDesktopを使用してTestcontainersでテストを実行しようとしても、Limaのポートフォワーディングの完了がコンテナ上のDBのセットアップ=>テスト実行に間に合わないことで今回のエラーが発生したと考えられます。

よって、上記の問題に対してはポートが公開されるまで待機するForExposedPortを使用する(golangを使用していない場合はそれに該当する他言語のメソッド)、もしくはDockerDesktopといったLimaを使用しないアプリケーションでdockerを動かし、Testcontainersを実行することで問題を解決できると結論づけました。

# さいごに１
この記事の執筆をきっかけに調査を行った結果、ようやくまともにTestcontainerを動かせるようになりました。この労力を無駄にしないためにも今後の業務に活かしていきたいと思います。

今回はLimaのフォワーディングの詳細について踏み込めれなかったので、今後も調査して何か分かり次第この記事を修正していこうと思います。

# さいごに２
最後までご覧いただきありがとうございます！
明日は[@satoshihiraishi](https://qiita.com/satoshihiraishi)さんの担当です。お楽しみに！

# 参考
- https://testcontainers.com/
- https://golang.testcontainers.org/modules/postgres/
- https://github.com/testcontainers/testcontainers-go/tree/main/modules/postgres
- https://node.testcontainers.org/supported-container-runtimes/
- https://docs.rancherdesktop.io/references/architecture/
- https://github.com/lima-vm/lima
- https://docs.rancherdesktop.io/how-to-guides/using-testcontainers/
- https://github.com/abiosoft/colima/issues/71
- https://github.com/rancher-sandbox/rancher-desktop/issues/2609