この記事でできること
稼働中のサービスでDBのカラムを別のものへ置き換えるとき、いきなり入れ替えるとデプロイ中に新旧のプロセスが混在して壊れます。そのため「新しいカラムを足して両方書く(expand)」→「古いカラムへの依存を消して削除する(contract)」の二段階で進めるのが定番です。
この進め方は、expandを終えた時点で移行が止まりやすいという弱点があります。新しいカラムで動いていて、テストも通り、利用者から見た挙動も変わらないため、止まっていること自体が誰にも通知されないからです。
この記事は、expandで止めずにcontractまで到達するための、着手前に決めておくことと実行時の順序をまとめたものです。個人開発している技術記事キュレーションサービスDevPickで、認証トークンのハッシュ化移行をexpandのまま放置した経験がもとになっています。
1. 着手前に、contractの完了条件を「消えている状態」で書き出す
expandを始める前に、移行の完了条件を文章で残します。このとき「新しいカラムで認証できる」のような動くことを条件にすると、expandの完了と区別がつきません。次のように、消えていることで書きます。
- 旧カラムがスキーマに存在しない
- 旧カラムへ書き込むコードが0箇所
- 新旧を分岐する互換コードが0箇所
- クライアント側の旧経路が0箇所
コードコメントに「contractリリース後に削除する」と書くのは、完了条件の代わりにはなりません。コメントは検索しないと出てこないうえ、期限も担当も持たないためです。issueなど、着手待ちの一覧に載る場所へ置きます。
2. 「まだ終わっていない」を機械的に検出できるようにする
移行が止まっていることに気づく手段を、記憶以外に用意します。expand期間が数日で終わる想定でも、必ず用意します。
- 旧カラムを参照・書き込みしているコードを検出する。ORMの属性名やカラム名でgrepし、CIのチェックに入れておくと期限を過ぎた放置に気づける
- 旧カラムに値が入っている行数を数えるクエリを用意する。expandが正しく動いていれば新規行が増え続けるはずなので、この件数が減らないこと自体が「contractへ進んでいない」証拠になる
とくに移行の目的がセキュリティ改善(平文をハッシュへ、など)の場合、expandの段階では新旧が同じ行に併存するため、改善の効果はまだ出ていません。「対応済み」として扱わないよう、検出手段は着手時に作っておきます。
3. expand期間の互換経路は、読み取りだけに留める
expand期間中は「古いカラムでも認証・参照できる」互換経路を入れます。ここで、読み取りのついでに新しいカラムを埋める遅延移行を入れないことをおすすめします。
読み取り処理に書き込みが混ざると、同じ行に同時アクセスが来たときの競合を自前で捌く必要が出ます。片方が更新に成功し、もう片方は更新対象が0件になってやり直す、という制御が必要になり、リトライで収束しなかったときの扱いを間違えると、有効なリクエストに「該当なし」を返すことになります。認証まわりであれば、正規の利用者にエラーを返す経路がここで生まれます。
移行そのものは、アクセスに便乗させず、定期実行のバッチで一括して進めます。読み取り側は「新しい方で探し、無ければ古い方で探す」だけにします。競合制御が不要になり、進捗も件数で確認できます。
4. contractのマイグレーションは「バックフィル → 削除 → NOT NULL化」の順に、1つにまとめる
古いカラムを落とす前に、新しいカラムがまだ空(NULL)の行が残っていないか確認します。expand期間中に旧バージョンのプロセスが作った行が該当します。
順序が重要です。ハッシュ値のように元の値からしか作れないデータの場合、先に古いカラムを消すと埋める材料が永久に失われます。
- 新しいカラムがNULLの行を、古いカラムの値からバックフィルする
- 古いカラムを削除する
- 新しいカラムをNOT NULLにする
この3つは同じマイグレーションの中で実行します。別々のマイグレーションに分けると、1だけ適用された状態や、2だけ適用された状態が生まれ得ます。
5. クライアント側の旧経路は「送るのをやめる」が先、「受け付けるのをやめる」が後
移行がサーバー内で完結せず、クライアント側にも旧経路がある場合(古い独自ヘッダーを送る、ブラウザに保存した値を送る、など)は、撤去の順序で利用者が締め出されます。
クライアントが旧経路を送るのをやめるリリースを先に出し、サーバーが旧経路を受け付けるのをやめるリリースを後に出します。 逆順にすると、古い画面を開いたままの利用者や、新しい経路(Cookieなど)をまだ持っていない利用者が、その瞬間に認証を失います。
この2つは同じリリースにまとめません。あいだに、利用者が新しい経路を確実に持つまでの期間を置きます。Cookieのように認証成功時に張り直される仕組みであれば、その期間中に自然に移行が完了します。
まとめ
- 完了条件は「新しい方が動く」ではなく「旧カラム・互換コードが0箇所」で書き、コードコメントではなくissueに置く
- 旧カラムの参照をgrepで検出できるようにし、旧カラムに値が入っている行数を数えて、止まっていることに気づけるようにする
- expand期間の互換経路は読み取りだけにする。移行は定期実行のバッチで進め、読み取り中の書き込みによる競合制御を持ち込まない
- contractは「バックフィル → 削除 → NOT NULL化」を1つのマイグレーションでこの順に
- クライアント側は「送るのをやめる」が先、「受け付けるのをやめる」が後。同じリリースにまとめない
この手順に行き着くまでに何を間違えたか(なぜexpandで止まったのか、なぜ遅延移行を選んだのか)は、Zenn版に書きました。