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

Rails: Shopifyの頻繁な大規模スキーマ変更を支える「差し替え可能な」マイグレーションバックエンド(翻訳)

概要

CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

日本語タイトルは内容に即したものにしました。

Rails: Shopifyの頻繁な大規模スキーマ変更を支える「差し替え可能な」マイグレーションバックエンド(翻訳)

本記事では、Railsのマイグレーションを差し替え可能(swappable)にするバックエンドについて解説します。マイグレーションの実行方法をカスタマイズするこの機能は、ほとんど知られていません。

以前のShopifyでは、Railsのマイグレーションを社内の「スキーママイグレーションサービス」で動かすために、モンキーパッチや頼りないSQLパーサーに依存していました。そこで私たちは、Railsのマイグレーションランナーをよりシンプルな形でニーズに合わせるために、差し替え可能なバックエンド機能を開発しました。本記事では、この機能を構築した理由と手法、そしてShopifyが大規模なデータベースマイグレーションを実現するために、この機能をどう活用しているかについて解説します。


Shopifyでは、多くのRailsアプリケーションでデータベースマイグレーションを毎週数百件も実施しています。どのマイグレーションについても安全性を念入りに検査し、出店者にダウンタイムが生じない形で実行しなければなりません。大規模なスキーマ変更をオンラインで実行するために、以前の私たちは特殊なツールとLHM(Large Hadron Migrator)に依存していました。

shopify/lhm - GitHub

2021年になって、Shopifyのデータベースチームは新しい集中型のスキーママイグレーションを実行するための「スキーママイグレーションサービス」の設計に取りかかりました。目標の1つは、開発者がRailsのマイグレーション機能に手を加えることなく、スキーマ変更を安全かつダウンタイムゼロで実行できるようにすることです。

データベースチームは、この問題解決のためにスキーママイグレーション用のgemを構築していたのですが、gemの実装はそう簡単にはいきませんでした。マイグレーションのチェックを安全に実施してスキーママイグレーションサービスに送信するために、このgemはモンキーパッチや複雑なRACCパーサーに依存していたのです。

ShopifyのRailsインフラストラクチャチームは、この機会にスキーママイグレーションのニーズをもっとエレガントに解決できる機能をフレームワークに組み込むことにしました。私たちは、マイグレーションを柔軟に実行できるようにするため、差し替え可能なマイグレーションバックエンドを構築しました(これはRails 7.0以降で利用可能です)。

それでは、Shopifyがこの機能をどのように活用して大規模なデータベースマイグレーションを安全に実行しているかを詳しく見ていきましょう。

🔗 production環境のマイグレーションで他の環境と異なるアプローチが必要な理由

bin/rails db:migrateコマンドをdevelopment環境で実行する場合は、マイグレーションメソッドがデータベースに対して直接実行されます。個別のcreate_tableadd_columnadd_indexの呼び出しは、その都度スキーマ変更用のSQLに直接変換されます。
この方法はローカル開発中なら問題ありませんが、Shopifyほどの規模になると、production環境でこれと同じ方法を用いてスキーマを変更するわけにはいきません。

上述のLHMは、スキーママイグレーションをオンラインで実行するためのツールです。つまり、テーブルをロックしない形でマイグレーションを実行可能にし、マイグレーションが完了するまでシステムを動かし続けられるようにします。私たちは、ダウンタイムゼロでスキーマ変更を実行するために長年LHMを使い続けてきましたが、裏を返せばRailsネイティブのマイグレーションAPIをこれまで使えなかったということでもあります。

Shopifyのデータベースチームは、素のRailsが持つマイグレーション機能を開発者たちが再び使えるようにするとともに、背後で引き続きオンラインスキーマ変更が確実に実行されるようにするための「スキーママイグレーションサービス」を構築する決断を下しました。また、マイグレーションにまつわる開発体験を改善する以下のようなアイデアも盛り込みました。

  1. マイグレーション実施前に安全性チェックをパスすることを必須化する
    (例: カラム変更操作でブロッキングされないか、マイグレーションの操作対象テーブルが1個だけになっているか)

  2. マイグレーションを集中管理マネージャに送信することで、複数のデータベースシャードにまたがるスキーマ変更のオーケストレーションを容易にし、テストやリトライの振る舞いを改善する

  3. 開発者にマイグレーションの詳しい実施状況を提供する
    実行中のマイグレーションやマイグレーションの進捗などを総合的なUIで確認可能にする

