GKEでのDBマイグレーション自動化手法


QualiArts Advent Calendar 2020、6日目担当の8kkaです。
今回はGKE環境でのDBマイグレーション自動化手法について書こうと思います。

1. 前提の設計など

今回は以下の構成で組まれているシステムに対して、自動化手法を組み込んでいきます。

  • アプリケーションはGoで記述されている
  • GKE上で稼働させている
  • CDはArgoCDを利用している
  • DBはCloudSQLを利用している

紹介しているコードやアーキテクチャは、実際に構築してみたものを記事用に編集して掲載しています。

ライブラリやツールの紹介をしつつ記述していますが、アーキテクチャの全体像だけ知りたい方は4. アーキテクチャと処理フローの画像だけ参照すると何となくイメージは掴めると思います。

2. マイグレーション実行用のコンテナイメージ作成

マイグレーションの処理には、golang-migrateというライブラリを利用します。
まずはこのライブラリを使ってマイグレーションを実行するコンテナイメージを作成します。

2.1 golang-migrate

golang-migrateは、Go言語で記述されたDBマイグレーションのライブラリです。
Goのライブラリとしても使えるし、CLIから実行する事も出来ます。
マイグレーションファイルはローカルからだけではなく、GitHubやS3、GCSから取得する事も出来ます。
また、利用可能なDBが多い事も特徴で、2020/12/06現在では以下のDBが対応されています。

  • PostgreSQL
  • Redshift
  • Ql
  • Cassandra
  • SQLite
  • SQLCipher
  • MySQL/ MariaDB
  • Neo4j
  • MongoDB
  • CrateDB
  • Shell
  • Google Cloud Spanner
  • CockroachDB
  • ClickHouse
  • Firebird
  • MS SQL Server

2.2 マイグレーションファイルの構成

公式が推奨するマイグレーションファイルの構成(ファイル名)は以下になります。

{version}_{title}.up.{extension}
{version}_{title}.down.{extension}

upはバージョンを1つ上げる際に使用されるファイル、downは1つ下げる際に使用されるファイルです。
空のファイルを作成した場合は、空のクエリを実行しようとするので注意が必要です。
{version} の命名については 1_master.up.sql, 2_master.up.sql のような連番にする方法や、タイムスタンプを用いる方法があります。
複数のリリースバージョンの開発が並行して進む場合があるため、リリースバージョンに合わせて {major}{minor}{patch}_schema.up.sql という規則で作成してみます。

v1.0.0 -> 001000000_schema.up.sql
v1.1.1 -> 001001001_schema.up.sql

例えば、v1.0.0でitemテーブルを作成してv1.1.1でvalueカラムを追加する際は、以下のようなファイル名とSQLを記述します。

001000000_schema.up.sql
CREATE TABLE IF NOT EXISTS `item` (
  `id` varchar(255) NOT NULL COMMENT 'アイテムID',
  `name` varchar(255) NOT NULL COMMENT '名称',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='アイテム';
001001001_schema.up.sql
ALTER TABLE item ADD COLUMN value INT NOT NULL COMMENT '効果値' AFTER name;

v1.0.0のアプリがデプロイされている環境では001000000_schema.up.sqlで定義されているitemテーブルがマイグレートされ、v1.1.1のアプリがデプロイされている環境では、valueカラムが追加されたitemテーブルがマイグレートされます。

golang-migrateでDBのマイグレーションを実行すると、対象のDBにschema_migrationsというテーブルが作成され、そのテーブル内で現在のバージョンを管理することになります。
schema_migrationsはMySQLに対して実行した場合の名前で、CloudSpannerに実行した場合はSchemaMigrationsになるなど、DBによって命名は多少変わります。

2.3 実行用のコンテナイメージ

DBマイグレーションの実行は、Goで作ったアプリケーションをコンテナ化してArgoCD上のJobとして実行します。
コンテナ化するアプリケーションは以下のようなコードです。

package main

import (
    "context"
    "log"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/mysql"
    _ "github.com/golang-migrate/migrate/v4/source/file"
    "golang.org/x/sync/errgroup"
)

func main() {
    eg, _ := errgroup.WithContext(context.Background())
    eg.Go(func() error {
        if err := migrateMySQL(); err != nil {
            return err
        }
        return nil
    })
    if err := eg.Wait(); err != nil {
        log.Panic(err)
    }
}

func migrateMySQL() error {
    m, err := migrate.New(
        "file://./db/ddl/master",
        "mysql://{user}:{password}@tcp({port})/{db}",
    )
    if err != nil {
        return err
    }
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return err
    }
    return nil
}

Goroutineを使用しているのは、複数のデータベースに対してマイグレーションを実行する際に並列に処理したいためです。
(今回省略していますが、複数のDBを利用する要件もありました。)

