本記事は
【Advent Calendar 2025】
18日目①の記事です。
🌟🎄
17日目
▶▶ 本記事 ▶▶
18日目②
🎅🎁

みなさんこんにちは。井手です。
遡ること7月ごろ、Blue/Green デプロイ*1(以下、B/Gデプロイ)の新たな選択肢として、ECSネイティブのB/Gデプロイ(以下、ECSネイティブ方式)が発表されました。
従来、B/GデプロイにはCodeDeployとの連携が必要でした。しかし、ECSネイティブ方式の登場によって状況が大きく変わりました。
最大の変化は、ECS側で設定が完結すること、そしてCodeDeployの制約を超え、より高度な機能を利用できるようになったことです。 具体的には以下の4点があげられます。
CodeDeploy関連のリソースを用意する必要が無い
- appspec.yml、デプロイグループ等を設定する必要がなくなり、リソース管理がラクになった。
Service Connect*2を使用しているECSサービスでもB/Gデプロイができる
- CodeDeployではローリングアップデートのみ可能だった。
サーキットブレイカー*3による自動ロールバック
- CodeDeployではECSのサーキットブレイカー機能は使用することができず、別途自動ロールバック用のCloudWatchアラームを作成する必要があった。
本番・テストトラフィックの切り替えがリスナー単位からリスナールール単位に
- ポートだけではなく、ヘッダーやパスによって、本番・テストトラフィックが切り替えられるようになった。
こう見ると『もうECSネイティブ方式一択じゃん!』と思う方が多数派ではないでしょうか。実際、AWSもCodeDeployからの移行ガイドを公開しており、ECSネイティブ方式を推奨していると考えられます。
私も当初は「ECSネイティブ方式一択だ」と思い込み、その方針でB/Gデプロイフローの構築を進めていました。 ですが、実際に調査や検証を進めるうちに、注意すべきポイントが見えてきたのです。
注意点は?
先に結論です。ECSネイティブ方式では、「トラフィックの再ルーティング」のタイミングを指定することができません。
ここで言う「トラフィックの再ルーティング」とは本番通信の向き先を新環境に切り替えることを意味します。(下図参照)


CodeDeployでは、本番通信の切り替えを、「直ちに実施するか」、あるいは「タイミングを指定して実施するか」を選択することができました。

一方、ECSネイティブ方式では、本番トラフィックの移行が直ちに実施される仕様になっています。つまり、本番通信が切り替わる前に、十分な動作検証の時間を確保したい、という場合は、「タイミングを指定して実施する」仕組みを自前で実装する必要があるのです。
ではどうするのか
はやくアップデートが来ないか首を長くして待っていますが、現状では、ライフサイフルフックという機能でデプロイの進捗をコントロールすることができます。
具体的には、デプロイの適当なタイミングでLambdaを実行することができて、そのLambdaのレスポンスによってデプロイの進行・ロールバック・一時停止を制御する、といった仕組みです。
Lambda を実行できるタイミングは以下の通り。
スケールアップ前 (PRE_SCALE_UP): Greenサービス開始前スケールアップ後 (POST_SCALE_UP): Greenサービスの起動が開始。テストトラフィックはルーティングされる前-
テストトラフィック移行 (TEST_TRAFFIC_SHIFT): テストトラフィックがGreenサービスへのルーティングを開始 -
テストトラフィック移行後 (POST_TEST_TRAFFIC_SHIFT): テストトラフィックがGreenサービスにルーティングされた後 -
本番トラフィック移行 (PRODUCTION_TRAFFIC_SHIFT): 本番トラフィックがGreenサービスへのルーティングを開始 本番トラフィック移行後 (POST_PRODUCTION_TRAFFIC_SHIFT): 本番トラフィックがGreenサービスへルーティングした後
また、Lambda 関数は hookStatusをReturn する必要があります。
SUCCEEDED: 次のデプロイステージに進む。FAILED: ロールバックする。IN_PROGRESS: 30 秒後(この秒数は指定可能)に Lambda 関数を再試行する。
例えば、テストトラフィック移行後に動作確認をしたい!という場合は、POST_TEST_TRAFFIC_SHIFTで Lambda 関数を実行するように設定します。
そのLambda関数ではReturn 値として{"hookStatus": "IN_PROGRESS" } を返すことで、デプロイを一時停止して動作確認の時間が確保できるというわけです。
B/Gデプロイの流れ
ここからは、ライフサイクルフックを活用し、デプロイの途中で中断や承認ステップを挟んだB/Gデプロイの流れについて解説していきます。
1. デプロイ前
本番用・テスト用のリスナールールにBlue・Green両方のターゲットグループが紐づいている状態です。トラフィックの向き先は、ターゲットグループの「重みづけ」に基づいて制御されます。
この時点では、本番用・テスト用リスナールール共にBlue:100 Green:0の重みが設定されています。