以前DBチームが開発したスキーママイグレーション用gemは、マイグレーションの安全性チェックや集中管理マネージャへの送信を制御していました。しかし、初期実装がRailsの既存のマイグレーションのコードパスに対するモンキーパッチに強く依存していました。このgemは、通常のマイグレーションを実行する代わりに、あらゆるSQL文をキャプチャするためのパッチをRailsに適用していたのです。

さらにこのgemは、SQLからスキーマ変更操作を抽出したり安全性チェックを実施したりするのにRACCパーサーに依存していました。それが終わると、集中管理マネージャへの送信用にJSON形式のDDL(データ定義言語: Data Definition Language)に変換していました。

Railsインフラストラクチャチームは、この変更が、Railsのマイグレーション実行をより柔軟にして、Shopifyのスキーマ変更で大量のモンキーパッチや複雑なRACCパーサーのメンテナンスを不要にするための絶好の機会であることに気づきました。

🔗 Railsで差し替え可能なマイグレーションを構築するための戦略

2022年にこのプロジェクトが開始されたとき、gemでRailsにモンキーパッチを当てる方法から脱却するための方法をいくつも検討しました。
方法の1つは、静的解析を用いることでマイグレーションを実行せずに解析できるようにするというものでした。
別の方法は、あらゆるマイグレーション操作ごとにそれ用のスキーマ定義オブジェクトを提示して(AddColumnDefinitionCreateTableDefinitionなど)、Railsがそのオブジェクトでスキーマ変更をRuby形式で公開して、SQLやJSONなどの任意のフォーマットに変換可能にするというものでした。

一方Railsコアチームは、こうしたスキーマ定義によってActive Recordがさらに複雑になるのではないかと懸念を示したため、ほどなく私たちは方針を転換してStrategyパターンというよりシンプルな手法に切り替えました。マイグレーションで表現するスキーマ変更方法を根本的に変えるのをやめて、マイグレーションとコネクションアダプタの間に中間オブジェクトを導入して、実行の振る舞いをカスタマイズ可能にすることにしました。これによって抽象化がよりクリーンになり、Active Record内部を大量に改変せずに問題を解決できるようになりました。

