🛠️

【Rails】rakeタスクからRidgepoleでマイグレーションを実行する

2023/12/18に公開

Happy Elements Advent Calendar 2023 12月18日の記事です。

はじめに

はじめまして、Happy Elements株式会社でゲームエンジニアとして働いているdaisdiceです。

3年程前に同じくゲーム業界から転職し、現在はメルクストーリアグループでサーバーサイドの開発/運用を担当しています。
今年は9周年、特別篇、エイプリルフール、コラボイベント、9.5周年記念、10周年カウントダウン、めざせ!みんなのメフテルハーネ展などのイベントを担当しました。
来たる10周年に向けて、イベント盛りだくさんのあっという間の1年でした。
イベント開発の傍らでコツコツとクエストやアイテム周りのパフォーマンス・チューニングもしてたので、レスポンスが速くなったことに気づいてくれた方がいれば嬉しいです。

今回はメルクストーリアに関する内容ではありませんが、マルチDBのRailsアプリケーションにRidgepoleでマイグレーションを行うrakeタスクを実装したので、紹介したいと思います。

出来上がったrakeタスクは以下の通りです。

% bundle exec rake -T ridgepole
rake ridgepole:apply:all             # 全てのDBに対するapply
rake ridgepole:apply:global          # globalに対するapply
rake ridgepole:apply:parallel        # 全てのDBに対するhost単位の並列apply
rake ridgepole:apply:user_shard_01   # user_shard_01に対するapply
rake ridgepole:apply:user_shard_02   # user_shard_02に対するapply
rake ridgepole:export:all            # 全てのDBに対するexport
rake ridgepole:export:global         # globalに対するexport
rake ridgepole:export:user_shard_01  # user_shard_01に対するexport
rake ridgepole:export:user_shard_02  # user_shard_02に対するexport

以下のようなRidgepole用のrakeタスクを追加するgemがGitHub上に公開されています。
今回はマルチDB環境での並列実行を実現するため自作しました。

Ridgepoleとは

RidgepoleはRailsのmigrateに代わり、DBのテーブルスキーマ(テーブル構造)を管理するgemです。
https://github.com/ridgepole/ridgepole

Railsのmigrateが時系列のmigrateファイルでスキーマを差分管理するのに対し、Ridgepoleは単一のスキーマファイルでスキーマを管理します。
Ridgepoleのスキーマファイルは以下のようなコマンドでDBに反映出来ます。

# applyオプションでスキーマファイルからDBに差分を反映する
% bundle exec ridgepole --apply --config config/database.yml --spec-name global --file db/schemafiles/global.rb
Apply `db/schemafiles/global.rb`
-- create_table("informations", {:id=>{:type=>:integer, :comment=>"お知らせID", :unsigned=>true}, :charset=>"utf8mb4", :collation=>"utf8mb4_bin", :comment=>"お知らせ"})
   -> 0.0102s
   
% bundle exec ridgepole --apply --config config/database.yml --spec-name user_shard_01 --file db/schemafiles/user_shard.rb
Apply `db/schemafiles/user_shard.rb`
-- create_table("users", {:id=>{:type=>:integer, :comment=>"ユーザーID", :unsigned=>true, :default=>nil}, :charset=>"utf8mb4", :collation=>"utf8mb4_bin", :comment=>"ユーザー"})
   -> 0.0108s

既に作成済のテーブルスキーマに、スキーマファイルとDBで差分がある場合は、Ridgepoleが差分を解釈し、同期するALTER TABLEが発行されます。
細かなRidgepoleの使い方については多くの方が記事にされているので、ここでは割愛します。

Ridgepoleを利用するメリット

開発中にテーブルスキーマを変更しやすくなるというのが最大のメリットだと考えています。

