ECSのネイティブBlue/Greenが登場したので検証!フック・Dark Canary・コントローラ更新も強力
日本時間2025年7月18日、ECSのデプロイが大幅に強化されたので、試しつつ情報を整理してみました。単純にBlue/Greenがネイティブで可能になっただけではありませんでした。
先に要点
- CodeDeployを使わずとも、ネイティブでBlue/Greenデプロイが可能になった
- lifecycle hooksによって、様々なタイミングでLambdaによる検証も可能
 - Test listener/listener ruleによって、エンドユーザ影響ゼロで本番環境での事前検証も可能(Dark Canary)
 
 - Service ConnectでもB/Gが可能に
 - デプロイコントローラをサービス作成後に変更可能に
 - CodeDeployによるB/Gは今後避けた方がよさそう。移行ガイドもあり
 
アップデートの概要
CodeDeployを用いずともECSの組み込み機能としてBlue/Greenデプロイが可能になりました。
コンソールでも選択可能になっています。

このB/Gデプロイではオプションとして以下2つの機能も追加され、自動かつ安全なデプロイを実現しやすそうです。いずれもCodeDeployで類似機能があります。
- Deployment lifecycle hooks によるカスタム検証
 - Test listener / listener rule による Dark Canary
 
また、「デプロイコントローラをサービス作成後に変更可能になった」という地味に大きなアップデートもあります。
従来
ECSでBlue/Greenデプロイを行う際にはCodeDeployを組み合わせることが一般的でしたが、以下のような辛みがありました。
- CodeDeployの事前準備が必要で面倒
 - サービス作成後にローリングアップデート↔︎CodeDeploy B/Gの変更ができなかった
 - CodeDeployと組み合わせた際には諸制約があった
- 例: Service Connectを利用できない
 
 
また、ローリングアップデートでも以下のような課題がありました。
- 成否判定の柔軟さがあまり高くない(CloudWatchアラームやデプロイメントサーキットブレーカーがあるとはいえ)
 - ロールバックに時間がかかる(タスクを新しく起動し直すため)
 
今回のアップデートの嬉しさ
- CodeDeployの準備不要で、簡単にBlue/Greenデプロイが可能に
 - 
サービス作成後でもローリングアップデート↔︎B/Gの変更が可能
- 基本は
strategyを変更するだけ。サービス再作成&移行は不要 - 「最初はシンプルにローリングアップデートで、必要になったらB/Gに変更」が簡単に
 - 移行方法の詳細はこちらのDocs
- B/G→ローリングアップデートの方法も記載されているため、ローリングアップデートが非推奨というわけではなさそう
 
 
 - 基本は
 - CodeDeployにあった諸機能が利用できるので、便利 + CodeDeployから移行しやすそう
- Lambdaで柔軟な検証を行える
 - エンドユーザ影響ゼロで新しいバージョンの検証を本番環境で行える
 
 - ネイティブ機能ということもあり、CodeDeploy連携に比べて今後も制約が少ないと思われる
 - 
