ECS ネイティブの Blue/Green デプロイを CDK の L2 コンストラクトで実装してみた

ECS ネイティブの Blue/Green デプロイを CDK の L2 コンストラクトで実装してみた

先日、CodeDeploy を必要としない ECS ネイティブの Blue/Green デプロイ機能を利用できるようになりました。

https://aws.amazon.com/jp/blogs/aws/accelerate-safe-software-releases-with-new-built-in-blue-green-deployments-in-amazon-ecs/

ECS Service Connect と Blue/Green デプロイを併用できることや、構成が複雑になりがちだった Blue/Green デプロイをシンプルに定義できることがメリットとして挙げられます。
そんな ECS の Blue/Green デプロイが CDK の L2 コンストラクトを利用して実装できるようになっていたので、試してみました。

https://github.com/aws/aws-cdk/releases/tag/v2.211.0

書き方は、CDK リポジトリの README.md に記載されています。

import * as lambda from "aws-cdk-lib/aws-lambda";

declare const cluster: ecs.Cluster;
declare const taskDefinition: ecs.TaskDefinition;
declare const lambdaHook: lambda.Function;
declare const blueTargetGroup: elbv2.ApplicationTargetGroup;
declare const greenTargetGroup: elbv2.ApplicationTargetGroup;
declare const prodListenerRule: elbv2.ApplicationListenerRule;

const service = new ecs.FargateService(this, "Service", {
  cluster,
  taskDefinition,
  deploymentStrategy: ecs.DeploymentStrategy.BLUE_GREEN,
});

service.addLifecycleHook(
  new ecs.DeploymentLifecycleLambdaTarget(lambdaHook, "PreScaleHook", {
    lifecycleStages: [ecs.DeploymentLifecycleStage.PRE_SCALE_UP],
  })
);

const target = service.loadBalancerTarget({
  containerName: "nginx",
  containerPort: 80,
  protocol: ecs.Protocol.TCP,
  alternateTarget: new ecs.AlternateTarget("AlternateTarget", {
    alternateTargetGroup: greenTargetGroup,
    productionListener:
      ecs.ListenerRuleConfiguration.applicationListenerRule(prodListenerRule),
  }),
});

target.attachToApplicationTargetGroup(blueTargetGroup);

ECS Service の定義で deploymentStrategy として BLUE_GREEN を選択しつつ、service.loadBalancerTarget を利用してターゲットグループに追加する際に、テスト用のリスナーや追加のターゲットグループを指定します。
ターゲットグループを 2 つ用意することで、各リスナーからフォワーディングするターゲットを ECS が良い感じに切り替えてくれます。
そのため、AWS マネージドポリシーである AmazonECSInfrastructureRolePolicyForLoadBalancers を付与したインフラストラクチャロールを用意する必要があることも注意が必要です。 
上記 README.md 記載のコード例は簡略化されているので、付随するリソース定義も含めて実装してみたのが下記リポジトリになります。

https://github.com/masutaro99/cdk-ecs-blue-green/blob/main/packages/iac/lib/iac-stack.ts

CDK は下記構成を作成できるようにしています。

ecs-arch2.png

Synth して生成された CloudFormation は下記のようになります。
TargetGroupArn として Blue 側のターゲットグループが、AdvancedConfiguration.AlternateTargetGroupArn として Green 側のターゲットグループがきちんと指定されていますね。