Railsで適用済みのmigrateファイルの内容を変更する場合、migrateファイルを変更するのではなく、変更内容を反映する別のmigrateファイルを作成して適用する、というのが推奨手順です。
他のメンバーと共有の開発環境(サーバ)を使って開発する場合に、仕様変更やリファクタリングで適用済みのmigrateファイルを変更したい場面はよくあるかと思います。
その度にmigrateファイルを追加すると、無駄なファイルが増えたり、本番リリース時に無駄なマイグレーションが実行されたり、という悩みがあるので、可能な限り最終形を一つのmigrateファイルにまとめた方が運用上の利点があります。

適用済みのmigrateファイルはschema_migrationsテーブルで管理されており、migrateファイルを変更してもrake db:migrateでは反映されず、rake db:rollback->rake db:migrateという手順を踏む必要があり、煩わしいです。
別のmigrateファイルが間に挟まる場合はさらに厄介で、結局はmigrateファイルの変更内容をDBに反映するためのALTER TABLEクエリを作成して発行し、強引につじつまを合わせることがよくあります。

Ridgepoleの場合は単一のスキーマファイルで管理するため、開発中に何度テーブルスキーマを変更しても、最終形がスキーマファイルに残り、テーブルスキーマを変更しやすいです。

環境

  • Ruby 3.2.2
  • Rails 7.0.8
  • Ridgepole 1.2.1

実装

DBの接続定義

デフォルトのdatabase.ymlにRidgepole用のridgepole_schema_fileという独自項目を追加しました。
ridgepole_schema_fileがDB単位のスキーマファイルのパスになります。
シャーディング(水平分散)するDBは全て同じテーブル定義になるため、同じスキーマファイルを指定しています。

config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  collation: utf8mb4_bin
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: <%= ENV.fetch("MYSQL_USERNAME") { "hogehoge" } %>
  password: <%= ENV.fetch("MYSQL_PASSWORD") { "fugafuga" } %>
  host: localhost

# 環境に依らないDB接続設定のテンプレート
# ridgepole_schema_fileの設定
template:
  global_template: &global_template
    <<: *default
    ridgepole_schema_file: db/schemafiles/global.rb
  user_shard_template: &user_shard_template
    <<: *default
    ridgepole_schema_file: db/schemafiles/user_shard.rb

development:
  # シャーディングしないグローバルDB
  global: &global_dev
    <<: *global_template
    database: global_dev
  global_replica:
    <<: *global_dev
    replica: true

  # シャーディングするユーザーDB
  user_shard_01: &user_shard_01_dev
    <<: *user_shard_template
    database: user_shard_01_dev
  user_shard_01_replica:
    <<: *user_shard_01_dev
    replica: true

  user_shard_02: &user_shard_02_dev
    <<: *user_shard_template
    database: user_shard_02_dev
  user_shard_02_replica:
    <<: *user_shard_02_dev
    replica: true

Ridgepoleのラッパークラス

rakeタスクや別のコードから呼び出しやすいよう、applyとexportに対応したRidgepoleの簡単なラッパークラスを実装しました。
同一テーブルに対する複数の変更を1つのALTER TABLEにまとめるbulk-changeオプションはデフォルトで有効にしました。
MySQLのALTER TABLEは多くの場合にテーブルの再作成を伴うので、1つのクエリにまとめない場合はカラム数分のテーブル再作成が行われ、テーブルの規模によっては長時間を要します。
(bulk-changeが要らないケースってあるんでしょうか?)

lib/ridgepole_wrapper.rb
# frozen_string_literal: true

require 'open3'

