SWEが知っているべき、DBカラム変更時のダウンタイム回避プラクティス
DBスキーマのメンテナンスは開発部の責任
AI時代になってもSWE(人間)がやらなきゃいけないことって色々あるけど、一つはDBスキーマを含むアーキテクチャの適切なメンテナンスですよね。
システムが前提するビジネスロジックや、業務オペレーションは絶対に完成せず、日々変化します。
それに伴ってDBスキーマを適切に更新していなければ(=リファクタリングしていなければ)、オリジナルの意味の「技術的負債」となるでしょう。
業務上の意味を示していないカラム名や、使用していないカラムが放置されていたりしませんよね?

その一方で、他のプログラミング上の知見と比べると、日常的に行われるべき「DBスキーマの変更」のプラクティスは、Web上にドキュメントとしてまとまっていないように思います。
もちろん、それぞれの企業が実践している手順は存在するのですが、その手順は入社して業務に携わらなければ分からない。
もしかするとこのあたりが(も)、いわゆる「未経験」のSWEと、大手Web系企業のエンジニアのスキル差に繋がっているのかもと思い、筆を執った次第です。
問題提起:strong_migrationを入れただけでは不十分
この領域は、Railsなら例えばstrong_migrationというgemが有名で、広く使われています。弊社でも採用してます。
ただし、ドキュメントを読めば分かる通り、採用したとて「安全に処理を行い、安全を検証できたらsafety_assuredで囲んでマイグレーション」という流れになるんですよね。
class RemoveColumn < ActiveRecord::Migration[8.1]
def change
safety_assured { remove_column :users, :name }
end
end
つまり、結局は開発チームを構成する各メンバーが
- この手順はどういう問題に対処しているのか?
- なぜこの手順を踏めば安全なのか?
を理解していないと、いつsafety_assuredを使って良いのか分からず、安全でないままデプロイが進行していくことになります。
DBカラム変更時に起こり得る不具合
大きく分けると、以下の2種類が考えられます:
1. DB負荷によるもの
レコード数が多いテーブル[1]へのマイグレーションで、実行時間が極端に長くなったり、CPU/メモリ使用率が高くなって、アプリケーションに影響が出るパターン。
ローカル環境や、データをマスクしたQA環境などでは問題なく動作することもあり、本番デプロイ時に初めて問題が発覚する、という地獄を見ることもある。
こちらも無視できない問題ですが、今回は紙面の都合上割愛します。
2. アプリケーション整合性によるもの
BE/FEのアプリケーションコードが、古いカラムを参照している場合に発生する不具合。
今回の記事では、こちらに焦点を当てて解説します。
アプリケーション整合性を考慮する
気にすることは、古いバージョンの「外側」から、新しいバージョンの「内側」にアクセスしても問題ないか? ということに尽きます。
前提
本記事では、以下のような構成を前提とします。
[Database] → [Backend] → [Frontend]
- 一旦バックエンドはRuby on Railsを想定しますが、他ORMでも同様かと思われます。
- フロントエンドは、バックエンドと処理が分離しています。Railsのviewでも、Railsとは別にホストされたReactアプリでも、モバイルアプリやブラウザ拡張機能でも同様です。
- デプロイはDB→BE→FEの順に(=つまり内側から外側へ)、それぞれのデプロイが完全に完了したら次のデプロイに進む、という流れで行います。
DB/BE間の整合性
古いBEアプリから、新しいDBスキーマを参照しても、問題なく動作する必要があります。
デプロイの分割、ignored_columnsの設定
strong_migrationで考慮されている問題ですが、
- 1回目のデプロイ:
ignored_columnsを設定して、古いカラムへの参照を消したBEアプリケーションをデプロイ - 2回目のデプロイ:カラムを削除するマイグレーションを実行、
ignored_columnsを削除
という手順を踏むことで、カラムのリネーム・削除には対応することができます。
class User < ApplicationRecord
self.ignored_columns += [:old_column_name]
end
enumerate_columns_in_select_statementsの設定
前項はカラムのリネーム・削除についての手順ですが、Railsを通常通り使っているだけだと、実はカラムの追加においてもignored_columnsが必要な場合があります。
簡単に言うと、Rails*PostgreSQLでは全カラムを取得しようとするが、Rails起動時にキャッシュしているカラム情報と、リクエスト時に取得したカラムが異なるとPreparedStatementCacheExpiredを吐くというもの。
上記記事が詳しくまとまっていますが、enumerate_columns_in_select_statementsを設定することでこの問題を回避することができます。
config.active_record.enumerate_columns_in_select_statements = true
この変更は7系で追加されたのですが、「ほんの最近まで、RoRという、業界で最高水準に枯れたORMでさえ、カラム追加という日常的なマイグレーションすらも、気軽には行えなかった」 という事実は、本番アプリケーションにおけるデプロイフローの難しさを実感させます。
BE/FE間の整合性
古いFEアプリから、新しいBEアプリを参照しても、問題なく動作する必要があります。
DB/BE間と違って、「内側であるBEもビジネスロジックを抱えている」という問題が発生します。
このBE/FEの変換処理は、「BEとFEの境界面(View層やController層の引数)」に集約させることになるでしょう。

