Kubernetes環境でDBスキーマ変更を止めずに進める:ゼロダウンタイム移行の実践戦略

「カラムを追加するだけだから大丈夫」──この油断が、本番障害の入口になります。Kubernetes のように複数バージョンの Pod が同時に存在する環境では、DB スキーマ変更はアプリ変更よりも慎重に扱う必要があります。

本記事では、Expand-Contract パターンを中心に、ゼロダウンタイムを目指すための具体手順を解説します。実際の運用では、DDLの速さより「互換性のある期間をどう作るか」が勝負です。

1. なぜKubernetesでDB移行が難しいのか

Kubernetesでは、ローリングアップデート中に新旧Podが混在します。つまり次の状態が同時に発生します。

  • 新アプリは新スキーマを期待
  • 旧アプリは旧スキーマしか知らない
  • DBは1つしかない

このとき破綻するのが「破壊的変更を先に適用する」ケースです。たとえば旧カラムを即削除すると、旧Podがエラーを連発します。

2. 基本戦略:Expand → Migrate → Contract

ゼロダウンタイム移行の原則はこの3段階です。

  1. Expand: 互換性を壊さない変更を先に入れる(新カラム追加など)
  2. Migrate: アプリを段階的に切替え、データを移行する
  3. Contract: 旧仕様を最終削除する(十分な監視後)

この順序なら、どの時点でも旧新どちらのアプリも動作可能にできます。

3. 具体例:users.full_namefirst_name / last_name へ分割

3.1 Expand フェーズ

まず破壊的でないDDLを適用します。

1
2
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name text;

この時点で旧アプリは full_name を使い続けられます。新アプリは新カラムに対応した実装を持っていても、まだ必須にしません。

3.2 アプリを「両対応」にする

書き込み時は両方へ保存(dual write)し、読み込み時は新カラム優先 + 旧カラムフォールバックにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def save_user_name(user_id: str, full_name: str):
    first, last = split_name(full_name)
    db.execute(
        """
        UPDATE users
        SET full_name = %s,
            first_name = %s,
            last_name = %s
        WHERE id = %s
        """,
        (full_name, first, last, user_id),
    )


def read_user_display_name(row):
    if row.first_name and row.last_name:
        return f"{row.first_name} {row.last_name}"
    return row.full_name

この両対応期間を作るのが、ゼロダウンタイムの本質です。

3.3 バックフィル(既存データ移行)

大規模テーブルでは一括更新を避け、チャンク更新します。

1
2
3
4
5
6
7
-- 疑似コードイメージ
UPDATE users
SET first_name = split_part(full_name, ' ', 1),
    last_name = split_part(full_name, ' ', 2)
WHERE id > :last_id
  AND id <= :last_id + 10000
  AND (first_name IS NULL OR last_name IS NULL);

ジョブ実装時のポイント:

  • 1チャンクごとに commit
  • 再開可能なチェックポイント(last_id)を保存
  • 実行時間帯を制御(ピーク時間回避)

3.4 Contract フェーズ

全Podが新実装になり、フォールバックが不要と判断できたら旧カラム削除へ進みます。

1
ALTER TABLE users DROP COLUMN full_name;

削除は必ず最後です。ここを急ぐとロールバック不能になります。

4. マイグレーション実行方式の選び方

Kubernetesでは主に3パターンがあります。

A. CI/CDで先行実行(推奨)

デプロイ前に migration Job を走らせ、成功後にアプリを更新。

  • メリット: 実行順序を固定しやすい
  • デメリット: 長時間 migration の扱いが難しい

B. initContainer 実行

Pod起動時に migration を走らせる方式。

  • メリット: 実装が単純
  • デメリット: 複数Pod同時起動で競合しやすい

C. 専用 Migration Controller / Job

Argo Workflows などで明示的に管理。

  • メリット: 大規模運用で監査しやすい
  • デメリット: 初期構築コストが高い

中規模までなら A が最も事故が少ないです。

5. Alembic/Flyway運用の実務ポイント

5.1 1 migration = 1責務

