EcAuthDocs

マイグレーションのデプロイ順序

旧コードが変換後 DB に書き込む窓を塞ぐための順序ルール。マイグレーションの種別によって安全な順序が変わる

要点

production.yml / staging.yml のジョブ順は migratedeployverify である。この順序は追加のみのマイグレーションでは正しく、値の変換や削除では危険。 どちらに該当するかを、マイグレーションを書いた時点で判定して記録する。

staging はマージした時点で自動的に migrate される

staging.ymlmain への push で起動し、push には inputs が 無いため inputs.action || 'migrate-and-deploy'(33 行目)でフォールバックし、 (inputs.dry_run || false) != true(139 行目)が真になって実際に DB を更新する。 つまり staging では「マージ前に手動で deploy-only を挟む」ことができない

dry_run 既定 トリガー 手動で順序を制御できるか
staging.yml false main への push で自動 + workflow_dispatch できない(マージ時点で migrate される)
production.yml true workflow_dispatch のみ できる(dry_run=false の明示が要る)

したがって順序の担保はワークフローの叩き方ではなくリリースの切り方で行う

「常に deploy 先行」ではない

値の変換で事故が起きたからといって、すべてを deploy-only 先行にすると 逆向きに壊れる。新コードが必要とするテーブルがまだ存在しないためである (後述「実例 2」)。順序は種別で切り替える。

判定表

マイグレーション種別 安全な手順 理由
追加のみ
テーブル / カラム / 索引の追加、制約の緩和
1 リリースのまま migrate-and-deploy 旧コードは新構造を知らないので無害。新コードは新構造を必要とするので先に作る必要がある
値の変換
ハッシュ化、形式変更、既存カラムの書き換え
リリースを 3 段に分割(compat → transform → cleanup)。各リリースは migrate-and-deploy 旧コードが変換後 DB に旧形式を書き込む窓を塞ぐ。変換は旧形式対応の除去よりに置く
削除・リネーム
カラム / テーブル削除、リネーム
リリースを 3 段に分割(expand → transition → contract)。各リリースは migrate-and-deploy 旧コードが存在しない列を参照して落ちる。フォールバック実装も旧構造を参照するため除去リリースが要る
制約強化
UNIQUE 追加
リリースを 2 段に分割(compat → enforce)。重複解消と制約追加は enforce の同一マイグレーションに入れる(後述) 旧コードが違反データを書き得る。既存データの重複は順序では解決しない
backfill を伴うなら「追加のみ」ではない

構造を作るだけ(既存データを動かさない、または新コードがそれを正として読まない)なら 1 リリースで安全だが、新コードが新構造を正として読むなら話が変わる。 staging.yml は migrate → deploy 順なので、backfill 完了から新コードのロールアウト 完了までに旧インスタンスが旧構造へ作った行は新構造に載らない。この場合は二重書きと追いつき backfill が要る = 削除・リネームの 3 段の入り口(expand)として扱う。 EcAuth#521(b2b_user_identity の新設)がこれに当たる。

追加と削除を混在させない

混在するとどちらの順序も安全にならない

deploy-only 先行では新コードが必要とする追加分がまだ存在せず Invalid object name で落ちる。migrate-and-deploy では削除分が 旧インスタンスを壊す。migrate-only は未適用マイグレーションを全部 適用するため、片方だけ先に流すこともできない。

したがってリリースを分割する。

互換コードの merge とマイグレーションの merge を分ける。同じ PR に入れない。 どちらも 3 段になるが、マイグレーションを置く段が種別で違うので取り違えないこと。

削除・リネームの場合(旧構造の削除が最後)

リリース コード マイグレーション migrate 実行時に動いているコード
1(expand) 新構造へ二重書き + 読みは新旧両対応 新構造の追加 + backfill 旧コード。追加のみなので無害
2(transition) 旧構造への参照を完全に除去(ORM のマッピングを含む) 追いつき backfill(冪等) リリース 1 の互換コード。二重書きしている
3(contract) 変更なし 旧構造の削除 リリース 2 のコード。旧構造を一切参照しない
リリース 2 を省略できない

リリース 1 のコードは旧構造を読むフォールバックを持つため、そのまま旧構造を落とすと落ちる。 さらに EF Core はマップ済みプロパティをあらゆるクエリの SELECT に含めるので、 B2BUser.ExternalId のようなプロパティが残ったまま列を落とすと、フォールバック経路 だけでなくそのエンティティの全読み取りが失敗する(= B2B 機能の全面停止)。

