🪤

SWEが知っているべき、DBカラム変更時のダウンタイム回避プラクティス

に公開

DBスキーマのメンテナンスは開発部の責任

AI時代になってもSWE(人間)がやらなきゃいけないことって色々あるけど、一つはDBスキーマを含むアーキテクチャの適切なメンテナンスですよね。

システムが前提するビジネスロジックや、業務オペレーションは絶対に完成せず、日々変化します。
それに伴ってDBスキーマを適切に更新していなければ(=リファクタリングしていなければ)、オリジナルの意味の「技術的負債」となるでしょう。

https://t-wada.hatenablog.jp/entry/ward-explains-debt-metaphor

業務上の意味を示していないカラム名や、使用していないカラムが放置されていたりしませんよね?

メンテナンスされていないDBスキーマに困り果てる新規メンバー

その一方で、他のプログラミング上の知見と比べると、日常的に行われるべき「DBスキーマの変更」のプラクティスは、Web上にドキュメントとしてまとまっていないように思います。

もちろん、それぞれの企業が実践している手順は存在するのですが、その手順は入社して業務に携わらなければ分からない。

もしかするとこのあたりが(も)、いわゆる「未経験」のSWEと、大手Web系企業のエンジニアのスキル差に繋がっているのかもと思い、筆を執った次第です。

問題提起:strong_migrationを入れただけでは不十分

この領域は、Railsなら例えばstrong_migrationというgemが有名で、広く使われています。弊社でも採用してます。

https://github.com/ankane/strong_migrations

ただし、ドキュメントを読めば分かる通り、採用したとて「安全に処理を行い、安全を検証できたら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. 1回目のデプロイ:ignored_columnsを設定して、古いカラムへの参照を消したBEアプリケーションをデプロイ
  2. 2回目のデプロイ:カラムを削除するマイグレーションを実行、ignored_columnsを削除

という手順を踏むことで、カラムのリネーム・削除には対応することができます。

user.rb
class User < ApplicationRecord
  self.ignored_columns += [:old_column_name]
end

enumerate_columns_in_select_statementsの設定

前項はカラムのリネーム・削除についての手順ですが、Railsを通常通り使っているだけだと、実はカラムの追加においてもignored_columnsが必要な場合があります

簡単に言うと、Rails*PostgreSQLでは全カラムを取得しようとするが、Rails起動時にキャッシュしているカラム情報と、リクエスト時に取得したカラムが異なるとPreparedStatementCacheExpiredを吐くというもの。

https://tech.unifa-e.com/entry/2021/06/09/135506

上記記事が詳しくまとまっていますが、enumerate_columns_in_select_statementsを設定することでこの問題を回避することができます。

config/application.rb
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層

古いフィールド名でアクセスしてもアクセスできるようにする。

hoge_type.rb
class ObjectTypes::HogeType < BaseObject
- field :before_field, String
+ field :before_field, String, method: :after_field, deprecation_reason: "削除予定"
+ field :after_field, String
end

Controller層

古い引数を受け取っても、また、新しい引数がなくても、問題なく処理できるようにする。

update_hoge.rb
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_columnafter_columnにリネームしたいとしましょう。

1回目デプロイ

1-1. after_columnを追加するマイグレーション

db/migrate/20201212102345_add_after_column_into_hoges.rb
add_column :hoges, :after_column, :string, null: true, comment: '追加カラム'

1-2. BEにおけるignore_columnsの設定

app/models/hoge.rb
class Hoge < ApplicationRecord
+  self.ignored_columns += %w[before_column]
end

1-3. BEにおけるロジックの修正

app/models/hoge.rb
class Hoge < ApplicationRecord
  def some_method
-   "#{before_column}です"
+   "#{after_column}です"
  end
end

1-4. 境界面対応

app/graphql/object_types/hoge_type.rb
class ObjectTypes::HogeType < BaseObject
- field :before_column, String
+ field :before_column, String, method: :after_column, deprecation_reason: "削除予定"
+ field :after_column, String
end
app/graphql/mutations/update_hoge.rb
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_columnafter_columnにコピーする処理を実装。

2回目デプロイ

2-1. before_columnを削除するマイグレーション

safety_assuredはstrong_migrationを使っている場合。

db/migrate/20201212102345_remove_before_column_from_hoges.rb
safety_assured do
  remove_column :hoges, :before_column, :string, null: false, comment: '削除カラム'
end

2-2. BEにおけるignore_columnsの削除

app/models/hoge.rb
class Hoge < ApplicationRecord
-  self.ignored_columns += %w[before_column]
end

2-3. 境界面対応の削除

app/graphql/object_types/hoge_type.rb
class ObjectTypes::HogeType < BaseObject
- field :before_column, String, method: :after_field, deprecation_reason: "削除予定"
  field :after_column, String
end
app/graphql/mutations/update_hoge.rb
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スキーマは、あらゆる開発の土台です。適切にメンテしていきましょう。
ご安全に!

脚注
  1. やりたい処理と場合によるが、例えば10万〜100万レコード以上。なので意外とすぐに来る ↩︎

MyVision技術ブログ

Discussion