# Ridgepoleのラッパークラス
class RidgepoleWrapper
  attr_accessor :spec_name

  # コンストラクタ
  # @param [String] spec_name 接続定義名
  # @return [RidgepoleWrapper]
  def initialize(spec_name)
    @spec_name = spec_name
  end

  # DBの接続定義ファイルのパス
  # @return [Pathname] DBの接続定義ファイルのパス
  def config_path
    Rails.root.join('config/database.yml')
  end

  # DBの接続定義
  # @return [ActiveRecord::DatabaseConfigurations::HashConfig]
  def db_config
    ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: @spec_name)
  end

  # スキーマファイルのパス
  # @return [String] スキーマファイルのパス
  def schema_file_path
    db_config.configuration_hash[:ridgepole_schema_file]
  end

  # DBにスキーマファイルを適用する
  # @param [Hash] options オプション
  def apply(**options)
    fixed_options = {
      'apply' => true,
    }
    default_options = {
      'env' => Rails.env,
      'config' => config_path,
      'spec-name' => @spec_name,
      'file' => schema_file_path,
      'bulk-change' => true,
    }
    options = default_options.merge(options).merge(fixed_options)

    execute(**options)
  end

  # DBからスキーマファイルを出力する
  # @param [Hash] options オプション
  def export(**options)
    fixed_options = {
      'export' => true,
    }
    default_options = {
      'env' => Rails.env,
      'config' => config_path,
      'spec-name' => @spec_name,
      'output' => schema_file_path
    }
    options = default_options.merge(options).merge(fixed_options)

    execute(**options)
  end

  # オプション文字列の構築
  # @param [Hash] options オプション
  # @return [String] オプション文字列
  def build_option_string(**options)
    filtered_options = options.filter_map do |k, v|
      # nilとfalseはオプションから外す
      next if v.nil?
      next if v == false

      # trueはオプション名のみ
      v == true ? "--#{k}" : "--#{k}=#{v}"
    end

    filtered_options.join(' ')
  end

  # コマンドの実行
  # @param [Hash] options オプション
  def execute(**options)
    base_command = 'bundle exec ridgepole'
    option_string = build_option_string(**options)
    command = "#{base_command} #{option_string}"
    stdout, stderr, status = Open3.capture3(command)

    raise stderr unless status.success?

    puts stdout
  end
end

rakeタスク

上記で実装したRidgepoleWrapperのapplyとexportをrakeタスクに組み込みました。
工夫した点は以下のとおりです。

  • DB単位の実行
    任意のDBに対してマイグレーションを実行できるように、database.ymlに定義されるDBから動的にrakeタスクが定義されるようにしました。(ridgepole:apply:{spec_name})
    db:migrateのコードを参考にしています。

  • 並列実行
    Parallelがインストールされる場合はDBのホスト単位でマイグレーションを並列実行できるようにしました。(ridgepole:apply:parallel)
    マルチDB構成の場合、直列にマイグレーションを実行すると、DBの数と変更内容によって長時間を要するためです。

lib/tasks/ridgepole.rake
# frozen_string_literal: true

require 'ridgepole_wrapper'

# DBの接続定義の読み込み
databases = ActiveRecord::Tasks::DatabaseTasks.setup_initial_database_yaml

namespace :ridgepole do
  namespace :apply do
    # bundle exec rake ridgepole:apply:all
    desc '全てのDBに対するapply'
    task all: :environment do
      ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
        ridgepole = RidgepoleWrapper.new(db_config.name)
        ridgepole.apply
      end
    end

    # 定義されるすべてのdbに対する動的なタスクの定義
    ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name|
      # bundle exec rake ridgepole:apply:{spec_name}
      desc "#{name}に対するapply"
      task name => :environment do
        ridgepole = RidgepoleWrapper.new(name)
        ridgepole.apply
      end
    end

    # Parallelがインストールされる場合のみ定義
    if defined?(Parallel)
      # bundle exec rake ridgepole:apply:parallel
      desc '全てのDBに対するhost単位の並列apply'
      task parallel: :environment do
        # 同一hostでマイグレーションが同時実行されないようにhost単位で接続定義を集約
        db_config_by_host_map = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).group_by(&:host)
        Parallel.each(db_config_by_host_map) do |_, db_configs|
          db_configs.each do |db_config|
            ridgepole = RidgepoleWrapper.new(db_config.name)
            ridgepole.apply
          end
        end
      end
    end
  end

  namespace :export do
    # bundle exec rake ridgepole:export:all
    desc '全てのDBに対するexport'
    task all: :environment do
      exported_file_path_map = {}
      ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
        ridgepole = RidgepoleWrapper.new(db_config.name)

        # シャーディングDBの重複export対策
        # export済の同名ファイルはexportをスキップ
        file_path = ridgepole.schema_file_path
        next if exported_file_path_map[file_path]

        ridgepole.export
        exported_file_path_map[file_path] = true
      end
    end

    ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name|
      # bundle exec rake ridgepole:export:{spec_name}
      desc "#{name}に対するexport"
      task name => :environment do
        ridgepole = RidgepoleWrapper.new(name)
        ridgepole.export
      end
    end
  end