2.デプロイ開始
デプロイが開始されると、Green用のターゲットグループでタスクが起動されます。その後、テスト用リスナールールのターゲットグループの重みが変化することで、テストトラフィックがGreen環境に切り替えられます。
テストトラフィックが移行したのち、ライフサイフルフック用のLambdaが起動します。
ライフサイフルフック用のLambdaでは主に2つの処理が行われています。
- Green用ターゲットグループへの通信チェック&ステータスコード確認
- S3バケットに「承認ファイル」(※後述)があるかどうかの確認
これらの処理の結果によって、以下のhookStatus を Return するようにしています。
| hookStatus | 条件 | 概要 |
|---|---|---|
| IN_PROGRESS | ターゲットグループのステータスが200以上300未満 | 30秒後にLambdaが再試行 |
| SUCCEEDED | S3に承認ファイルがある& ターゲットグループのステータスが200以上300未満 | デプロイが次のステージに向かう |
| FAILED | それ以外 | ロールバックされる |
3 デプロイ中断~承認~デプロイ再開
3-1 動作確認フェーズ
テストトラフィックの切り替えが完了すると、デプロイジョブは承認ステージへ進みます。
このステージではユーザーによる入力が必要となり、入力が行われるまで Lambda は IN_PROGRESS を返し続けます。この間に、テストポートやパスを使った動作検証が可能です。
※ ALBでアクセス制御したかったので、Lambdaをプライベートサブネットに置いて、NAT Gateway経由で通信するようにしました。ALBのセキュリティグループにはNAT GatewayのIPを許可しています。

3-2 承認フェーズ
動作確認後にユーザー入力が行われると、S3に承認ファイルが配置されます。ファイル検出後、Lambdaは SUCCEEDED を返し、本番トラフィック移行が始まります。

4 本番トラフィック移行
本番用リスナールールの重みが変化し、トラフィックがGreen環境に向きます。切り替え後も、ベイク時間*4の間は新旧両方のタスクが並行して稼働します。この期間内はBlue環境へのロールバックが可能です。
5 デプロイ完了
ベイク時間経過後、Blue 環境のタスクが削除されます。

