こんにちは!スタンドアロンと聞くとどうしても攻殻機動隊を連想してしまう、しゅん(@MxShun)です。

今回は、Go 製のスタンドアロン DB マイグレーションツール bitbucket.org/liamstask/goose から github.com/pressly/goose へスムーズに移行するハウツーを書きます。

目次

はじめに

goose は、Go で作られた DB マイグレーションツールです。Go でできたスタンドアロンな DB マイグレーションツールとしては第一に候補として上がる、いわばデファクトスタンダードと言っても差し支えないでしょう。

簡単な例をあげます。

-- +goose Up
CREATE TABLE post (
    id int NOT NULL,
    title text,
    body text,
    PRIMARY KEY(id)
);

-- +goose Down
DROP TABLE post;

のような SQL ファイルを用意し、

$ goose up
$ goose down

と実行することで、それぞれ DB の更新と切戻しを適用できます。DB マイグレーションの適用状況はテーブルで管理、共有されます。

+---+--------------+----------+-------------------+
|id |version_id    |is_applied|tstamp             |
+---+--------------+----------+-------------------+
|1  |0             |1         |2018-09-15 12:00:00|
|2  |20180915120000|1         |2018-09-15 12:00:00|
|3  |20181001120000|1         |2018-10-01 12:00:00|
+---+--------------+----------+-------------------+

goose の主要なサプライヤには bitbucket.org/liamstask/goose と、それをフォークした github.com/pressly/goose があります。それぞれのプロジェクト指標は下記の通りです(執筆時点)。あくまで DB マイグレーションツールなのでベンチマークはありません;)

プロジェクト スター数 フォーク数 最終更新
bitbucket.org/liamstask/goose – (Bitbucket なのでスターなし) 107 2018-03-01
github.com/pressly/goose 3,558 388 2023-01-27

問題

上に書いたプロジェクト指標から分かる通り、bitbucket.org/liamstask/goose は2018年3月1日の更新を最後に開発がストップしています。これに伴う問題の一つが、Apple silicon での非動作です。

$ goose -version                                                                                                                                                                                   
segmentation fault  goose -version

Apple silicon 標準化の潮流において、bitbucket.org/liamstask/goose 利用者は漏れなく向き合わなければならない問題と言えます。幸いにもフォークプロジェクトの github.com/pressly/goose はこの問題をクリアしており、移行コストの観点でも bitbucket.org/liamstask/goose から github.com/pressly/goose へ移行するのが自然な流れかと思います。

移行

github.com/pressly/goose は、bitbucket.org/liamstask/goose との違いを次のように整理しています。

  • No config files
  • Default goose binary can migrate SQL files only
  • Go migrations:
    • We don’t go build Go migrations functions on-the-fly
      from within the goose binary
    • Instead, we let you create your own custom goose binary, register your Go migration functions explicitly and run complex migrations with your own *sql.DB connection
    • Go migration functions let you run your code within an SQL transaction, if you use the *sql.Tx argument
  • The goose pkg is decoupled from the binary:
    • goose pkg doesn’t register any SQL drivers anymore, thus no driver panic() conflict within your codebase!
    • goose pkg doesn’t have any vendor dependencies anymore
  • We use timestamped migrations by default but recommend a hybrid approach of using timestamps in the development process and sequential versions in production.
  • Supports missing (out-of-order) migrations with the -allow-missing flag, or if using as a library supply the functional option goose.WithAllowMissing() to Up, UpTo or UpByOne.
  • Supports applying ad-hoc migrations without tracking them in the schema table. Useful for seeding a database after migrations have been applied. Use -no-versioning flag or the functional option goose.WithNoVersioning().

取り立てて考えなければいけないのは、次の 3 つでしょう。

1. No config files

bitbucket.org/liamstask/goose では慣例的に DB 設定郡を dbconf.yml に外部定義し、これを利用します。

development:
    driver: postgres
    open: user=liam dbname=tester sslmode=disable

