DBのデフォルト値を変えたのに本番だけ直らない。犯人はActiveRecordのカラムキャッシュだった

8分で読めます

visible という真偽値のカラムが、本番でだけ意図と逆の値で保存される。そんなバグを踏んだ。原因を突き止めてDB側を直したはずが、それでも実トラフィックには反映されない。しかも rails console で確認すると「正しく直っている」ように見える。この一連の流れがなかなか厄介だったので、備忘も兼ねて残しておく。

何が起きていたか

Catalog::Item#visible は、アイテム作成の POST /catalog/items で本来 true として保存されるべきカラムだった。ところが本番でだけ、なぜか false で保存されている。ローカルやステージングでは再現しない。本番固有の現象だった。

コードを追うと、visible はどこでも明示的に代入していなかった。create! の時点で DBカラムのデフォルト値に完全に委ねる設計になっていた。つまりアプリのコードは初期表示状態について何も言っておらず、真実はDBのスキーマ側にしかなかった。

そして本番DBを見ると、犯人はすぐわかった。当該カラムのデフォルトが false になっていたのだ。ローカルの db/schema.rbtrue だったのに、本番の実DBだけ食い違っていた。

とりあえずDBのデフォルトを直した。が、直らない

原因がDBのデフォルト値なら、話は単純に見えた。デフォルトを true に変えればいい。

ALTER TABLE catalog_items
  ALTER COLUMN visible SET DEFAULT true;

実行してエラーもなし。これで新規作成分は true で入るはず、と思って実際にエンドポイントを叩いてみると、保存された値はやっぱり false

……直っていない。DBのデフォルトは確かに true に変わっているのに、アプリを通すと false で INSERT され続ける。ここでしばらく固まった。

なぜ再起動が必要だったのか

結論から言うと、ALTER TABLE ... SET DEFAULT を実行しただけでは、稼働中のECSタスクには効かない。タスクを入れ替えていなかったのが失敗だった。

理由は ActiveRecord のカラム情報キャッシュにある。仕組みを分解すると3つの事実が効いてくる。

1つめ。ActiveRecord はテーブルのカラム情報(columns_hash、ここにデフォルト値も含まれる)を、プロセス起動後に一度だけDBから読み込む。そして読み込んだあとは、プロセスが生きている間ずっとメモリにキャッシュし続ける。毎リクエストごとにスキーマを問い合わせたりはしない。

2つめ。だから起動済みのプロセスに対して裏で ALTER TABLE ... SET DEFAULT を打っても、そのプロセスがメモリに抱えているキャッシュは更新されない。DB側の実体とアプリ側の認識が乖離した状態になる。

3つめが地味に重要で、AR は create! のとき、自分が認識しているデフォルト値を明示的に INSERT 文へ書き込む。カラムを省略して「DB側のデフォルトに任せる」わけではない。ARが「このカラムのデフォルトは false だ」と記憶していれば、生成される SQL は INSERT ... visible = false になる。

この3つが噛み合うと何が起きるか。古いキャッシュ(false)を抱えたまま長時間動き続けている本番のWebタスクは、DB側のデフォルトを true に変えたあとも、visible = false を明示的に INSERT し続ける。DBのデフォルト値なんて参照する隙もなく、ARが記憶している古い値で上書きしていく。実トラフィックが直らなかったのはこれが理由だった。

いちばん紛らわしかったのは console

ここが今回の教訓の核心なので強調しておきたい。切り分けのために SSH でつないで rails console を開き、こう確認した。

Catalog::Item.column_defaults["visible"]
# => true
Catalog::Item.new.visible
# => true

どちらも true を返す。「ちゃんと直ってるじゃないか」と一瞬安心した。でも実エンドポイントを叩くと保存値は false のまま。console は正しいのに本番の挙動は間違っている、という気持ちの悪い状態だった。

