🔭

Go製モダンマイグレーションツールのAtlasを使用してみた

2024/03/23に公開

GoでDBマイグレーションをどうやるのがいいか検討していたところAtlasというツールが良さそうだったので一通り使ってみたまとめです。

対象読者

  • GoプロジェクトのDBマイグレーションに興味がある人
  • Atlasの使い方に興味がある人

Atlasとは

Atlasはデータベーススキーマを管理するためのツールおよびwebサービスを提供しています。従来までのバージョンによるマイグレーション管理だけでなくDBの理想状態を宣言的に記述することでスキーマを反映することができます。DBの理想状態はHCL、sql、jsonがサポートされています。

Atlasは非常に高機能でCLIツール、GitHub ActionsのようなCI、Terraformプロバイダーなどを提供しています。

Go製のORMであるentで採用されているマイグレーションツールですがent以外のORMと合わせて使用できますし、go-migrateやgooseのような他のマイグレーションツールとも組み合わせて使用することができます。

install

curl -sSf https://atlasgo.sh | sh

他にもmacであればHomebrewなどでもインストール可能

https://atlasgo.io/getting-started#installation

DBの検査

atlasコマンドを使ってローカル環境のDBを検査し、スキーマファイルを作成してみたいと思います。まずは以下のコマンドを実行してローカルにMySQLコンテナを起動します。

docker run --rm -d --name atlas-demo -p 13306:3306 -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=example mysql

起動できたら以下のコマンドを実行してテーブルを作成します。

docker exec atlas-demo mysql -ppass -e 'CREATE table example.users(id int PRIMARY KEY, name varchar(100))'

実行できたらatlas schema inspectコマンドを実行してDBを検査し、スキーマファイルを作成します。

atlas schema inspect -u "mysql://root:pass@localhost:13306/example" > schema.hcl
schema.hcl
table "users" {
  schema = schema.example
  column "id" {
    null = false
    type = int
  }
  column "name" {
    null = true
    type = varchar(100)
  }
  primary_key {
    columns = [column.id]
  }
}
schema "example" {
  charset = "utf8mb4"
  collate = "utf8mb4_0900_ai_ci"
}

出力するファイルは他にSQLとJSON形式で出力することも可能です。

atlas schema inspect -u "mysql://root:pass@localhost:13306/example" --format '{{ sql . }}' | cat