ServiceD69D759B:
  Type: AWS::ECS::Service
  Properties:
    Cluster:
      Ref: ClusterEB0386A7
    DeploymentConfiguration:
      BakeTimeInMinutes: 2
      MaximumPercent: 200
      MinimumHealthyPercent: 50
      Strategy: BLUE_GREEN
    DesiredCount: 1
    EnableECSManagedTags: false
    HealthCheckGracePeriodSeconds: 60
    LaunchType: FARGATE
    LoadBalancers:
      - AdvancedConfiguration:
          AlternateTargetGroupArn:
            Ref: AppTargetGroupGreen5462D905
          ProductionListenerRule:
            Ref: AlbListenerRuleE27FB4D8
          RoleArn:
            Fn::GetAtt:
              - InfrastructureRoleB0C76DC8
              - Arn
          TestListenerRule:
            Ref: AlbTestListenerRule5FCB082B
        ContainerName: app
        ContainerPort: 80
        TargetGroupArn:
          Ref: AppTargetGroupBlueE8C741C5
    NetworkConfiguration:
      AwsvpcConfiguration:
        AssignPublicIp: DISABLED
        SecurityGroups:
          - Fn::GetAtt:
              - AppSg898BCA4E
              - GroupId
        Subnets:
          - Ref: VpcPrivateSubnet1Subnet536B997A
          - Ref: VpcPrivateSubnet2Subnet3788AAA1
    PlatformVersion: 1.4.0
    TaskDefinition:
      Ref: TaskDefinitionB36D86D9

CloudFormation 実装については AWS 公式ドキュメントにもリファレンスがあり、同じ設定になっていることも確認できました。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/migrate-codedeploy-to-ecs-bluegreen-cloudformation-template.html

Blue/Green デプロイの流れ

公式ドキュメント記載の図がわかりやすいので引用します。

blue-green-1.png

Amazon ECS blue/green service deployments workflow

下記のような流れになります。

  1. 本番リスナーから Blue 側 (左側) のターゲットグループにトラフィックを流している状態
  2. Green 側 (右側) のターゲットグループに新しいバージョンのタスクを登録して、テストリスナー経由でリクエストを受けれるようにする
  3. 一定時間経過後に本番リスナー側も Green 側にトラフィックを流すように変更

これらの切り替えを ECS 側が Modify Rule API を叩きながら良い感じにやってくれます。
常に 2 つのターゲットグループが存在しますが、デプロイ開始時にどちらのターゲットグループにリクエストが流れているかはデプロイする度に交互に入れ替わります。
また、POST_TEST_TRAFFIC_SHIFT (テストリスナー経由で新しいバージョンのアプリケーションにアクセスできるようになった直後) などに Lambda 関数を利用して、アプリケーションの正常性テストを仕込むことが可能です。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/blue-green-deployment-how-it-works.html#blue-green-deployment-stages

デプロイしてみる

ではやってみます。
CDK デプロイ後にリクエストを投げると、下記のように返ってきます。

% curl IacSta-Alb16-c6mbkOEOUI88-826201051.ap-northeast-1.elb.amazonaws.com
Hello World v1

このタイミングでは Blue 側のターゲットグループにのみ ECS タスクがターゲットとして登録されています。

ターゲットグループ (Blue)

blue.png

ターゲットグループ (Green)

green.png

リスナーはどちらも Blue 側のターゲットグループにトラフィックを流しています。

本番リスナー

prod-listener.png

テストリスナー

test-listner.png

アプリケーションを書き換えてデプロイします。
ルートパスにアクセスして返却される文字列だけを書き換えます。

https://github.com/masutaro99/cdk-ecs-blue-green/blob/51e75d5eb84119f3b40d65915517b1ccbfc3b7f4/packages/server/src/index.ts#L6

ContainerImage.fromAsset を利用しているので、アプリケーションコードを変更した上で、cdk deploy を実行すればコンテナイメージがビルドされた上でデプロイされます。

https://github.com/masutaro99/cdk-ecs-blue-green/blob/51e75d5eb84119f3b40d65915517b1ccbfc3b7f4/packages/iac/lib/iac-stack.ts#L59

CDK 上の差分としては下記でデプロイします。

