アプリとDBでプログレッシブは別物 — 各層の実測

トラフィックは割合で割れるがスキーマは1つしかない。アプリ層(ECS ネイティブ B/G・promotion gate・canary: 失敗 0/1679 vs 69/2880)とDB層(expand/contract・INSTANT DDL判定・COPY 2M行56秒・fast clone)それぞれの仕組みと実測、そして両層の噛み合わせ(旧バイナリ×新スキーマ)を1本で。

はじめに

前編で、プログレッシブデリバリーを「置く・当てる・見せる」に分解しました。この記事はその実装編で、アプリ層と DB 層ではプログレッシブの意味がまったく違う、という話をします。

前半でアプリ層、後半で DB 層、最後に両者の噛み合わせを、それぞれ実測値つきで書きます。検証環境は Go + ECS Fargate + ALB + MySQL/Aurora です。

アプリ層: トラフィックを空間で割る

仕組み — ECS がロードバランサを掛け替える

CodeDeploy を使わない ECS ネイティブのデプロイ(2025-10 GA)は、ALB のリスナールールの forward 先を、ECS 自身が2枚のターゲットグループ(TG)の間で掛け替えることで成立します。

ALB

切替/カナリアで掛け替え

TEST_TRAFFIC_SHIFT 中

ルールを書き換え

ルールを書き換え

フック呼び出し

ALARM でロールバック

本番リスナー :80

テストリスナー :8081

TG blue → 旧タスク

TG green → 新タスク

ECS デプロイ制御

Lambda フック

CloudWatch アラーム

load_balancer {
  target_group_arn = aws_lb_target_group.photo.arn        # 本番用
  advanced_configuration {
    alternate_target_group_arn = aws_lb_target_group.photo_alt.arn # 2枚目
    production_listener_rule   = aws_lb_listener_rule.photo.arn    # default_action 不可。明示ルール必須
    role_arn                   = aws_iam_role.ecs_infra.arn        # ECS が ELB を操作するロール
  }
}
deployment_configuration {
  strategy             = "BLUE_GREEN"   # CANARY / LINEAR は別 strategy
  bake_time_in_minutes = 2
}

測る前提として、「どの版が応答したか」を外から見る観測口が要ります(同じイメージの新旧タスクは応答から区別できない)。全応答に X-Service-Revision ヘッダを載せる数行を、認証より外側に入れました。これが無いと検証は「重みを設定した」で止まります。

実測 — 同じ壊れた版を3つの防衛層で

切替の無停止は確認済み(0.5秒間隔の可用性ループで、B/G 切替をまたぐ 503 リクエスト非200 ゼロ。カナリアでは 200/800 = 20% の重みを ALB 上で観測)。その上で、同じ壊れた版(healthz は正常・書き込みだけ 500)を3つの層でデプロイして失敗数を測りました。

① promotion gate — 失敗 0 / 1,679。 TEST_TRAFFIC_SHIFT 段階では本番トラフィックを1件も流す前に、テストリスナー経由で新版にテストトラフィックが当たります。そこに Lambda フックを置く:

# healthz だけでは「書き込みのみ壊れる版」を素通しする。書き込みまでやる
def check():
    for _ in range(3):
        urlopen(base + "/healthz")     # 到達性(base はテストリスナー)
    POST(base + "/v2/photos", ...)     # 書き込み(DB 経路まで通す)

def handler(event, context):
    return {"hookStatus": "SUCCEEDED" if check() else "FAILED"}

壊れた版はフックの書き込み検証で捕捉され(verdict: create 500)、103秒で自動ロールバック。デプロイ全体をまたいで流し続けた実 API 負荷 1,679 リクエストはエラー0。ゲートの価値は検証の深さで決まります — healthz だけ見るフックならこの版は素通りしていた。

② カナリア + アラーム — 失敗 69 / 2,880(2.4%)。 ゲートを外して(テストで検知できない壊れ方の代役)CANARY 20% + ALB 5xx アラーム連動で流すと、発火と同刻にロールバック開始、4分50秒で復旧。失敗 69 件の正体は「メトリクス集計60秒 + 計測遅延」という検知の窓そのものです。

"deploymentConfiguration": {
  "strategy": "CANARY",
  "canaryConfiguration": { "canaryPercent": 20.0, "canaryBakeTimeInMinutes": 3 },
  "alarms": { "alarmNames": ["target-5xx"], "enable": true, "rollback": true }
}

③ stopDeployment — 32秒。 bake 中に aws ecs stop-service-deployment --stop-type ROLLBACK を1発で、本番の応答が旧版に戻るまで32秒。bake 中は旧タスクが生きているので、戻すのは TG の向き先変更だけ。bake はロールバック先を温存する保険料です。

アプリ層のまとめ: 3層は直列で、テストで落とせる壊れ方は本番に触らせず(0/1,679)、抜けたものをカナリアが 2.4% に閉じ、それも抜けたら32秒で戻す。

DB 層: スキーマを時間で分ける

なぜ割合で割れないか

