Rails: Shopify流「非同期マイグレーション」でデプロイを安定させよう(翻訳)

概要

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

内容に即した日本語タイトルにしています。

Rails: Shopify流「非同期マイグレーション」でデプロイを安定させよう(翻訳)

スキーマ変更を伴うコードをデプロイするたびに、bin/rails db:migrateを実行して新しいActive Recordマイグレーションを適用しなければなりません。これは共通で使われるデプロイスクリプトです(Capistranoを参照)。

デプロイの一環としてマイグレーションを走らせることは、多くの企業で用いられているデフォルトのアプローチですが、Railsコミュニティはどうしたわけかこれ以外の方法を検討したことがありません。マイグレーションを行うと、その分リリース手順が複雑になりませんか?

  • マイグレーションが失敗した場合、デプロイを取り消すべきか?
    • 取り消ししたいのであれば、マイグレーションが失敗するまでに新しいコードが短時間production環境で実行されていた可能性があるが、ロールバックしたときにその新しいコードがさらなる問題を引き起こすかもしれない。
  • 利用しているデータベースが複数ある場合(シャーディングを使っている可能性もある)、それぞれのデータベースにマイグレーションを適用しなければならなくなる。
  • マイグレーションに時間がかかると(数時間)、その間デプロイを完了できなくなる。
    • マイグレーションを実行するアクターでssh接続が失われた場合など
  • クラウド環境(HerokuやKubernetes)の場合、マイグレーションを実行する「after deploy」的フックが使えるとは限らない。

本記事では、デプロイ手順からマイグレーションを削ぎ落とす方法について解説します。Shopifyが辿り着いた非同期マイグレーションなる手法は、「デプロイが終わってから最終的に適用する」「制御は人間が行う」がポイントです。

非同期マイグレーションのしくみ

最初に、db:migrateで実際に何が行われているかを理解しておく必要があります。

Active RecordのRakeタスクを見てみると、ActiveRecord::Base.connection.migration_context.migrateに対してcallを行っていることがわかります。ここがマイグレーション実行のエントリポイントでなければなりません。(ENV['VERSION']のような)引数を付けずに呼び出すと、MigrationContext#migrateがマイグレーション用のクラスごとにMigrationProxy作成し、続いてMigrator.new.migrate呼び出します

マイグレーションが呼び出されるしくみを理解できましたので、これを非同期的な手順として再設計し、デプロイ中にマイグレーションが走らないようにする準備が整いました。マイグレーションをバックグラウンドジョブから実行するという方法はどうでしょうか?

ペンディング中のマイグレーションがあると、そのたびにバックグラウンドジョブに送り込んで実際のマイグレーションをそこで実行し、結果を出力するというアイデアです。どんな実装が可能か見てみましょう。

最初に、(sidekiq-cronなどのツールを用いて)ペンディング中のマイグレーションがあるかどうかを数分おきに繰り返しチェックするジョブをスケジューリングする必要があります。

class MigrationAutoCannonJob < ApplicationJob
  def perform
    return unless migration_context.needs_migration?

    pending_migrations = (migration_context.migrations.collect(&:version) - migration_context.get_all_versions)
    # ここで実行する!
  end

  private

  def migration_context
    ActiveRecord::Base.connection.migration_context
  end
end

マイグレーションは複数同時に実行できない「ブロッキングプロセス」であることを忘れてはなりません。直前のマイグレーションが完了するまでは次のマイグレーションを実行してはならないのです。また、マイグレーションの実行状況を監視できるようにしておきたいので、実行状況を追いかけるためのActiveRecordモデルを1つ作成してみましょう。

$ rails generate model async_migration version:integer state:text
# app/models/async_migration.rb
class AsyncMigration < ApplicationRecord
end
# 必ずuniqueインデックスを追加すること!

それではこの自動機関砲的なジョブ(auto cannon job)を更新して実行状況をトラッキングし、一度に1つのマイグレーションだけが実行されるようにしてみましょう。

class MigrationAutoCannonJob < ApplicationJob
  def perform
    return unless migration_context.needs_migration?

    if AsyncMigration.where(state: "processing").none?
      AsyncMigration.create!(version: pending_migrations.first, state: "processing")
    end
  end

  def pending_migrations
    (migration_context.migrations.collect(&:version) - migration_context.get_all_versions)
  end

  # ジョブが続く

このジョブによってasync_migrationsテーブル内にエントリが1つ作成されますが、これは他のエントリが”processing”でない場合に限って行われます。これによって複数のマイグレーションが同時に走ることを防止します。ジョブそのものが競合に対して保護されていない点には注意を払う必要がありますが、スケジューリングされたインスタンスは1つしかないので、その点は大丈夫です。

それでは、マイグレーションを実際に行うモデル用のコールバックを1つ作成してみましょう。

class AsyncMigration < ApplicationRecord
  after_commit :enqueue_processing_job, on: :create

  private

  def enqueue_processing_job
    MigrationProcessingJob.perform_later(async_migration_id: id)
  end
end

AsyncMigrationが作成されるたびに、実際のマイグレーションを実行するMigrationProcessingJobがキューに送り込まれます。このジョブがどのようなものか見てみましょう。

class MigrationProcessingJob < ApplicationJob
  def perform(params)
    async_migration = AsyncMigration.find(params.fetch(:async_migration_id))

    all_migrations = migration_context.migrations
    migration = all_migrations.find { |m| m.version == async_migration.version }

    # 実際のマイグレーション
    ActiveRecord::Migrator.new(:up, [migration]).migrate

    async_migration.update!(state: "finished")
  end

  def migration_context
    ActiveRecord::Base.connection.migration_context
  end
end

まだまだ足りないものがありますが、アイデアについてはこれで把握できることでしょう。2つのジョブと1件のデータベースレコードを組み合わせることで、バックグラウンドで1件ずつ実行されるマイグレーションをスケジューリングできます。

本記事のコード例はいずれも「作り中そのもの」である点にご注意ください。もっときちんとやりたいのであれば、以下の点にも配慮が必要になるでしょう。

  • エラーハンドリングがまったく行われていない。マイグレーションがエラー終了した場合にAsyncMigrationのステータスを更新したいところ。
  • 最大リトライ数がジョブで定義されていない。マイグレーションのリトライまでやりたいかどうか検討したいところ。
  • マイグレーションの所要時間の測定と永続化もやっておきたいところ。

可能性はいくらでも考えられます。管理UIを構築してマイグレーションを監視できるようにすることもできますし、マイグレーションの完了または失敗をSlackチャンネルに通知することもできます。

Shopifyでは数百にのぼるデータベースシャーディングを扱っており、スキーマ変更のたびにシャーディングひとつひとつでマイグレーションを実行しなければなりません。マイグレーション手順がデプロイスクリプトの一部に組み込まれていると、リリースプロセスがぐらついてしまうでしょう。Shopifyでは代わりに、リリースの完了後に最終的に提供される「非同期マイグレーション」を用いています。非同期マイグレーションは、Shopifyが誇る1日50回を超えるリリースを支える重要な機能のひとつです。

マイグレーションのステータスを次のようにSlackチャンネルに流すことも可能です。

こうした機能が心にズドッと突き刺さりましたら、私のチームで一緒に働きましょう!ぜひShopifyの採用ページをご覧ください。

関連資料

最新記事を追いかけたい方はTwitterで@kirshatrovのフォローをお願いします。

関連記事

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

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

Rails: PostgreSQLのマイグレーション速度を改善する(翻訳)

デザインも頼めるシステム開発会社をお探しなら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探訪シリーズ