追加開発時の悩みのタネ、マイグレーションの課題に立ち向かうための武器 pgroll
はじめに
サービス開発に携わっている方であれば、誰しも一度はマイグレーションに頭を抱えたことがあるのではないでしょうか。
後方互換性の確保、大量レコードによる長時間のロックなど、マイグレーションの実施にあたってはいくつか考慮しなければならない点が存在します。
新規開発時であればあまり気にしなくても良かったこれらの課題も、サービスを止めずに実施するとなると非常にやっかいです。
こういった事情から、歴史的経緯によって負債化してしまったDBを運用しておられる方も多いのではないでしょうか。
pgroll
そんなマイグレーションの悩みを解消してくれるかもしれないツールがpgroll
です。
pgrollを用いると、互換性を保ったまま、ロックを取らずにマイグレーションを行うことができるとのこと。
Postgres 14.0以降に対応しており、RDSやAuroraでも使用可能なようです。
動かしてみる
READMEの手順を参考に実際に動かしてみます。
環境準備
pgroll: 0.4.1 postgresql: 14.10
今回はPostgres14系の環境を用意するためにdockerを使用します。
docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres:14.10-bullseye
pgrollはbrewでインストールできます。
brew tap xataio/pgroll brew install pgroll
初期化
pgroll init
を実行するとSUCCESS Initialization complete
というメッセージが返ってきました。
成功したようです。
スキーマの状態を確認してみます。
postgres=# \dn List of schemas Name | Owner --------+---------- pgroll | postgres public | postgres (2 rows)
public
スキーマの他に、pgroll
というスキーマが作成されていますね。
このスキーマでマイグレーションの状態を管理しているのでしょうか。
初回マイグレーション
次にマイグレーションの内容をJSONに記載します。 READMEにサンプルが用意されていたので、ありがたく使わせていただきました。
{ "name": "initial_migration", "operations": [ { "create_table": { "name": "customers", "columns": [ { "name": "id", "type": "integer", "pk": true }, { "name": "name", "type": "varchar(255)", "unique": true }, { "name": "bio", "type": "text", "nullable": true } ] } } ] }
id, namae, bio
というカラムを持つcustomer
テーブルを生成する定義のようです。
start
コマンドを実行するとマイグレーションが実行されます。
pgroll start 01_init.json SUCCESS New version of the schema available under the postgres "public_01_init" schema
成功しました。
テーブル定義を見てみます。
postgres=# \dt List of relations Schema | Name | Type | Owner --------+-----------------------+-------+---------- public | _pgroll_new_customers | table | postgres (1 row) postgres=# \d _pgroll_new_customers Table "public._pgroll_new_customers" Column | Type | Collation | Nullable | Default --------+------------------------+-----------+----------+--------- id | integer | | not null | name | character varying(255) | | not null | bio | text | | | Indexes: "_pgroll_new_customers_pkey" PRIMARY KEY, btree (id) "_pgroll_new_customers_name_key" UNIQUE CONSTRAINT, btree (name)
customer
にプレフィックスが付いた_pgroll_new_customers
という名前でテーブルが生成されていますね。
Viewも見てみます。
postgres=# select schemaname, viewname From pg_views; schemaname | viewname --------------------------+--------------------------------------- public_initial_migration | customers ... postgres=# select schemaname, viewname, definition From pg_views where viewname = 'customers'; schemaname | viewname | definition --------------------------+-----------+------------------------------------- public_initial_migration | customers | SELECT _pgroll_new_customers.name,+ | | _pgroll_new_customers.bio, + | | _pgroll_new_customers.id + | | FROM _pgroll_new_customers; (1 row)
publicスキーマに作成されたテーブルにアクセスするような定義になっていますね。
ここで突然の仕様変更
あーっとここで突然の仕様変更です。bio
は必須項目になってしまいました。
もちろんアプリケーション側だけで対応することもできますが、経緯を知らないとbio
カラムだけnot null
が付与されていない理由がわからなくなってしまいますよね?
できればマイグレーションも行いたいところです。
pgrollの状態
ここで、実際にマイグレーションを適用する前にpgrollが持つ状態について少し補足します。
pgrollではマイグレーションをstart
とcomplete
2つのステップに分けて扱います。
startフェーズ
カラム追加など、ロールバック可能な操作のみテーブルに適用し、マイグレーション前後の状態を両方保持している状態。
マイグレーション前後のデータをスキーマを分けることで管理しており、search_path
を切り替えることで新旧両方の定義にアクセスすることができます。
rollback
コマンドでマイグレーション前の状態にロールバックできます。
また、complete
コマンドでcompleteフェーズに移行します。
completeフェーズ
カラム削除など、不可逆な操作をテーブルに適用し、マイグレーションが完了した状態。
マイグレーション後のスキーマにのみアクセスできます。
マイグレーションを可逆な部分と不可逆な部分に分けて管理することで互換性を担保しているようです。
いざマイグレーション
まずはさきほど開始した初回のマイグレーションを完了させます。
pgroll complete SUCCESS Migration successful!
※ pgroll start ${file} --complete
とすると一気にcomplete状態にすることができるようです。初回マイグレーションはこちらのほうが良さそうですね。
マイグレーション後の状態を確認するためにデータをinsertしておきました。
postgres=# select * from customers; id | name | bio ----+------+---------- 1 | me | it`s me. 2 | you | (2 rows)
続いてbio
カラムがnot null
になるようにマイグレーションを実施します。
{ "name": "01_customers_bio_not_null", "operations": [ { "alter_column": { "table": "customers", "column": "bio", "nullable": false, "up": "(SELECT CASE WHEN bio IS NULL THEN '私だ' ELSE bio END)", "down": "bio" } } ] }
bio
カラムがnull
の場合は代わりにデフォルト値を設定するようにしました。
マイグレーションを実行します。
pgroll start 01_customers_bio_not_null.json SUCCESS New version of the schema available under the postgres "public_01_customers_bio_not_null" schema
スキーマを確認してみます。
postgres=# \dn List of schemas Name | Owner ----------------------------------+---------- pgroll | postgres public | postgres public_01_customers_bio_not_null | postgres public_initial_migration | postgres (4 rows)
マイグレーションに伴ってpublic_01_customers_bio_not_null
スキーマが増えていますね。
テーブル定義を見てみます。
postgres=# \dt List of relations Schema | Name | Type | Owner --------+-----------+-------+---------- public | customers | table | postgres (1 row) postgres=# \d customers; Table "public.customers" Column | Type | Collation | Nullable | Default -----------------+------------------------+-----------+----------+--------- id | integer | | not null | name | character varying(255) | | not null | bio | text | | | _pgroll_new_bio | text | | | Indexes: "_pgroll_new_customers_pkey" PRIMARY KEY, btree (id) "_pgroll_new_customers_name_key" UNIQUE CONSTRAINT, btree (name) Check constraints: "_pgroll_add_column_check_bio" CHECK (_pgroll_new_bio IS NOT NULL) NOT VALID Triggers: _pgroll_trigger_customers__pgroll_new_bio BEFORE INSERT OR UPDATE ON customers FOR EACH ROW EXECUTE FUNCTION _pgroll_trigger_customers__pgroll_new_bio() _pgroll_trigger_customers_bio BEFORE INSERT OR UPDATE ON customers FOR EACH ROW EXECUTE FUNCTION _pgroll_trigger_customers_bio()
新たに_pgroll_new_bio
カラムが追加され、NOT NULL
のCHECK制約が付与されていますね。
Nullable
はまだtrue
のままです。
また、トリガーが設定されていますね。定義を確認してみましょう。
postgres=# select prosrc from pg_proc where proname = '_pgroll_trigger_customers__pgroll_new_bio'; prosrc ----------------------------------------------------------------------------------- + DECLARE + "bio" "public"."customers"."_pgroll_new_bio"%TYPE := NEW."_pgroll_new_bio";+ "id" "public"."customers"."id"%TYPE := NEW."id"; + "name" "public"."customers"."name"%TYPE := NEW."name"; + latest_schema text; + search_path text; + BEGIN + SELECT 'public' || '_' || latest_version + INTO latest_schema + FROM "pgroll".latest_version('public'); + + SELECT current_setting + INTO search_path + FROM current_setting('search_path'); + + IF search_path = latest_schema THEN + NEW."bio" = bio; + END IF; + + RETURN NEW; + END; (1 row) postgres=# select prosrc from pg_proc where proname = '_pgroll_trigger_customers_bio'; prosrc ------------------------------------------------------------------------------------------ + DECLARE + "bio" "public"."customers"."bio"%TYPE := NEW."bio"; + "id" "public"."customers"."id"%TYPE := NEW."id"; + "name" "public"."customers"."name"%TYPE := NEW."name"; + latest_schema text; + search_path text; + BEGIN + SELECT 'public' || '_' || latest_version + INTO latest_schema + FROM "pgroll".latest_version('public'); + + SELECT current_setting + INTO search_path + FROM current_setting('search_path'); + + IF search_path != latest_schema THEN + NEW."_pgroll_new_bio" = (SELECT CASE WHEN bio IS NULL THEN '私だ' ELSE bio END);+ END IF; + + RETURN NEW; + END; (1 row)
データに変更が入った際にスキーマ間のダブルライトを行う役割のようです。
これで新旧両方のスキーマでデータを同期することができます。
次にView定義を見てみます。
postgres=# select schemaname, viewname From pg_views; schemaname | viewname ----------------------------------+--------------------------------------- public_initial_migration | customers ... public_01_customers_bio_not_null | customers postgres=# select schemaname, viewname, definition From pg_views where schemaname = 'public_initial_migration' and viewname = 'customers'; schemaname | viewname | definition --------------------------+-----------+------------------------- public_initial_migration | customers | SELECT customers.name,+ | | customers.bio, + | | customers.id + | | FROM customers; (1 row) postgres=# select schemaname, viewname, definition From pg_views where schemaname = 'public_01_customers_bio_not_null' and viewname = 'customers'; schemaname | viewname | definition ----------------------------------+-----------+-------------------------------------- public_01_customers_bio_not_null | customers | SELECT customers.name, + | | customers.id, + | | customers._pgroll_new_bio AS bio+ | | FROM customers; (1 row)
それぞれスキーマを透過的に扱えるように定義されています。
では、旧バージョンのスキーマに対してデータをinsertしてみます。
postgres=# set search_path to 'public_initial_migration'; SET postgres=# insert into customers (id, name, bio) values (3, 'who', null); INSERT 0 1 postgres=# select * from customers; name | bio | id ------+----------+---- me | it`s me. | 1 you | | 2 who | | 3 (3 rows)
bio
はnullableになっていますね。
この状態で新バージョンのスキーマを確認します。
postgres=# set search_path to 'public_01_customers_bio_not_null'; SET postgres=# select * from customers; name | id | bio ------+----+---------- me | 1 | it`s me. you | 2 | 私だ who | 3 | 私だ (3 rows)
デフォルト値を反映した値が同期されていますね。
続いて、新バージョンのスキーマに対してnullをinsertしてみます。
postgres=# insert into customers (id, name, bio) values (4, 'hoge', null); ERROR: new row for relation "customers" violates check constraint "_pgroll_add_column_check_bio"
ちゃんとエラーになりました。
今度はnullではなく値を入れてみます。
postgres=# insert into customers (id, name, bio) values (4, 'hoge', 'meeee'); INSERT 0 1 postgres=# select * from customers; name | id | bio ------+----+---------- me | 1 | it`s me. you | 2 | 私だ who | 3 | 私だ hoge | 4 | meeee (4 rows) postgres=# set search_path to 'public_initial_migration'; SET postgres=# select * from customers; name | bio | id ------+----------+---- me | it`s me. | 1 you | | 2 who | | 3 hoge | meeee | 4 (4 rows)
旧バージョンのスキーマにもデータが同期されています。
では、completeフェーズに移行させてみます。
pgroll complete SUCCESS Migration successful!
スキーマを確認します。
postgres=# \dn List of schemas Name | Owner ----------------------------------+---------- pgroll | postgres public | postgres public_01_customers_bio_not_null | postgres (3 rows)
旧バージョンのスキーマが削除されています。
Viewを確認します。
postgres=# select schemaname, viewname From pg_views; schemaname | viewname ----------------------------------+--------------------------------------- ... public_01_customers_bio_not_null | customers ...
当然Viewも削除されています。
テーブル定義を確認します。
postgres=# \d customers; Table "public.customers" Column | Type | Collation | Nullable | Default --------+------------------------+-----------+----------+--------- id | integer | | not null | name | character varying(255) | | not null | bio | text | | not null | Indexes: "_pgroll_new_customers_pkey" PRIMARY KEY, btree (id) "_pgroll_new_customers_name_key" UNIQUE CONSTRAINT, btree (name)
CHECK制約やトリガーが削除され、Nullable=true
になっています。
マイグレーションが完了した状態になっていますね。
さいごに
- pgrollはマイグレーションを可逆な部分と不可逆な部分に切り分けて管理してくれます
- スキーマを分けることで実現されており、データの同期もトリガーが行ってくれるため、ロールバックも容易に実現できそうです
- 今回は検証しませんでしたが、実際にスキーマを切り替えて運用する際には
search_path
を修正する必要があるため、サービス開発で利用する際には言語やフレームワークに応じた仕組みを検討する必要があります