【Ruby on Rails】稼働中アプリケーションでいきなりremove_columnするのが危険な理由

## はじめに
Ruby on Railsでアプリケーションを構築していて「仕様変更に伴いカラムを使わなくなったので削除しよう」という機会はあるかと思います。その際、ソースコードをチェックして当該カラムを参照している箇所が無かったからといって、いきなり `remove_column` をしてしまうのはちょっと待った方が良いかもしれません。
特に、下記のような冗長構成を取っている場合は注意が必要です。


```mermaid
graph LR;
    WebServer1[サーバ1] ---> Database[データベース];
    WebServer2[サーバ2] ---> Database;

    Database ---> WebServer1;
    Database ---> WebServer2;
    
    %% ノードの色指定
    style WebServer1 fill:#66ccff,stroke:#333,stroke-width:2px;
    style WebServer2 fill:#66ccff,stroke:#333,stroke-width:2px;
    style Database fill:#99ff99,stroke:#333,stroke-width:2px;
```

## 何が発生するのか

例えば、以下のように「projects」「tasks」テーブルを作成していたとします。(projectsとtasksは「1 対 多」の関係）
```ruby
class CreateProjects < ActiveRecord::Migration[7.1]
  def change
    create_table :projects do |t|
      t.string :name, null: false, comment: 'プロジェクト名'
      t.string :category,          comment: 'カテゴリ名' # このカラムを削除します
      t.timestamps
    end
  end
end

class CreateTasks < ActiveRecord::Migration[7.1]
  def change
    create_table :tasks do |t|
      t.references :project
      t.string     :name, null: false, comment: 'タスク名'
      t.timestamps
    end
  end
end
```
```ruby:project.rb
class Project < ApplicationRecord
  has_many :tasks
end
```

```ruby:task.rb
class Task < ApplicationRecord
  belongs_to :project
end
```

categoryカラムを使わなくなったので、ソースコード上で参照している箇所が無いことを確かめたのち、カラム削除用のマイグレーションファイルを作って「サーバ1」「サーバ2」で動かしているRailsアプリケーションへ反映します。
```ruby
class RemoveCategoryToProjects < ActiveRecord::Migration[7.1]
  def change
    remove_column :projects, :category, :string
  end
end
```

すると、変更をアプリケーションへ反映中に、サーバ2で下記のエラーが流れ始めました。
```
An error occurred when inspecting the object: #<ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'projects.category' in 'field list'>
```

## ActiveRecordがテーブル情報をキャッシュしている
実は、ActiveRecordはテーブル情報をキャッシュしており、`eager_load` 等のメソッドではキャッシュ情報を元にクエリを組み立てています。

```
irb(main):003> Task.eager_load(:project)
  SQL (1.2ms)  SELECT `tasks`.`id` AS t0_r0, `tasks`.`project_id` AS t0_r1, `tasks`.`name` AS t0_r2, `tasks`.`created_at` AS t0_r3, `tasks`.`updated_at` AS t0_r4, `projects`.`id` AS t1_r0, `projects`.`name` AS t1_r1, `projects`.`category` AS t1_r2, `projects`.`created_at` AS t1_r3, `projects`.`updated_at` AS t1_r4 FROM `tasks` LEFT OUTER JOIN `projects` ON `projects`.`id` = `tasks`.`project_id` /* loading for pp */ LIMIT 11
An error occurred when inspecting the object: #<ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'projects.category' in 'field list'>
```

今回の例は、「サーバ1への変更反映が完了してcategoryカラムが削除されたが、**サーバ2が持っているキャッシュがcategoryカラムを参照し続けてエラーを引き起こした**」というものでした。
サーバ2のキャッシュも更新されれば解決しますが、どうしても複数サーバへの変更反映(≒キャッシュ更新)にタイムラグが発生する以上、稼働中のアプリケーションでいきなりカラムを削除するのは危険なようです。


## ignored_columnsを使う
安全にカラムを削除するには、`ignored_columns` を用いた2段階のアプローチが推奨されているようです。
具体的には、まずは削除予定のカラムに対し `ignored_columns` を指定して参照とキャッシュを断ち、その後に `remove_column` のマイグレーションを実行するというものです。

```diff_ruby
class Project < ApplicationRecord
  has_many :tasks

  # カラムが存在していても参照・キャッシュしなくなる
  self.ignored_columns += [:category]
end
```

まだcategoryカラムがprojectsテーブルに存在している状態でも、当該カラムを参照しなくなっていることが確認できます。
```
irb(main):005> Project.columns_hash["category"]
=> nil
```

## まとめ
- 削除する予定のカラムを直接参照していなくても、ActiveRecordがキャッシュとして情報を保持している
- 安全にカラムを削除するならば、先に `ignored_columns` 指定をかけたソースコードを反映させてから、`remove_column` を行う

## 参考
[ActiveRecord::ModelSchema::ClassMethods](https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema/ClassMethods.html#method-i-ignored_columns)
[Rails: Active Recordモデルのカラムを安全に削除する（翻訳）](https://techracho.bpsinc.jp/hachi8833/2023_04_24/128393)
[ActiveRecord の Schema Cache と運用 Tips - Please Sleep](https://please-sleep.cou929.nu/rails-activerecord-schema-cache.html)