inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

RidgepoleによるDBスキーマの宣言的定義に移行して便利になりました(ちょっと高速化もしました)

こんにちは osyoyu です。このたびワンバンクを支える本体アプリケーション、通称core-apiのデータベーススキーマ管理をRidgepole (ridgepole/ridgepole) に置き換えて、人生が便利になりました。悲願達成です。

本記事では導入にあたっての工夫をご紹介します。高速化のためにやったことも最後に書いているので、すでに宣言的ライフを送られている皆様もぜひどうぞ。


まずはワンバンクの本体アプリケーションにおける、最新の rails stats をご覧ください。

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
...
| Models               |  64726 |  46242 |    1277 |    4274 |   3 |     8 |
...
+----------------------+--------+--------+---------+---------+-----+-------+

Modelは1277クラス、テーブルの数も500以上と、それなりの規模のRailsアプリケーションとなっております。このコードベースを20人以上の開発者が同時に編集している様子をご想像ください。

Rails標準のマイグレーションの課題と、Ridgepoleについて

ワンバンクの開発では、これまでRails標準のMigrationsの仕組みを使っていました。しかし開発者が増加し、同時に進行している開発施策も増えたことで、細かな問題が顔を出す頻度も高まっていました。

たとえば検証環境の共有DBのスキーマの問題。検証環境に流されたマイグレーションは必ずしもmainにマージされていくとは限らず、orphanとなったテーブルやカラムが残される、ということがよくありました。気づいた人が手動でALTER TABLEを実行して解消する活動を通して治安を維持していましたが、良くない状態であったことは確かです。

また、ロールバック時の対処の複雑さも課題のひとつでした。デプロイ直後に問題が起きたとき、影響の緩和のために早急にロールバックしたいところ、まずはデプロイされた差分に新規のマイグレーションがないかを確認し、あればロールバック前に db:rollback する、productionで解消されたら同じことをstagingでも行う…… など、複雑な手順が必要となっていました。

上記のことに限らず、マイグレーションを使った開発体験は必ずしも最高のものではないと筆者自身が感じていたこともあり、データベーススキーマを宣言的に取り扱えるRidgepoleの導入を提案し、移行を果たしました。

Ridgepoleでは変更の差分を記述するのではなく、Terraformのように最終的な望む状態を記述します。存在しないカラムをスキーマファイルに書けば ALTER TABLE ADD されますし、存在するカラムをファイルから削除すれば ALTER TABLE DROP COLUMN が走ります。便利。

# db/schemata/cards.schema.rb
 create_table :cards, id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
   t.bigint :user_account_id, unsigned: true, null: false
   ...
-  t.datetime :activated_at
+  t.datetime :activated_at, null: false # NOT NULL 制約を設定
   t.datetime :restricted_at, comment: "利用制限が設定された日時"
   t.timestamps

+  # インデックスを追加
+  t.index [:user_account_id], name: :index_cards_on_user_account_id

   t.foreign_key :user_accounts
 end

Rails標準のマイグレーションは覚えるべきメソッドが多いところ、 create_table ひとつ覚えれば良い状態になったことも非常に好評です。ワンバンクではテーブルやカラムのコメント機能を活用して簡易的なドキュメンテーションをしていますが、こちらもより活発になりそうです。

また、スキーマの差分ではなく全体に目がよく向くようになったことで、重複していて無駄な複合インデックスなどの発見も進んでいます。同じテーブルを見ているはずが、認知の仕方が違うだけでこうも変わるものかと、少し驚いています。

さらには、どうやらAIとの相性もマイグレーションより良いようです。Devinがschema.rbのコミットを漏らす事件もなくなりました。面白いものですね。



開発者のワークフロー

Ridgepoleの導入のやりかたには強い標準と呼べるものはなく、各社それぞれ運用のコツと呼べるものがあるようです。ここではワンバンクで導入して便利だなと思っているポイントをいくつか書いてみます。

まずはファイルの整理。スキーマファイルは db/Schemafile をエントリーポイントとしつつ、1 table 1 file の形で整理しました。移行に際しては ridgepole --export の結果をちょっとしたRubyスクリプトで分割・整形しました。

# db/Schemafile
schema_files = Dir.glob("#{File.dirname(File.expand_path(__FILE__))}/schemata/*.schema.rb")
schema_files.each do |schema|
  require schema
end

% tree db/schemata
db/schemata
├── cards.schema.rb
├── fraud_detection_rules.schema.rb
└── users.schema.rb