Stack IacStack
Resources
[~] AWS::ECS::TaskDefinition TaskDefinition TaskDefinitionB36D86D9 replace
 └─ [~] ContainerDefinitions (requires replacement)
     └─ @@ -2,7 +2,7 @@
        [ ] {
        [ ]   "Essential": true,
        [ ]   "Image": {
        [-]     "Fn::Sub": "${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-${AWS::AccountId}-${AWS::Region}:b79076cbf77fc246e7a63db98ef2b6a8c75caefed5303da257407479c9aa9cbe"
        [+]     "Fn::Sub": "${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-${AWS::AccountId}-${AWS::Region}:241dd8d12ad62a4f35c4783300f1c93b7f51cbda58e4adce7c3e0b55c972ffbc"
        [ ]   },
        [ ]   "LogConfiguration": {
        [ ]     "LogDriver": "awslogs",

デプロイ後しばらくすると、テストリスナー経由で新しいバージョンにアクセスできるようになります。

% curl http://IacSta-Alb16-c6mbkOEOUI88-826201051.ap-northeast-1.elb.amazonaws.com:8080
Hello World v2

デプロイ段階でいうと、「テストトラフィック移行後」になった段階です。

blue-green2.png

CloudTrail を見ると ecs.amazonaws.com が Modify Rule API を利用して、リスナールールが各ターゲットグループにトラフィックを流す際の重みを変更していることがわかります。

{
  "eventVersion": "1.11",
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "XXXXXXX:ECSNetworkingWithELB",
    "arn": "arn:aws:sts::XXXXXXX:assumed-role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6/ECSNetworkingWithELB",
    "accountId": "XXXXXXX",
    "accessKeyId": "XXXXXXX",
    "sessionContext": {
      "sessionIssuer": {
        "type": "Role",
        "principalId": "XXXXXXX",
        "arn": "arn:aws:iam::XXXXXXX:role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6",
        "accountId": "XXXXXXX",
        "userName": "IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6"
      },
      "attributes": {
        "creationDate": "2025-08-30T05:35:19Z",
        "mfaAuthenticated": "false"
      }
    },
    "invokedBy": "ecs.amazonaws.com"
  },
  "eventTime": "2025-08-30T05:35:19Z",
  "eventSource": "elasticloadbalancing.amazonaws.com",
  "eventName": "ModifyRule",
  "awsRegion": "ap-northeast-1",
  "sourceIPAddress": "ecs.amazonaws.com",
  "userAgent": "ecs.amazonaws.com",
  "requestParameters": {
    "ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/ef249d1afd29053c/8e3689f373a18f80",
    "actions": [
      {
        "type": "forward",
        "forwardConfig": {
          "targetGroups": [
            {
              "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
              "weight": 0
            },
            {
              "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
              "weight": 100
            }
          ],
          "targetGroupStickinessConfig": {
            "enabled": false
          }
        }
      }
    ]
  },
  "responseElements": {
    "rules": [
      {
        "ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/ef249d1afd29053c/8e3689f373a18f80",
        "priority": "1",
        "conditions": [
          {
            "field": "path-pattern",
            "values": ["*"],
            "pathPatternConfig": {
              "values": ["*"]
            }
          }
        ],
        "actions": [
          {
            "type": "forward",
            "forwardConfig": {
              "targetGroups": [
                {
                  "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
                  "weight": 100
                },
                {
                  "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
                  "weight": 0
                }
              ],
              "targetGroupStickinessConfig": {
                "enabled": false
              }
            }
          }
        ],
        "isDefault": false
      }
    ]
  },
  "requestID": "f26ab8db-ed22-4531-9a13-056dc5c63e99",
  "eventID": "3306d1b5-7699-42d6-9667-de0e0947d15c",
  "readOnly": false,
  "eventType": "AwsApiCall",
  "apiVersion": "2015-12-01",
  "managementEvent": true,
  "recipientAccountId": "XXXXXXX",
  "eventCategory": "Management"
}

先にテストリスナーだけが Green 側のターゲットグループにトラフィックを流すようになっていました。

blue-green3.png

少しすると、本番側へもリクエストが通るようになります。

% curl http://IacSta-Alb16-c6mbkOEOUI88-826201051.ap-northeast-1.elb.amazonaws.com
Hello World v2

本番リスナーの設定を確認すると、こちらも Green 側のターゲットグループにトラフィックを流すようになっていました。

blue-green4.png

CloudTrail を見れば、 ecs.amazonaws.com が同様に Modify Rule API を叩いていることを確認できます。

{
  "eventVersion": "1.11",
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "XXXXXXX:ECSNetworkingWithELB",
    "arn": "arn:aws:sts::XXXXXXX:assumed-role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6/ECSNetworkingWithELB",
    "accountId": "XXXXXXX",
    "accessKeyId": "XXXXXXX",
    "sessionContext": {
      "sessionIssuer": {
        "type": "Role",
        "principalId": "XXXXXXX",
        "arn": "arn:aws:iam::XXXXXXX:role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6",
        "accountId": "XXXXXXX",
        "userName": "IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6"
      },
      "attributes": {
        "creationDate": "2025-08-30T05:36:00Z",
        "mfaAuthenticated": "false"
      }
    },
    "invokedBy": "ecs.amazonaws.com"
  },
  "eventTime": "2025-08-30T05:36:00Z",
  "eventSource": "elasticloadbalancing.amazonaws.com",
  "eventName": "ModifyRule",
  "awsRegion": "ap-northeast-1",
  "sourceIPAddress": "ecs.amazonaws.com",
  "userAgent": "ecs.amazonaws.com",
  "requestParameters": {
    "ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/1d61e03731b12062/001283748f5a49e2",
    "actions": [
      {
        "type": "forward",
        "forwardConfig": {
          "targetGroups": [
            {
              "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
              "weight": 0
            },
            {
              "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
              "weight": 100
            }
          ],
          "targetGroupStickinessConfig": {
            "enabled": false
          }
        }
      }
    ]
  },
  "responseElements": {
    "rules": [
      {
        "ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/1d61e03731b12062/001283748f5a49e2",
        "priority": "1",
        "conditions": [
          {
            "field": "path-pattern",
            "values": ["*"],
            "pathPatternConfig": {
              "values": ["*"]
            }
          }
        ],
        "actions": [
          {
            "type": "forward",
            "forwardConfig": {
              "targetGroups": [
                {
                  "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
                  "weight": 100
                },
                {
                  "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
                  "weight": 0
                }
              ],
              "targetGroupStickinessConfig": {
                "enabled": false
              }
            }
          }
        ],
        "isDefault": false
      }
    ]
  },
  "requestID": "1056961e-b202-41ce-aca9-90e51585b878",
  "eventID": "6431fa6d-572d-4196-b45a-9cba6f8405ce",
  "readOnly": false,
  "eventType": "AwsApiCall",
  "apiVersion": "2015-12-01",
  "managementEvent": true,
  "recipientAccountId": "XXXXXXX",
  "eventCategory": "Management"
}

