goose で DB マイグレーションを行う

# はじめに
この記事は [Wano Advent Calendar 2024](https://qiita.com/advent-calendar/2024/wano-group) 19日目の記事になります。

簡単に自己紹介をさせて頂くと、私は今年の3月に Wano へ入社して TuneCore Japan (以降 TCJ) のバックエンドの開発・運用を主に行っています。

直近では「[アーティストマイルストーン 2024](https://www.tunecore.co.jp/news/559)」の開発・実施を担当しました。
たくさんのアーティストの方々が SNS にシェアしてくれてとても嬉しかったです。ありがとうございます！

さて、今回は以下についてお話をします。

- ローカル環境の DB マイグレーションを goose を用いて行うようにした
- goose の実行環境を VSCode の Dev Container で作成した

# 前提
## TCJ での DB のマイグレーション運用
まず前提として TCJ には DB のマイグレーションファイルを管理するリポジトリがあり、リポジトリ内には以下のフォルダが存在します。

- wip フォルダ: 開発中機能のマイグレーションファイル群
- production フォルダ: 本番環境に適用済のマイグレーションファイル群

現状 DB マイグレーションを要する機能を開発する際は以下のように運用しています。[^1]

1. feature ブランチを切り wip フォルダ内にマイグレーションファイル(実行する SQL を羅列したのみのファイル)を作成する
    - ```
      (root)
      ├── wip/
      |   └── create_user_table.sql
      └── production/
          └── (空)
      ```
2. 機能の開発が終わり、本番リリースする際 production ブランチにマージする
3. すると GitHub Actions により以下の処理が自動的に行われる
    - wip フォルダ内のマイグレーションファイルを production フォルダへ移動
    - ファイル名に prefix として `20241219.000` `20241219.001` のような日付・連番を付与
    - ```
      (root)
      ├── wip/
      |   └── (空)
      └── production/
          └── 20241219.000_create_user_table.sql
      ```
4. 本番 DB へのマイグレーション適用を手動で実施する
5. 他の開発者は production ブランチの最新を取得し、 production フォルダ内のマイグレーションファイルをローカル DB に適用する
    - 適用方法は各メンバーの自由

## ローカル DB に適用する際の課題
「5」のステップにおいて、最初は特にマイグレーションツールなどを使わずに手動で運用していました。以下のような流れです。

1. ローカル環境で最新のプログラムを取得した際、新規追加されたテーブルがローカル環境に存在しなかったりでエラーになり、マイグレーションが必要なことに気付く
2. マイグレーションファイルのリポジトリ内でエラーの原因となったテーブルを検索
3. そのテーブルを作成するマイグレーションファイルまでを実行

しかしそうすると(当然ながら)以下の問題点が発生します。

- マイグレーションファイルをどこまで適用したのかわからなくなる
- 実行が漏れるマイグレーションファイルも出てくる

これらを解消するため、goose を用いてローカル環境の DB をマイグレーションするようにしました。

# goose の導入
[goose は Go 製のマイグレーション管理ツール](https://github.com/pressly/goose)です。

## 使い方
簡単に使い方を説明します。詳細はドキュメントを参照して下さい。

### 準備
goose を使うにはまずマイグレーションファイル管理用のフォルダを作成してマイグレーションファイルを追加していきます。`20241208000000` のような prefix 文字列は goose で決められた形式です。この日付・連番によってマイグレーションの順序が定義されます。

```
migrations/
├── 20241208000000_create_user_table.sql
├── 20241208000001_add_birthday_column.sql
└── 20241218000000_create_song_table.sql
```


マイグレーションファイルは以下のような書式で記載します。`goose up` はマイグレーションを進める際のクエリ、`goose down` はマイグレーションを戻す際のクエリです。

```sql
-- +goose Up
CREATE TABLE `user` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(255) NOT NULL,
    PRIMARY KEY (`id`)
);

-- +goose Down
DROP TABLE `user`;
```

### 実行例

まずは `goose status` コマンドを実行してみます。
マイグレーションを実行する前であれば以下のように表示されます。Pending と表示され、マイグレーションが何も適用されていないことがわかります。

```sh
$ goose status
2024/12/18 23:51:27     Applied At                  Migration
2024/12/18 23:51:27     =======================================
2024/12/18 23:51:27     Pending                  -- 20241208000000_create_user_table.sql
2024/12/18 23:51:27     Pending                  -- 20241208000001_add_birthday_column.sql
2024/12/18 23:51:27     Pending                  -- 20241218000000_create_song_table.sql
```

1つマイグレーションファイルを適用してみましょう。

```sh
$ goose up-by-one
2024/12/18 23:51:33 OK   20241208000000_create_user_table.sql (11.29ms)
```

もう一度 `goose status` コマンドを実行してみると、1つマイグレーション実行されたことがわかります。

```sh
$ goose status
2024/12/18 23:52:12     Applied At                  Migration
2024/12/18 23:52:12     =======================================
2024/12/18 23:52:12     Wed Dec 18 23:51:33 2024 -- 20241208000000_create_user_table.sql
2024/12/18 23:52:12     Pending                  -- 20241208000001_add_birthday_column.sql
2024/12/18 23:52:12     Pending                  -- 20241218000000_create_song_table.sql
```

最後までマイグレーションするには `goose up` を実行します。

```sh
$ goose up
2024/12/18 23:53:08 OK   20241208000001_add_birthday_column.sql (6.25ms)
2024/12/18 23:53:08 OK   20241218000000_create_song_table.sql (1.98ms)
```

```sh
$ goose status
2024/12/18 23:53:22     Applied At                  Migration
2024/12/18 23:53:22     =======================================
2024/12/18 23:53:22     Wed Dec 18 23:51:33 2024 -- 20241208000000_create_user_table.sql
2024/12/18 23:53:22     Wed Dec 18 23:53:08 2024 -- 20241208000001_add_birthday_column.sql
2024/12/18 23:53:22     Wed Dec 18 23:53:08 2024 -- 20241218000000_create_song_table.sql
```

### 管理テーブル
マイグレーションを実行すると実行対象の DB に goose_db_version というテーブルが作られます。内容は以下のようになっていると思います。

|id  |version_id  |id_applied |tstamp |
|---|---|---|---|
|1  |0 |1  |2024-12-18 11:21:08
|2  |20241208000000 |1  |2024-12-18 23:51:33
|3  |20241208000001 |1  |2024-12-18 23:53:08
|4  |20241218000000 |1  |2024-12-18 23:53:08

もし goose 管理外でマイグレーション適用した等の理由でスキップしたいマイグレーションがあった場合には、このテーブルにレコードを追加すればマイグレーションが実行されたことになります。

## 導入
「前提」で説明した運用に合わせて DB マイグレーション管理用のリポジトリで goose 実行環境を作ります。
今回は [VSCode の Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) を用いて環境を作りました。コンテナで環境を作成したのは他メンバーも容易に goose を使えるようにしたかったからです。

まず最初に、リポジトリは以下のような構成となります。[^2]

```
(root)
├── .devcontainer/
|   ├── devcontainer.json
|   └── Dockerfile
├── wip/
├── production/
|   ├── 20241208.000_create_user_table.sql
|   ├── 20241208.001_add_birthday_column.sql
|   └── 20241218.000_create_song_table.sql
├── migrations/
|   ├── .keep
|   ├── 20241208000000_create_user_table.sql
|   ├── 20241208000001_add_birthday_column.sql
|   └── 20241218000000_create_song_table.sql
└── create_migration_files.sh
```

やることは大きく分けて以下の2つです。

- マイグレーションファイルの生成 (create_migration_files.sh)
- Dev Container 環境の設定

### マイグレーションファイルの生成
TCJ のマイグレーションファイル管理リポジトリは特に goose の利用を前提としていたものではないので、マイグレーションファイルやフォルダ構成は goose に適したものにはなっていません。

そのため、production フォルダ内のファイルを元として migrations フォルダ内に goose で実行できるマイグレーションファイルを作成する create_migration_files.sh を作成しました。
行う変換は主に以下のとおりです。文字列ベースで処理すれば良いのみなので特に難しい処理ではありませんでした。[^3][^4]

- ファイル名の prefix を goose の形式に変換する
    - `20241208.000` -> `20241208000000`
- ファイルの内容を goose の形式に変更する
    - といってもファイルの先頭に `-- +goose Up` を１行追加するのみ
    - goose down の方は今回対応せず

### Dev Container 環境の設定
以下のように設定ファイルを作成します。

```devcontainer.json
{
	"name": "goose_migration_test",
	"build": {
	  "dockerfile": "Dockerfile"
	},
	"customizations": {
	  "vscode": {
		"settings": {
		  "terminal.integrated.shell.linux": "/bin/bash"
		}
	  }
	},
	"postCreateCommand": "chmod 755 /workspaces/goose_migration_test/create_migration_files.sh"
}
```

Dockerfile は以下のように定義します。(ここでは MySQL を想定した設定にしています)

```Dockerfile
FROM golang:1.23-bullseye

ARG USERNAME=root

RUN apt-get update -y \
    && DEBIAN_FRONTEND=noninteractive \
    apt-get install -y --no-install-recommends \
    git \
    vim \
    locales \
    tzdata \
    default-mysql-client \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

RUN go install github.com/pressly/goose/v3/cmd/goose@latest

ENV TZ=Asia/Tokyo \
    GOOSE_DRIVER=mysql \
    GOOSE_DBSTRING=[DBユーザー名]:[DBパスワード]@tcp([DBホスト]:[DBポート])/[DB名]?parseTime=true \
    GOOSE_MIGRATION_DIR=/workspaces/goose_migration_test/migrations

WORKDIR /workspaces
```

golang の入る Docker イメージを元にし、go install で goose をインストールしています。
GOOSE_DRIVER, GOOSE_DBSTRING, GOOSE_MIGRATION_DIR の環境変数で DB 接続情報やマイグレーションファイルのフォルダパスを指定していて、goose はこれらの環境変数に従ってマイグレーションを実行します。

なお、DB サーバーが存在する Docker のネットワークを指定したい場合などは devcontainer.json の runArgs で指定できます。

```
	"runArgs": [
	  "--network", "network-name"
	],
```

これらの設定により、マイグレーションファイルを管理しているリポジトリを取得して VSCode で開いて Dev Container で立ち上げるのみで前述した goose の各コマンドやマイグレーションファイル生成スクリプトが実行できるようになります。[^5]

# まとめ
今回は以下について記載しました。

- Go 製のマイグレーション管理ツールである goose の使い方
- Dev Container を用いて goose の実行環境を構築する方法
- TCJ での DB マイグレーション運用と、それを前提とした goose 導入方法 (ローカル DB 対象)

個人的な所感ですが、マイグレーション管理ツールを使うようにしてかなり DB の最新化のストレスが軽減されました。マイグレーションファイルは月に数十ファイル程度のペースで追加されているため手動ではなかなか骨が折れます。
導入前はエラーが起きて気付くまでマイグレーション適用を先送りにしがちでしたが、ツールを導入したことにより定期的にローカル DB を最新化するようになりました。
日常的に継続して行う作業を効率化しておくことはやはり大事ですね。

以上です、読んで頂きありがとうございました！

[^1]: 簡単のためステージング環境への反映などは割愛します。
[^2]: 簡略化して記載しており、実際のフォルダ構成は若干異なります。
[^3]: 実際にはここで挙げた以外の役割も担っているのですが、今回の話題に関連する部分のみに限定して記載します。
[^4]: 余談ですが Chat GPT が割と一瞬でスクリプトの大枠を書いてくれました。
[^5]: Docker 自体のインストールや Dev Container のプラグインの追加などは行われている前提。