開発者は db/schemata 以下のファイル(1ファイル = 1テーブル)を編集し、手元で ./bin/ridgepole を実行して妥当そうであることを確かめ、それをコミットする、というワークフローです。本番環境に対してはデプロイのタイミングで適用されます*1

% ./bin/ridgepole apply
Apply `/db/Schemafile`
-- add_column("balance_transactions", "local_amount", :decimal, {precision: 13, scale: 3, after: "exchange_rate"})
   -> 0.1224s
-- add_column("balance_transactions", "local_currency", :string, {after: "local_amount", limit: 255})
   -> 0.0610s

余談ですが、拡張子は標準の .schema ではなく、.schema.rb としました。モードラインに対応していないエディタでも、特別な工夫なくRubyスクリプトと認識されて便利です。

Pull requestでのDDL表示・危険な変更の指摘

スキーマに変更がある場合、実行される予定のALTER TABLEがpull requestにコメントされるようにしました。

カラム名の変更に限ってはRidgepole専用の記法である renamed_from を使わなければ「カラムの削除+カラムの作成」が実行され、そのカラムにあったデータが消失してしまいます。カラム名の変更は稀であるからこそ、うっかりで事故を起こさないよう、専用の警告を導入しました。

RSpecの実行直前にtest DBにスキーマを適用

spec/rails_helper.rb に以下の2行を仕込み、テストの実行前に必ずtest DBのスキーマが正しいことを保証しています。ねらいは ActiveRecord::Migration.maintain_test_schema! と同じです。

# Checks for pending schema changes and applies them before tests are run.
raise "Failed to apply ridgepole schema changes" unless system("./bin/ridgepole apply")

Viewの管理

Ridgepoleはviewの管理に対応していないので、特定の環境変数が指定されたときだけ create or replace view が実行されるようにしました。

create_view_sql = <<~__SQL__
  create or replace view balance_summaries as
  select
     b.id AS balance_id
     , (coalesce(cash_balances.fund_amount,0) + ...) AS total_amount
  from
      balances
      inner join ...
__SQL__
execute(create_view_sql) do |c|
  # View が存在しない or REPLACE_VIEWS=1 の場合に実行される
  ENV['REPLACE_VIEWS'] == '1' || c.raw_connection.query("show tables like 'balance_summaries'").each.size == 0
end

ところでRidgepoleはデータマイグレーション(DML)の実行ツールとしては不適だという議論があります。ワンバンクではseed-fu(seed-do)やワンショットなスクリプトで対応しているので、ここは問題になりませんでした。 Ridgepoleとマイグレーションとの併用は簡単なので、用途によって使い分けるのも手ではないかと考えています。



移行パスの整備

マイグレーション / Ridgepole間で差分がないことの自動検証

移行作業は (1) Ridgepole定義を生成・コミットする (2) 適用時に差分が出ないことを検証・確認する (3) マイグレーションを廃止する というステップを踏みました。

意外にも時間がかかったのが (2) の検証です。ワンバンクには local, staging, productionなど、いくつかの環境があります。各環境で意図しないALTER TABLEが起こらないよう、デプロイ時に以下のコマンドを実行して結果を確認しつつ進めることにしました。

bundle exec db:migrate && ridgepole --apply --dry-run

この結果、各環境の実際のスキーマとschema.rbとがそれぞれ微妙に異なることがわかりました。最初に述べたマイグレーションの弱点が出ていますね。schema.rbとproductionのどちらのスキーマを「正」とすべきなのか? などを事前に検討できたので、これはやって良い施策でした。

現在はRidgepoleのスキーマ定義が「正」という方式に移行できたので、環境間の差分もないはずです。

開発者向けの移行ガイドの準備 & db/ 以下の変更の全レビュー

Railsのcreate_tableがそのまま使えるといえども、新しいツールに慣れるのは多少なりとも時間がかかるものです。そこで、ワンバンクで頻出するスキーマ変更と必要になる操作をまとめた移行ガイドを準備し、周知しました。

また、移行作業は慎重を期して数日に及んだため、その間にスキーマ変更をしたい開発者にはマイグレーションとRidgepole定義との両方を記述してもらわねばならないことがありました。片方にしか加えていない変更が生まれてしまわないよう、どちらの定義を使ってもテストが通ることを保証するようなCIを準備して、開発者自身で気づけるようカバーしたつもりです。

