Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: データベーススキーマをダウンタイムなしで変更する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

後半で紹介されているgemについては先週のRailsウォッチもどうぞ。

  • 2018/04/09: 初版公開
  • 2022/10/25: 細部を更新

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つあります。

  1. マイグレーションが実行された時刻はこのカラムに保存されない
  2. マイグレーションの実行にかかった時間はこのカラムに保存されない
  3. マイグレーションの実行時に動かしていた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:migrateafter_deployマイグレーションを含むあらゆるマイグレーションを実行できるようにすべきですし、安全のために、デプロイの「ゼロダウンタイム」をないがしろにするアプリを除外できるようにすべきです。

rakeタスクに新しくmigrate:premigrate:postを導入すべき

アプリコードとの互換性を壊さないマイグレーションを実行する場合は、常に次のようにします。

rake db:migrate:pre
# `after_deploy!`なしですべてのマイグレーションを実行する

破壊的な操作を行う場合は、常に次のようにします。

rake db:migrate:post
# `after_deploy!`ありですべてのマイグレーションを実行する

まとめ

今日、ダウンタイムゼロのデプロイを「安全に」始める方法を模索している方には次をおすすめします。

  1. デプロイ前マイグレーションやデプロイ後マイグレーションを実行する形でビルド手順を見直す(OutriggerHandcuffsを使用)

  2. Strong Migrationsで「強制」を徹底する

ツイートより

関連記事

Rails: マイグレーションを実行せずにマイグレーションのSQLを表示する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。