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

[QualiArts Advent Calendar 2020](https://qiita.com/advent-calendar/2020/qualiarts)、6日目担当の8kkaです。
今回はGKE環境でのDBマイグレーション自動化手法について書こうと思います。

# 1. 前提の設計など
今回は以下の構成で組まれているシステムに対して、自動化手法を組み込んでいきます。

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

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

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

# 2. マイグレーション実行用のコンテナイメージ作成
マイグレーションの処理には、[golang-migrate](https://github.com/golang-migrate/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](https://argoproj.github.io/argo-cd/user-guide/resource_hooks/) という仕組みを使って、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. アーキテクチャと処理フロー
全体のアーキテクチャと処理フローはこちらになります。
![スクリーンショット 2020-12-06 3.16.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66083/dae7a0fd-1103-f422-e5f0-b9b9de1ae24d.png)

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

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

ここまで閲覧頂き、ありがとうございました。
明日は [hikaru-suzuki](https://qiita.com/hikyaru-suzuki) さんの記事です。