はじめに
カラムを削除する機会があり、安全な手順を自分なりに考えてみました。
最初は「remove_column を書いてデプロイするだけでは?」と思っていたのですが、調べていくと意外と考慮すべきことがありました。同じように迷った方の参考になれば幸いです。
キーワード整理
Rails カラム削除
├── スキーマキャッシュ
│ ├── ActiveRecord::Base.columns
│ └── プロセスが生きている間保持される
├── ゼロダウンタイムデプロイ
│ └── 新旧プロセスが同時稼働する瞬間がある
└── ignored_columns
├── DBにカラムがあってもRailsから見えなくする設定
└── 即時ロールバックできる保険として機能する
なぜカラムをそのまま削除してはいけないのか?
Rails(ActiveRecord)はアプリ起動時に、DBのスキーマ情報(カラム名・型など)をメモリ上にキャッシュします。
Article.columns
# => [#<ActiveRecord::ConnectionAdapters::Column name="id" ...>,
# #<ActiveRecord::ConnectionAdapters::Column name="legacy_tag_id" ...>, ...]
このキャッシュはプロセスが再起動されるまで保持されます。
本番環境ではゼロダウンタイムデプロイ(サービスを止めずに新しいコードに切り替えるデプロイ方式)が一般的で、新旧のアプリプロセスが同時に稼働する瞬間があります。この状態でカラムを削除するマイグレーションを先に流すと、以下の問題が起きます。
DBにないカラムを参照しようとすると PG::UndefinedColumn などのエラーが発生します。
さらに、問題が発覚したときの復旧も困難です。カラムが既に消えているため、そのカラムへの書き込みが失敗していた可能性があり、データも欠損しています。ロールバックしてもデータは戻りません。
一方、後述する ignored_columns を使った手順であれば、問題が発覚しても設定を外すだけで即時ロールバックできます。DBにはカラムがまだ残っているためデータの欠損もありません。ignored_columns を設定しておけば、問題が起きても設定を外すだけで元に戻せます。
いきなり削除せずに ignored_columns を使おう
ignored_columns は、DBにカラムが存在していても Rails から見えないようにするモデルの設定です。
class Article < ApplicationRecord
self.ignored_columns = ["legacy_tag_id"]
end
ignored_columns を設定すると、対象カラムが column_names から除外され、SELECT にも INSERT にも含まれなくなります。新旧プロセスが混在するデプロイ直後でも、DBにはカラムが残っているため両プロセスともエラーになりません。
ActiveRecord の内部的な仕組み
Rails 起動から column_names が使われるまでの流れは3段階に分かれています。
① Rails 起動時:DB に対して SHOW FULL FIELDS などの SQL を発行し、テーブルの全カラム情報を取得します。この結果は接続層の SchemaCache に保存されます。SchemaCache は DB の実態を反映したキャッシュです。
② モデルファイルの読み込み時:article.rb が読み込まれると、self.ignored_columns = ["legacy_tag_id"] によってクラス変数 @ignored_columns にリストが保存されます。同時にモデル層の columns_hash キャッシュが nil にリセットされますが、これはフィルター済みのキャッシュを作るわけではありません。万が一 ignored_columns= の設定より先に column_names が呼ばれてキャッシュ済みだった場合に、古いキャッシュを破棄するための安全策です。
③ 初回アクセス時(load_schema!):column_names や attribute_names が初めて呼ばれたタイミングで load_schema! が実行されます。ここで SchemaCache の全カラムと @ignored_columns の両方を参照して Hash#except でフィルターした結果を columns_hash として確定・凍結します。フィルター済みキャッシュが実際に作られるのはこのタイミングです。
つまり ②はキャッシュの無効化、③がフィルター済みキャッシュの構築です。②だけではカラムは認識されなくなりません。
SchemaCache は DB の全カラムを持ち続けます。columns_hash は③の load_schema! が「SchemaCache の全カラム」と「②で登録した @ignored_columns」の両方を参照して初めて構築されます。@ignored_columns が③で使われるからこそ②の登録が必要で、②と③は別のステップです。
設定が正しく効いているか確認する
rails console で以下を実行して確認できます。
# ignored_columns に設定されているか確認
Article.ignored_columns
# => ["legacy_tag_id"]
# column_names から除外されているか確認
Article.column_names.include?("legacy_tag_id")
# => false
# 属性一覧からも消えていることを確認
Article.attribute_names.include?("legacy_tag_id")
# => false
legacy_tag_id が column_names に含まれていなければ、Rails はそのカラムを認識していない状態です。
ignored_columns を追加してもすぐカラムを削除しないこと
すぐに削除するのは避けた方がよいです。
アプリケーションには、毎日動くコードだけでなく、月次バッチ・特定条件でしか通らない処理など頻度が低いコードパスが存在します。ignored_columns を追加してデプロイした直後に問題が出なくても、「そのカラムに触るコードがたまたま実行されていないだけ」という可能性があります。
十分な期間を置いて、低頻度のコードパスも含めて問題が出ないことを確認してからカラム削除に進むのが安全です。
安全なカラム削除の3ステップ
上記を踏まえると、以下の3段階に分けて進めるのが安全です。
Step 1: ignored_columns 追加 → デプロイ & 十分な期間様子見
Step 2: カラム削除 migration → デプロイ & 動作確認
Step 3: ignored_columns 削除 → デプロイ
Step 1:ignored_columns を追加する
class Article < ApplicationRecord
self.ignored_columns = ["legacy_tag_id"]
end
デプロイ後、エラーモニタリングで問題が出ていないことを確認します。低頻度のコードパスも一通り実行されるまで、十分に時間をおきます。
Step 2:カラム削除のマイグレーションを実行する
class RemoveLegacyTagIdFromArticles < ActiveRecord::Migration[7.1]
def up
remove_column :articles, :legacy_tag_id
end
def down
add_column :articles, :legacy_tag_id, :bigint
end
end
Step 3:ignored_columns の設定を削除する
DBにもアプリにも legacy_tag_id は存在しなくなり、クリーンな状態に戻ります。
class Article < ApplicationRecord
# ignored_columns の設定を削除
end
各ステップが完了したときの状態をまとめると以下のとおりです。
| ステップ | DB のカラム | アプリの挙動 |
|---|---|---|
| Step 1 前 | あり | 参照する |
| Step 1 後(様子見) | あり | 無視する |
| Step 2 後 | なし | 無視する |
| Step 3 後 | なし | 設定もなし |
まとめ
| 疑問 | 答え |
|---|---|
| なぜそのまま削除してはいけないのか | Rails がスキーマをメモリにキャッシュしており、ゼロダウンタイムデプロイ中に旧プロセスがエラーになる。問題発覚時にデータも欠損しており復旧が困難になるから |
ignored_columns を使うメリットは何か |
DBにカラムがあっても Rails から見えなくなり、問題時は設定を外すだけで即時ロールバックできる保険になる |
ignored_columns 追加後すぐに削除していいのか |
ダメ。月次バッチなど低頻度のコードパスが実行されるまで十分に期間をおく必要がある |