github.com/pressly/goose では、文字通りこの設定ファイルが使えません。代わりに、これらを宣言的に実行する必要があります。

# Usage: goose [OPTIONS] DRIVER DBSTRING COMMAND
$ goose postgres "user=liam dbname=tester sslmode=disable" up

これに対するスムーズな移行のアイデアとして、「シェルスクリプトをかませる」があります。具体的には、次のようなシェルファイルを用意します。なお、簡易的な例なので DB 認証情報はハードコーディングしていますが、実際には env ファイル等へ逃がすべきです。

DIR="./db/migrations"
DRIVER="postgres"
DBSTRING_LOCAL="user=liam dbname=tester sslmode=disable"
DBSTRING_DEVELOPMENT="user=liam dbname=tester_dev sslmode=disable"
DBSTRING_STAGING="user=liam dbname=tester_stg sslmode=disable"
DBSTRING_PRODUCTION="user=liam dbname=tester_prd sslmode=disable"

# https://github.com/pressly/goose
# NOTE: Macの利用しか想定していないので他プロセッサで利用する場合はバイナリと分岐の追加が必要
function up() {
  if [ "$(uname -m)" == "arm64" ]; then
    ./db/bin/goose_darwin_arm64 -dir=${DIR} ${DRIVER} "${DBSTRING}" up
  else
    ./db/bin/goose_darwin_x86_64 -dir=${DIR} ${DRIVER} "${DBSTRING}" up
  fi
}

function down() {
  if [ "$(uname -m)" == "arm64" ]; then
    ./db/bin/goose_darwin_arm64 -dir=${DIR} ${DRIVER} "${DBSTRING}" down
  else
    ./db/bin/goose_darwin_x86_64 -dir=${DIR} ${DRIVER} "${DBSTRING}" down
  fi
}

ENV=$(echo "$2" | tr '[:lower:]' '[:upper:]')
DBSTRING=$(eval echo '${DBSTRING_'"$ENV"'}')
$1

私が goose を移行したプロジェクトでは、もともと Makefile を利用してオペレーションのマニュアル・共通化をしていました。それも相まってユーザインタフェースを変更することなく移行できました。

  goose-up:
-   goose -env local up
+   @sh ./goose.sh up local
$ make goose-up

2. Go migrations

bitbucket.org/liamstask/goose は上で述べたような SQL ファイルの他に、Go ファイルで定義したマイグレーションを実行することもできます。

package main

import (
    "database/sql"
    "fmt"
)

func Up_20130106222315(txn *sql.Tx) {
    fmt.Println("Hello from migration 20130106222315 Up!")
}

func Down_20130106222315(txn *sql.Tx) {
    fmt.Println("Hello from migration 20130106222315 Down!")
}

github.com/pressly/goose ではデフォルトでこれができません。代わりに、カスタム goose アプリケーションのバイナリファイルを生成する必要があります。

  1. Move main.go into your cmd/ directory
  2. Rename package name in all *_.go migration files from main to migrations.
  3. Import this migrations package from your custom cmd/main.go file:
import (
  // Invoke init() functions within migrations pkg.
  _ "github.com/pressly/goose/example/migrations-go"
)

難点は、カスタム goose アプリケーションの生成とバージョン管理のコストが発生することでしょう。特殊なユースケースを除いて Go ファイルでマイグレーションを管理するべき理由はないように思えます。これを機に「SQL ファイルでマイグレーションを管理する方式に変更する」ことを検討してもよいかもしれません。

以上の 2 点を踏まえ、各環境で検証をすれば比較的スムーズに移行が可能かと思います。

3. Hybrid versioning

github.com/pressly/goose が推奨している Hybrid versioning にも触れておきます。要約すると、そのコンセプトは「タイムスタンプと連番の両方を利用したバージョニング」です。

