追加開発時の悩みのタネ、マイグレーションの課題に立ち向かうための武器 pgroll

2023.12.11

はじめに

サービス開発に携わっている方であれば、誰しも一度はマイグレーションに頭を抱えたことがあるのではないでしょうか。

後方互換性の確保、大量レコードによる長時間のロックなど、マイグレーションの実施にあたってはいくつか考慮しなければならない点が存在します。

新規開発時であればあまり気にしなくても良かったこれらの課題も、サービスを止めずに実施するとなると非常にやっかいです。

こういった事情から、歴史的経緯によって負債化してしまった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にサンプルが用意されていたので、ありがたく使わせていただきました。

01_init.json

{
  "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ではマイグレーションをstartcomplete2つのステップに分けて扱います。

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になるようにマイグレーションを実施します。

01_customers_bio_not_null.json

{
  "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を修正する必要があるため、サービス開発で利用する際には言語やフレームワークに応じた仕組みを検討する必要があります

参考