ridgepoleのメリットと使い方

Ruby on Railsの標準的なデータベース定義ツールはActiveRecordの”マイグレーション”です。これは、データベース定義の変更点をSQLではなくRubyスクリプトで記述し、バージョン管理を可能にするものです。
ただ、マイグレーションはDB定義の変更が増えてくると無数のファイルが生成されて見通しが悪くなります。

ridgepoleを使うことで、マイグレーションと同様の記法でDB定義を1つの設定ファイルに集約でき、冪等な設定で運用できるようになります。また、RDBMSのドライバーを切り替えることで、PostgreSQL / MySQL / SQLite などデータベース間のスキーマ移行も簡単になります。

旧聞ながら、 クックパッドにおける最近のActiveRecord運用事情の説明から、クックパッド本サービスの大規模環境で利用されていることが分かります。

同じ目的のツールに sqldefがあります。いずれが適しているかはプロジェクトの重点により決めます。

Rails以外のフレームワーク向けの活用

ridgepoleはRailsをターゲットとして作られたツールですが、Railsを導入していない環境でも活用できます。
たとえばPHPのWebフレームワークでもマイグレーションによるスキーマ管理を行うものがあり、メリット・デメリットの事情は同じです。

結局のところDB管理は、本線アプリのコーディングと別の管理が求められるため、ridgepoleを利用する余地はあります。
ただし、利用しているWebフレームワークとDB上のスキーマの規約を理解したうえで使う必要はあります。

RailsマイグレーションDSLの影響

ridgepoleのスキーマ定義は、 Railsマイグレーションのcreate_tableと同じ文法のDSLで書きます。
過不足なくridgepole向けといえるリファレンスがなく、Railsマイグレーションの解説には周辺情報が付いてしまっている点は分かりづらいかもしれません。

説明はないのですが、ActiveRecordがサポートしている拡張型も利用できます。 RailsガイドのPostgreSQL解説に説明があります。
特殊な型もありますが、 UUIDやBytea、JSONを確認したところマイグレートできました。

このようにRailsのActiveRecordが備えている機能は、実際には使えるものが多くあり、おそらくユーザーが想像しているよりridgepoleは高機能です。
またDSLではありますが、Rubyで書いた文も一部有効であるため、同じ構成の複数のテーブルを簡潔に定義できたりもします。

一方で、スキーマがRailsのデフォルトの変更に影響される面もあります。
過去の例では、idのデフォルト型がinteger->bigintに拡張されたり、timestampの詳細精度が変更されたりしました。

Railsはベストプラクティスを強いるフレームワークであり、ユーザーよりも速く進化していきます。
作成タイミングによってDBスキーマが異なるということも起きるのがRails wayなのです。

インストールと基本的な使い方

セットアップは、bundlerを利用する場合、Gemfileに gem “ridgepole” を追加してbundle installです。

ridgepoleの基本的な使い方は、Schemafileというファイルにデータベース定義を記述しておき、以下のようにridgepoleコマンドでDBMSに設定を反映するだけです。

$ bundle exec ridgepole -c config/database.yml -f config/Schemafile -a

このコマンドにより、データベースのスキーマがSchemafileの定義と同じ状態になるようにDDLが発行されます。(--dry-runオプションを付けるとDDLの内容を確認できます)

引数の-aがapply、-cはRails標準のデータベース接続設定ファイルdatabase.ymlを指定しています。database.ymlは一般的なRailsの接続設定と全く同じです。

Schemafileにはカラムの定義にくわえて、インデックス、複合インデックスの定義も可能です。テーブル定義がまとまっているので、MySQLでdescribeしなくてもSchemafileを読めばクラス構造をほぼ把握できます。

カラム定義に指定できるオプションは、 ActiveRecordのadd_columnと同様です。

database.ymlの環境変数をコマンドラインから指定

docker / kubernetes の環境で、databaseの接続ホストが動的に決まるためdatabase.ymlにerbを用いて変数で指定したいケースがあります。Railsは

host: <%= ENV['PG1_SERVICE_HOST'] %>
password: <%= ENV['POSTGRES_PASSWORD'] %>

のような記述で環境変数から接続情報を取得できるのですが、ridgepoleコマンドがテンプレート変数を処理できないケースに遭遇しました。

この場合、

$ ridgepole -c config/database.yml -f config/Schemafile -E PG1_SERVICE_HOST=192.168.0.1 -E POSTGRES_PASSWORD=connectionpass -a

と-Eオプションで設定することで期待どおりに動作しました。

Rails向けの使い方

CREATE DATABASE/DROP DATABASEはRails標準のrails dbを利用して、以下のようなコマンドセットでライフサイクル管理が可能になります。(PostgreSQL/MySQLに直接接続してCREATE DATABASEする手順でもとくに問題なく動作します)

