Cloud Spanner Emulator を Go のテストから起動する今どきのやりかた
この記事は apstndb Advent Calendar 2024 の 8日目の記事 & Spanner Advent Calendar 2024 の8日目の記事です。
Spanner に依存するコードのテストをする時、困るのは Spanner は OSS ではないクラウドサービスとしてのみ存在することです。
Spanner の実インスタンスをテストに使うこともできますが、コスト、スキーマの管理などさまざまな問題があります。
- 時間: Spanner インスタンスを毎回立ち上げてシャットダウンするのは時間が掛かる。
- コスト: Spanner は 100 PU(それまで標準だったノードに対する1/10の単位)からの小さいインスタンスも作成できるようになったとはいえ、月額66ドル程度は掛かる。
- 手間: インスタンスにテスト用のデータベースのスキーマを用意し、毎回掃除するのは手間が掛かる。
- 100 PU あたり10データベースまでの制限があるためテスト用データベースを作りっぱなしというわけにはいかない。
- 無料トライアルインスタンスも存在するが、これはこれで制限が多い。
- プロジェクトに1つまで、削除しても再作成はできない。
- データベースは5つまで。
- そもそも90日しか使えず、 継続的なテストと開発には使うなと書いてある。
Spanner のエミュレータとして特記すべきものはおそらく下記の3つでしょう。
-
GoogleCloudPlatform/cloud-spanner-emulator
- 公式ドキュメント Emulate Spanner locally にも書かれており Cloud Support の対象でもある公式エミュレータ
Please file bugs and feature requests using GitHub's issue tracker or using the existing Cloud Spanner support channels.
- 公式ドキュメント Emulate Spanner locally にも書かれており Cloud Support の対象でもある公式エミュレータ
-
googleapis/google-cloud-go の spannertest
- 最も古い実装だが、その parser である spansql と共に事実上凍結されている。
-
gcpug/handy-spanner
- kazegusuri さんによる実装: https://speakerdeck.com/kazegusuri/handy-spanner-gcpug
Spanner は高度なデータベースであるため現在十分に追従できているものは公式の Spanner エミュレータです。
もちろん Spanner エミュレータに問題がないわけではありません。
- Spanner エミュレータは Apache ライセンスのオープンソースソフトウェアですが、オープンに開発されているわけではなくコントリビューションは受け付けていません。問題があってプルリクエストを送るのは無駄な作業です。
-
We are currently not accepting external code contributions to this project.
-
-
Features and Limitations に書かれた制限は正しく理解しましょう。
- 特にデータベースへの並行する変更はサポートされていません(内部的にグローバルロックがあります)。
The emulator only allows one read-write transaction or schema change at a time. Any concurrent transaction will be aborted. Transactions should always be wrapped in a retry loop. This recommendation applies to the Cloud Spanner service as well.
- 特にデータベースへの並行する変更はサポートされていません(内部的にグローバルロックがあります)。
- Spanner の機能で Spanner エミュレータがサポートしていないものはドキュメントされていないものもあります。
- 一般的に新機能は6ヶ月以内の実装を目標にしているようです。
Currently, our aim is to make newly features available in the Emulator within 6 months of their launch in the production service.
- 最近はサービスのリリースノートに乗るより先に Spanner エミュレータに実装される機能もあり、改善されているようには思います。
- 時々未対応のまま忘れられている機能もあるように思うので、気付いたら issue をあげましょう。
- 一般的に新機能は6ヶ月以内の実装を目標にしているようです。
それでも Spanner エミュレータがこれからも最も完成度が高いものとして開発が続けられるというのは確かなため、この記事ではここからは公式の Spanner エミュレータのみに言及します。
公式ブログでも紹介されている Spanner エミュレータを Go のテストから起動する方法: testcontainers-go
実は、 Go のテストの中から Spanner エミュレータを起動する方法についてはすでに Google Cloud 公式ブログの記事、エミュレータを使用した Spanner インテグレーション テストにて解説されています。
具体的にはプログラム(主にテスト内)で Docker コンテナを起動するための OSS フレームワークフレームである testcontainers の Go 実装である testcontainers-go を使って Spanner エミュレータを使う方法について解説しています。
この記事に書いてある内容はどんなコンテナにも応用でき十分に問題は解決できるのですが、testcontainers.GenericContainer()
は Spanner エミュレータを知っているわけではないので、色々と設定が必要となります。
- gRPC, REST それぞれ export されているポートを扱うためのポートマッピングやコンテナネットワークの設定(
ExposedPorts
,Networks
,NetworkAliases
) - 起動完了を検知するための設定(
WaitingFor
) - 起動した後でエンドポイントを使うまでも少し煩雑
testcontainers-go の GCloud モジュール
実はもう少し簡単な方法が testcontainers-go によって提供されています。
testcontainers にはモジュールの概念があり、有名なコンテナイメージについては設定をほとんどすることなく起動やポートのマッピングができるようになっています。
testcontainers-go と Google Cloud の組み合わせでは、 GCloud モジュール が Spanner エミュレータにも対応しています。
GCloud モジュールを使うと、コンテナの起動と使用するエンドポイントの取得までが非常に楽になります。必要なコードはこれだけです。
ctx := context.Background()
spannerEmulator, err := gcloud.RunSpanner(ctx, "gcr.io/cloud-spanner-emulator/emulator:1.5.28")
if err != nil {
return
}
defer spannerEmulator.Terminate(ctx)
endpoint := spannerEmulator.URI
これで全て解決でしょうか。既存の OSS で何も不満がなければどんなに良かったことでしょうか。
セットアップの単純化のニーズの整理
エミュレータを使用した Spanner インテグレーション テスト を改めて読んでみると、 Spanner エミュレータの起動そのものはテストで Spanner エミュレータを使うコードの一部でしかありません。
起動したばかりの Spanner エミュレータはまっさらな状態であり、 SQL を実行するために必要な Spanner インスタンスやデータベースをそれぞれ別々の Admin API クライアントを作って作成する必要があります。
さらにテスト用のデータベーススキーマの DDL やテストデータの DML の実行などの下準備をしなければテストを実行する段階まで辿りつくことができません。
これらのコードは全てのプロジェクトで各々書かないといけないものでしょうか?そんなことはないでしょう。
つまり、次のようなニーズがあることが分かります。
- SQL の実行に使う Spanner インスタンスとデータベースは自動的に生成したい。
- (Java の Spanner クライアントライブラリにはこれをする
autoConfigEmulator
があるのに Go の Spanner クライアントライブラリにはない…)
- (Java の Spanner クライアントライブラリにはこれをする
- スキーマの DDL, テストデータの DML をできるだけ簡単に投入したい。
また、テストケースごとに Docker コンテナとして別の Spanner エミュレータを起動するのはクリーンではありますが、オーバーヘッドが大きすぎます。
Spanner エミュレータの FAQ には次のような記述があります。
What is the recommended test setup?
Use a single emulator process and create a Cloud Spanner instance within it. Since creating databases is cheap in the emulator, we recommend that each test bring up and tear down its own database. This ensures hermetic testing and allows the test suite to run tests in parallel if needed.
推奨する使い方は、複数のテストケースでエミュレータを共有し、それぞれのテストケースではデータベースを分けることでテストが独立するようにするのが推奨とのことです。
この使い方を正しくやろうとすると、データベース ID が重複しないようにセットアップするなどそれなりに煩雑になりそうですね。
私のソリューション: spanemuboost
私個人で開発しているため自由に変更できる Spanner 関係ツールとして execspansql, spanner-mycli など複数あり、
それらの統合テストを Spanner エミュレータベースにして改善する中で私自身も上のニーズを解決するためのものを書くこととなりました。
これを SPANner EMUlator の BOOtStrapper ということで、 apstndb/spanemuboost という名前で公開しました。
spanemuboost を使うと、インスタンスとデータベースは自動的に作成され、設定済みの Spanner クライアントをすぐに使うことができます。
func ExampleNewEmulatorWithClients() {
ctx := context.Background()
_, clients, teardown, err := spanemuboost.NewEmulatorWithClients(ctx)
if err != nil {
log.Fatalln(err)
return
}
defer teardown()
// You can use Clients.ProjectID, Clients.InstanceID, Clients.DatabaseID.
err = clients.Client.Single().Query(ctx, spanner.NewStatement("SELECT 1")).Do(func(r *spanner.Row) error {
fmt.Println(r)
// Output: {fields: [type:{code:INT64}], values: [string_value:"1"]}
return nil
})
if err != nil {
log.Fatalln(err)
}
}
また、テストケースごとに異なるデータベースを使う推奨構成にも対応しています。
コンテナの起動時にはインスタンスのみをセットアップし、ランダムな名前のデータベースとそれに対するクライアントをテストケースごとに生成することが下記のように手軽に行えます。
func ExampleNewEmulatorAndNewClients() {
ctx := context.Background()
emulator, emulatorTeardown, err := spanemuboost.NewEmulator(ctx,
spanemuboost.EnableInstanceAutoConfigOnly(),
)
if err != nil {
log.Fatalln(err)
return
}
defer emulatorTeardown()
var pks []int64
for i := 0; i < 10; i++ {
func() {
clients, clientsTeardown, err := spanemuboost.NewClients(ctx, emulator,
spanemuboost.EnableDatabaseAutoConfigOnly(),
spanemuboost.WithRandomDatabaseID(),
spanemuboost.WithSetupDDLs([]string{"CREATE TABLE tbl (PK INT64 PRIMARY KEY)"}),
spanemuboost.WithSetupDMLs([]spanner.Statement{
{SQL: "INSERT INTO tbl(PK) VALUES(@i)", Params: map[string]any{"i": i}},
}),
)
if err != nil {
log.Fatalln(err)
return
}
defer clientsTeardown()
// You can use Clients.ProjectID, Clients.InstanceID, Clients.DatabaseID.
err = clients.Client.Single().Query(ctx, spanner.NewStatement("SELECT PK FROM tbl")).Do(func(r *spanner.Row) error {
var pk int64
if err := r.ColumnByName("PK", &pk); err != nil {
return err
}
pks = append(pks, pk)
return nil
})
if err != nil {
log.Fatalln(err)
}
}()
}
fmt.Println(pks)
// Output: [0 1 2 3 4 5 6 7 8 9]
}
-
spanemuboost.NewEmulator()
にspanemuboost.EnableInstanceAutoConfigOnly()
を渡すことでエミュレータと共にインスタンスのみを初期化可能 -
spanemuboost.NewClients()
に初期化済のエミュレータとspanemuboost.EnableDatabaseAutoConfigOnly()
を渡すことでデータベースの初期化とクライアントの作成可能-
WithRandomDatabaseID()
も渡すとデータベース名をランダム化(WithDatabaseID(string)
で指定も可能) -
WithSetupDDLs()
,WithSetupDMLs()
も渡すとテストケースにあったテストスキーマ、テストデータの投入可能
-
これで前述した私のユースケースはほぼ満たされました。実際に spanner-mycli の integration_test.go でこの spanemuboost を活用し、従来よりも簡潔で効率も良い統合テストを行うことができています。
まとめ
- Spanner の統合テストには公式の Spanner エミュレータを使うことができます。
- 一般的にコンテナを Go の統合テスト内で立ち上げるには testcontainers-go が有用です。
- testcontainers-go には Spanner エミュレータに対応した GCloud モジュールがあります。
- この度公開した spanemuboost を使い、私は Spanner エミュレータを使ったテストの煩雑さを解決することができました。
余談: spanner-mycli --embedded-emulator
testcontainers はテストでしか使えないわけではなく、プロセス内で Docker コンテナを起動する任意のプログラムで使うことができます。
spanner-mycli は --embedded-emulator
フラグを渡すことで Spanner の実インスタンスの代わりに自前で立ち上げた Spanner エミュレータに接続して動作することができます。
$ spanner-mycli --embedded-emulator
Connected.
[emulator-project:emulator-instance:emulator-database]
spanner> SELECT 1 AS n;
+---+
| n |
+---+
| 1 |
+---+
1 rows in set (444.708us)
ネタのような機能に見えるかもしれませんが、 Spanner の実インスタンスがなくても spanner-mycli の検証ができることや、
逆に Spanner エミュレータの挙動を試すこともできるため意外と使いがいがある機能になっています。
この --embedded-emulator
も裏でインスタンスとデータベースの作成が必要なため spanemuboost のユースケースとなっています。
Discussion