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を記述します。
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='アイテム';
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 さんの記事です。