-- Create "users" table
CREATE TABLE `users` (`id` int NOT NULL, `name` varchar(100) NULL, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

DBスキーマの変更を反映する

上記で作成したusersテーブルに紐づくブログ投稿テーブルを追加することを考えてみます。schema.hclに以下のようにテーブルを追加します。

schema.hcl
table "blog_posts" {
  schema = schema.example
  column "id" {
    null = false
    type = int
  }
  column "title" {
    null = true
    type = varchar(100)
  }
  column "body" {
    null = true
    type = text
  }
  column "author_id" {
    null = true
    type = int
  }
  primary_key {
    columns = [column.id]
  }
  foreign_key "author_fk" {
    columns     = [column.author_id]
    ref_columns = [table.users.column.id]
  }
}

宣言的マイグレーション

Atlasのマイグレーション方法は2種類存在し、従来のバージョンによるマイグレーションと理想状態を宣言的に書いて管理する方法があります。宣言的マイグレーションはより現代的で分業化が進んだ現代の開発現場においてアプリケーションエンジニアはDBのあるべき姿だけを考えることができその理想状態を稼働中のインフラ環境に反映する部分はインフラエンジニアが担当することができます。

どちらが優れているということはなく、どちらの方法でもAtlasを使用できるようになっているので所属する組織やチームの状況を見て選択すればいいと思います。

以下は宣言的マイグレーションの実施例です。

atlas schema apply \
  -u "mysql://root:pass@localhost:13306/example" \
  --to file://schema.hcl

-- Planned Changes:
-- Create "blog_posts" table
CREATE TABLE `blog_posts` (
  `id` int NOT NULL,
  `title` varchar(100) NULL,
  `body` text NULL,
  `author_id` int NULL,
  PRIMARY KEY (`id`),
  CONSTRAINT `author_fk` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`)
);
? Are you sure?: 
  ▸ Apply
    Lint and edit
    Abort
  • -u マイグレーション実行対象のDBです。上記の例だとローカルで起動しているDBコンテナを指しています。
  • --to 理想状態。上記の例だとスキーマファイルを指定している。

Applyを選択することでDBにスキーマが反映されているはずです。スキーマが反映されていることをAtlas CloudのWebUIで視覚的に確認することもできます。

% atlas schema inspect \
  -u "mysql://root:pass@localhost:13306/example" \
  --web

? Where would you like to share your schema visualization?: 
  ▸ Publicly (gh.atlasgo.cloud)
    Privately (junichi-yamanaka.atlasgo.cloud)

スキーマをPublicに共有するかPrivateにするか選択できるので好きな方を選択してください。なお、Atlasのフリープランでスキーマの共有はできます。アカウントの作成が必要であれば事前にatlas cloudのアカウントをこちらから作成してください。

参考までにPublicに公開したスキーマがこちらです

バージョンマイグレーション

従来方のバージョンによるマイグレーション方法も使用できます。バージョンによるマイグレーション管理をする場合は以下のようなatlas migrateコマンドを実行します。

atlas migrate diff create_blog_posts \
  --dir "file://migrations" \
  --to "file://schema.hcl" \
  --dev-url "docker://mysql/8/example"
  • --dir マイグレーションファイルを配置するディレクトリを指定
  • --to 理想状態。ここで指定したスキーマとの差分をマイグレーションファイルとして生成する。
  • --dev-url マイグレーションファイルを生成する過程で一時的にdockerを使用します。上記の例ではAtlasが用意している特別なdockerドライバを使用して一時的な環境を用意し使用している。

コマンドが成功すると以下のようにファイルが生成されている。

migrations
├── 20240321134630_create_blog_posts.sql
└── atlas.sum
20240321134630_create_blog_posts.sql
-- Create "users" table
CREATE TABLE `users` (`id` int NOT NULL, `name` varchar(100) NULL, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- Create "blog_posts" table
CREATE TABLE `blog_posts` (`id` int NOT NULL, `title` varchar(100) NULL, `body` text NULL, `author_id` int NULL, PRIMARY KEY (`id`), INDEX `author_fk` (`author_id`), CONSTRAINT `author_fk` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
atlas.sum
h1:D890RldtkzxGSTnkN1Cb4+DK8T43H2/5xxJL/CFlDp4=
20240321134630_create_blog_posts.sql h1:I2bENIDZBusIADyoJmXX3Ff/yI6Hvj8uLC5GaBcU67s=

マイグレーションファイルが生成されたので以下のようにコマンドを実行してDBにマイグレーション内容を反映できます。

atlas migrate apply \
  --url "mysql://root:pass@localhost:13306/example" \
  --dir "file://migrations"         
Migrating to version 20240321134630 (1 migrations in total):

  -- migrating version 20240321134630
    -> CREATE TABLE `users` (`id` int NOT NULL, `name` varchar(100) NULL, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
    -> CREATE TABLE `blog_posts` (`id` int NOT NULL, `title` varchar(100) NULL, `body` text NULL, `author_id` int NULL, PRIMARY KEY (`id`), INDEX `author_fk` (`author_id`), CONSTRAINT `author_fk` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
  -- ok (50.749209ms)

  -------------------------
  -- 72.911125ms
  -- 1 migration
  -- 2 sql statements

(前回までの内容が残っているとうまくいかないかもしれないので、その場合は一旦DBを初期化してから試してみてください。)

宣言的マイグレーション vs バージョン管理型のマイグレーション

宣言的マイグレーションの利点はアプリケーション開発エンジニアとインフラエンジニアの間で作業を完全に分業できることです。アプリケーションエンジニアはDBの理想状態のことだけ考えればよく現在のスキーマからどのようにその状態に近づけようとということは考えなくて済みます。インフラエンジニアはアプリケーションエンジニアが構築したDBスキーマをどう反映するかを考えればよく、そのためのガイドラインをAtlasはドキュメントとして公開してくれています。

バージョン管理型のマイグレーションはRailsやLaravelといったフルスタックフレームワークのワークフローに組み込まれていたり、FlywayといったOSSがあったりと古くからプログラミング言語を問わず使われてきた手法だと思います。この手法のメリットはDBスキーマの移行計画をバージョン管理に含めることができ、アプリケーションコードとともにレビューができることです。DBのスキーマ変更はシステム開発の中でもかなり神経を使う部分なのでこのようにレビューができるようになっていることを好む組織やチームも多いでしょう。しかし、欠点としては移行計画の負担がアプリケーションエンジニアにかかることです。簡単なマイグレーションであればいいですがもしかしたらDBに関する深い知識を求められるかもしれません。

Atlasの公式には第3の手法として上記2つの手法を組み合わせたVersioned Migration Authoring(訳し方がわかなかったので原文そのまま)を提唱している。これは、チーム内でレビューできるようにバージョン管理をするがその移行計画はあるべきDBの理想状態を宣言的に作成し、その理想状態に近づけるような移行計画はAtlasを使い自動で生成するという手法です。公式ドキュメントには以下のように記載されています。

バージョン管理されたマイグレーション・オーサリングでは、ユーザーは希望する状態を宣言し、アトラス・エンジンを使って既存の状態から新しい状態への安全なマイグレーションを計画します。しかし、プランニングと実行を連動させる代わりに、プランは通常のマイグレーションファイルに書き込まれ、ソースコントロールにチェックインし、手動で微調整し、通常のコードレビュープロセスでレビューすることができます。

個人的に使ってみた感想ですがAtlasのCLIは柔軟性がかなりあって、理想状態の指定の仕方はスキーマファイルだけでなく実際のDBを指定することもでき、組織やチーム内にあった使い方を模索する感じがいいんじゃないかなと思いました。ただ、理想状態であるスキーマファイルから移行計画を自動で生成する体験はかなりよかったです。

設定ファイルの作成

Atlasを使用した開発のプロジェクト設定ファイルをatlas.hclという形式で作成することができます。

atlas.hcl
/ Define an environment named "local"
env "local" {
  // Declare where the schema definition resides.
  // Also supported: ["file://multi.hcl", "file://schema.hcl"].
  src = "file://schema.hcl"

  // Define the URL of the database which is managed
  // in this environment.
  url = "mysql://root:pass@localhost:13306/example"

  // Define the URL of the Dev Database for this environment
  // See: https://atlasgo.io/concepts/dev-database
  dev = "docker://mysql/8/dev"
}

env "dev" {
  // ... a different env
}
atlas schema inspect --env local
atlas schema apply --env local

上記のようにlocalとdevのような複数環境の設定を書くことができる。

AtlasはHCLを用いてこういった設定やテーブル定義をごりごり書くことができる。ファイル内で宣言した変数や外部からの入力値を使用したり、よりプログラム的にDBを管理することができます。本記事では触れませんがTerraformプロバイダも提供されており、インフラ領域で宣言的に管理することができそうです。

Go SDKの使用

AtlasはGo製のツールですがCLIツールとして提供されているためプログラミング言語を問わず使用することができます。しかし、atlasexecという薄いラッパーが用意されているためGoのプロジェクトから利用することができるようにもなっています。

以下、簡単にatlasexecを使用した例です。

mkdir go-sdk-demo
cd go-sdk-demo
go mod init go-sdk-demo
go get ariga.io/atlas-go-sdk/atlasexec
atlas migrate new --edit create_users

エディタが立ち上がるので以下のようにテーブル定義を書きます。

CREATE TABLE users (
    id int PRIMARY KEY,
    name VARCHAR(255) NOT NULL
);

エディタを閉じると以下のようにマイグレーションファイルが作成されていると思います。

% tree migrations 
migrations
├── 20240322135013_create_users.sql
└── atlas.sum

main.goを以下のように作成します。

main.go
package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "ariga.io/atlas-go-sdk/atlasexec"
)

func main() {
    // Define the execution context, supplying a migration directory
    // and potentially an `atlas.hcl` configuration file using `atlasexec.WithHCL`.
    workdir, err := atlasexec.NewWorkingDir(
        atlasexec.WithMigrations(
            os.DirFS("./migrations"),
        ),
    )
    if err != nil {
        log.Fatalf("failed to load working directory: %v", err)
    }
    // atlasexec works on a temporary directory, so we need to close it
    defer workdir.Close()

    // Initialize the client.
    client, err := atlasexec.NewClient(workdir.Path(), "atlas")
    if err != nil {
        log.Fatalf("failed to initialize client: %v", err)
    }
    // Run `atlas migrate apply` on a SQLite database under /tmp.
    res, err := client.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{
        URL: "sqlite:///tmp/demo.db?_fk=1&cache=shared",
    })
    if err != nil {
        log.Fatalf("failed to apply migrations: %v", err)
    }
    fmt.Printf("Applied %d migrations\n", len(res.Applied))
}

作成できたら以下のようにして実行します。

go run main.go

Applied 1 migrations

Go SDKの使いどころですが、テストコードを書く時にTestMainにマイグレーション処理を書くことでテストを始める際に完全なスキーマを用意することができます。ORMであるentも内部的にはGoのSDKを使ってentの関数からマイグレーションができるようになっているようです。

docker-composeの使用

Go SDKを紹介しましたがテストコードを書く時にできればまっさらなDB環境を外部依存なく作成したいですよね??

Goではdockertesttestcontainers-goを利用することでテストコードにコンテナの起動・破棄を簡単に組み込むことができます。

最近Docker社がTestcontainersの開発元を買収したのが関係あるかはわかりませんがtestcontainers-goの開発が盛んなようにも見えるのと、docker-composeの利用ができるようになっていたので今回はtestcontainers-goをAtlasとともに使用する例も紹介してみたいと思います。

ローカル開発用にDBコンテナを用意する

マイグレーションファイルはここまでで作成したものを使いまわします。

tree migrations 
migrations
├── 20240321134630_create_blog_posts.sql
└── atlas.sum

次にdocker-composeファイルを以下のように作成します。

compose.yaml
version: "3.9"
services:
  mysql:
    image: mysql:8.0.29
    platform: linux/amd64
    healthcheck:
      test: mysqladmin ping -ppass
    environment:
      MYSQL_DATABASE: test
      MYSQL_ROOT_PASSWORD: pass
    ports:
      - "13306:3306"
    networks:
      - db
  migrate:
    image: arigaio/atlas:latest
    command: >
      migrate apply
      --url mysql://root:pass@mysql:3306/test
    networks:
      - db
    depends_on:
      mysql:
        condition: service_healthy
    volumes:
      - ./migrations/:/migrations
networks:
  db:
docker compose up -d

...
[+] Running 3/3
 ✔ Network atlas-testcontainers-demo_db           Created                                                                                         0.3s 
 ✔ Container atlas-testcontainers-demo-mysql-1    Healthy                                                                                         0.7s 
 ✔ Container atlas-testcontainers-demo-migrate-1  Started 

これでローカル開発用にDBを作成できたうえに、DBスキーマもマイグレーションが実行され用意できてるはずです!確認してみます。

docker compose exec mysql mysql -uroot --port 13306 -ppass test -e 'show tables;'
mysql: [Warning] Using a password on the command line interface can be insecure.
+------------------------+
| Tables_in_test         |
+------------------------+
| atlas_schema_revisions |
| blog_posts             |
| users                  |
+------------------------+

ちゃんとテーブルが作成されています!

テスト用のコンテナをdocker-composeから用意する

まずは以下のようにGoプロジェクトを作成します。

mkdir atlas-testcontainers-demo
cd atlas-testcontainers-demo
go mod init atlas-testcontainers-demo

testcontainers-goのcomposeモジュールをインストールします。

go get github.com/testcontainers/testcontainers-go/modules/compose

compose.yamlは前述のものをそのまま使いたいがローカル開発用とは別に起動して使いたいのでもう一つ用意します。ポートやmigreationsファイルの指定だけ変更してます。

testdata/compose.yaml
version: "3.9"
services:
  mysql:
    image: mysql:8.0.29
    platform: linux/amd64
    healthcheck:
      test: mysqladmin ping -ppass
    environment:
      MYSQL_DATABASE: test
      MYSQL_ROOT_PASSWORD: pass
    ports:
      - "23306:3306"
    networks:
      - db
  migrate:
    image: arigaio/atlas:latest
    command: >
      migrate apply
      --url mysql://root:pass@mysql:3306/test
    networks:
      - db
    depends_on:
      mysql:
        condition: service_healthy
    volumes:
      - ../migrations/:/migrations
networks:
  db:

compose.yamlが用意できたら以下のようなテストファイルを用意します。

db_test.go
package db_test

import (
	"context"
	"fmt"
	"log"
	"os"
	"testing"

	tc "github.com/testcontainers/testcontainers-go/modules/compose"
)

func TestMain(m *testing.M) {
	compose, err := tc.NewDockerCompose("testdata/compose.yaml")
	if err != nil {
		fmt.Println(err.Error())
		log.Fatal(err)
	}

	downFunc := func() error {
		return compose.Down(context.Background(), tc.RemoveOrphans(true), tc.RemoveImagesLocal)
	}

	ctx, cancel := context.WithCancel(context.Background())

	// コンテナの起動
	if err = compose.Up(ctx, tc.Wait(true)); err != nil {
		fmt.Println(err.Error())
		cancel()
		_ = downFunc() // 手抜き
		log.Fatal(err)
	}

	code := m.Run()

	// 後処理
	cancel()
	if err = downFunc(); err != nil {
		log.Fatal(err)
	}

	os.Exit(code)
}

func TestDB(t *testing.T) {
	t.Log("Start test!!")
}

go test ./... -test.v
# github.com/fsnotify/fsevents
cgo-gcc-prolog:454:2: warning: 'FSEventStreamScheduleWithRunLoop' is deprecated: first deprecated in macOS 13.0 - Use FSEventStreamSetDispatchQueue instead. [-Wdeprecated-declarations]
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/CoreServices.framework/Frameworks/FSEvents.framework/Headers/FSEvents.h:1153:1: note: 'FSEventStreamScheduleWithRunLoop' has been explicitly marked deprecated here

...

=== RUN   TestDB
    db_test.go:46: Start test!!
--- PASS: TestDB (0.00s)
PASS
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-migrate-1  Stopping
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-migrate-1  Stopped
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-migrate-1  Removing
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-migrate-1  Removed
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-mysql-1  Stopping
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-mysql-1  Stopped
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-mysql-1  Removing
 Container e395fbe6-c9b1-43ac-8711-bc2c04541c6c-mysql-1  Removed
 Network e395fbe6-c9b1-43ac-8711-bc2c04541c6c_db  Removing
 Network e395fbe6-c9b1-43ac-8711-bc2c04541c6c_default  Removing
 Network e395fbe6-c9b1-43ac-8711-bc2c04541c6c_db  Removed
 Network e395fbe6-c9b1-43ac-8711-bc2c04541c6c_default  Removed
ok  	atlas-testcontainers-demo	36.589s

このようにTestMainに書くことでテスト実行前にマイグレーションを実行したDBコンテナを起動し、テストの終了時にコンテナを自動で破棄します。この処理をテスト関数の中で書くこともできますが毎回起動と終了を繰り返すとテスト時間がかなり伸びてしまうのでTestMainに書きました。そのためテスト間でDBを共有することになるためそれぞれのテストが影響しないようにデータの後始末は確実にするのが望ましいです。

おわりに

本記事ではGo製のモダンマイグレーションツールのAtlasを紹介しました!けっこう長くなってしまいましたがまだまだ試せていないことが多く、Atlasが非常に高機能なことがわかります。具体的には以下の内容については本記事では触れられていません。

  • k8sエコシステムとの統合(ArgoやHelm)
  • GitHub ActionsなどのCI環境への組み込み
  • TerraformによるDBスキーマの管理
  • GORMやgolang-migrate, gooseなどのGo製の各種ツールとの組み合わせ
  • スキーマの更新->push->マイグレーション作成->CI/CD->DBスキーマの反映といったDBのパイプライン作成

AtlasはGo製ですがメインはCLIツールとhclおよびsqlファイルによるスキーマ管理のためプログラム言語を問いません!従来のバージョン管理型のマイグレーションから宣言的なスキーマ管理、自動で移行計画を作成してくれるのは非常に開発体験が良かったです。

個人的にはDBを含めた結合テストはtestcontainersのようなライブラリを利用してテスト用のコンテナを起動したいのとローカル開発用にもDBコンテナを用意したく、Atlasのdockerイメージを用いたdocker-composeとtestcontainers-goのcomposeモジュールを使うことで両方とも手に入るのがかなり嬉しいです。

自前でマイグレーション処理を書けばいいのですがテストコードにテスト以外の処理を極力書きたくないのでdocker-composeファイルを指定して起動都愛具レーション実行できるのがいいなと思っています。

ということでテスト体験をよくするためにもAtlasを使ってみてはいかがでしょうか!あとtestcontainers!

今回は以上です🐼

(余談)
本当はsqlcとAtlasの組み合わせの開発体験記事を書こうと思ったのですがAtlasの書くことが多すぎてsqlcのこと書けませんでした。sqlcは多くのマイグレーションツールと合わせて使用することができますがAtlasもサポートしていますよ!

GitHubで編集を提案

Discussion