リリース 1 の backfill だけでは取りこぼす

staging.yml は migrate → deploy 順なので、リリース 1 の backfill が終わった時点では まだ旧コードが動いている。backfill 完了から二重書きコードのロールアウト完了までに 旧インスタンスが作った行は、旧構造にしか存在しない。この行はリリース 2 で旧構造の参照を 外すと解決できなくなり、リリース 3 で復旧元まで消える。

そのためリリース 2 のマイグレーションに冪等な追いつき backfill を置く。 リリース 2 の migrate が走る時点で動いているのはリリース 1 の二重書きコードなので、これで 取りこぼしが閉じる。NOT EXISTS ガードを付けてリリース 1 と同じ SQL を再実行すればよい。

読み取り時の遅延移行(フォールバックで解決したついでに新構造へ書く)を実装していても、 その後ログインしたユーザーしか救済しないので代わりにはならない。EcAuth#521 は B2BPasskeyService にこの遅延移行を持つが、それとは別に追いつきが要る。

EcAuthDocs#110 が実際にこの形になっている。EcAuth#521 がリリース 1、 GetUnclaimedByExternalIdAsyncB2BUser.ExternalId の除去がリリース 2、 b2b_user.external_id の削除がリリース 3。

値の変換の場合(値を変換するマイグレーションが中間)

同一カラムの形式を変える場合(平文 → ハッシュ等)は、変換を旧形式対応の除去より前に置く

リリース コード マイグレーション migrate 実行時に動いているコード
1(compat) 新旧両形式を読め、新形式で書く 無し 旧コード。migrate は no-op
2(transform) 変更なし 既存値の変換 リリース 1 の互換コード。両形式を読める
3(cleanup) 旧形式の読み取りを除去 無し リリース 2 と同じコード。変換は完了済み
順序を削除・リネームと同じにしてはいけない

旧形式の読み取りを変換より先に外すと、未変換の既存行が残っている間ずっと解決に失敗する (認証が通らない)。削除・リネームではマイグレーションが最後だが、値の変換では中間である。

変換は旧形式の行だけを対象にする

リリース 1 が既に新形式で書いているため、リリース 2 の時点で同一カラムには 新旧が混在している。ここで全行を無条件に変換すると、リリース 1 が書いた行を 二重に変換して復元不能に壊す(ハッシュのハッシュ等)。変換 SQL は旧形式だけを 識別する述語(長さ・形式マーカー・TRY_CONVERT 等)を持つか、冪等 (新形式に適用しても値が変わらない)でなければならない。EcAuth#521 では二重ハッシュを 実際に踏んで修正している。

in-place 変換では二重書きができない

1 つのカラムに 2 形式は持てないため、リリース 1 のロールアウト中に新インスタンスが新形式で 書いた行を旧インスタンスが読めない窓が原理的に残る。この窓が許容できない場合は、 新カラムを追加して削除・リネームのパターンに落とし込む (新カラムへ二重書き → backfill → 参照切替 → 旧カラム削除)。

リリース N の deploy 完了までリリース N+1 をマージしない

staging.yml には run 間の concurrency が無く、deployneeds[build, migrate](自分の run 内のみ)なので、2 つの PR を続けて マージするとリリース N の deploy 完了前にリリース N+1 の migrate が始まりうる。 分割しただけでは安全にならない。ロールアウト確認まで済ませてから次をマージすること。

未適用マイグレーションを溜めないこと。複数溜まると種別が混ざり、この分割が 無意味になる。staging は main へのマージごとに自動適用されるので通常は溜まらないが、 production は手動 dispatch なので溜まりうる。

UNIQUE 追加時の重複検査

UNIQUE を追加するときは順序だけでは足りない。既存データに重複があると CREATE UNIQUE INDEX 自体が失敗し、migrate-only がそこで止まる。 デプロイ順序とは独立に、事前に実 SQL Server で重複を検査して解消しておく。

SELECT col_a, col_b, COUNT(*) AS dup
FROM dbo.some_table
GROUP BY col_a, col_b
HAVING COUNT(*) > 1;

EcAuth#521 では、b2b_user(organization_id, external_id) を UNIQUE へ 戻す Down がまさにこれで失敗することを実測している。

The CREATE UNIQUE INDEX statement terminated because a duplicate key was found
for the object name 'dbo.b2b_user' and the index name
'IX_b2b_user_organization_id_external_id'. The duplicate key value is (2, DUP-HASH).