上の全部 — B/G の並走、canary の窓、bake — は新旧のバイナリが同じ DB に同時接続する時間を作ります。スキーマは1つ。つまりアプリ層のプログレッシブは、「新旧どちらのバイナリでも動くスキーマ」という前提の上にしか成立しません。

そこで DB のマイグレーションを expand / contract の2系統に分けます。

リリースN+1以降

リリースN

expand

互換を保って広げる

(列追加は NULL/既定値つき)

アプリのデプロイ

新旧同時稼働の窓

contract

旧が消えてから縮める

(列削除・制約強化)

「旧バイナリが expand 済みスキーマで本当に動くのか」は実測で確認しました。列追加「前」のコミットからビルドした旧イメージを、追加「後」のスキーマに接続 → 書き込み 201・一覧 200・新列は既定値の NULL。互換が守られている限り、アプリ側は何段構えのデプロイでもできます。

expand に置いてよい DDL を機械で縛る

expand が「互換を保つ」だけでは足りず、オンラインで安全に流せる必要もあります。MySQL の ALTER TABLE には3方式あり、影響が桁で違います。

方式何をするか所要
INSTANTメタデータのみ数十 ms、行数非依存
INPLACE再構築するが並行 DML は概ね可行数に比例
COPYテーブル丸ごと作り直し行数に比例して長い + DML 制限

指定しなければ MySQL は通る方式へ黙って落とします。軽いつもりの DDL が COPY で流れても「成功」する — だから ALGORITHM=INSTANT を明示する規約にし、CI が検査します。明示すれば通らない方式は ERROR 1845 で失敗し、判定そのものが結果として返る。

この判定は手元の MySQL 8.4 と Aurora(8.4互換版)で1行も違いませんでした。COPY にしかならない DDL(ADD CHECK 等)は 2M 行で 56秒 — 1億行なら45分超の見積もりで、これはデプロイ戦略でなく計画(gh-ost / 保守時間)の問題です。その計画に使えるのが Aurora fast clone: copy-on-write なのでデータ量に依存せず約5分半で本番同等の使い捨て DB が出ます。デプロイの部品ではなく、COPY 系 DDL のリハーサル用です。

読み替えはフラグ、contract はフラグ 100% の後

expand で列を足しただけでは何も変わりません。新しい列を読む側の切り替えはデプロイでなくフィーチャーフラグでやります(切替は実測 14 秒・再起動なし)。切替に失敗してもフラグを戻すだけで、デプロイもマイグレーションも巻き戻さない。フラグが 100% に到達して旧経路のコードを消した後、はじめて contract(旧カラムの削除)を実行します。読みと書きがコード上で分かれていると、二重書きと読み切替を独立に進められます(CQS がスキーマを動かすときに効く)。

実例: カラム改名を3リリースに割って無停止で通す

部品を並べただけでは絵空事なので、カラム改名を止めずに通した実測を置きます。RENAME COLUMN 一撃は互換の窓を壊す(旧バイナリが旧名で読み書きした瞬間に死ぬ)ので、3リリースに割ります。

リリース3

リリース2

間

リリース1

expand: ADD COLUMN 新カラム

(NULL 許容 = INSTANT)

アプリ: 二重書き

(旧・新の両方へ。読みは旧のまま)

バックフィル(別ジョブ。

デプロイに同梱しない)

読みをフラグで新カラムへ段階切替

(戻すのもフラグだけ)

feature flag 100% → 旧経路コード削除

→ contract: DROP COLUMN 旧

割り方の理由: 二重書きが先でないとバックフィル済みの行に差分が生まれ続ける/バックフィル(行数比例)をリリースのクリティカルパスに置かない/読み切替の失敗をフラグ1本で戻せる形にする/contract は旧経路が消えた後でのみ安全。負荷を流し続けながらの通し実行でリクエストエラー 0 件・新旧カラムの一致率 100% でした。この型(Parallel Change)の深掘りは無停止移行の記事へ。

噛み合わせ: 順序も機械で守る

2つの層の接続部も実測しました。

つまり「DB は互換を保って先に広げ、アプリは何段構えでも安全に切り替え、失敗はどの層でも縛った範囲に閉じる」という全体像が、両層の実測でつながります。

踏んだ罠

まとめ

層プログレッシブの形鍵の実測
アプリトラフィックを空間で割る(promotion gate → canary → bake)失敗 0/1,679 vs 69/2,880、stopDeployment 32秒
DBスキーマを時間で分ける(expand → 同時稼働窓 → contract)旧バイナリ×新スキーマが正常動作、INSTANT 判定は Aurora でも一致、COPY 2M行56秒
読み替えフラグで切替、contract はフラグ 100% の後切替 14 秒。改名一式エラー 0 件で完走
接続部順序は実行機構で守るmigrate 失敗 → release 不開始

一言でまとめると: アプリは割合で出す。DB は段階的には出せないから、互換の窓で参加し、窓に置けるもの(INSTANT)を機械で選別し、置けないもの(COPY)は窓の外で計画する。

では、これらの部品をどの変更にどう組み合わせ、誰が判断するのか — 選び方、そして選ばせない運用に続きます。

参考

← Back to all posts