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

概要

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

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

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つ作って値をそこに複製し、古いカラムをリードオンリーにしてからトリガーで延期削除をかけます。

テーブル名を変更したりテーブルを削除する場合も同じような要領で進めます。

延期削除のロジックはColumnDropperTableDropperで見られます。

自分たち自身を信用しない

アプリごとに行うさまざまな作業において、「徹底」はひとつの大きな問題です。

安全を担保するためのパターンはいろいろありますが、それでも、カラムやテーブルの削除を絶対に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で、マイグレーションに複数のフェーズを定義できます。

訳注: handcuffs: 手錠

さて皆さんはどうすべきでしょうか?

弊社で使っているカラムやテーブルの延期削除パターンは、社内ではうまくいっていますが、まだ理想的とは言えません。データの「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
# runs all migrations without `after_deploy!`

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

rake db:migrate:post
# runs all migrations with `after_deploy!`

まとめ

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

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

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

ツイートより

関連記事

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

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