この Down は非一意で再作成する形に変更した。一意性を緩和した索引を Down で UNIQUE へ戻す設計は、機能が使われた後にこそ失敗するので避ける。

重複解消だけを先に流してはいけない

旧コードが重複を書き続けている限り、解消しても制約追加までの間に再び作られてマイグレーションが 落ちる。逆に互換コードのロールアウト前に制約を張ると、旧インスタンスの通常リクエストが 一意制約違反で失敗する。どちらの片側だけでも安全にならない。

リリース コード マイグレーション migrate 実行時に動いているコード
1(compat) 重複を作らない(衝突を検出して弾く / 既存行を再利用する) 無し 旧コード。migrate は no-op
2(enforce) 変更なし 重複解消 + UNIQUE 追加(同一マイグレーション) リリース 1 のコード。新たな重複を作らない

重複解消と制約追加を別マイグレーションに分けない。分けると、その間に流れるリクエストが リリース 1 のコード経由であっても、解消済みの重複を作り直す余地を残すことになる。 なお解消 SQL は DML なので EXEC() でラップする。

なぜ効くのか

効いているのは順序そのものではなく、データが変わる瞬間に、両形式を扱えるコードだけが動いている という状態である。

sequenceDiagram
    autonumber
    participant W as ワークフロー
    participant A as App Service
    participant D as Azure SQL

    Note over W,D: 危険な順序(値の変換を migrate-and-deploy で流す)
    W->>D: migrate(値を新形式へ変換)
    Note over A: まだ旧コード(再起動ラグ)
    A->>D: 旧形式で書き込み ← 不整合が生まれる
    W->>A: deploy(新コードへ)

    Note over W,D: 安全な形(リリース分割・削除の例)
    W->>A: リリース1 deploy(二重書き + 読みは新旧両対応)
    Note over A,D: 混在期間。二重書きなので旧インスタンスも読める
    W->>W: ロールアウト確認してから次をマージ
    W->>A: リリース2 deploy(旧構造への参照を完全に除去)
    Note over A,D: 動いているコードは旧構造を一切参照しない
    W->>W: ロールアウト確認してから次をマージ
    W->>D: リリース3 migrate(旧構造を削除)
    Note over A,D: 参照するコードがもう居ないので安全

リリース 1 のデプロイ中は App Service の再起動ラグで旧インスタンスが残るが、 二重書きしているので旧インスタンスも新規データを読める。リリース 3 の migrate が走るときには 旧構造を参照するコードがもう動いていない。

手順

追加のみの場合

既定のまま 1 回の workflow_dispatch で流す。

action=migrate-and-deploy, dry_run=false

削除・リネームの場合

リリースを分割し、各リリースは既定のまま流す。次のリリースをマージする前に、 前のリリースの deploy とロールアウト確認を必ず終わらせる。

リリース 1(expand: 二重書き + 読みは新旧両対応)を main へマージ
    → staging が自動で migrate-and-deploy
    → production: action=migrate-and-deploy, dry_run=false
    → ロールアウト完了を確認 ★ここを待たずに次をマージしない

リリース 2(transition: 旧構造への参照を完全に除去)を main へマージ
    → staging が自動で migrate-and-deploy(migration は無し)
    → production: action=migrate-and-deploy, dry_run=false
    → ロールアウト完了を確認 ★

リリース 3(contract: 旧構造の削除)を main へマージ
    → staging が自動で migrate-and-deploy(旧構造を参照するコードはもう居ない)
    → production: action=migrate-and-deploy, dry_run=false

値の変換の場合

マイグレーションが 2 段目に来る点が上と違う。取り違えると未変換行が残っている 間ずっと解決に失敗する。

リリース 1(compat: 新旧両形式を読め、新形式で書く)を main へマージ
    → staging が自動で migrate-and-deploy(migration は無し)
    → production: action=migrate-and-deploy, dry_run=false
    → ロールアウト完了を確認 ★

リリース 2(transform: 既存値の変換)を main へマージ
    → staging が自動で migrate-and-deploy(両形式を読めるコードが動いている)
    → production: action=migrate-and-deploy, dry_run=false
    → 変換完了とロールアウトを確認 ★

リリース 3(cleanup: 旧形式の読み取りを除去)を main へマージ
    → staging が自動で migrate-and-deploy(migration は無し)
    → production: action=migrate-and-deploy, dry_run=false

