Rails: データベーススキーマをダウンタイムなしで変更する(翻訳)
Discourseのメンバーはいつも継続的開発の大ファンであり、コミットのたびにCIのテストスイートと対決しています。すべてのテスト(UI、単体、結合、スモーク)にパスすれば、自動的にコードの最新バージョンがhttps://meta.discourse.orgにデプロイされるようになっています。
私たちが継続的開発というパターンに沿って実践していることで、ソフトウェアを自分でインストールした数千人のユーザーがいつどんなときでもtests-passed
バージョンに安全にアップグレードできるようになっています。
私たちは頻繁にデプロイを行っているので、デプロイ中に止まったりしないようひときわ注意を払う必要があります。アプリのデプロイ中に止まってしまうよくある原因のひとつが、データベーススキーマの変更です。
スキーマ変更に伴う問題
現在のデプロイのしくみを雑に説明すると次のような感じになります。
- データベースを新しいスキーマに移行する
- アプリを単一のDockerイメージにバンドルする
- レジストリにプッシュする
- 古いインスタンスを止め、新しいインスタンスをpullして立ち上げる(これを繰り返す)
万が一互換性のないデータベーススキーマを作成してしまうと、古いバージョンのコードを実行している古いアプリのインスタンスが全部止まってしまうリスクが生じます。実際には数十分の停止💥という形で表面化します。
これはActiveRecordにとって特に悲惨な状況です。production環境のデータベーススキーマはキャッシュされており、スキーマでカラムのリネームや削除を行う何らかの変更が生じると、たちまち影響範囲にあるモデルであらゆるクエリが効かなくなってしまい、無効なスキーマの例外がraise
されるリスクが生じます。
この問題を克服し、停止時間を最小限にとどめてスキーマ変更を安全にデプロイできるようにするために、私たちは数年がかりでさまざまなパターンを導入してきました。
マイグレーションの情報を詳しくトラッキングする
ActiveRecordにはschema_migrations
という名前のテーブルが1つあります。ここには、実行されたマイグレーションに関する情報が保存されます。
残念なことに、このテーブルに保存されている情報の量は極端に制限されています。実際、次の情報ぐらいしかありません。
connection.create_table(table_name, id: false) do |t|
t.string :version, version_options
end
このテーブルには、実行したマイグレーションの「バージョン」を保存する孤立したカラムが1つあります。
- マイグレーションが実行された時刻はこのカラムに保存されない
- マイグレーションの実行にかかった時間はこのカラムに保存されない
- マイグレーションの実行時に動かしていたRailsのバージョンに関する情報が何もない
この情報の少なさ(特に「いつ実行されたのか」という情報が取れないこと)のせいで、スキーマ変更をきれいに扱えるシステムの構築が困難になっています。さらに、情報が貧弱なためにマイグレーション中に発生する奇妙でわけの分からない問題のデバッグがものすごくつらくなります。
Discourseでは、Railsにモンキーパッチを適用することでマイグレーションの情報を詳しくログ出力しています(github.com: 記事では一部省略)。
module FreedomPatches
module SchemaMigrationDetails
def exec_migration(conn, direction)
rval = nil
time = Benchmark.measure do
rval = super
end
sql = <<SQL
INSERT INTO schema_migration_details(
version,
hostname,
name,
git_version,
duration,
direction,
rails_version,
created_at
) values (
パッチのおかげで、マイグレーションがらみの情報を非常に詳しく取れるようになりました。これはマジでRailsに取り入れるべきです。
カラム削除の延期実行
マイグレーションログを詳しく取れるようになり、これまでのマイグレーションが「いつ行われた」かがすっかりわかるようになったので、カラムの削除を延期実行(defer=先延ばし)できるようになりました。
つまり、危険なスキーマ変更を実行する前に、新しいコードがスキーマ変更を行おうとしていることを私たちが認識することを保証できるようになったのです。
実際には、私たちはカラムの削除にはマイグレーションを使いません。その代わり、カラム削除の延期実行についてはdb/seedで面倒を見ます(github.com: 記事では一部省略)。
Migration::ColumnDropper.drop(
table: 'users',
after_migration: 'DropEmailFromUsers',
columns: %w[
email
email_always
mailing_list_mode
email_digests
email_direct
email_private_messages
external_links_in_new_tab
enable_quoting
dynamic_favicon
disable_jump_reply
edit_history_public
automatically_unpin_topics
digest_after_days
auto_track_topics_after_msecs
new_topic_duration_minutes
last_redirected_to_top_at
これらの延期削除は、(次回のマイグレーションサイクルの)実行中に参照された特定のマイグレーションの少なくとも30分後に実行されます。新しいアプリのコードが確実に存在することで安心感を得られます。
カラム名を変更する場合は、新しくカラムを1つ作って値をそこに複製し、古いカラムをリードオンリーにしてからトリガーで延期削除をかけます。
テーブル名を変更したりテーブルを削除する場合も同じような要領で進めます。
延期削除のロジックはColumnDropperクラスとTableDropperクラスで見られます。
自分たち自身を信用しない
アプリごとに行うさまざまな作業において、「強制」はひとつの大きな問題です。
安全を担保するためのパターンはいろいろありますが、それでも、カラムやテーブルの削除を絶対にActiveRecordマイグレーション方式で行うべきではないことを忘れてしまったりします。
マイグレーション中に危険なスキーマ変更を絶対にやらかさないようにするために、PG gemにパッチを当てて、マイグレーションのコンテキストで特定のステートメントの利用を禁止しました。
DROP TABLE
やカラムのDROP
を行おうとしても例外がraise
されます。
これで、社内のベストプラクティスを無視してリスクのあるスキーマ変更をかけることが困難になりました。
== 20180321015226 DropRandomColumnFromUser: migrating =========================
-- remove_column(:categories, :name)
WARNING
-------------------------------------------------------------------------------------
An attempt was made to drop or rename a column in a migration
SQL used was: 'ALTER TABLE "categories" DROP "name"'
Please use the deferred pattrn using Migration::ColumnDropper in db/seeds to drop
or rename columns.
Note, to minimize disruption use self.ignored_columns = ["column name"] on your
ActiveRecord model, this can be removed 6 months or so later.
This protection is in place to protect us against dropping columns that are currently
in use by live applications.
rake aborted!
StandardError: An error has occurred, this and all later migrations canceled:
Attempt was made to rename or delete column
/home/sam/Source/discourse/db/migrate/20180321015226_drop_random_column_from_user.rb:3:in `up'
Tasks: TOP => db:migrate
(See full trace by running task with --trace)
このロジックはsafe_migrate.rbで参照できます。これは最近のパターンなので、ある日付以降のマイグレーションにのみ強制しています。
その他の方法
gemでできることもあれば、gemではできないこともあります。
- Strong Migrations
- このgemは強制機能を提供します。このgemでは、PostgreSQLでインデックスをコンカレントに作成するかどうかなどの興味深い条件を多数設定することもできます。強制は、ActiveRecordのマイグレータにパッチを当てる形で行います。つまり、誰かが生SQLでやらかしてしまえば強制をすり抜けてしまいます。
- Zero downtime migrations
- これももStrong Migrationsと非常によく似たgemです。
- Outrigger
- このgemはマイグレーションにタグを付与できるようにします。これを用いてデプロイ前マイグレーションやデプロイ後マイグレーションを行うことで、デプロイを改善できます。デプロイ中の停止を避けられるようにマイグレーションを管理する手法としては、これが最もシンプルです。
- Handcuffs
- これはOutriggerととてもよく似たgemで、マイグレーションに複数のフェーズを定義できます。
さて皆さんはどうすべきでしょうか?
弊社で使っているカラムやテーブルの延期削除パターンは、社内ではうまくいっていますが、まだ理想的とは言えません。データの「seed」を担当するコードはスキーマの補正やカラム削除のタイミングも行っていて、本来あるべき姿まで十分制御されてはいません。
理想は、どんなときでもrake db:migrate
を実行するだけで何もかも魔法のようにやってくれることです。ホスティング先がどこであろうと、スキーマのバージョンが何であろうと関係なくやって欲しいのです。
とはいうものの、さしあたって本記事で私からおすすめしたいベストプラクティスは、さまざまなアイデアを合わせ技にすることです。どの方法も、Railsにふさわしい方法だからです。
ベストプラクティスの強制はRailsがやるべき
私としては、ActiveRecordに安全なスキーマ変更を強制するしくみを導入すべきだと思います。Railsを使っていれば誰しも多かれ少なかれ同じことに気づくでしょう。スキーマ変更のデプロイにおけるダウンタイムをゼロにできれば実用的です。
class RemoveColumn < ActiveRecord::Migration[7.0]
def up
# こういうのはエラーをraiseすべき
remove_column :posts, :name
end
end
さらに確実にするには、マイグレーションファイルにafter_deploy
フラグを追加することを全員に強制すべきです。
class RemoveColumn < ActiveRecord::Migration[7.0]
after_deploy! # こう指定するか、オプションをグローバルにオフするかのどちらかだけにする
def up
# Postクラスにignored_columns: [:name]がなければraiseすべき
remove_column :posts, :name
end
end
class RemoveColumn < ActiveRecord::Migration[7.0]
after_deploy!(force: true)
def up
# この場合はignored_columnsにかかわらずマイグレーションできるべき
remove_column :posts, :name
end
end
SQL解析に基づく強制は理想的だと思います。しかしこの方法はRailsの規模では無理がある可能性もあります。私たちの場合はサポートするデータベースが1つだけなので実用的です。
rake db:migrate
はこれまでどおり使えるべき
後方互換性のため、rake db:migrate
はafter_deploy
マイグレーションを含むあらゆるマイグレーションを実行できるようにすべきですし、安全のために、デプロイの「ゼロダウンタイム」をないがしろにするアプリを除外できるようにすべきです。
rakeタスクに新しくmigrate:pre
やmigrate:post
を導入すべき
アプリコードとの互換性を壊さないマイグレーションを実行する場合は、常に次のようにします。
rake db:migrate:pre
# `after_deploy!`なしですべてのマイグレーションを実行する
破壊的な操作を行う場合は、常に次のようにします。
rake db:migrate:post
# `after_deploy!`ありですべてのマイグレーションを実行する
まとめ
今日、ダウンタイムゼロのデプロイを「安全に」始める方法を模索している方には次をおすすめします。
- デプロイ前マイグレーションやデプロイ後マイグレーションを実行する形でビルド手順を見直す(OutriggerかHandcuffsを使用)
-
Strong Migrationsで「強制」を徹底する
ツイートより
"Railsはxxすべき" いや、それパッチ投げれば良いのでは… https://t.co/fwTYlkRcAp
— 神速 (@sinsoku_listy) April 9, 2018
概要
原著者の許諾を得て翻訳・公開いたします。
後半で紹介されているgemについては先週のRailsウォッチもどうぞ。