end

スキーマファイル

Ridgepoleで使用するスキーマファイルです。
中身はRubyなので、拡張子をrbにしておくとエディターでハイライトされたり、見やすくなるんじゃないかと思います。

以下のファイルはGUIを持つMySQLクライアントで作成したテーブルを、Ridgepoleでexportしたものです。
自分でスキーマファイルを実装する場合はapplyとセットでexportしておくと、スキーマファイル内が整列されてバージョン管理で差分を追跡しやすくなります。
(テーブルやオプションの順番の違いが差分として表れると追跡が辛いです。)

db/schemafiles/global.rb
# -*- mode: ruby -*-
# vi: set ft=ruby :
create_table "informations", id: { type: :integer, comment: "お知らせID", unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_bin", comment: "お知らせ", force: :cascade do |t|
  t.string "subject", limit: 64, default: "", comment: "件名"
  t.text "body", comment: "本文"
  t.datetime "created_at", precision: nil, null: false
  t.datetime "updated_at", precision: nil, null: false
end
db/schemafiles/user_shard.rb
# -*- mode: ruby -*-
# vi: set ft=ruby :
create_table "users", id: { type: :integer, comment: "ユーザーID", unsigned: true, default: nil }, charset: "utf8mb4", collation: "utf8mb4_bin", comment: "ユーザー", force: :cascade do |t|
  t.string "nickname", limit: 32, default: "", comment: "ニックネーム"
  t.datetime "created_at", precision: nil, null: false, comment: "登録日時"
  t.datetime "updated_at", precision: nil, null: false, comment: "更新日時"
end

実行

テーブル作成

ridgepole:apply:allでスキーマファイルの内容をDBに反映。

% bundle exec rake ridgepole:apply:all 
Apply `db/schemafiles/global.rb`
-- create_table("informations", {:id=>{:type=>:integer, :comment=>"お知らせID", :unsigned=>true}, :charset=>"utf8mb4", :collation=>"utf8mb4_bin", :comment=>"お知らせ"})
   -> 0.0088s
Apply `db/schemafiles/user_shard.rb`
-- create_table("users", {:id=>{:type=>:integer, :comment=>"ユーザーID", :unsigned=>true, :default=>nil}, :charset=>"utf8mb4", :collation=>"utf8mb4_bin", :comment=>"ユーザー"})
   -> 0.0102s
Apply `db/schemafiles/user_shard.rb`
-- create_table("users", {:id=>{:type=>:integer, :comment=>"ユーザーID", :unsigned=>true, :default=>nil}, :charset=>"utf8mb4", :collation=>"utf8mb4_bin", :comment=>"ユーザー"})
   -> 0.0104s

DBにスキーマファイルに対応するテーブルが作成されたことを確認。

mysql> use global_dev
Database changed
mysql> DESC informations;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| subject    | varchar(64)      | YES  |     |         |                |
| body       | text             | YES  |     | NULL    |                |
| created_at | datetime         | NO   |     | NULL    |                |
| updated_at | datetime         | NO   |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)

