📝

宣言的スキーマ管理ツール pistachio を作成しました

に公開
3

プラットフォームチームの菅原です。

最近、pistachioという宣言的スキーマ管理ツールを作成し[1]、本番環境のDBマイグレーションに導入したので紹介させてください。

pistachioについて

https://github.com/winebarrel/pistachio

「宣言的スキーマ管理」はTerraformのように「あるべきスキーマの状態」を記述し、差分を埋めるDDLを実行することでDBマイグレーションを行う方法です。
同様のツールとしてはatlassqldef、拙作ですがRidgepole、最近のものだとpgschemaなどがあります。

pistachioはPostgreSQL専用の宣言的スキーマ管理ツールで、SQLでスキーマを記述して管理します。

pistachioのワークフロー

まずdumpコマンドで既存のスキーマをファイルに出力します。

$ pista dump > schema.sql
$ cat schema.sql
-- Dump of schema public (1 table, 0 views, 0 enums, 0 domains)
-- public.users
CREATE TABLE public.users (
    id integer NOT NULL,
    name text NOT NULL,
    CONSTRAINT users_pkey PRIMARY KEY (id)
);

このファイルをgitに追加して、変更を加えます。

$ git add schema.sql
$ git commit -m 'Add schema.sql'
$ vi schema.sql
$ git diff
...
 CREATE TABLE public.users (
     id integer NOT NULL,
     name text NOT NULL,
+    email text,
     CONSTRAINT users_pkey PRIMARY KEY (id)
 );

schema.sqlとDBの差分をplanコマンドで確認します。

$ pista plan schema.sql
-- Plan for schema public (1 table, 0 views, 0 enums, 0 domains)
ALTER TABLE public.users ADD COLUMN email text;

applyコマンドで差分を適用します。

$ pista apply schema.sql
-- Apply to schema public (1 table, 0 views, 0 enums, 0 domains)
ALTER TABLE public.users ADD COLUMN email text;

schema.sqlとDBに差異が無ければ何も実行されません。

$ pista apply schema.sql
-- Apply to schema public (1 table, 0 views, 0 enums, 0 domains)
-- No changes

細かいオプションなどはREADME.mdgetting-started.mdを参照してください。

開発の経緯

pistachio以前

カンムのサービスでは元々、Python製の積み上げ型DBマイグレーションツールAlembicを利用してマイグレーションを行っていました。AlembicはSQLツールキットSQLAlchemyのモデル定義の差分からファイルを生成してマイグレーションを行うもので、Pythonでサービス開発をしていた経緯から採用されたものだと思います。

しかし私が入社したときにはすでにGoでのサービス開発が主流になっており、

  • GoのプロジェクトなのにPythonランタイムが必要
  • Pythonでのモデル(テーブル)定義が必要
  • マイグレーションに上下の依存関係があり並列開発がしにくい

といった点からAlembicでのDBマイグレーションはあまり使いやすいものではありませんでした。


Alembicは開発フローの至る所に浸透しており、一朝一夕で他のツールに置き換えられるものではなかったため、なかなか改善は進みませんでした。しかしあるときサービスの管理対象のDBが増えることになり「これ以上、GoのサービスでPythonランタイムへの依存を増やしたくない!」と一念発起し、すでに一部のサービスで導入実績のあったsqldef(psqldef)を導入することにしました。

私一人で作業し、期間はだいたい1か月ほど、修正ファイル数は600超程度。無事に導入は完了し、Pythonに依存しない並列開発可能なDBマイグレーションにすることができました。

sqldef→pistachio

sqldefはめちゃくちゃ便利なツールなのですが、カンムの環境では一つ問題がありました。

sqldefのPostgreSQL用のパーサーは2種類あり、一つはVitess由来のparser.yから生成されるgenericパーサー、もう一つはlibpg_query由来のpgqueryパーサーです。
デフォルトではgenericパーサーが使用され、genericパーサーのフォールバック、またはPSQLDEF_PARSER=pgqueryを設定したときにpgqueryパーサーが使われます。

genericパーサーはよくメンテナンスされているのですが、完全にPostgreSQLの文法をパースできるわけではなく、カンムのスキーマ定義では一部をパースできない状態であったため、pgqueryパーサーを使う必要がありました。
しかし、pgqueryパーサーはsqldef v4で削除される方針になっています。

https://github.com/sqldef/sqldef/blob/master/v4-migration.md#3-generic-parser-only-for-psqldef-no-pgquery-fallback

パースできないDDLについて、genericパーサーの改修も試みたのですが、PostgreSQLの未予約キーワード(Non-reserved Keywords)[2]の取り扱いが難しく、完全に修正することは断念しました。

pgqueryパーサーが使えなくなると、いずれバージョンアップを止めねばならず、バグフィックスや新しい文法への対応が難しくなります。そのような問題意識があって、なんとかしなければと考えていました。