まとめ
いかがでしたでしょうか。
実際に触ってみた感想としては、Lambdaのロジック検討や承認ロジックの設計、スクリプトの作り込み、さらにLambdaの実行コストなど、考えることが意外と多いという印象です。
冒頭でも述べましたが、ECSネイティブ方式が推奨となっているので、このあたりも近々アップデートがあるかもしれませんね。
CodeDeployからの移行を検討している方や、新規でECSのBlue/Greenデプロイを導入する方に、少しでもこの記事が参考になれば幸いです。
付録
以下に、今回検証で使用したLambda 関数、デプロイスクリプトを掲載しています。 また、ECS のタスクおよびサービスはecspresso、それ以外のインフラリソースは Terraform によって管理しています。
リソース一覧
config.yml
region: us-west-2 cluster: {{must_env `ECS_CLUSTER`}} service: {{must_env `ECS_SERVICE`}} service_definition: ecs-service-def.json task_definition: ecs-task-def.json timeout: "10m0s" plugins: - name: tfstate config: path: ./terraform/envs/{{ must_env `ENV` }}/terraform.tfstate
ecs-service-def.json
{ "availabilityZoneRebalancing": "ENABLED", "capacityProviderStrategy": [ { "base": 0, "capacityProvider": "FARGATE", "weight": 1 } ], "deploymentConfiguration": { "bakeTimeInMinutes": 5, "deploymentCircuitBreaker": { "enable": true, "rollback": true }, "lifecycleHooks": [ { "hookTargetArn": "{{tfstate `module.lambda.aws_lambda_function.test_terraform.arn`}}", "lifecycleStages": [ "POST_TEST_TRAFFIC_SHIFT" ], "roleArn": "{{tfstate `module.iam.aws_iam_role.lifecycle_role.arn`}}" } ], "maximumPercent": 200, "minimumHealthyPercent": 100, "strategy": "BLUE_GREEN" }, "deploymentController": { "type": "ECS" }, "desiredCount": 1, "enableECSManagedTags": true, "enableExecuteCommand": false, "healthCheckGracePeriodSeconds": 0, "launchType": "", "loadBalancers": [ { "advancedConfiguration": { "alternateTargetGroupArn": "{{tfstate `module.alb.aws_lb_target_group.green.arn`}}", "productionListenerRule": "{{tfstate `module.alb.aws_lb_listener_rule.production_lister_rule.arn`}}", "roleArn": "{{tfstate `module.iam.aws_iam_role.blue_green.arn`}}", "testListenerRule": "{{tfstate `module.alb.aws_lb_listener_rule.test_lister_rule.arn`}}" }, "containerName": "{{ must_env `CONTAINER_NAME` }}", "containerPort": 3000, "targetGroupArn": "{{tfstate `module.alb.aws_lb_target_group.blue.arn`}}" } ], "networkConfiguration": { "awsvpcConfiguration": { "assignPublicIp": "ENABLED", "securityGroups": [ "{{tfstate `module.sg.aws_security_group.ecs-sg.id`}}" ], "subnets": [ "{{tfstate `module.network.aws_subnet.private_a.id`}}", "{{tfstate `module.network.aws_subnet.private_b.id`}}", "{{tfstate `module.network.aws_subnet.private_c.id`}}" ] } }, "platformFamily": "Linux", "platformVersion": "LATEST", "propagateTags": "NONE", "schedulingStrategy": "REPLICA" }
ecs-task-def.json
{ "containerDefinitions": [ { "cpu": 0, "essential": true, "image": "{{tfstate `module.ecr.aws_ecr_repository.default.repository_url`}}:{{ must_env `IMAGE_TAG` }}", "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-create-group": "true", "awslogs-group": "{{tfstate `module.cloudwatch.aws_cloudwatch_log_group.default.id`}}", "awslogs-region": "us-west-2", "awslogs-stream-prefix": "ecs", "max-buffer-size": "25m", "mode": "non-blocking" } }, "name": "{{ must_env `CONTAINER_NAME` }}", "portMappings": [ { "appProtocol": "http", "containerPort": 3000, "hostPort": 3000, "name": "{{ must_env `CONTAINER_NAME` }}-3000-tcp", "protocol": "tcp" } ], "versionConsistency": "" } ], "cpu": "1024", "executionRoleArn": "{{tfstate `module.iam.aws_iam_role.default.arn`}}", "family": "{{ must_env `TASK_DEF` }}", "ipcMode": "", "memory": "3072", "networkMode": "awsvpc", "pidMode": "", "requiresCompatibilities": [ "FARGATE" ], "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" }, "taskRoleArn": "{{tfstate `module.iam.aws_iam_role.default.arn`}}" }
デプロイスクリプト
#!/bin/bash # ===== 環境変数設定 ===== export ENV="$1" export AWS_ACCOUNT_ID=あなたのAWSアカウントID export REPO_NAME="${ENV}-r-ide-repository" export ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.us-west-2.amazonaws.com/${REPO_NAME}" export ECS_SERVICE="${ENV}-r-ide-ecs-service" export ECS_CLUSTER="${ENV}-r-ide-cluster" export CONTAINER_NAME="${ENV}-r-ide-container" export TASK_DEF="${ENV}-r-ide-taskdef" # ===== コンテナのイメージタグ取得 ===== export IMAGE_TAG=$(aws ecr describe-images \ --repository-name "${REPO_NAME} \ --query 'sort_by(imageDetails,& imagePushedAt)[-1].imageTags' \) # ===== ecspresso diff を使って差分の確認 ===== ecspresso diff --config config.yml --no-color # ===== 第1の承認ステージ(差分確認) ===== read -p "【第1承認】差分は確認しましたか?承認しますか?(y/N): " ans1 if [[ ! "$ans1" =~ ^[Yy]$ ]]; then echo "[WARN] 第1承認が否認されました。処理を終了します。" exit 1 fi # ===== デプロイ開始 ===== ecspresso deploy --config config.yml --no-wait # ===== 第2の承認ステージ(動作確認) ===== read -p "【第2承認】動作確認しましたか?承認しますか?(y/N): " ans2 if [[ ! "$ans2" =~ ^[Yy]$ ]]; then echo "[WARN] 第2承認が否認されました。ロールバックして処理を終了します。" ecspresso rollback --config config.yml --wait-until stable exit 1 fi # ===== 承認ファイルをS3に保存 ===== # 承認ファイル保存用バケットプレフィックス作成 aws s3api put-object --bucket r-ide-ecs-deploy-approve --key "${IMAGE_TAG}"/ # S3バケット内のフォルダに承認ファイルを保存する ecspresso diff --config config.yml \ | sed -E 's/\x1B\[[0-9;]*m//g' \ > Approve.txt # ===== S3バケットに承認ファイルがアップロード===== aws s3 cp "./Approve.txt" "s3://r-ide-ecs-deploy-approve/${IMAGE_TAG}/Approve.txt" \ ## サービスが安定状態になる or ロールバックが成功するまで待つ ecspresso wait --config config.yml --timeout 20m --wait-until="stable"
Lambda
import os import boto3 import logging from botocore.exceptions import ClientError import urllib3 from urllib3.util import Retry logger = logging.getLogger() logger.setLevel(logging.DEBUG) # グローバルでHTTPクライアントを初期化 http = urllib3.PoolManager(timeout=urllib3.Timeout(connect=5.0, read=10.0)) ecs_client = boto3.client("ecs") s3_client = boto3.client("s3") ecr_client = boto3.client("ecr") def hook_succeeded(): return {"hookStatus": "SUCCEEDED"} def hook_failed(extra: dict | None = None): base = {"hookStatus": "FAILED"} if extra: base.update(extra) return base def hook_in_progress(delay_sec: int = 30): return {"hookStatus": "IN_PROGRESS", "callBackDelay": delay_sec} def check_service_revision(service_arn: str) -> bool: """ 指定の ECS Service が『新規作成(createService)』かどうかを推定。 返り値: True -> 新規作成と推定(初回は承認スキップ) False -> 更新と推定 判定ロジック: list_service_deployments の件数が 1 以下なら新規作成とみなす。 """ logger.info("Retrieving Service Revision List from ECS Service") try: response = ecs_client.list_service_deployments(service=service_arn) deployments = response.get("serviceDeployments", []) no_of_service_revisions = len(deployments) logger.info( f"Retrieved {no_of_service_revisions} service revisions for {service_arn}" ) if no_of_service_revisions <= 1: logger.info( "Retrieved a single or none service revision, therefore assuming createService" ) return True return False except ClientError as e: logger.error(f"Error listing ECS service deployments: {str(e)}") raise def check_s3_file(s3_bucket: str, image_tag: str) -> bool: file_key = f"{image_tag}/Approve.txt" logger.info(f"Checking if file {file_key} exists in bucket {s3_bucket}") try: s3_client.head_object(Bucket=s3_bucket, Key=file_key) logger.info(f"File {file_key} exists in bucket {s3_bucket}") return True except ClientError as e: code = e.response.get("Error", {}).get("Code", "") # 存在しないキー・バケットは "ファイルなし" と解釈 if code in ("404", "NoSuchKey", "NotFound", "NoSuchBucket"): logger.info(f"File {file_key} does not exist or bucket missing: {s3_bucket}") return False # それ以外(権限不足など)はFAILEDで返すため例外にはしない logger.error(f"Error checking S3 file: {str(e)}") return False def check_alb_health(app_url: str) -> tuple[bool, int | None]: """ ALB(もしくはエンドポイント)への GET を行い、ステータスが 200から299 なら Healthy。 成功時: (True, status) 失敗時: (False, status or None) """ try: response = http.request( "GET", app_url, retries=Retry(total=1, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504]), ) status = response.status logger.info(f"GET {app_url} response status: {status}") if 200 <= status < 300: return True, status else: return False, status except urllib3.exceptions.HTTPError as http_error: logger.error(f"HTTP connection error: {str(http_error)}") return False, None except Exception as e: logger.error(f"Unexpected error during ALB health check: {str(e)}") return False, None # ------------------------- # ECR関連の関数 # ------------------------- def get_latest_ecr_tags(): """ECRリポジトリの最新タグと日付タグを取得""" repository_name = os.getenv("ECR_REPOSITORY_NAME") registry_id = os.getenv("ECR_REGISTRY_ID") if not repository_name: logger.error("ECR_REPOSITORY_NAME is required") return None, None paginator = ecr_client.get_paginator("describe_images") kwargs = {"repositoryName": repository_name} if registry_id: kwargs["registryId"] = registry_id latest_time = None latest_tags: list[str] = [] for page in paginator.paginate(**kwargs): for detail in page.get("imageDetails", []): pushed_at = detail.get("imagePushedAt") tags = detail.get("imageTags", []) if pushed_at and tags: if latest_time is None or pushed_at > latest_time: latest_time = pushed_at latest_tags = tags # タグリストを丸ごと保持 if latest_tags: tag1 = latest_tags[0] tag2 = latest_tags[1] if len(latest_tags) > 1 else None if tag1 == "latest": latest_tag = tag1 date_tag = tag2 else: latest_tag = tag2 date_tag = tag1 logger.info(f"Latest ECR tags: latest={latest_tag}, date={date_tag}") return date_tag return None, None def lambda_handler(event, context): logger.info(event) try: # 仕様どおりの必須フィールドのみ検証 exec_details = event.get("executionDetails", {}) for var in ["serviceArn", "targetServiceRevisionArn"]: if var not in exec_details: logger.error(f"Event is missing required {var}") return hook_failed({"error": f"Missing {var}"}) s3_bucket = os.getenv("S3_BUCKET_NAME") # ※hookDetails ではなく環境変数や外部状態で if not s3_bucket: logger.error("S3_BUCKET_NAME env is not set") return hook_failed({"error": "Missing S3_BUCKET_NAME env"}) # 初回デプロイはスキップ(任意ロジック) service_arn = exec_details["serviceArn"] if check_service_revision(service_arn): return hook_succeeded() app_url = os.getenv("APP_URL") if not app_url: logger.error("APP_URL environment variable is not set") return hook_failed({"error": "Missing APP_URL configuration"}) healthy, status = check_alb_health(app_url) latest_tag = get_latest_ecr_tags() # None ガード file_exists = check_s3_file(s3_bucket, latest_tag) if healthy and file_exists: return hook_succeeded() if healthy and not file_exists: # 次回再試行を指示(状態は外部に保存する設計に) return hook_in_progress(30) return hook_failed({"status": status} if status is not None else None) except Exception as e: logger.exception(f"Unhandled exception: {e}") # どんな場合でも hookStatus を返す return hook_failed({"error": "Unhandled exception"})
ecspressoとTerraformの連携方法について詳しく知りたい方は、こちらのブログをご覧ください。
また、ecspresso を用いたB/Gデプロイ実装方法は以下文献を参考にしました。
*1:システムの稼働環境を2つ用意し、旧環境(Blue)と新環境(Green)を並行稼働させながら、安全かつ効率的に新バージョンをリリースするデプロイメント戦略
*2:サービス間通信を簡単かつ安全に実現するための機能
*3:デプロイ中に異常を検知した場合、自動的にロールバックを行う仕組み
*4:本番の通信を新しい Green 環境に移行した後に、もともとの Blue 環境を削除するまでの猶予時間のこと。
執筆者:井手 亮太
職種:インフラエンジニア
推しのサッカー選手:ケビン・デブライネ
執筆記事一覧:https://tech.nri-net.com/archive/author/r-ide-ryota