初歩的な注意点

ridgepoleを使う場合、マイグレーションを併用しないことが重要です。一見当然ですが、たとえばrailsコマンドでモデルを生成するとデフォルトでmigrationファイルも生成されるため、

$ rails g model SomeNewModel --skip-migration

のようにマイグレーションを作らないオプションを付けます。
不要なマイグレーションファイルが存在していると実行時にエラーになります。誤ってマイグレーションファイルが生成されてしまった場合には、単純にdb/migrate/にあるマイグレーションファイルをrmで削除します。

また、べき等な動作の結果、テーブル名・カラム名変更のつもりで安直に記述するとデータが消えうるため注意が必要です。名称変更の手順は、 ridgepoleでテーブル名・カラム名の変更手順で解説しています。

スキーマ変更後にインスタンス再起動

ridgepoleコマンドでDBスキーマを変更したあと、Railsから認識するためにはAPサーバーインスタンスを再起動すべきなようです。

カラム追加後、再起動しない状態でアクセスしたところ、ActiveModel::MissingAttributeError (can’t write unknown attribute…のようなエラーに直面して、再起動したところ直りました。

運用中の既存DBに途中から導入

既存のデータベースのスキーマ管理のため、途中からridgepoleを導入したい場合、-eオプションのスキーマダンプを活用できます。

データベース接続設定(ホスト、ID・パスワードなど)をconfig.ymlに記載し、

$ ridgepole -c config.yml -e -o Schemafile

を実行すると、データベース内のテーブル一式を定義したスキーマ設定ファイルを得られます。

安易に定義ファイルを分割すると危険だった

version 1.0で、未定義のテーブルが実DBに存在する場合にもテーブルを削除しない挙動となり、安全性が増しています
version 0.9以前では、ファイルをテーブル別に分割していて一部のファイルだけ参照してapplyしてしまうと、記述のないテープルについてはDROP TABLEの動作になり削除されます。

一般的には、version1.0以降を使用していることをよく確認すると安全です。

制約(CONSTRAINT)の追加

たとえば、PostgreSQLでUPSERTを利用するためにはユニーク制約が必要になりますが、制約(CONSTRAINT)を羃等に実装することは単純ではありません。
SQLで実装しようとすると Postgres: Add constraint if it doesn’t already existの議論のような工夫が必要になります。

ridgepoleでは、executeのブロックに実行条件を定義できるため、ADD CONSTAINTをIF NOT EXISTと同じように条件付きで実行できます。
公式ページにforeign key制約の例が掲載されており、PostgreSQL用に書き直すと、以下のようなコードになります。

execute("ALTER TABLE books ADD CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES authors (id)") do |c|
  c.raw_connection.query(<<-SQL).each.size.zero?
    SELECT 1 FROM information_schema.table_constraints
    WHERE table_name = 'books' AND constraint_name = 'fk_author' LIMIT 1
  SQL
end

また、PostgreSQLの場合、制約とインデックスは関連する別物であり、制約がインデックスを必要とするため、同名のインデックスをadd_indexで貼っておかないと、2回目の実行時にエラーになります。

ユニークインデックスの実例は以下の通りです。インデックスはadd_indexで定義し、制約からはUSING INDEXで参照する、というテクニックが必要になります。

add_index("books", ["author_id"], name: "unique_author", unique: true)
execute("ALTER TABLE books ADD CONSTRAINT unique_author UNIQUE USING INDEX unique_author") do |c|
  c.raw_connection.query(<<-SQL).each.size.zero?
    SELECT 1 FROM information_schema.table_constraints
    WHERE table_name = 'books' AND constraint_name = 'unique_author' LIMIT 1
  SQL
end

これ以外の指定方法では、初回実行または2回目以降の実行で相互にコンフリクトしてエラーになります。

この制約を利用したUPSERTの書き方については、 PostgreSQLで重複レコードをINSERTで解説しています。
UPSERTもexecute()で羃等に実行可能であるため、システム必須条件のマスターレコードもridgepoleでセットアップしてしまうのも一手です。

ridgepoleの基本機能はDDLの管理ですが、ライフサイクルがDDLと同様のレコードも集約するとコードの凝集性は向上します。

REPLACE句のないリソース

TYPEのようにOR REPLACEオプションのないリソースは、以下のように重複時の例外処理を追加するとridgepoleでべき等に実行できます。

execute("DO $$ BEGIN
    CREATE TYPE comment AS (
      id bigint,
      description text
    );
  EXCEPTION
    WHEN duplicate_object THEN null;
  END $$;")

ただし、変更を加えたい場合には、REPLACEはしないため一度削除して再CREATEする必要がある点に注意します。

中馬崇尋
Chuma Takahiro