概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Asynchronous Active Record migrations · Kir Shatrov
- 原文公開日: 2018/04/01
- 著者: Kir Shatrov -- 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のフォローをお願いします。