goose ではタイムスタンプベースのバージョニングが基本となっています。これは goose create でマイグレーションファイルを生成した際、プレフィックスとしてタイムスタンプが付与されることから明らかです。

$ goose create add_some_column sql
Created new file: 20170506082420_add_some_column.sql

goose はマイグレーションファイル名の辞書順、つまりタイムスタンプが若いものから見てどこまで処理して、どこから処理していないかを管理しています。一見理にかなったバージョニング方式に思えますが、課題が指摘されています。

https://github.com/pressly/goose/issues/63#issuecomment-428681694

指摘となる例示は次のようなものです。

1. 次のような 2 つのブランチがあるとします

  • joe ブランチ
migrations/2018-09-15-12:00:00_joe_1.sql
migrations/2018-10-10-12:00:00_joe_2.sql
  • alice ブランチ
migrations/2018-10-01-12:00:00_alice_1.sql
migrations/2018-10-18-12:00:00_alice_2.sql

2. joe ブランチを master ブランチに先にマージします

+  migrations/2018-09-15-12:00:00_joe_1.sql
+  migrations/2018-10-10-12:00:00_joe_2.sql

3. goose up を実行して master ブランチから staging 環境に対してマイグレーションを実施します

$ goose up
goose: migrating db environment 'staging', current version: 0, target: 2
OK    2018-09-15-12:00:00_joe_1.sql
OK    2018-10-10-12:00:00_joe_2.sql

4. alice ブランチを master ブランチにマージします

   migrations/2018-09-15-12:00:00_joe_1.sql
+  migrations/2018-10-01-12:00:00_alice_1.sql
   migrations/2018-10-10-12:00:00_joe_2.sql
+  migrations/2018-10-18-12:00:00_alice_2.sql

5. goose up を実行して master ブランチから staging 環境に対してマイグレーションを実施します

ここが問題のポイントです。2018-10-10-12:00:00_joe_2.sql までマイグレーションを適用した環境において、それよりも辞書順で先の 2018-10-01-12:00:00_alice_1.sql はこのときの goose up 対象外となってしまいます。

$ goose up
goose: migrating db environment 'staging', current version: 0, target: 0
OK    2018-10-02-12:00:00_alice_2.sql

6. goose up を実行して master ブランチから production 環境に対してマイグレーションを実施します

$ goose up
goose: migrating db environment 'production', current version: 0, target: 2
OK    2018-09-15-12:00:00_joe_1.sql
OK    2018-10-01-12:00:00_alice_1.sql
OK    2018-10-02-12:00:00_joe_1.sql
OK    2018-10-18-12:00:00_alice_2.sql

7. staging 環境で未検証の 2018-10-01-12:00:00_alice_1.sqlproduction 環境へ適用されたためアプリケーションがダウンしてしまいました

これを避けるため開発ブランチではタイムスタンプを利用したバージョニングを実施し、リリースブランチにマージされたタイミングで連番を振ってその連番に基づいてバージョニングをする手法が Hybrid versioning です。 先の例に Hybrid versioning を当てはめてみます。

2. joe ブランチを master ブランチに先にマージします

のタイミングで、マイグレーションファイルに連番を振ります。

migrations/2018-09-15-12:00:00_joe_1.sql -> migrations/0001_2018-09-15-12:00:00_joe_1.sql
migrations/2018-10-10-12:00:00_joe_2.sql -> migrations/0002_2018-10-10-12:00:00_joe_2.sql

4. alice ブランチを master ブランチにマージします

のタイミングでも、同じ要領でマイグレーションファイルに連番を振ります。

migrations/2018-10-01-12:00:00_alice_1.sql -> migrations/0003_2018-10-01-12:00:00_alice_1.sql
migrations/2018-10-18-12:00:00_alice_2.sql -> migrations/0004_2018-10-18-12:00:00_alice_2.sql

最終的には次のようになりますね。