種明かしをすると、この console は ALTER TABLE を実行したあとに起動した別プロセスだ。起動時にライブDBからカラム情報を読み直すので、当然、新しいデフォルト(true)を正しくキャッシュしている。一方、実トラフィックを捌いているWebタスクは変更前から動き続けている古いプロセスで、そちらは古いキャッシュ(false)を握ったまま。

console が正しいことと、実トラフィックを捌くWebタスクが正しいことは、まったく別の話だった。新しく開いた console はその瞬間のDBを反映するので、長時間稼働中のプロセスの状態を代弁してくれない。スキーマ変更の効果確認に新規 console を使ってはいけない、というのが今回いちばん刺さった学びだ。

正しい対応

やるべきことはシンプルで、DBスキーマ(デフォルト含む)を変えたら、全Webタスクを入れ替えてスキーマを読み直させる。

aws ecs update-service --cluster <cluster> --service <service> --force-new-deployment

--force-new-deployment で全タスクが置き換わり、新しく起動したプロセスが最新のスキーマをキャッシュする。ここで安心したいのは、イメージの再ビルドもコード変更も要らない点だ。リポジトリに schema_cache.yml.dump が無ければ、新タスクは起動時にライブDBを再イントロスペクトして正しいデフォルトを拾ってくる。同じイメージのまま force-new-deployment を打つだけでいい。

(逆に言うと、schema_cache をリポジトリに固めている構成なら、DBを直しただけでは足りず、そのキャッシュファイルの再生成とデプロイまで必要になる。自分たちの構成がどちらかは事前に把握しておきたい。)

そして検証は console ではなく実エンドポイント経由でやる。デプロイ後に1件だけ実際に作成し、保存された visibletrue になっていることを見て初めて「直った」と言える。console は別プロセスなので当てにならない。

再発防止として決めたこと

同じ落とし穴を二度踏まないために、ルールを言語化しておく。

本番DBのスキーマ変更、特に SET DEFAULT / change_column_default / add_column ... default: を実施したら、必ずアプリのプロセス(ECSタスク等)を再起動するか強制デプロイする。DBを触っただけで完了と思わない。

効果確認は稼働中のプロセス経由、つまり実リクエストで行う。新規 rails console は古いプロセスの状態を反映しないので、検証の道具にはしない。

もっと根っこの話をすると、重要な初期値をDBカラムのデフォルトだけに頼る設計そのものが環境差に弱い。今回のように環境ごとにスキーマがずれた瞬間、挙動が割れる。本当に重要な初期値なら、モデル側で明示してコードを唯一の真実にするほうが安全だ。

class Catalog::Item < ApplicationRecord
  attribute :visible, :boolean, default: true
end

こうしておけば、DBのデフォルトがどうであれアプリが true を明示的に INSERT する。before_validationafter_initialize で初期化する手もある。いずれにせよ、初期値の意図がコードに書いてあれば、DBスキーマの環境差に振り回されずに済む。

補足。既存データはどうなるか

最後に忘れがちな点をひとつ。SET DEFAULT はあくまで今後の新規行のデフォルトを変えるだけで、変更前に false で保存済みの行はそのまま残る。デフォルトを直しても過去のデータは遡って直らない。

なので、誤って false で入ってしまった既存データを補正するかどうかは、別途判断が必要になる。ここで注意したいのは、ユーザーが意図的に非表示にして false にしたケースと、バグで false になったケースが、値の上では区別できないこと。一括で true に上書きすると、正しく false にしていたユーザーのデータまで壊しかねない。作成日時やログと突き合わせて、バグの影響期間に作られた行だけを対象にするといった慎重さがいる。


振り返ると、DBを直した時点で満足してしまい、「アプリはDBを毎回見ているはず」という思い込みで詰まった。ActiveRecord がスキーマをキャッシュすること自体は知っていたのに、それが本番の実害と結びつくまでにひと手間かかった。スキーマ変更はDBとアプリプロセスの両方を揃えて初めて完了する、と体で覚えた一件だった。