production 限定の追加ガード(任意)

分割したうえで、production ではさらに 2 回に分けて dispatch できる。データを触る前に ロールアウトを目視確認したい場合に使う。staging では上記のとおり成立しない。

expand リリースには使えない

deploy-onlymigrate ジョブを skip して現在の HEAD をそのまま publish する(production.yml 31 行目)。expand のコードは新構造へ二重書きするため、 それを作る migration より先に公開すると Invalid object name で即座に失敗する。 適用してよいのはデプロイするコードが現行スキーマのままでも動くリリースだけ transition にも使えない。transition は追いつき backfill を持つため、 deploy-only にすると backfill より先に旧構造の参照を除去したコードが動き、 expand の窓でできた未移送の行を取得できなくなる。対象は contract / cleanup、および値の変換の compat・transform リリース (いずれもデプロイするコードが現行スキーマのままで動く)。

1) workflow_dispatch: action=deploy-only,  dry_run=false
2) ロールアウト完了を確認(下記)
3) workflow_dispatch: action=migrate-only, dry_run=false
ロールアウト完了の確認を省略しない

deploy ジョブの health check は /healthz が 200 を返すまで待つが、 これは新コードが応答することを示すだけで、旧インスタンスが残っていないことの 証明にはならない。スケールアウト構成では特に注意する。

確認の目安:

  • deploy ジョブが success で終わっている
  • Azure Portal で当該 App Service のインスタンスがすべて新リビジョンになっている
  • 起動ログ(AppServiceConsoleLogs)に新コード側の DbInitializer: Initialization completed が出ている

互換コードの設計

この方式は「新旧両対応コード」を一定期間生かす前提であり、 この互換コード自体がバグの温床になる。ただし表面積を絞りすぎても壊れる。

expand では二重書きが必須

リリース 1 のロールアウト中は新旧インスタンスが混在する。新インスタンスが新形式でしか 書かないと、その書き込みを旧インスタンスが読めず、ロールアウト中だけ失敗する。 EcAuth#521 の B2BUserService.CreateAsyncb2b_user.external_idb2b_user_identity の両方へ書いているのはこのため。二重書きの除去はリリース 2 で行う。

二重書きは同一トランザクションで行う

新旧 2 か所への書き込みが別々にコミットされると、後段が失敗したときに片方だけが残る。 片側だけ残った状態は、旧構造を読む旧インスタンスが同居するロールアウト中に実害になる (新コードは新しい値で解決するが、旧インスタンスは旧値のまま)。EcAuth#521 の B2BPasskeyService.SyncExternalIdIfChangedAsync は identity 側と旧カラム側をそれぞれ SaveChangesAsync していたため、EcAuth#528 でトランザクションに包んだ。 なおこの原子性は InMemory プロバイダーでは検証できない(トランザクションが no-op になる)。一意制約と同じく実 SQL Server でしか確認できない。

二重書きが生きている間は解決の一意性に注意する。旧構造は名前空間が粗いことが 多く、フォールバックを無条件に引くと別人に解決されうる。EcAuth#521 のレビュー指摘 4 件のうち 2 件がこれに該当した。

  1. Organization 単位のフォールバックが、別発行元のユーザーを返してしまう (そこへ identity を足すと別人が 1 つの subject に恒久統合される)
  2. 旧カラム側の衝突で 409 を返す際、先にコミット済みの identity 行が残り、 拒否したはずの subject に以降の解決が向く

フォールバック先は「まだ新構造で取得されていないレコード」に限定し、新構造への書き込みは 衝突判定を通してから行うこと。

実例