migrations/0001_2018-09-15-12:00:00_joe_1.sql
migrations/0002_2018-10-10-12:00:00_joe_2.sql
migrations/0003_2018-10-01-12:00:00_alice_1.sql
migrations/0004_2018-10-18-12:00:00_alice_2.sql

これにより、

5. goose up を実行して master ブランチから staging 環境に対してマイグレーションを実施します

のタイミングでアプリケーションダウンを検知できるようになるという訳です。

上記はあくまで Hybrid versioning のアプローチの一つで、具体的な方式はチームやプロダクト単位で意思決定をする必要がありますね。また、goose blog Adding support for out-of-order migrations に描かれたブランチモデルを参考にするとより理解が深まると思います。

未適用マイグレーションの取り扱い

最後に github.com/pressly/goose では言及のない、未適用マイグレーションの取り扱いの違いについても触れます。

冒頭で述べたとおり、goose では DB マイグレーションの適用状況をテーブルで管理します。これは bitbucket.org/liamstask/goosegithub.com/pressly/goose 共通の仕様です。

+---+--------------+----------+-------------------+
|id |version_id    |is_applied|tstamp             |
+---+--------------+----------+-------------------+
|1  |0             |1         |2018-09-15 12:00:00|
|2  |20180915120000|1         |2018-09-15 12:00:00|
|3  |20181001120000|1         |2018-10-01 12:00:00|
+---+--------------+----------+-------------------+

この状態から goose down を実行して直前のマイグレーションが未適用の状態にロールバックしてみます。

bitbucket.org/liamstask/goose では is_applied = false となります。

+---+--------------+----------+-------------------+
|id |version_id    |is_applied|tstamp             |
+---+--------------+----------+-------------------+
|1  |0             |1         |2018-09-15 12:00:00|
|2  |20180915120000|1         |2018-09-15 12:00:00|
|3  |20181001120000|0         |2018-10-01 12:00:00|
+---+--------------+----------+-------------------+

github.com/pressly/goose ではどうでしょう。レコード自体が削除されます。

+---+--------------+----------+-------------------+
|id |version_id    |is_applied|tstamp             |
+---+--------------+----------+-------------------+
|1  |0             |1         |2018-09-15 12:00:00|
|2  |20180915120000|1         |2018-09-15 12:00:00|
+---+--------------+----------+-------------------+

これに対応して goose upis_applied = false のレコードを無視するようになっています。goose status 上は Pending となっているにも関わらず、です。

+---+--------------+----------+-------------------+
|id |version_id    |is_applied|tstamp             |
+---+--------------+----------+-------------------+
|1  |0             |1         |2018-09-15 12:00:00|
|2  |20180915120000|1         |2018-09-15 12:00:00|
|3  |20181001120000|0         |2018-10-01 12:00:00|
+---+--------------+----------+-------------------+
$ goose status
Applied At                  Migration
=======================================
Sat Sep 15 12:00:00 2018 -- 001_2018-09-15-12:00:00_joe_1.sql
Pending                  -- 2018-10-01-12:00:00_alice_1.sql

$ goose up
goose: no migrations to run. current version: 0

したがって、未適用マイグレーションがある状態で bitbucket.org/liamstask/goose から github.com/pressly/goose へ移行すると、適用されなかったマイグレーションがゾンビのように残存することになります。一度 is_applied = false となったレコードを削除してからマイグレーションを実施しても構いませんが、すべてのマイグレーションを適用したクリーンな状態で bitbucket.org/liamstask/goose から github.com/pressly/goose へ移行したいものですね。

おわりに

今回は Go 製のスタンドアロン DB マイグレーションツール bitbucket.org/liamstask/goose から github.com/pressly/goose へスムーズに移行するハウツーを書きました。少しでも皆さんのツール移行の手助けとなっていれば幸いです。

サイバーエージェントには Go の資産がたくさんあり、より多くの Go エンジニアと一緒に働けることを期待しています。興味がある方はぜひ採用ページをご覧ください!

https://www.cyberagent.co.jp/careers/