Ruby Rails Migration

実体験から学ぶ、安全なMigrationライブラリの選定と移行

Ruby Rails Migration

こんにちは。SUZURI事業部の@kromiiiと申します。

今回は、本番環境で実行すると危険なマイグレーションを事前に察知するためのRubyのライブラリをstrong_migrationsからonline_migrationsに移行した話をします。

Strong migration とは

strong_migrations とは、マイグレーションの実行前に危険なマイグレーションを検出してくれるRubyのGemです。

例えば、以下のようなマイグレーションを実行したいとします。

その際にstrong_migrationsが導入されているRailsプロジェクトでは、このマイグレーションファイルが危険なmigrationであることを検知してくれます。

class AddCheckConstraint < ActiveRecord::Migration[6.1]
  def change
    add_check_constraint :users, "price > 0", name: "price_check"
  end
end

このmigrationファイルを実行しようとすると、以下のような警告が表示されます

=== Dangerous operation detected #strong_migrations ===

Adding a check constraint key blocks reads and writes while every row is checked.
Instead, add the check constraint without validating existing rows,
then validate them in a separate migration.

class AddCheckConstraint < ActiveRecord::Migration[6.1]
  def change
    add_check_constraint :users, "price > 0", name: "price_check", validate: false
  end
end

この警告は、チェック制約(check constraint) を追加する際の注意点について説明しています。具体的には、チェック制約を追加するときに、すべての行がチェックされるため、読み取りおよび書き込み操作がブロックされる可能性があるという内容です。そのため、既存の行を検証せずにチェック制約を追加し、別のマイグレーションでそれらを検証する方法を推奨しています。

こうしたmigrationファイルは構文的には問題ない内容ですが、大量のデータのあるテーブルやカラムで実行すると、本番環境での実行時に障害を引き起こす可能性があるため、事前に検知し、適切に対処する必要があります。

きっかけとなったインシデント

私が配属される前からSUZURIではstrong_migrationが開発環境に組み込まれており、ある程度の危険なマイグレーションは事前に検知することができていました。

すっかり安心しきっていた私は、ある日新しい機能を実装する際に、以下のようなマイグレーションファイルを作成してしまいました(テーブル名やカラム名は実際のものとは異なります)。

class UpdateIndexOnStocks < ActiveRecord::Migration[6.1]
  disable_ddl_transaction!
  
  def change
    remove_index :stocks, :product_id
    add_index :stocks, :product_id, unique: true,  algorithm: :concurrently
  end
end

内容としては、stocksテーブルのproduct_idカラムに対して、unique制約を持った新しいインデックスを作成するマイグレーションです。

このmigrationファイルは、ローカル環境では問題なく動作しており、strong_migrationsによる警告も表示されなかったため、検証用環境を経てそのまま本番環境にデプロイしました。 しかし、このmigrationを実行するや否や、本番環境のDBはダウンしてしまいました

原因と対処方法

問題の原因は、新しいインデックスを作成する前に古いインデックスを削除してしまったことです。

これにより、新しいインデックスが作成されるまでの間、インデックスがない状態でテーブルに大量のリクエストが飛び込み、結果としてデータベースのCPUリソースが急速に消耗してしまいました

なので対処法としては、中身の順番を入れ替えてindexを作成してから削除すれば問題ありませんでした

具体的には以下のようなマイグレーションファイルを作成すれば、本番環境での障害を回避できた可能性が高いです。

class UpdateIndexOnStocks < ActiveRecord::Migration[6.1]
  disable_ddl_transaction!

  def change
    add_index :stocks, :product_id, unique: true, algorithm: :concurrently, name: 'index_on_product_id_new'
    remove_index :stocks, column: :product_id, algorithm: :concurrently, name: 'index_on_product_id'
  end
end

ポストモーテム

この問題に気づくことができなかったのは、ひとえに自分のWebエンジニアとしての経験不足が大きな要因です。一方でペパボでは、障害が発生した後に、その振り返りを行うポストモーテムという文化があります(参考)。ここでは、インシデントの原因を人に起因するものとするのではなく、再発防止のための仕組みづくりを徹底する方針で振り返りが行われます。

その際に、今後こうしたインシデントを防ぐために、strong_migrationsによって危険なマイグレーションを通さない仕組みを追加するのが良いのではないかという意見が出ました。

再発防止のためにやったこと

まずは事業部シニアのshimoju氏のアドバイスをもとにstrong_migrationsのレポジトリに機能改善のイシュー を立ててみることにしました。

issue

返事が来ると良いねー」なんて話していた5分後、驚くことに光の速さで返事が来ました

reply

なんとすでにonline_migrationsというGemで同様の問題が対処されているとのことでした。

詳しく調べてみると、online_migrationsstrong_migrationsのsupersetとして作られたライブラリで、strong_migrationsで対応されていないチェックについても対応しているとのことでした。

https://github.com/fatkodima/online_migrations?tab=readme-ov-file#comparison-to-strong_migrations

早速試してみると、たしかに問題のmigrationは、online_migrationsを使うと、事前に検知されることが確認できました

== 20240821012753 RecreateIndex: migrating =====================
[online_migrations] DANGER: No lock timeout set
-- remove_index(:stocks, :product_id, {:algorithm=>:concurrently})
   -> 0.0150s
rails aborted!
StandardError: An error has occurred, all later migrations canceled: (StandardError)

⚠️  [online_migrations] Dangerous operation detected ⚠️

Removing an old index before replacing it with the new one might result in slow queries while building the new index.
A safer approach is to create the new index and then delete the old one.

警告の内容も、strong_migrationsと同様に、問題の原因と対処方法について詳細に説明してくれています

これを正しい順番に入れ替えると正常にmigrationが通ることも確認できました

online-migrationsへの移行

さらに詳細にstrong_migrationsとの比較を行った結果、事業部としてstrong_migrationsからonline_migrationsへの置き換えを判断しました。

移行にあたっては、strong_migrations gemの削除とonline_migrations gemの追加を行い、その後、configファイルを移行するだけで簡単に完了しました。

online_migrationsとstrong_migrationsの設定は共通する項目が多いので、項目ごとにチェックしていけば問題なく移行できると思います。

online_migrationsにはmigrationファイルのチェックのほかにbackground_migrationという機能もあるんですが、移行作業をシンプルにするため、こちらについては今回は利用しないことにしました。

おわりに

Writing a safe migration can be daunting. Numerous articles have been written on the topic and a few gems are trying to address the problem. Even for someone who has a pretty good command of PostgreSQL, remembering all the subtleties of explicit locking can be problematic.

これは online_migrationsのレポジトリのある一節ですが、私なりに要素をかいつまんで解釈すると、データベースのmigration作業においては熟達したプログラマーでも、その全てに気を払うことは難しい(かつ一つの判断の誤りが重大なインシデントにつながりうる) と理解しています。そのためにも、やはり属人的なアプローチの他に、問題をシステマティックに解決するための仕組みづくりが重要です。

自分自身のスキルを磨くだけでなく、障害を起こさないための仕組みづくりを続けていくことで、お客様により速くより安全に新しい機能を提供できるように努めていきたいと思います。