実例 1: 値の変換で事故が起きた(EcAuth#423)

B2BUser.external_id のハッシュ化対応。migrate 完了後〜アプリが新コードで 再起動完了するまでの窓で B2B パスキー登録リクエストが旧コードのインスタンスに処理され、 変換後 DB に plaintext の external_id が書き込まれた

実顧客は無傷で、影響は使い捨て E2E ユーザー 1 件のみだった(削除 + 再デプロイで解消)。 ただし本番でこれが実顧客に当たれば、管理画面ログイン不能に直結する。

実例 2: 追加のみを deploy 先行にすると壊れる(EcAuth#521)

b2b_user_identity テーブルの新設。これを deploy-only 先行でやっていたら 本番が落ちていた。

  • B2BUserService.CreateAsyncb2b_user_identity に INSERT する
  • B2BUserService.GetByIdentityAsync が同テーブルを SELECT する
  • SignupService も identity 行を作る

マイグレーション前にこのコードが動くと、B2B パスキー登録と申込のすべてが Invalid object name 'b2b_user_identity' で落ちる。

同じハザードは逆向きにも実在する。#521 では B2BPasskeySeederb2b_user_identity へ書くようになったのに RequiredMigration が 旧マイグレーション名のままで、「マイグレーションがデプロイに遅れた環境で表が無いまま INSERT に 到達し、起動初期化が落ちる」状態になっていた。シーダーが新しい表へ書くときは RequiredMigration をその表を作るマイグレーションへ更新する。

実例 3: 二重書きが生んだ不具合(EcAuth#521)

#521 は移行期間の安全網として b2b_user.external_id への書き込みを残した (二重書き)。レビューで見つかった 4 件の指摘のうち 2 件はこれに起因していた。

  1. 旧カラムを Organization 単位で無条件にフォールバック検索していたため、発行元 B の リクエストに発行元 A のユーザーが返り、別人が 1 つの b2b_subject へ 恒久統合されうる
  2. 旧カラム側の衝突で 409 を返す際、先にコミット済みの identity 行が残り、 拒否したはずの subject に以降の解決が向く

いずれも「旧カラムにも書き続けた」ことが前提の不具合で、読み取りだけの互換に留めていれば 発生しなかった。

チェックリスト

マイグレーションを含む PR を出すとき:

  • このマイグレーションは判定表のどの行か。PR 説明に明記したか
  • 値の変換 / 削除 / 制約強化なら、旧構造なしでも動くコードを別 PR で先にマージ したか(同じ PR に入れると staging がマージ時点で旧コード上に migrate してしまう)
  • expand なら二重書きしているか。フォールバック先を「新構造で未取得のレコード」に 限定できているか
  • 二重書きの新旧 2 か所を同一トランザクションで書いているか (別々にコミットすると片方だけ残る)
  • 削除・リネームなら、旧構造への参照(ORM のマッピングを含む)を除去するリリースを contract の前に用意したか
  • 削除・リネームなら、transition リリースに冪等な追いつき backfillを 含めたか(リリース 1 の backfill 後〜ロールアウト完了までの行を取りこぼすため)
  • 値の変換なら、変換 SQL が旧形式の行だけを対象にしているか、 または冪等か(リリース 1 が書いた新形式を再変換すると壊れる)
  • シーダーが新しい表へ書くなら RequiredMigration を更新したか
  • 実 SQL Server で検証したか(InMemory プロバイダーは一意インデックスを強制しないため、 制約まわりは実 DB でしか確認できない)

デプロイするとき:

  • 未適用マイグレーションを全部確認したか(前回デプロイから複数溜まっていることがある)
  • 1 本でも値の変換 / 削除 / 制約強化が混ざっていれば、前段のリリースが本番で稼働完了して いるかを確認したか(削除・リネームなら transition リリースまで済んでいるか)
  • 2 回 dispatch を使う場合、migrate-only の前にロールアウト完了を確認したか

残存リスク

この runbook は「分類し忘れ」を防げない。action の既定値は migrate-and-deploy であり、ルールが発動するのはデプロイする人が正しく分類したときだけ。 マイグレーションを書いた人とデプロイする人が別なとき、あるいは未適用マイグレーションが 複数溜まっているときに効いてくる。

機械的なガードを入れるなら、マイグレーションのファイル自身に種別を宣言させ、CI で宣言の 有無を検証し、migrate ジョブが未適用マイグレーションの宣言を読んで migrate-and-deploy を拒否する形が確実である。操作の内容から自動判定する (DropIndexmigrationBuilder.Sql の有無を見る)方式は採らないこと — EcAuth#521 は DropIndex(制約の緩和)と migrationBuilder.Sql(新表への backfill)の両方を含みながら実質は「追加のみ」であり、自動判定では誤って deploy 先行に 倒してしまう。種別は書いた人にしか判断できない。

関連

  • EcAuthDocs#94 データ変換マイグレーションのデプロイ順序(旧コード稼働窓の排除)
  • EcAuthDocs#110 B2B external_id を不変キー + 名前空間ベースの仕様に見直す
  • EcAuth#423 B2BUser.external_id のハッシュ化(実例 1)
  • EcAuth#521 B2B 識別子を b2b_user_identity へ分離(実例 2 / 3)