Service ConnectでBlue/Greenデプロイが可能に
- Service Connectのネックが一つ排除された上に、メンテされている証でもあるので、Service Connectを選びやすくなったと思います
 
 
アップデートの詳細
Deployment lifecycle hooks
ネイティブBlue/Greenにおいて、デプロイ中の各タイミングでLambda関数を呼び出してデプロイ成否をカスタムロジックで検証できます。
例えば、サービスのステータス監視、エンドポイントへのアクセス、テレメトリデータの監視などを行います。
これは CodeDeployのhooks機能 に似ています。
lifecycle stages
7種類のフックタイミング(lifecycle stages)があります。
- 
PRE_SCALE_UP: 新しいタスクが起動する前 - 
POST_SCALE_UP: 新しいタスクが起動されHealthyになった後 - 
TEST_TRAFFIC_SHIFT: テスト用トラフィックをGreen環境に0->100%流している最中 - 
POST_TEST_TRAFFIC_SHIFT: テスト用トラフィックをGreen環境が100%受けるようになった後 - 
PRODUCTION_TRAFFIC_SHIFT: 本番用トラフィックをGreen環境に切り替えている最中 - 
POST_PRODUCTION_TRAFFIC_SHIFT: 本番用トラフィックをGreen環境に切り替え完了した後 - 
RECONCILE_SERVICE: ACTIVEのサービスリビジョンが複数存在する状態でデプロイが開始される時- コンソールでは選択できず、CLIでは選択可能のもよう。正直よく分からず。
 
 
ロールバック発生時にはTEST_TRAFFIC_SHIFTとPRODUCTION_TRAFFIC_SHIFTがフックされます。
イベントのペイロード
イベントのペイロードとしてにはサービスのARNやWeightの情報が含まれるので、それらに応じた検証ロジックを組めます。
例:
Event: 
{
    "executionDetails": {
        "testTrafficWeights": {},
        "productionTrafficWeights": {
            "arn:aws:ecs:ap-northeast-1:<account-id>:service-revision/my-cluster/native-bg-1/9942985458929989075": 0,
            "arn:aws:ecs:ap-northeast-1:<account-id>:service-revision/my-cluster/native-bg-1/2948000638822554633": 100
        },
        "serviceArn": "arn:aws:ecs:ap-northeast-1:<account-id>:service/my-cluster/native-bg-1",
        "targetServiceRevisionArn": "arn:aws:ecs:ap-northeast-1:<account-id>:service-revision/my-cluster/native-bg-1/2948000638822554633"
    },
    "executionId": "06a4bc13-a7fa-4281-ab04-3aa34234ddxx",
    "lifecycleStage": "PRODUCTION_TRAFFIC_SHIFT",
    "resourceArn": "arn:aws:ecs:ap-northeast-1:<account-id>:service-deployment/my-cluster/native-bg-1/PNpQryOI09kD3iMrxsoxx"
}
関数の返り値
- 
hookStatus=SUCCEEDEDを返すと検証成功となり、デプロイが次の処理に進む - 
hookStatus=FAILEDを返すとロールバックが実行される - 
hookStatus=IN_PROGRESSを返すと一定時間後に関数が再度呼ばれる- ロングランの場合や、検証に必要なデータがまだ取得できない段階で使えそう
 - 