「追加 + データ移行 + 削除」を1ファイルに詰め込むと失敗時に戻しづらくなります。Expand/Migrate/Contract を別 migration に分けて、各段階を検証可能にしてください。

5.2 lock timeout を設定する

DDL はロック待ちでアプリを止めることがあります。PostgreSQL なら lock_timeout を設定して、危険な待機を避けます。

1
2
SET lock_timeout = '5s';
ALTER TABLE ...;

失敗時は即中断し、メンテナンス時間帯に再実行する判断が可能です。

5.3 破壊的変更前に計測窓を設ける

旧カラム参照がゼロになったことを、メトリクスやログで一定期間確認してから削除します。目安として 7〜14日程度の観測期間を取ると安全です。

6. ロールバック戦略

ゼロダウンタイム設計では、ロールバック可能性を最初に決めます。

  • Expand段階: ほぼ即ロールバック可能
  • Migrate段階: dual write を維持していればロールバック可能
  • Contract段階: 削除後は復元コスト高(バックアップ依存)

したがって、Contract 実施前に以下を満たす必要があります。

  • 直近スナップショット取得済み
  • 復元手順を演習済み
  • 影響範囲(API・バッチ・BI)を棚卸し済み

7. 監視項目と成功判定

移行中は「成功したか」より「安全に進んでいるか」を見ます。

  • APIエラー率(4xx/5xx)
  • DBロック待ち時間
  • スロークエリ件数
  • migration ジョブ進捗(処理済み件数、残件数)
  • 旧カラム参照回数

成功判定の例:

  1. 新旧Pod混在中のエラー率に有意な悪化なし
  2. バックフィル完了率100%
  3. 旧カラム参照0が連続7日
  4. Contract後24時間で異常なし

8. よくある失敗と回避策

失敗1: 非NULL制約を早く付けすぎる

バックフィル完了前に NOT NULL を付けると、旧データで失敗します。まずは nullable で追加し、データ移行後に制約追加が正解です。

失敗2: インデックス作成で書き込み停止

大きいテーブルで通常インデックス作成を行うとロックが重くなります。PostgreSQL では CREATE INDEX CONCURRENTLY を使って影響を下げます。

失敗3: migration の実行主体が複数

同時に2つのJobが走ると競合します。Kubernetes Job は排他制御(Lease/Lock)を持たせるか、CI側で単一実行を保証してください。

9. 実運用テンプレート:Migration Job と段階リリース

最後に、現場でそのまま使える最小テンプレートを示します。ポイントは「DDLを一気にやらない」「アプリ側フラグで読取切替を制御する」の2点です。

9.1 Migration Job(Expand専用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
apiVersion: batch/v1
kind: Job
metadata:
  name: users-expand-20260305
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/example/api:1.42.0
          command: ["bash", "-lc", "alembic upgrade 20260305_expand_users_name"]
          envFrom:
            - secretRef:
                name: api-db-secret

Expand 用 Job を分離しておけば、失敗時にアプリデプロイを止める判断が明確になります。backoffLimit: 0 にして「失敗を隠さない」運用にするのも実務で有効です。

9.2 段階リリース手順(例)

  1. Expand Job 実行(DDLのみ)
  2. アプリ v1(dual write + fallback read)を10%配信
  3. エラー率・ロック待ちを30分監視
  4. 50% → 100%へ段階拡大
  5. バックフィル Job 実行
  6. 旧参照ゼロ確認後に Contract 実施

Kubernetes では kubectl rollout status deployment/api -n prod を必ず監視に組み込み、配信完了判定を人間が明示的に確認する運用が安全です。

まとめ

KubernetesでのDBマイグレーションは、DDLテクニックだけでは成功しません。重要なのは、

  • 互換性期間を意図的に作る
  • 段階を分けて進める
  • ロールバック可能性を先に設計する
  • 監視で「削除してよい」根拠を取る

この4点です。Expand-Contract を守るだけで、移行の失敗率は目に見えて下がります。スキーマ変更は怖い作業ですが、手順化すれば再現可能な運用にできます。次回の変更から、ぜひこの流れで試してみてください。