NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

注目のタグ

    ECS Blue/Greenデプロイ攻略:ライフサイクルフックで本番切り替えを制御する

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

    みなさんこんにちは。井手です。

    遡ること7月ごろ、Blue/Green デプロイ*1(以下、B/Gデプロイ)の新たな選択肢として、ECSネイティブのB/Gデプロイ(以下、ECSネイティブ方式)が発表されました。

    aws.amazon.com

    従来、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ネイティブ方式を推奨していると考えられます。

    docs.aws.amazon.com

    私も当初は「ECSネイティブ方式一択だ」と思い込み、その方針でB/Gデプロイフローの構築を進めていました。 ですが、実際に調査や検証を進めるうちに、注意すべきポイントが見えてきたのです。

    注意点は?

    先に結論です。ECSネイティブ方式では、「トラフィックの再ルーティング」のタイミングを指定することができません。

    ここで言う「トラフィックの再ルーティング」とは本番通信の向き先を新環境に切り替えることを意味します。(下図参照)

    本番トラフィックの移行前 ( 改めてECSのデプロイ方法を整理する - NRIネットコムBlogから引用)
    本番トラフィックの移行後

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

    トラフィックを移行するタイミングを指定できた

    一方、ECSネイティブ方式では、本番トラフィックの移行が直ちに実施される仕様になっています。つまり、本番通信が切り替わる前に、十分な動作検証の時間を確保したい、という場合は、「タイミングを指定して実施する」仕組みを自前で実装する必要があるのです。

    ではどうするのか

    はやくアップデートが来ないか首を長くして待っていますが、現状では、ライフサイフルフックという機能でデプロイの進捗をコントロールすることができます。

    docs.aws.amazon.com

    具体的には、デプロイの適当なタイミングで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の連携方法について詳しく知りたい方は、こちらのブログをご覧ください。

    tech.nri-net.com

    また、ecspresso を用いたB/Gデプロイ実装方法は以下文献を参考にしました。

    github.com

    *1:システムの稼働環境を2つ用意し、旧環境(Blue)と新環境(Green)を並行稼働させながら、安全かつ効率的に新バージョンをリリースするデプロイメント戦略

    *2:サービス間通信を簡単かつ安全に実現するための機能

    *3:デプロイ中に異常を検知した場合、自動的にロールバックを行う仕組み

    *4:本番の通信を新しい Green 環境に移行した後に、もともとの Blue 環境を削除するまでの猶予時間のこと。

    執筆者:井手 亮太
    職種:インフラエンジニア
    推しのサッカー選手:ケビン・デブライネ
    執筆記事一覧:https://tech.nri-net.com/archive/author/r-ide-ryota