公式ブログによると30秒間隔で、実際に試したところ確かに30秒間隔だった
 
 
余談: ローリングアップデートでもごく一部使えそう??
コンソールでは、ローリングアップデートを選択すると lifecycle hooks や bake time の設定ができません。
しかし、CLIからは ローリングアップデートでかつ lifecycle hooks 等を選択することも可能でした。LBの設定もB/Gのものが残っています。
update-service結果
$ aws ecs update-service --service arn:aws:ecs:ap-northeast-1:<account-id>:service/my-cluster/native-bg-1 --cluster my-cluster --deployment-configuration '{"strategy":"ROLLING","lifecycleHooks":[{"hookTargetArn":"arn:aws:lambda:ap-northeast-1:<account-id>:function:ecs-native-bg-test","roleArn":"arn:aws:iam::<account-id>:role/ecs-native-bg-lambda-demo","lifecycleStages":["RECONCILE_SERVICE","PRE_SCALE_UP","POST_SCALE_UP","TEST_TRAFFIC_SHIFT","POST_TEST_TRAFFIC_SHIFT","PRODUCTION_TRAFFIC_SHIFT","POST_PRODUCTION_TRAFFIC_SHIFT"]}]}' --task-definition apache-httpd-fam:90
{
    "service": {
        "serviceName": "native-bg-1",
        "loadBalancers": [
            {
                "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:<account-id>:targetgroup/t-kikuc-ecs-simple-target-group/326d7078f952fac2",
                "containerName": "web",
                "containerPort": 80,
                "advancedConfiguration": {
                    "alternateTargetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:<account-id>:targetgroup/t-kikuc-ecs-simple-target-group2/a0bf6027242f9d67",
                    "productionListenerRule": "arn:aws:elasticloadbalancing:ap-northeast-1:<account-id>:listener-rule/app/t-kikuc-ecs-sample-alb/f9976c57bd1f9916/287be54d0fd6ce32/fe48b7ea5708dc76",
                    "testListenerRule": "arn:aws:elasticloadbalancing:ap-northeast-1:<account-id>:listener-rule/app/t-kikuc-ecs-sample-alb/f9976c57bd1f9916/287be54d0fd6ce32/bfa91bfcc3fdc7c8",
                    "roleArn": "arn:aws:iam::<account-id>:
                }
            }
        ],
        ...
        "deploymentConfiguration": {
            "deploymentCircuitBreaker": {
                "enable": true,
                "rollback": true
            },
            "maximumPercent": 200,
            "minimumHealthyPercent": 100,
            "strategy": "ROLLING", # ココと下が共存している
            "bakeTimeInMinutes": 5,
            "lifecycleHooks": [
                {
                    "hookTargetArn": "arn:aws:lambda:ap-northeast-1:<account-id>:function:ecs-native-bg-test",
                    "roleArn": "arn:aws:iam::<account-id>:role/ecs-native-bg-lambda-demo",
                    "lifecycleStages": [
                        "RECONCILE_SERVICE",
                        "PRE_SCALE_UP",
                        "POST_SCALE_UP",
                        "TEST_TRAFFIC_SHIFT",
                        "POST_TEST_TRAFFIC_SHIFT",
                        "PRODUCTION_TRAFFIC_SHIFT",
                        "POST_PRODUCTION_TRAFFIC_SHIFT"
                    ]
                }
            ]
        },
        ...
実際にデプロイしてみると、PRE_SCALE_UPのフックだけLambdaが呼ばれていました。どれほど想定された挙動かは分かりません。
Test Listener / Listener Rule (Dark Canary)
テスト用のリスナー・リスナールールを使うことで、本番トラフィックをGreen環境に流す前に開発者やテスターがGreen環境にアクセスできます。
エンドユーザがアクセスしないので、「Dark Canary」とも呼ばれます。
メリット
単純なB/Gと比較して、「Green環境にトラフィックを100%流したら、一時的とはいえ大失敗」のリスクを減らせます。
また、Green環境は本番相当なので「Staging環境では問題なかったのに、本番環境ではトラブル発生した...」のリスクも減らせます。
使い方
別ポートを持つリスナーや、ヘッダーやソースIPなどの条件を持つリスナールールを用いることで、エンドユーザとは別ルートで開発者用のアクセス方法を用意できます。
このフェーズの成否はTEST_TRAFFIC_SHIFTとPOST_TEST_TRAFFIC_SHIFTのhooksによって検証されます。
- 
hookStatus=IN_PROGRESSを返せば、本番影響ゼロのままロールバックできます。 - 
hookStatus=IN_PROGRESSを返している限り、デプロイはIN_PROGRESSのまま進行しません。(タイムアウト値は不明。3時間以上であることは検証済み)- 手動検証を含める場合、何かしらのフラグをLambdaに監視させて、フラグが立ったら
hookStatus=SUCCEEDEDを返す といった仕組み必要がありそうです。 
 - 手動検証を含める場合、何かしらのフラグをLambdaに監視させて、フラグが立ったら
 
付随して、デプロイコントローラをサービス作成後に更新可能に
地味にドキュメントが更新されていたので紹介します。
前提として、デプロイコントローラは以下の3種があります。
- 
ECS(今回強化されたもの。最もメジャー) - 
CODE_DEPLOY(従来のB/Gデプロイ) - 
EXTERNAL(魔改造向け。詳細:ECS外部デプロイ&TaskSet実践ガイド) 
従来はサービス作成後に変更できませんでしたが、今回のネイティブB/Gアプデと同時に、更新可能になっていました。サポートされた更新パターンは以下の4種類です。
- 
CODE_DEPLOY->ECS - 
CODE_DEPLOY->EXTERNAL - 
ECS->EXTERNAL - 
EXTERNAL->ECS 
おや?
 CODE_DEPLOYタイプが廃止される気配 ※CodeDeploy自体ではない
上記の更新パターンで、CODE_DEPLOYへの更新2種がサポートされていないですね。
CODE_DEPLOYのDocsでは新しいネイティブB/Gが明確に推奨されています。
We recommend that you use the Amazon ECS blue/green deployment.
さらに、コンソールからは CODE_DEPLOY の選択肢が消えています。
CODE_DEPLOYからECSへの移行Docsも提供されています
おそらく、コレもあってデプロイコントローラの更新がサポートされたのではと推測しています。
一方でEXTERNALの方には非推奨の記載や移行ガイドは特に見つからず。EXTERNALはしばらく安泰そうで安心しています。
EKS on Fargateに対するAuto Mode然り、上位互換的な選択肢が出てから非推奨/廃止になるのはいいですね。アレとかアレと違って...
このアップデートの嬉しさ
先述のように、CODE_DEPLOYユーザがECSに移行するのが楽になりました。
また、ECSにおいてPipeCDへの移行が一気に楽になりました。
背景として、PipeCDでは ECSのデプロイにおいて現状で EXTERNAL を利用しています。
そのため従来は、従来はローリングアップデート(ECS)やCODE_DEPLOYのユーザがPipeCDに移行する際にはサービス再作成が必要でした。稼働中のサービスであればALBリスナーなどを駆使した移行が必要があり、非常に面倒でした。
今後はサービス再作成を経ずともPipeCDへの移行(および逆方向も)が可能になりました。
他にも、「ECSを使っていたけど、後からEXTERNALで自由にやりたくなった」や「とりあえずEXTERNAL試して、面倒になったらECSに戻す」もできます。
注意点
VPC LatticeやService Connectを利用中のサービスでは ECSからの移行は不可 といった制約もあります。
You can't update the deployment controller of a service from the ECS deployment controller to any of the other controllers if it uses VPC Lattice or Amazon ECS Service Connect.
仕組み(ALBの場合)
デプロイの流れ
公式図には違和感と不足があったので、作図してみました。

- 初期状態〜Green環境の起動
- 初期状態。Blue環境が100%のトラフィックを受信している
 - Green環境のタスクを起動し、Green用のTarget Groupに紐付ける
 - ALBがGreen環境へのヘルスチェックを実行
 
 - Green環境の内部テストを行う(本番トラフィックはまだ流れない)
 - 本番トラフィックをGreen環境に切り替える
- All at onceにおいてはここは短時間
 
 - Greenに切替後〜デプロイ完了
- モニタリング: CloudWatchのアラームなどを監視し、問題があれば自動ロールバック
- Bake Timeパラメータで指定した時間が経過するまで継続
 
 - Blue環境のタスクを削除
 - デプロイ完了
 
 - モニタリング: CloudWatchのアラームなどを監視し、問題があれば自動ロールバック
 
次のデプロイではBlueとGreenとが入れ替わり、Target Group GreenからTarget Group Blueに移行していく流れになります。
ロールバック時
ロールバックは併存しているBlue環境にリスナールールでトラフィックを戻すのみで、タスク起動がないため、ローリングアップデートに比べて高速です。
実際に試してみた
この公式ブログを参考に、ALBでのネイティブB/Gを試してみました。
必要なリソースの詳細はこちら:
1. サービス更新
以下のように設定しました。
- 
task definition: httpd→nginxに変更
 - 
Deployment options
- Deployment controlle type: 
ECS- 従来はここで 
ECSorCODE_DEPLOYの選択だった 
 - 従来はここで 
 - strategy: Blue/Green
 - Bake time: 5分
 - 
lifecycle hooks:
- Lambda function: ALBのURLにアクセスして
"hookStatus": "SUCCEEDED"を返すだけの関数を作成関数のコード
import json import urllib3 import logging import base64 import os # Configure logging logger = logging.getLogger() logger.setLevel(logging.DEBUG) # Initialize HTTP client http = urllib3.PoolManager() def lambda_handler(event, context): """ Validation hook that tests the green environment by accessing "/" """ logger.info(f"Event: {json.dumps(event)}") logger.info(f"Context: {context}") try: test_endpoint = os.getenv("APP_URL") response = http.request( 'GET', test_endpoint, timeout=30 ) logger.info(f"GET / response status: {response.status}") # Check if response has OK status code (200-299 range) if 200 <= response.status < 300: logger.info("test passed - received OK status code") return { "hookStatus": "SUCCEEDED" } else: logger.error(f"test failed - status code: {response.status}") return { "hookStatus": "FAILED" } except Exception as error: logger.error(f"test failed: {str(error)}") return { "hookStatus": "FAILED" } - Role: 
lambda:InvokeFunctionを付与したロール。参考- ECSがLambdaを呼び出すためのロール
 
 - Lifecyle stages: 6点全てを選択
 
 - Lambda function: ALBのURLにアクセスして
 
 - Deployment controlle type: 
 - 
Load balancing
- Role: PolicyはこのDocsを参考に設定
- ECSがリスナールールを更新するためのロール
 - 上記DocsのPolicyではPermisison errorが発生したため、
elasticloadbalancingのDescribeTargetGroups,DescribeTargetHealth,RegisterTargets,DeregisterTargets権限を追加しました。 
 - Load balancer type: ALB
 - Listener(本番用): HTTP:80
 - Production listener rule(本番用): リスナーのデフォルト
 - Test listener(Greenへのテストアクセス用): ポートを変えて HTTP:81
 - Test listener rule(Greenへのテストアクセス用): リスナーのデフォルト
 - Target group(Blue環境用): HTTP:80を持つIPタイプ
 - 
Alternate target group(Green環境用): Blue用と同じ設定を持つもの
- "Create alternate target group" を選択すれば、名前をつけるだけで新規作成できます
 
 
 - Role: PolicyはこのDocsを参考に設定
 
2. デプロイ
2-1. テストトラフィック
まず前段として、Greenのタスクが起動し、POST_SCALE_UPのlifecycle hooksも成功しました。
テスト用リスナー(HTTP:81)経由でGreenにアクセス可能になります。
Green用の81番ポートではnginxにアクセスできました。

Blue用の80番ポートではhttpdにアクセスできました。

ALBを見ると、テスト用である81番ポートのリスナールールはGreen向け(group2)に変更されています。(無関係なtarget groupはこのルールから外されました)

一方で、本番用である80番ポートのリスナールールはBlue向けのままです。

POST_TEST_TRAFFIC_SHIFTのlifecycle hooksも成功しました。
2-2. 本番トラフィックの切り替え
本番トラフィックがGreenに切り替わります。
80番ポートのアクセス先がnginxに切り替わりました。

ALBのリスナールールを見ると、本番用リスナーもGreen向けに変更されていました。

なお、この段階でもテスト用リスナーからGreen環境にアクセスは可能でした。
2-3. Bake time
この段階ではBlue環境には本番トラフィックは流れていませんが、ロールバック高速化のためにBlueのタスクも起動したままです。

TargetがGreen、SourceがBlue
Bake time経過後、Blue環境のタスクは削除されました。

イベント履歴にも出力されています。

デプロイ中の状況把握
デプロイ中、現在どのstageにいるかはDeployments画面から確認できます。

stageをクリックすると、hooksがどのLambda関数に紐づいているかを確認できます。

個人的には、Eventsにもstageの開始・終了状況が出力されると、どこにどれだけ時間がかかっているか把握できたり、トラブルシューティング時に役立ちそうで嬉しいです。
3. Hooks で失敗させてみる
今度はPOST_SCALE_UPのlifecycle hooksで失敗させ、ロールバックが実行されるか試します。
1. Lambda関数を以下に差し替える
常に失敗させるコード
import logging
import json
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
def lambda_handler(event, context):
    logger.info(f"always return failure")
    return {
        "hookStatus": "FAILED"
    }
2. Lifecycle hooks は POST_SCALE_UP のみに変更
ロールバック中のPRODUCTION_TRAFFIC_SHIFTでもhookが失敗すると Rollback failed となり、本検証においては面倒なため。
3. サービスを更新してデプロイする
デプロイをトリガーするために、タスク定義のリビジョンを変更しました。
POST_SCALE_UPが失敗し、ロールバックが開始されました。

注意点
- Canaryは未対応
- CodeDeployではCanaryも選択できたので、将来的に対応されることを期待します
 - 
Traffic shiftingの部分が今後選択可能になりそうにも見える...
 
 
- 
Auto Scalingを利用していると、Auto Scalingのタイミングやなんらかの状況ではデプロイが失敗するかも、とのこと
If your service uses auto scaling, be aware that auto scaling is not blocked during a blue/green deployment, but the deployment might fail under certain circumstances.
 
余談: 「ローリングアップデート」デプロイタイプ呼称問題
デプロイタイプECSは今後何と呼べばよいのでしょうか?
従来は「ECS(ローリングアップデート)」のような表記がありましたが、B/Gも対応したのでややこしいです。
また、ドキュメントにも「ローリングアップデートを利用している場合...」という記載が複数箇所にありますが、デプロイタイプ=ECSのことを指していると思われます。更新されるのを待ちたいです。例:
Only services that use rolling deployments are supported with Service Connect.
おわりに
デプロイコントローラの更新含め、ここ最近のECSで最大級のアップデートだと思います。
Service Connect の Blue/Green も気になります。
Service Connect と Externalデプロイのサポート継続がわかって安堵しています。
一方で、今後はCODE_DEPLOYタイプは避けるのが無難に思えます。
Discussion