以下、graphql-rubyを使用している場合で解説しますが、他のツールを使っていても同様です。
View層
古いフィールド名でアクセスしてもアクセスできるようにする。
class ObjectTypes::HogeType < BaseObject
- field :before_field, String
+ field :before_field, String, method: :after_field, deprecation_reason: "削除予定"
+ field :after_field, String
end
Controller層
古い引数を受け取っても、また、新しい引数がなくても、問題なく処理できるようにする。
class Mutations::UpdateHoge < BaseMutation
- argument :before_argument, String, required: true
+ argument :before_argument, String, required: false, deprecation_reason: "削除予定"
+ argument :after_argument, String, required: false, comment: "後ほどrequired: trueに変更"
argument :unrelated_argument, String, required: true
def resolve
+ new_params = params.except(:before_argument, :after_argument)
+ if params.key?(:before_argument) || params.key?(:after_argument)
+ new_params.merge!(after_argument: params[:before_argument] || params[:after_argument])
+ end
+ Hoge.update!(new_params)
- Hoge.update!(params)
end
end
実際にカラムをリネームする例
Hogeというモデルのbefore_columnをafter_columnにリネームしたいとしましょう。
1回目デプロイ
1-1. after_columnを追加するマイグレーション
add_column :hoges, :after_column, :string, null: true, comment: '追加カラム'
1-2. BEにおけるignore_columnsの設定
class Hoge < ApplicationRecord
+ self.ignored_columns += %w[before_column]
end
1-3. BEにおけるロジックの修正
class Hoge < ApplicationRecord
def some_method
- "#{before_column}です"
+ "#{after_column}です"
end
end
1-4. 境界面対応
class ObjectTypes::HogeType < BaseObject
- field :before_column, String
+ field :before_column, String, method: :after_column, deprecation_reason: "削除予定"
+ field :after_column, String
end
class Mutations::UpdateHoge < BaseMutation
- argument :before_column, String, required: true
+ argument :before_column, String, required: false, deprecation_reason: "削除予定"
+ argument :after_column, String, required: false, comment: "後ほどrequired: trueに変更"
def resolve
+ new_params = params.except(:before_column, :after_column)
+ if params.key?(:before_column) || params.key?(:after_column)
+ new_params.merge!(after_column: params[:before_column] || params[:after_column])
+ end
+ Hoge.update!(new_params)
- Hoge.update!(params)
end
end
1-5. FEの修正
削除予定のbefore_columnへの参照を削除し、after_columnへの参照に置き換える。
1-6. データ移行バッチなど
バッチファイルなどで、before_columnをafter_columnにコピーする処理を実装。
2回目デプロイ
2-1. before_columnを削除するマイグレーション
safety_assuredはstrong_migrationを使っている場合。
safety_assured do
remove_column :hoges, :before_column, :string, null: false, comment: '削除カラム'
end
2-2. BEにおけるignore_columnsの削除
class Hoge < ApplicationRecord
- self.ignored_columns += %w[before_column]
end
2-3. 境界面対応の削除
class ObjectTypes::HogeType < BaseObject
- field :before_column, String, method: :after_field, deprecation_reason: "削除予定"
field :after_column, String
end
class Mutations::UpdateHoge < BaseMutation
- argument :before_column, String, required: false, deprecation_reason: "削除予定"
- argument :after_column, String, required: false, comment: "後ほどrequired: trueに変更"
+ argument :after_column, String, required: true
def resolve
- new_params = params.except(:before_column, :after_column)
- if params.key?(:before_column) || params.key?(:after_column)
- new_params.merge!(after_column: params[:before_column] || params[:after_column])
- end
- Hoge.update!(new_params)
+ Hoge.update!(params)
end
end
結び
業務上の実態に沿ったDBスキーマは、あらゆる開発の土台です。適切にメンテしていきましょう。
ご安全に!
-
やりたい処理と場合によるが、例えば10万〜100万レコード以上。なので意外とすぐに来る ↩︎
株式会社MyVision開発部のテックブログです! 採用情報はこちら corporate.my-vision.co.jp/engineering-careers
Discussion