Railsで不要なテーブルと古いmigrationファイルを削除する

はじめまして、ハートレイルズの境 (@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を使ってテーブルを作成・操作すると、レコードのINSERTUPDATEのときに自動的にupdated_atカラムにその時点の日付が入ります。 (update_columnupdate_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 を叩いたり (executeselect_all) テーブルのスキーマを変えたり (add_columncreate_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さんの素敵なところの一つと思います。