【Rails】rakeタスクからRidgepoleでマイグレーションを実行する
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です。
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は全て同じテーブル定義になるため、同じスキーマファイルを指定しています。
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が要らないケースってあるんでしょうか?)
# 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の数と変更内容によって長時間を要するためです。
# 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
しておくと、スキーマファイル内が整列されてバージョン管理で差分を追跡しやすくなります。
(テーブルやオプションの順番の違いが差分として表れると追跡が辛いです。)
# -*- 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
# -*- 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
カラムを追加。
# -*- 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、とても便利ですね。
個人的には積極的に使っていきたいな、と思っています。
Discussion