pgschemaへの置き換えも検討したのですが、plan実行時に「内蔵のPostgreSQLインスタンスを起動してSQLを検証する」という重たい設計のため実行に時間がかかり、手元でサクサク動かしたい場合には向いていないという結論になりました。

結局、pg_query_goを使ったPostgreSQL専用の宣言的スキーマ管理ツールを自作しようと思い、ちまちまとアイデアの検証や実装を進めていたのですが、途中からAIを導入したところ一気に作業が進み、本番環境への導入のめどが立ったため、今回、sqldefからpistachioへと置き換えることになりました。

pistachioの特徴

PostgreSQLネイティブなパーサー

内部でpg_query_goを使用しており、PostgreSQL本体と同一のパーサーでSQLを解析します。

対象スキーマの明示的な指定

管理対象のスキーマを-nフラグで明示的に指定します。意図しないスキーマへの変更を防ぎ、操作範囲を明確にします。

依存関係を考慮した実行順序

ENUM→ドメイン→テーブル→ビューといったオブジェクト間の依存関係をトポロジカルソートで解決し、正しい順序でDDLを実行します。

スキーマ名の省略・マッピング

--omit-schemaで出力から修飾名を省略できます。また--schema-map public=stagingのようにSQLファイル上のスキーマ名を実行時に別の名前へ差し替えることが可能です。

デフォルトで DROP が無効

明示的に --allow-drop を指定しない限り、テーブルやカラムの削除は実行されません。抑制された DROP は -- skipped: コメントとしてプラン出力に表示されるため、何が保護されたか確認できます。

デフォルトでトランザクションが無効

ロックの粒度を細かくするためにデフォルトでトランザクションを無効にしています。--with-txで明示的にトランザクションを有効にすることもできます。

CREATE INDEX CONCURRENTLY のサポート

-- pista:concurrentlyディレクティブにより、本番環境でのテーブルロックを回避するインデックス操作をサポートしています。

-- pista:concurrently
CREATE INDEX idx_users_name ON public.users USING btree (name);

オブジェクト・カラムのリネーム対応

-- pista:renamed-fromディレクティブにより、テーブル・カラム・制約・インデックス・ビュー・ENUM・ドメインのリネームを明示的に指示できます。

条件付き SQL の実行

-- pista:executeディレクティブで、関数やトリガーなど宣言的に差分管理しにくいオブジェクトを扱えます。チェック用のSQLを添えることで、条件を満たす場合のみ実行され、冪等性を保てます。

-- pista:execute SELECT obj_description(to_regprocedure('public.get_user_count()'), 'pg_proc') IS DISTINCT FROM 'v1'
DO $do$ BEGIN
  CREATE OR REPLACE FUNCTION public.get_user_count() RETURNS bigint AS $body$
    SELECT count(*) FROM public.users;
  $body$ LANGUAGE sql;
  COMMENT ON FUNCTION public.get_user_count() IS 'v1';
END $do$;

まとめ

pistachioはPostgreSQL本体と同一のパーサー(pg_query_go)を採用することで、PostgreSQLの文法を正確にパースできるようしました。DROPのデフォルト無効化や CREATE INDEX CONCURRENTLYのサポートなど、本番環境での安全な運用を意識した設計にしているつもりです。PostgreSQLのスキーマ管理でお困りの方はぜひ使ってみてください。

脚注
  1. 14年ぶり2回目 ↩︎

  2. 文脈によって識別子に使用可能なキーワード。FUNCTIONの仮引数名など https://www.postgresql.jp/document/18/html/sql-keywords-appendix.html ↩︎

株式会社カンム

Discussion

シロシロ

興味深く拝見しました。
1点、インデックスの差分管理について質問です。

部分インデックス(WHERE 付きインデックス)を運用している場合、以下のような変更はサポートされていますか?

  • インデックス名は同じまま
  • 初期作成時は通常インデックスだった
  • 後から WHERE 句を追加して部分インデックス化したくなった
    (または WHERE 条件自体を変更したくなった)

このケースは PostgreSQL 的には ALTER INDEX で WHERE句(predicate)を変更できないため、
実質 DROP + CREATE / リプレイス が必要になる認識です。

pistachio ではこの差分を検知して、
安全にインデックスの再作成(必要なら concurrently)まで扱えるのでしょうか?

psqldef だとこのケースは before_apply 等での手動対応になる認識ですが、
pistachio では predicate 変更を schema diff として検知し、index recreate まで自動で扱える設計でしょうか?

Genki SugawaraGenki Sugawara

pistachio ではこの差分を検知して、
安全にインデックスの再作成(必要なら concurrently)まで扱えるのでしょうか?

差分を検知して drop / create でインデックスの再作成になります。 ディレクティブ等で concurrently をつけることは可能です

https://github.com/winebarrel/pistachio/pull/128

シロシロ

ご回答ありがとうございます!