作業中のみならず、移行期間の前後は担当者として db/ 以下に加わる変更にはすべて目を通し、レビューをしました。すべての差分を知っているというだけで、最終的な切り替えのスイッチを押すときの気持ちが随分楽になるというものです。

Slackをザッピングする習慣も役立ちました



Ridgepoleの高速化

最後はちょっとした自慢です。

ワンバンクのRailsアプリに試しにRidgepoleを導入してみたところ、実行時間が妙に長いことに気がつきました。差分がなくとも15秒ぐらいかかってしまいます。CI環境ではその10倍以上かかっていることもありました。

筆者はコマンドの実行をあまり待てない質なので、高速化に取り掛かったところ、めでたく実行時間を1.5秒にまで短縮することに成功しました。CI環境では100倍速です。

スキーマダンプ時の4000クエリぐらい削減 (fast_schema_dumper)

Ridgepoleはスキーマファイルと「適用対象のDBの現在のスキーマ」との差分を計算しています。「現在のスキーマ」を得るためにActiveRecordに実装されている SchemaDumperを利用していますが、ActiveRecordのデフォルトの実装ではテーブルごとに SHOW FULL FIELDS FROM #{table_name}SHOW CREATE TABLE #{table_name} を実行しています。ワンバンクのケースでは、スキーマダンプのために4000クエリほどを要していました。

しかし、MySQLではINFORMATION_SCHEMAをクエリすることで全テーブル分の情報を一発で引いてくることができます。これを利用して、数クエリで必要な情報を全部かきあつめるSchemaDumperの代替実装 “fast_schema_dumper” をつくりました。

これで5秒以上の高速化に成功しています。

ラッパーをRake taskではなくシェルスクリプトにする

ridgepole コマンドの実行にはいくらかのオプションの指定が必要です。これらをRake taskにまとめて rails ridgepole:apply で簡単に実行できるようにしていたのですが、こうするとすべてのGemのロードの時間がまるまる乗ってきて損でした。RailsのデフォルトのRakefileをそのまま使っている限りこうなります。

以下のような単なるシェルスクリプトにすることで、さらに数秒速くなりました。

#!/bin/bash
set -euo pipefail
APPLY_FLAG=$1; shift
# Rails.root 相当を計算
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RAILS_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
RAILS_ENV="${RAILS_ENV:-development}"
# 上述の fast_schema_dumper をロード
RUBYOPT="-rbundler/setup -rridgepole -rfast_schema_dumper/ridgepole" bundle exec ridgepole \
  -E "$RAILS_ENV" \
  -c "${RAILS_ROOT}/config/database.yml" \
  -f "${RAILS_ROOT}/db/Schemafile" \
  $APPLY_FLAG \
  "$@" # --drop-table など追加の引数を渡せて便利

しかし、RailsアプリのGemのロードに妙に時間がかかりがちなのはなんなのでしょうね。

Ridgepole自体を高速化する

プロファイラの結果をながめていたら、diffy gemを使ってビジュの良いdiffを生成する処理がかなりコストフルであることに気がつきました。実際には差分ゼロのテーブルに対してもこれが走っていたので、これをほどよく削減。

この過程で気がついたのですが、diffyが内部的に利用している diff コマンドが環境にないと、Ruby実装の rdiff にフォールバックするようです。これが思いの外遅く、CIで10倍以上遅い主因でした(minimalなコンテナなので diff がなかった)。

これもまたかなり高速化に寄与しました。

余談:sqldef等の他ツールとの比較

Ridgepoleによく似たツールとして、sqldef/sqldefも検討しました。試したところ0.6秒程度で完走でき、Ridgepoleと比べて圧倒的に高速でした。しかし上述の高速化策がよく効いたこと、そしてRailsプロジェクトゆえSQLの CREATE TABLE による定義よりも create_table DSLのほうが馴染みがあり移行が容易であったことから、最終的にRidgepoleを採用しました。

最近ではAtlasSchemaHeroといったツールも登場しているようです。比較してみるのも面白いかもしれません。


というわけで、Ridgepoleへの移行についてご紹介しました。Ridgepoleを開発されたwinebarrelさんへの謝辞を述べ、本稿を締めたいと思います。ありがとうございました。


株式会社スマートバンクではDDLが好きなエンジニアを募集しています。

*1:非常に時間がかかるALTER TABLEが生成される場合は別途工夫が必要になりそうです

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.