テストリスナーと本番リスナーで 2 回分 ModifyRule が実行されましたが、この間の時間差は 40 秒程度でした。
そして切り替え時の時間差をカスタマイズする設定がないことには注意が必要です。
ECS ネイティブの Blue/Green デプロイでは「ベイク時間」と呼ばれる待ち時間を設定可能ですが、これはトラフィック切り替え後に既存バージョンのタスクを残す時間であり、本番リスナーの切り替えを待ってくれる時間ではないです。

ベイク時間 – 本番トラフィックが移行した後に、ブルーおよびグリーンのサービスリビジョンの両方が同時に実行される期間。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/deployment-type-blue-green.html

そのため、テストリスナーだけ切り替わった状態で何らかのテストを行うためには、Lambda を用意してライフサイクルフックとして設定する必要があります。
Blue/Green デプロイにおいて切り戻しするかどうかの判断フローは予め定めておくべきで、それを Lambda として実装すれば良いのですが、単純に CodeDeploy で設定できた部分ができなくなっているので注意が必要です。

ロールバック

設定したベイク時間の間は旧タスクも生き続けるので、リスナー設定の変更のみで切り戻しできます。
「ロールバック」を実行すれば、デプロイ同様 ECS が Modify Rule を実行してくれる形です。

bake-time.png

とはいえ、ロールバックを実行してからリスナーが書き換わるまでに 30 秒弱かかっていたので、テストリスナーだけが切り替わったタイミングで可能な限りきちんとテストを組みたいですね。

roll-back.png

この記事をシェアする

facebookのロゴhatenaのロゴtwitterのロゴ

© Classmethod, Inc. All rights reserved.