2022年6月になって、私たちはRailsのマイグレーションに上述の「実行戦略(execution strategy)」パターンを提案するプルリクをRailsに送信しました(#45324)。このプルリクは、Migrationクラスとコネクションアダプタの間にstrategyオブジェクトを導入します。これによって、マイグレーションがスキーマステートメントコマンドをmethod_missingで直接コネクションに委譲するのではなく、差し替え可能なstrategyオブジェクトに委譲するようになります。

たとえば、マイグレーションでcreate_tableなどのメソッドを呼び出すとします。Railsはこの呼び出しをマイグレーションのstrategyオブジェクト(デフォルトではActiveRecord::Migration::DefaultStrategy)に渡します。

module ActiveRecord
  class Migration
    class DefaultStrategy < ExecutionStrategy
      private
        def method_missing(method, ...)
          connection.send(method, ...)
        end

        def respond_to_missing?(method, include_private = false)
          connection.respond_to?(method, include_private) || super
        end

        def connection
          migration.connection
        end
    end
  end
end

デフォルトのstrategyオブジェクトはマイグレーション用メソッドをコネクションに送信し、そこでデータベースに対してSQLが実行されます。これは従来のマイグレーション実行方法と同じなので、ほとんどのRails開発者は、背後でstrategyオブジェクトが動いていることに気づきません!
しかしこのマイグレーションstrategyクラスはマイグレーションの実行方法をカスタマイズ可能になりました。Rails 7.0以降では、config.active_record.migration_strategyを(config/environments/production.rb)などの環境設定ファイルで設定できます。この設定には、クラスオブジェクトまたはクラス名の文字列を渡します。

# lib/custom_migration_strategy.rb

class CustomMigrationStrategy < ActiveRecord::Migration::DefaultStrategy
  def drop_table(*)
    raise "Dropping tables is not supported!"
  end
end
# config/environments/production.rb

Rails.application.configure do
  config.active_record.migration_strategy = CustomMigrationStrategy
end

これで、bin/rails db:migrateコマンドを実行すると、Railsのあらゆるマイグレーションメソッドがカスタムのstrategyに渡されるようになり、マイグレーションの実行方法を完全に制御可能になります。

注意: production以外の環境では従来のローカル開発用のstrategyを変更しないことをおすすめします。そうすることで、production環境では高度なマイグレーションツールを安全に利用しつつ、ローカル環境では今まで通り高速かつシンプルなマイグレーションを利用できます。Shopifyでは実際にそうしています。

🔗 production向けのマイグレーションをJSONにシリアライズする

Railsで差し替え可能なマイグレーションバックエンドがサポートされるようになったことを受けて、私たちはマイグレーションをJSONとしてシリアライズするカスタムstrategyを実装しました。このために、私たちのgemにJsonSerializationStrategyクラスを導入しました。このクラスには、マイグレーションで利用できるスキーマ変更メソッドがひと通り実装されていて、Railsのスキーマ定義APIを用いて必要なスキーマオブジェクトをビルドします。続いて、それらオブジェクトをスキーマ操作を記述するJSONペイロードに変換します。以下はcreate_table操作をキャプチャするときの様子です。

class JsonSerializationStrategy < ActiveRecord::Migration::DefaultStrategy
  attr_accessor :connection, :operations

  def initialize(connection)
    @connection = connection
    @operations = []
  end

  def create_table(...)
    td = connection.build_create_table_definition(...)
    ddl = connection.schema_creation.accept(td)
    definition = extract_table_definition(td.name, ddl)

    operations << {
      type: :sql,
      op: :create_table,
      params: {
        name: td.name,
        definition: definition,
      },
    }
  end

  private

  def extract_table_definition(table_name, ddl)
    table_name_pattern = /^CREATE TABLE #{connection.quote_table_name(table_name.to_s)} /
    ddl.sub(table_name_pattern, "")
  end
end

差し替え可能なstrategyを用いてproduction環境でマイグレーションを実行する様子を以下に簡略化して示します。

class ExternalMigrationsRunner
  def upload_migration(migration)
    # マイグレーションを実行するが、JsonSerializationStrategyを使っているので
    # SQLは実行せず、代わりにstrategyオブジェクトがすべての操作をJSONとしてキャプチャする
    runnable_migration = migration.migration_class.new
    if runnable_migration.respond_to?(:change)
      runnable_migration.change
    elsif runnable_migration.respond_to?(:up)
      runnable_migration.up
    end

    # strategyオブジェクトからシリアライズ済み操作を抽出する
    operations = runnable_migration.execution_strategy.operations

    # API経由でマイグレーションをアップロードする
    ApiClient.upload_migration(
      name: migration.name,
      database: database_name,
      identifier: migration.version,
      operations: operations,  # スキーマ変更のJSON表現
      table_name: migration.table,
      author: migration.author
    )
  end
end

🔗 マイグレーションstrategyを自動設定する

production向けのマイグレーションstrategyを個別のRailsアプリケーションで設定する代わりに、スキーママイグレーションgemのイニシャライザを用いて自動設定することにしました。

# lib/schema_migrations/railtie.rb
require "rails/railtie"

class Railtie < Rails::Railtie
  ...

  initializer "schema_migrations.migration_strategy_config" do |app|
    next unless Rails.env.production?

    app.config.active_record.migration_strategy = JsonSerializationStrategy
  end
end

これによって、スキーママイグレーションgemを導入したすべてのRailsアプリケーションは、production環境でマイグレーションがインターセプトされてシリアライズされるようになります。

🔗 安全性チェックを見直す: SQLをパースする方式からランタイム分析への移行

上流側のstrategy機能の開発と並行して、私たちのチームはもうひとつの重大な問題である安全性チェックにも取り組んでいました。Shopifyのproduction環境でマイグレーションを実行する前に、gemが安全性チェックを実施して、ダウンタイムにつながる以下のような誤りを事前に検出します。

  • NOT NULLカラムをデフォルト値なしで追加する
    (詳しくはShopifyの記事をご覧ください)
  • カラム名の変更(下流の依存機能が壊れる)
  • カラム型を互換性のない型に変更する

これらのチェックは開発中にも実行されるため、開発者はデプロイ前にすぐ気付けるようになります。

gemの安全性チェックの以前の実装は、SQL文字列を分析するためにRACCパーサーに依存していましたが、これが不安定で、SQL構文を変更したり新しいエッジケースに遭遇したりするたびに、RACCパーサーを更新しなければなりませんでした。私たちは、実際に集中管理マネージャに送信して実行されるマイグレーションとは別に、安全性チェックのマイグレーションを行うための独立したワークフローも必要としていたので、RACCパーサーと縁を切って複雑さを大幅に軽減できる新しいアプローチを採用しました。私たちは、マイグレーションを実行してメソッド呼び出しの結果をすべて記録するMigrationOperationRecorderを開発しました。

class MigrationOperationRecorder
  def initialize(migration_class)
    @migration = migration_class.new
  end

  def record
    singleton_class = @migration.singleton_class
    singleton_class.include(RecordMigrationOperations)

    if @migration.respond_to?(:change)
      @migration.change
    elsif @migration.respond_to?(:up)
      @migration.up
    end

    @migration.method_calls
  end
end

このRecordMigrationOperationsモジュールは、Railsのマイグレーションで使っているのと同じmethod_missingメカニズムを用いて動作します。ActiveRecord::Migrationmethod_missingを用いてコマンドを実行strategyにルーティングするので、RecordMigrationOperationsにメソッド呼び出しを保存するための#method_missingを代わりに定義しました。

module RecordMigrationOperations
  def method_missing(method, *args, **options, &block)
    @method_calls << MigrationOperation.new(
      method: method,
      args: args,
      options: options
    )
  end

  def method_calls
    @method_calls ||= []
  end
end

操作が記録されたら、個別の安全性チェックでマイグレーションデータを検査できるようになります。以下はSingleTableCheckの例です。

class SingleTableCheck < BaseSafetyCheck
  def initialize(migration)
    @inspected_migration = migration
  end

  def check
    # @inspected_migrationは特殊なオブジェクトで、
    # マイグレーションが実行するあらゆる操作の情報を含み、
    # MigrationOperationRecorder#recordから返される
    tables = @inspected_migration.tables

    return if tables.one?

    raise SafetyCheckError,
      "マイグレーションで扱うテーブルは1個でなければならない"\
      "#{tables.to_sentence}テーブルを#{tables.length}個のマイグレーションに分割せよ"
  end
end

このチェックでアクセスしている@inspected_migration.tablesは分析フェーズで抽出され、マイグレーションで扱うテーブルが1個だけであることを検証します。このチェックが失敗するとSafetyCheckErrorがraiseされ、問題の修正を開発者に指示する明確なメッセージが出力されます。

🔗 安全性チェックでマイグレーション戦略を使わない理由

Strategyパターンをもう1つ作るのではなく、MigrationOperationRecordermethod_missingを使ったことに首を傾げる方もいらっしゃるかもしれません。構築した新機能を安全性チェックでも使ってよさそうに思われるかもしれませんが、そうしなかった理由は「関心の分離」とシンプルさのためです。

安全性チェックとマイグレーションの実行はそもそも目的が異なります。

マイグレーションの実行
環境が異なる場合(development vs production)では振る舞いを変える必要があるため、差し替え可能でなければなりません。development環境のマイグレーションはSQLを直接実行しますが、production環境ではJSONにシリアライズしてからリモートサービスに送信します。
安全性チェック
こちらはどんな場所でも同じように実行される必要があります。安全性チェックでは、マイグレーションでどの操作が実行されるかを分析したいのであって、スキーマ変更を実行したいのではありません。development環境でもCIでもproduction環境でも同じ安全性チェックが実行されます。

安全性チェックはmethod_missingで実装する方が、すべてのマイグレーションDSLメソッドが自動的にキャプチャされるのでシンプルになり、マイグレーションDSLメソッドを明示的に列挙する必要がなくなります。Strategyパターンでは、すべてのマイグレーションメソッドを明示的に実装する必要があります。呼び出されるマイグレーションメソッドと引数だけを記録したいなら、method_missingのアプローチを選ぶ方が理にかなっています。

🔗 アダプタごとのマイグレーションstrategy

グローバルなマイグレーションstrategyを採用する際の課題の1つは、マルチプルデータベースを採用しているRailsアプリケーションの対応が不十分になることです。Shopifyは創業以来MySQLを主に使い続けていますが、最近はMySQL以外のデータベースの利用も検討しています。マイグレーションをシリアライズするための要件はデータベースごとに異なるため、マイグレーション対象となるデータベースに合わせてマイグレーションstrategyを調整する必要があります。

これは、gemのマイグレーションstrategyで実行時にデータベースアダプタを検査して、アダプタに適したシリアライズロジックにディスパッチすれば一応実現できます。しかし私たちはRailsがネイティブに処理できるアダプタディスパッチのロジックを再実装しているところなので、この方法は理想的とは言えません。
上流側のソリューションにここが欠けていたと感じられたため、先月Railsにアダプタ単位のマイグレーションstrategyを追加するためのプルリクをオープンしました(#56204)。この機能はRails 8.2で利用可能になる予定です。

以下のようにグローバルなstrategyを1個だけ設定する代わりに、

config.active_record.migration_strategy = JsonSerializationStrategy

アダプタクラスにstrategyを直接登録できるようになります。

ActiveSupport.on_load(:active_record_trilogyadapter) do
  ActiveRecord::ConnectionAdapters::TrilogyAdapter.migration_strategy =
    MysqlStrategy
end

ActiveSupport.on_load(:active_record_postgresqladapter) do
  ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.migration_strategy =
    PostgreSQLStrategy
end

これによってRailsは、マイグレーションで使われるデータベースアダプタに応じて適切なstrategyを自動的に選択します。
たとえば、Trilogyアダプタを利用するMySQLデータベースを対象とするマイグレーションを実行すれば、MysqlStrategyが選択されます。PostgreSQLデータベースを対象とするマイグレーションを実行すれば、PostgreSQLStrategyが選択されます。現在のアダプタにstrategyが設定されていない場合は、グローバルなstrategyにフォールバックします。

🔗 RailsでできることはRailsにやってもらおう

設定より規約は、Railsの設計哲学のひとつです。
Railsアプリの多くはRailsのマイグレーションがどのように実行されるかを考えなくてもよいので、デフォルトのマイグレーションstrategyはシンプルにできます。
アプリケーションでマイグレーションの実行方法をカスタマイズする必要が生じたら、Railsフレームワークが明確な拡張ポイントを提供してくれます。アプリケーションは、要件の変化に応じて設定可能な振る舞いを選べるようになります。

これは、オープンな場で機能を実装することで誰もが恩恵を受けられるようになるという話でもあります。モンキーパッチをShopify社内のみの利用にとどめて、今後もRailsに頑張ってパッチを当て続けるという選択肢もありました。しかし私たちは、自分たちがメンテナンスしやすいソリューションをShopifyのために構築したことで、Railsコミュニティにマイグレーションの振る舞いをカスタマイズする新しいツールも提供できました。

皆さんも、特定のユースケースでRailsの限界に直面することがあったら、自社内だけで解決するだけなく、自社とコミュニティの双方でメリットが得られるような貢献をRails本家に行う機会があるかどうかを検討してみませんか。

関連記事

Rails: さようならRack::BodyProxy、こんにちはrack.response_finished(翻訳)

Ruby が JIT コードを実行するメカニズムを読み解く(翻訳)


CONTACT

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