はじめまして、ハートレイルズの境 (@kazsakai) です。
色々あって今は長野県の伊那という、地理的には日本列島の中心らへんだけどあらゆる大都市から満遍なく遠い片田舎に暮らしています。(ちなみにアニメ聖地巡礼発祥の地だそうで)
Kaizen Platformさんの社員ではなくパートナーという立場ではありますが、ほぼ最初期くらいから開発に関わっているエンジニアの一人として、今回こちらのブログにお邪魔させていただきます。
Rails の不要テーブルと migration ファイルを整理したい
Kaizen Platformさんのプロダクトは日々着実に拡大を続けていて、githubの社内リポジトリ数も今や200を超えていますが、そんなKaizenのプロダクトも最初期には単一のRailsリポジトリからスタートしました。
最初期のプロダクト名「planBCD」にちなんだそのRailsリポジトリplanbcd
は、少しずつリファクタリングされながら今でもサービスの中核に残り続けています。
しかし、このplanbcd
はさすがにもう5年を超える歳月を経たリポジトリだけあって、その間に内部のデータ構造も様々な変遷を経ていて、MySQLのテーブル周りもかなり散らかった状態になっていました。
具体的には、
- もう使ってないはずのテーブルが
db/schema.rb
に載っている - 新しくDBを作って
rake db:migrate
するとmigrationファイル群の色んなところでエラーが出て動かない
等々の課題があって、致命的というわけではないけれども放っておきたくもない状態にありました。
前々から何とかしたいとは思っていたのですが、最近新しい機能をリリースして一息ついたタイミングでこの問題を解消する機会に恵まれましたので、今回はどうやってこれらを解消したのかを紹介したいと思います。
不要なテーブルを削除する
解消前の時点でplanbcd
にはMySQLのテーブルが165個ありましたが、このうち現在はもう使われていなさそうなものが多数存在していました。
これらが残っていてもコードやDBを眺める際にノイズにしかならないので、使われていないものは削除することにしました。
Rails で使っていないテーブルを探す
まず本当に使われていないかどうかを調べるために、ちょっとしたスクリプトを走らせてみます。
Rails
で普通にActiveRecord
を使ってテーブルを作成・操作すると、レコードのINSERT
やUPDATE
のときに自動的にupdated_at
カラムにその時点の日付が入ります。
(update_column
やupdate_all
メソッドを使うと入らないのですが、Kaizenさんでこれらのやや行儀の悪いメソッドを使う人は僕以外あまりいないので、大体このカラムが信用できそうでした)
これを利用して、各テーブルの最終更新日時と、ついでにレコード数を調べてみます。
railsコンソール上で、
ActiveRecord::Base.connection.tables.map do |table_name| count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{table_name}") column_names = ActiveRecord::Base.connection.columns(table_name).map(&:name) time_column = column_names.include?('updated_at') ? :updated_at : nil time_column ||= column_names.include?('created_at') ? :created_at : nil last_updated_at = nil if time_column && count > 0 sql = "SELECT #{time_column} FROM #{table_name} ORDER BY #{time_column} DESC LIMIT 1" last_updated_at = ActiveRecord::Base.connection.select_value(sql) end { table_name: table_name, count: count, time_column: time_column, last_updated_at: last_updated_at, } end
のようなスクリプトを走らせて、
=> [{:table_name=>"account_statements", :count=>22323, :time_column=>:updated_at, :last_updated_at=>2016-03-02 03:02:34 UTC}, {:table_name=>"acct_areas", :count=>2, :time_column=>:updated_at, :last_updated_at=>2015-05-14 00:40:46 UTC}, {:table_name=>"acct_contact_roles", :count=>146, :time_column=>:updated_at, :last_updated_at=>2015-12-04 23:36:44 UTC}, ...]
のようにテーブル名、レコード数、最終更新日時を取得してみました。
ここで全テーブル名の取得等にActiveRecord::Base.connection
を使っていますが、ここにはRailsのコンソールやスクリプトで生 SQL を叩いたり (execute
やselect_all
) テーブルのスキーマを変えたり (add_column
やcreate_index
) できるメソッドが詰まっているので、覚えておくとちょっとしたときに使えて便利です。
こうして取得したテーブル一覧を眺めて、本当に使われていないかどうかの判断の材料とします。 例えばレコード数は大量にあるのに最終更新日時が数年前で止まっているテーブルなどは高確率でもう使われていないと思っていいでしょう。
ただ実際にはこれだけで判断するわけではなく、Railsのコードと、あとテーブルの役割や歴史的経緯から見てこれはまだ要るはず、これはもう要らないはずと総合的に判断していきました。
こうした結果、66個の消してもよさそうなテーブルのリストができあがりました。
いきなり消さずにリネームして様子を見る
165個中66個のテーブルを消せそうでしたが、いきなりこの数のテーブルを消すのは怖いので、削除の前にもう一段階挟むことにします。
まず、こんな風に削除予定のテーブルを別の名前にリネームするmigrationファイルを用意しました。
class RenameLegacyTables < ActiveRecord::Migration def change rename_table :acct_areas, :zzz_acct_areas rename_table :acct_contact_roles, :zzz_acct_contact_roles # ...略... end end
もしテーブルを消して問題が起こるようならリネームするだけでも同じ問題が起こるはずなので、こうしてリネームして様子を見ることにします。
今回、リネーム後の名前は分かりやすければなんでも良かったのでzzz_
をつけてみました。
ただrename_table
ではテーブルと同時にそのテーブルに張られたインデックスもリネームされて、MySQLの名前の上限64字を超えるエラーが幾つか出てしまったので、そのあたりは別途インデックスの方もリネームしていました。
ここはもうちょっと短いプレフィックスか、名前が長くならないような命名の方が良かったかもしれません。
ともかくこうして削除予定のテーブルをリネーム後、自動テストの全パスとステージング環境での確認を経て、本番環境へと反映します。 リネームした状態で本番環境でしばらく運用して、問題が起こらなければテーブル削除する予定でした。
が、やはり想定外の問題の一つや二つは起こるもので、Chartioで書いていたグラフの一つが動かなくなるという問題が発生してしまいました。 Chartioのクエリで今回削除しようとしていたテーブルの幾つかが参照されていて、そこに気づかないままリネームしてしまったのです。
幸い、Kaizen社内で参照する指標のためのグラフでユーザー影響は無かったのですが、リネームだけにしておいたためすぐ復旧することができました。 テーブル削除していたら、バックアップからの復旧となりもう少し手数がかかっていたと思います。
こうして一部のテーブルは元に戻したものの、以降一週間ほど様子を見ても特に問題は起こらず、残りのテーブルは本当に消してもよさそうなので、実際に削除へと進みます。
テーブルをバックアップを取って削除する
本当に消してもよさそうなテーブルが分かってきたので、今度はそれらをこんなmigrationファイルで削除します。
class DropLegacyTables < ActiveRecord::Migration def backup_table(name, timestamp) local_d = "#{Rails.root}/dump" Dir.mkdir(local_d) unless File.directory? local_d dumpfile = "#{local_d}/#{timestamp}-#{Rails.env}-#{name}.dump.gz" options = connection.raw_connection.query_options.slice(:database, :username, :password, :host, :socket) cmds = ['mysqldump', "-u #{options[:username]}", "--password='#{options[:password]}'", options[:socket].present? ? "-S #{options[:socket]}" : "-h #{options[:host]}", options[:database], name, "| gzip > #{dumpfile}"] cmd = cmds.join(' ') puts "executing: #{cmd.gsub(/(--password)=[^ ]*/, '\1=xxxx')}" system(cmd) or raise "command failed!" end def up legacy_tables = [ :zzz_acct_areas, :acct_contact_roles, # ...略... ] legacy_tables.select! do |table_name| table_exists?(table_name) end # backup tables by mysqldump timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S') legacy_tables.each do |table_name| backup_table table_name, timestamp end # rename foreign keys before drop tables legacy_tables.each do |table_name| foreign_keys(table_name).each do |fk| remove_foreign_key table_name, name: fk.name end end # then drop tables legacy_tables.each do |table_name| drop_table table_name end end def down puts "rollback of DropLegacyTables does't restore legacy tables." end end
ここでは念のため削除前にテーブル毎にmysqldump
でバックアップをしています。
また、他のテーブルと外部キー制約のリレーションがあるテーブルもあるので、それら外部キー制約は先にremove_foreign_key
で削除しています。
最後にdrop_table
でテーブルを削除することで、不要テーブルの削減は完了です。
migration ファイルを整理する
さて、不要テーブルの削除は終わったものの、db/migrate/
以下には削除済みのテーブルの分を含めて大量のmigrationファイルが残っていました。
この時点でおよそ500個弱のmigrationファイルがあって、単に数が多いだけならそれほど害は無いのですが、
- 新しくDBを作って
rake db:migrate
するとmigrationファイル群の色んなところでエラーが出て動かない
という問題があって、新しく参加したエンジニアが手元に開発環境を構築するときには、他の既に動いている環境のDBからmysqldump
してもらってくるしかないという状況でした。
このエラーの原因のほとんどは単純で、例えばmigrationファイルに
class AddPricingPlanIdToUsers < ActiveRecord::Migration def up add_column :users, :pricing_plan_id, :integer plan_enterprise = PricingPlan.where(label: 'enterprise_jp_v1').first User.where(role: User::Role::STANDARD).find_each do |user| user.pricing_plan_id = plan_enterprise.id user.save! end end def down remove_column :users, :pricing_plan_id end end
のように、カラムを追加したついでにモデルクラスを使って値を設定しているところでひっかかっていました。 これを書いた当時は動いていたはずですが、その後の経緯でクラスそのものが無くなったりして動かないコードになってしまったというわけです。
migrationファイルで将来変わりうるクラスや定数を使うのは悪いコードで、本当は
def up add_column :users, :pricing_plan_id, :integer plan_enterprise_id = connection.select_value('SELECT id FROM pricing_plans WHERE label = "enterprise_jp_v1"') role = 1 # User::Role::STANDARD execute <<-SQL UPDATE users SET users.pricing_plan_id = #{plan_enterprise_id} WHERE role = #{role} SQL end
のように生のSQLで設定するようなコードにすべきでした。
とは言え既にある500個近いmigrationファイルのうち、現在動かないコードが何箇所もあって、最初から全て通るように修正するのはそれなりに手間がかかりそうです。
そもそも古いmigrationファイルのコードを見ることは経験上ほとんどなく、残っていても考古学的な価値くらいしかないので、手間をかけて修正するほどでもないと考えて、今回これらを一気に削除することにしました。
削除すると言ってもdb:migrate
したときにちゃんとDBが最新の状態になるようにしたいので、削除する代わりに最新のdb/schema.rb
相当のmigrationファイルを用意することにします。
まずは本番のサーバーでrake db:schema:dump
を実行して最新のdb/schema.rb
を取得します。
ActiveRecord::Schema.define(version: 20180626172837) do create_table "active_admin_comments", force: :cascade do |t| t.string "resource_id", limit: 255, null: false t.string "resource_type", limit: 255, null: false # ...略...
このdefine
内が最新のテーブルのスキーマ情報になっていて、このコードはそのままmigrationファイルで使えるので、これを新たに作ったmigrationファイルの中にコピーします。
class SchemaSnapshot < ActiveRecord::Migration def up return if table_exists? :active_admin_comments create_table "active_admin_comments", force: :cascade do |t| t.string "resource_id", limit: 255, null: false t.string "resource_type", limit: 255, null: false # ...略...
ここで最初にテーブルが既に存在したらスキップする処理を入れておきます。
この新しいmigrationファイルは新たにDB作成してdb:migrate
したときのためのもので、既存のDBがある環境ではここでスキップさせるようにします。
これで新しい環境でこのmigrationファイルが実行されるとdb/schema.rb
相当の状態になるところまで進みますが、db/schema.rb
には載らないものの動作に必要な情報が古いmigrationファイルに残っている場合があります。
具体的には、絵文字対応のために一部カラムをutf8mb4に変更していたり、必要な初期レコードをテーブルに投入したり、ストアドファンクションを定義したりといったコードです。
これらを古いmigrationファイルから洗い出して、新しいmigrationファイルの中に
# set utf8mb4 columns execute "ALTER TABLE users CHANGE `username` `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" execute "ALTER TABLE users CHANGE `comment` `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # ...略... # init membership_roles execute <<-SQL INSERT INTO membership_roles (id, name, ancestry, receive_emails, created_at, updated_at, is_inheritable, is_root_organization_only, type) VALUES (1, 'owner', NULL, TRUE, NOW(), NOW(), TRUE, TRUE, 'MembershipRole::Organization'), (2, 'admin', '1', TRUE, NOW(), NOW(), FALSE, FALSE, 'MembershipRole::Organization'), (3, 'member', '1/2', FALSE, NOW(), NOW(), FALSE, FALSE, 'MembershipRole::Organization') SQL # ...略...
のように手作業で移植して、ようやく一つの巨大なmigrationファイルが完成しました。
これを古いmigrationファイルを全削除した代わりに配置すれば完了です。
ちなみにこの古いmigrationファイルを捨ててdb/schema.rb
から新しいmigrationファイルを作るのはおそらくsquasher gemでもできそうだったのですが、それ自体は手作業でもすぐできるのと、db/schema.rb
に載らない情報の移植はどのみち必要だったので、今回はこうして手作業で済ませました。
もっと素直な内容のmigrationであればsquasher gemでまとめた方が簡単かもしれません。
最後に
KaizenのRailsリポジトリの一つでは今回こんな風に古いテーブルやmigrationファイルの整理を行っていました。 約3分の1のテーブルと大量のmigrationファイルが消えたので、随分とすっきりしたように思います。
動作に支障は無いけれども古くて整理した方がよいものはテーブル周りに限らず放置しがちで、蓄積していくと徐々に、新しい何かを作る際に開発しづらい環境になってしまいます。 もちろん新機能の開発も重要なのですが、その一方でこうした過去の負債もバランス良く精算して継続的に開発しやすい環境を作ることも大事です。
実際のところ他社さんではこうしたところにはなかなか工数を取れないのですが、その点、こうやって過去の負債を解消するための工数もしっかり確保できるのは、Kaizen Platformさんの素敵なところの一つと思います。