migrate.ErrNoChangeのエラー判定を除外しているのは、「マイグレーション実行時にスキーマ変更がなかった場合エラーとして終了させないようにする」ための記述です。
例えばv1.0.0からv1.1.0に上げる場合、カラムが追加されるのはv1.1.1からなのでスキーマに変更が入らず、エラーとなってしまいます。
こちらを回避するため、migrate.ErrNoChangeの判定を実装しています。

上記アプリケーションをリリースバージョンのタグをつけてコンテナイメージ化します。
コンテナイメージはCloudBuildで作成し、ContainerRegistryに保存しておきます。
(ArtifactRegistryも使ってみたいですが、現状まだ触れていません。)

作成したマイグレーションファイルは./db/ddl/masterに保存しておき、コンテナ内に一緒に詰めておきます。
イメージにタグを付ける際、タグバージョンとコンテナ内に保存されているマイグレーションファイルの最新バージョンが同じものとなります。

例: v1.1.0のコンテナ内マイグレーションファイル
./db/ddl/master/001000000_schema.up.sql

例: v1.1.1のコンテナ内マイグレーションファイル
./db/ddl/master/001000000_schema.up.sql
./db/ddl/master/001001001_schema.up.sql

3. ArgoCDを使ったマイグレーションの自動実行

作成したコンテナイメージはGKE上のArgoCDを通して実行します。
ここでは、ArgoCDの ResourceHooks という仕組みを使って、GKEへのアプリケーションデプロイ前にマイグレーションを実行する方法を記述します。

3.1 ArgoCD Resource Hooks

ArgoCDにはResourceHooksという仕組みがあり、同期の操作前、操作中、操作後にスクリプトを実行する事ができます。
マニフェストの例はこちら。

apiVersion: batch/v1
kind: Job
metadata:
  generateName: db-schema-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded

argocd.argoproj.io/hookでHookのタイミングを指定し、argocd.argoproj.io/hook-delete-policyでHookリソースの削除を設定できます。
このマニフェストの場合は、「同期前にJobが実行され、Jobが成功したらHookリソースを削除する」という挙動になります。

3.2 Hook Policy

argocd.argoprj.io/hookで設定できる項目は以下になります。

hook ポリシー 動作
PreSync マニフェスト適用前に実行
Sync PreSync完了時に実行
Skip マニフェスト適用をスキップ
PostSync マニフェスト適用に成功したら実行
SyncFail マニフェスト適用が失敗したら実行

今回はDBマイグレーションをマニフェスト適用前に実行したいので、PreSyncのポリシーを利用します。
また、argocd.argoprj.io/hook-delete-policyで設定できる項目は以下になります。

hook-delete ポリシー 動作
HookSucceeded フックで実行した処理が成功したらフックリソース削除
HookFailed フックで実行した処理が失敗したらフックリソース削除
BeforeHookCreation 新しいフックリソースが作られる前に既存のフックリソース削除

DBマイグレーションで実行したJobが成功した場合はリソースを削除し、失敗した場合は調査のためPodを残したいので、HookSucceededを利用します。

3.3 反映させるマニフェスト

実際に反映させるマニフェストは以下のような記述になります。

apiVersion: batch/v1
kind: Job
metadata:
  generateName: db-migrate
  namespace: job
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  template:
    spec:
      containers:
      - name: db-migrate
        image: "gcr.io/test-app/github.com/qualiarts/migrate:v1.0.0"
        imagePullPolicy: Always
        command:
        - "/migrate"
      restartPolicy: Never
  backoffLimit: 0

Jobのリソースとして作成し、ResourceHooksのアノテーションを追加します。
実行はContainerRegistryに保存してあるマイグレーション用のイメージを利用します。
このマニフェストをArgoCDのApplicationリソースに紐付ける事で、ResourceHooksの仕組みが動作します。

4. アーキテクチャと処理フロー

全体のアーキテクチャと処理フローはこちらになります。

app-serverにマイグレーションファイルを作成し、タグが切られたタイミングでCloudBuildが起動し、タグバージョンが指定されたコンテナイメージを作成します。
コンテナイメージ作成後、helmに記載されているアプリのバージョンを上げてmasterブランチにプッシュすると、ArgoCDが変更を検知してResourceHooksの処理が走ります。
ResourceHooksのマイグレーション処理が正常に完了すると、DBスキーマが更新されてからhelmの差分同期が走ります。

まとめ

golang-migrateとArgoCDのResourceHooksを利用する事で、DBのスキーママイグレーションを自動化する事が出来ました。
今回CloudSQLを例に挙げて記述しましたが、golang-migrateで対応されているCloudSpannerなど他のDBでも同じように構築する事が出来るので、似たような環境で構築されている方はぜひお試しください。
(一応、筆者が実際に確認したのはCloudSQLとCloudSpannerだけです。)

ここまで閲覧頂き、ありがとうございました。
明日は hikaru-suzuki さんの記事です。