mysql> use user_shard_01_dev
Database changed
mysql> DESC users;
+------------+------------------+------+-----+---------+-------+
| Field      | Type             | Null | Key | Default | Extra |
+------------+------------------+------+-----+---------+-------+
| id         | int(10) unsigned | NO   | PRI | NULL    |       |
| nickname   | varchar(32)      | YES  |     |         |       |
| created_at | datetime         | NO   |     | NULL    |       |
| updated_at | datetime         | NO   |     | NULL    |       |
+------------+------------------+------+-----+---------+-------+
4 rows in set (0.00 sec)

mysql> use user_shard_02_dev
Database changed
mysql> DESC users;
+------------+------------------+------+-----+---------+-------+
| Field      | Type             | Null | Key | Default | Extra |
+------------+------------------+------+-----+---------+-------+
| id         | int(10) unsigned | NO   | PRI | NULL    |       |
| nickname   | varchar(32)      | YES  |     |         |       |
| created_at | datetime         | NO   |     | NULL    |       |
| updated_at | datetime         | NO   |     | NULL    |       |
+------------+------------------+------+-----+---------+-------+
4 rows in set (0.00 sec)

カラム追加

スキーマファイルのusersテーブルにprofileカラムを追加。

db/schemafiles/user_shard.rb
# -*- mode: ruby -*-
# vi: set ft=ruby :
create_table "users", id: { type: :integer, comment: "ユーザーID", unsigned: true, default: nil }, charset: "utf8mb4", collation: "utf8mb4_bin", comment: "ユーザー", force: :cascade do |t|
  t.string "nickname", limit: 32, default: "", comment: "ニックネーム"
  t.string "profile", limit: 256, default: "", comment: "プロフィール"
  t.datetime "created_at", precision: nil, null: false, comment: "登録日時"
  t.datetime "updated_at", precision: nil, null: false, comment: "更新日時"
end

ridgepole:apply:allでDBに反映。

% bundle exec rake ridgepole:apply:all 
Apply `db/schemafiles/global.rb`
No change
Apply `db/schemafiles/user_shard.rb`
-- change_table("users", {:bulk=>true})
   -> 0.0184s
Apply `db/schemafiles/user_shard.rb`
-- change_table("users", {:bulk=>true})
   -> 0.0169s

usersテーブルにprofileカラムが追加されたことを確認。

mysql> use user_shard_01_dev
Database changed
mysql> DESC users;
+------------+------------------+------+-----+---------+-------+
| Field      | Type             | Null | Key | Default | Extra |
+------------+------------------+------+-----+---------+-------+
| id         | int(10) unsigned | NO   | PRI | NULL    |       |
| nickname   | varchar(32)      | YES  |     |         |       |
| profile    | varchar(256)     | YES  |     |         |       |
| created_at | datetime         | NO   |     | NULL    |       |
| updated_at | datetime         | NO   |     | NULL    |       |
+------------+------------------+------+-----+---------+-------+
5 rows in set (0.00 sec)

mysql> use user_shard_02_dev
Database changed
mysql> DESC users;
+------------+------------------+------+-----+---------+-------+
| Field      | Type             | Null | Key | Default | Extra |
+------------+------------------+------+-----+---------+-------+
| id         | int(10) unsigned | NO   | PRI | NULL    |       |
| nickname   | varchar(32)      | YES  |     |         |       |
| profile    | varchar(256)     | YES  |     |         |       |
| created_at | datetime         | NO   |     | NULL    |       |
| updated_at | datetime         | NO   |     | NULL    |       |
+------------+------------------+------+-----+---------+-------+
5 rows in set (0.00 sec)

おわりに

今回はマルチDBのRailsアプリケーションでRidgepoleを使うためのrakeタスクを実装しました。
簡易な実装で、特にオプション周りはまだ改善の余地がありますが、なにかの参考になれば幸いです。
(--dry-run--drop-tableオプションあたりをrakeタスクから使いやすくすると便利そうに思います。)

Ridgepole、とても便利ですね。
個人的には積極的に使っていきたいな、と思っています。

Happy Elements

Discussion