LayerX エンジニアブログ

LayerX の エンジニアブログです。

Database Migration with Atlas

この記事は、LayerX Tech Advent Calendar 2022 7日目の記事です。


Enabling Team エンジニアの @yyoshiki41(中川佳希)です!

直近チームで、 ORMフレームワークである ent を使う機会があり、 そこで利用している Atlas というデータベースマイグレーションエンジンについての紹介です。

ent

ent の詳細な説明は省略しますが、以下のような特徴があります。

  • Go 用の ORM フレームワーク
  • Go で Schema を記述し、コード生成を行う
    • コード生成により、静的型付けされた明示的なAPIを実現
    • アプリケーションの中で扱いやすいようモデリングした Schema とそのリレーションを定義可能
      • e.g.) Edges で、エンティティ間のリレーションを表現
  • Eager Loading, Hook, Privacy レイヤなどの拡張も実装可能

Database Migration

データベースのテーブル定義をどうやって管理していくかは継続的なサービス開発には必須のテーマです。 選択肢も豊富で以下のようなものが思い浮かびます。

  • WebフレームワークやORMが提供するツールを利用する
  • プログラミング言語やフレームワークに依存しないツールを利用する

そして、ツールによってもスキーマをどのように記述するかは様々です。 SQLを直接記述するものから専用のDSL、プログラミング言語のクラス定義を用いるなど多岐にわたります。

どのツールが最善かは開発スタイル、アプリケーションによりますが、今回 ent を利用していく上で採用した開発フローとマイグレーション方法を紹介します。

Migration Flows

複数の環境に対して、別々のスタイルを取っています。

  1. Declarative Migrations (automatically): ローカル開発環境、テスト実行環境ではプロセス起動時に自動で定義したスキーマへのマイグレーションを行う
  2. Versioned Migrations: ステージング環境、リリース環境は、コマンド経由で実行タイミングを制御して用意したSQLで差分のマイグレーションを行う

1 で自動でのマイグレーションを採用している理由は、開発者体験の向上のためです。monorepo, マイクロサービスを前提に考えると、複数のデータベースに他の人が行ったマイグレーションを毎回行うのは手間がかかります(そして忘れることもしばしば)。また、(差分変更がどのように行われるか一定の注意は必要ですが)開発する側として宣言的に ent スキーマをかくだけで良いのもストレスが少ない方法です。 ent は、自動でのマイグレーションを行える関数を自動生成します。以下は、ドキュメントにあるサンプルコードです。単体テスト時も TestMain に書くだけでよいためショートカットが可能です。

package main

func main() {
    // Connect to the database (MySQL for example).
    client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
    if err != nil {
        log.Fatalf("failed connecting to mysql: %v", err)
    }
    defer client.Close()
    ctx := context.Background()
    // Run migration.
    if err := client.Schema.Create(ctx); err != nil {
        log.Fatalf("failed creating schema resources: %v", err)
    }
    // ... Continue with server start.
}

2 で用意したSQLで計画的にマイグレーションを行っている理由は複数あります。まず、アプリケーションデプロイとマイグレーション実行のプロセスを独立にしたい点があります。各プロセスの中断やロールバックの制御が取れないのは運用上不便です。また、実行SQLを確認したい理由もあります。MySQL のような Online DDL の仕組みがあっても、すべての ALTER 文でサポートされるものではないため、オンラインで実行可能かは開発者での判断が必要です。データボリュームに依るところも機械的には判断出来ない部分です。

この 1. Declarative Migrations => 2. Versioned Migrations をシームレスに互換性を持って行う為に利用するのが Atlas です。

Atlas

atlasgo.io

冒頭でマイグレーションエンジンと書きましたが、非常に多機能で以下のような特徴があります。

  • ent がデフォルトで採用しているマイグレーションエンジン
  • GoライブラリとしてもCLI としても利用可能
  • マイグレーションファイルの作成、Apply、差分チェックなどが出来る
  • 複数のマイグレーションツールの解釈(インポート、アウトプット)が出来る
     e.g.) golang-migrate, goose, flyway, liquibase and dbmate.

ent が生成してくれるマイグレーション実行を行う関数 (client.Schema.Create) の内部実装には Atlas がライブラリとして利用されています。
今回は例として、ent スキーマから dbmate 用のマイグレーションファイルを生成してみます。

package main

import (
    "context"
    "log"

    atlasmigrate "ariga.io/atlas/sql/migrate"
    "ariga.io/atlas/sql/sqltool"
    "entgo.io/ent/dialect"
    "entgo.io/ent/dialect/sql/schema"
    "github.com/org/repo/ent/migrate"
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    ctx := context.Background()
    // Create a local migration directory able to understand dbmate migration file format for replay.
    dir, err := sqltool.NewDBMateDir("./migrations")
    if err != nil {
        log.Fatalf("failed creating migration directory: %v", err)
    }
    // Write atlas.sum file to the local migration directory.
    sum, err := dir.Checksum()
    if err != nil {
        log.Fatalf("failed check atlas.sum: %v", err)
    }
    if err := atlasmigrate.WriteSumFile(dir, sum); err != nil {
        log.Fatalf("failed writing atlas.sum: %v", err)
    }
    // Migrate diff options.
    opts := []schema.MigrateOption{
        schema.WithDir(dir),                         // provide migration directory
        schema.WithMigrationMode(schema.ModeReplay), // provide migration mode
        schema.WithDialect(dialect.MySQL),           // Ent dialect to use
    }
    // Generate migrations using Atlas support for MySQL (note the Ent dialect option passed above).
    err = migrate.Diff(ctx, "mysql://root:password@localhost:3306/atlas_dev_database", opts...)
    if err != nil {
        log.Fatalf("failed generating migration file: %v", err)
    }
}

処理の流れとしては、以下です。

  1. Versioned Migrations として管理されている既存のマイグレーションファイルを読み込む
  2. sum ファイルで既存のマイグレーションファイルの状態が正しいかチェック(バージョンが抜けていたり、変更が加わっていないか)
  3. 既存のマイグレーションと ent スキーマの差分を計算する
  4. 計算した差分の Versioned Migrations 用のファイルを生成する

最後に、3. の差分を生成している仕組みをみていきます。

Calculating diffs

重要なコンセプトとして、Dev Database と呼ばれるものがあります。
これは Atlas が 既存のマイグレーションファイル を適用するデータベースのことです。空の状態で用意しておけば自動で今ある Versioned Migrations を適用して、現在のデータベーススキーマ(Current State)を再現してくれます。ここから現在のデータベーススキーマ(Current State)と ent スキーマ(Desired State)の差分を計算しています。
Dev Database は特別な状態管理など不要です。Versioned Migrations ディレクトリさえ渡せば、常に現在のデータベーススキーマを再現(Replay モードと呼ばれている)させ、差分計算後はクリーンナップしてくれているため、Docker コンテナだけ準備するだけなのでローカル環境・CI環境への組み込みとの相性も良いです。

開発者はデプロイ前に生成される差分のSQLファイルを新たに Versioned Migrations ディレクトリに追加するだけでよく、差分を手で書く必要がなくなるのは大きなメリットです。
Atlas は他にも実際にデータベース同士でスキーマ比較なども出来ますが、今回はここまでです。次の Advent Calendar の記事もお楽しみください!