CloudFormationによるECSのBlue/Greenデプロイの挙動を図解したい

## はじめに
こんにちは。

Amazon ECSはCloudFormation経由でBlue/Greenデプロイを行うことができます。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/blue-green.html

挙動のイメージが湧かなかったので実際に動かして整理してみました。

## 図解
早速ですがスタックの更新によってBlue/Greenデプロイが起こった際の挙動を図にすると以下になります。（図内のリソースはスタックの新規作成時に作成されていた前提）

![hvIIJW606R8oBLw7S1gg1711700424-1711700725.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/d7250478-64ce-f967-255e-a88cb9febe45.gif)




## 検証に使ったコード (AWS CDK)
を参考で以下に置いておきます。↑の図の構成を実現できます。

<details><summary>lib/ecs-bg-stack.ts</summary>

```TypeScript
import {
  aws_ecs as ecs,
  aws_ec2 as ec2,
  aws_elasticloadbalancingv2 as elbv2,
  aws_iam as iam,
  Stack,
  StackProps,
  CfnOutput,
  Duration,
  CfnCodeDeployBlueGreenHook,
  CfnTrafficRoutingType,
} from "aws-cdk-lib";
import { Construct } from "constructs";

export class EcsBgStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
      maxAzs: 2,
    });

    // ELBのセキュリティグループ
    const lbSG = new ec2.SecurityGroup(this, "LbSG", {
      vpc: vpc,
    });
    lbSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
    lbSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(8080));

    const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
      vpc: vpc,
      internetFacing: true,
      securityGroup: lbSG,
    });
    new CfnOutput(this, "AlbDnsName", {
      value: "http://" + alb.loadBalancerDnsName,
    });

    const cluster = new ecs.Cluster(this, "Cluster", {
      clusterName: "EcsBgCluster",
    });

    // ECSサービスのセキュリティグループ
    const serviceSG = new ec2.SecurityGroup(this, "ServiceSG", {
      vpc: vpc,
    });

    serviceSG.connections.allowFrom(alb, ec2.Port.tcp(80));
    // ECS Task Definition
    const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
      memoryLimitMiB: 512,
      cpu: 256,
    });

    const containerName = "web";
    taskDefinition.addContainer("Container", {
      image: ecs.ContainerImage.fromRegistry("nginx:latest"),
      containerName: containerName,
      command: [
        "/bin/sh",
        "-c",
        "echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #00FFFF;} </style> </head><body> <div style=color:white;text-align:center> <h1>Amazon ECS Sample App</h1> <h2>Congratulations!</h2> <p>Your application is now running on a container in Amazon ECS.</p> </div></body></html>' > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'",
        //"echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #097969;} </style> </head><body> <div style=color:white;text-align:center> <h1>Amazon ECS Sample App</h1> <h2>Congratulations!</h2> <p>Your application is now running on a container in Amazon ECS.</p> </div></body></html>' > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'"
      ],
      portMappings: [
        {
          containerPort: 80,
        },
      ],
    } as ecs.ContainerDefinitionOptions);

    const targetGroupBlue = new elbv2.ApplicationTargetGroup(
      this,
      "TargetGroupBlue",
      {
        vpc: vpc,
        port: 80,
        targetType: elbv2.TargetType.IP,
        healthCheck: {
          path: "/",
          protocol: elbv2.Protocol.HTTP,
          interval: Duration.seconds(30),
          timeout: Duration.seconds(3),
        },
      }
    );

    const targetGroupGreen = new elbv2.ApplicationTargetGroup(
      this,
      "TargetGroupGreen",
      {
        vpc: vpc,
        port: 80,
        targetType: elbv2.TargetType.IP,
        healthCheck: {
          path: "/",
          protocol: elbv2.Protocol.HTTP,
          interval: Duration.seconds(30),
          timeout: Duration.seconds(3),
        },
      }
    );

    // 本番用Listener
    const listenerBlue = alb.addListener("ListenerBlue", {
      port: 80,
      defaultAction: elbv2.ListenerAction.weightedForward([
        {
          targetGroup: targetGroupBlue,
          weight: 100,
        },
      ]),
    });

    // テスト用Listener
    const listenerGreen = alb.addListener("ListenerGreen", {
      port: 8080,
      defaultAction: elbv2.ListenerAction.weightedForward([
        {
          targetGroup: targetGroupBlue,
          weight: 100,
        },
      ]),
    });

    // ECS Service
    const service = new ecs.CfnService(this, "Service", {
      cluster: cluster.clusterName,
      deploymentController: {
        type: ecs.DeploymentControllerType.EXTERNAL,
      },
      desiredCount: 1,
      propagateTags: ecs.PropagatedTagSource.SERVICE,
    });
    service.node.addDependency(listenerBlue);
    service.node.addDependency(listenerGreen);
    service.node.addDependency(targetGroupBlue);
    service.node.addDependency(targetGroupGreen);

    // ECS TaskSet
    const taskSet = new ecs.CfnTaskSet(this, "TaskSet", {
      cluster: cluster.clusterName,
      service: service.attrName,
      scale: { unit: "PERCENT", value: 100 },
      taskDefinition: taskDefinition.taskDefinitionArn,
      launchType: ecs.LaunchType.FARGATE,
      loadBalancers: [
        {
          containerName: containerName,
          containerPort: 80,
          targetGroupArn: targetGroupBlue.targetGroupArn,
        },
      ],
      networkConfiguration: {
        awsVpcConfiguration: {
          assignPublicIp: "DISABLED",
          securityGroups: [serviceSG.securityGroupId],
          subnets: vpc.selectSubnets({
            subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
          }).subnetIds,
        },
      },
    });

    new ecs.CfnPrimaryTaskSet(this, "PrimaryTaskSet", {
      cluster: cluster.clusterName,
      service: service.attrName,
      taskSetId: taskSet.attrId,
    });

    // CloudFormationマクロの設定
    this.addTransform("AWS::CodeDeployBlueGreen");
    const taskDefLogicalId = this.getLogicalId(
      taskDefinition.node.defaultChild as ecs.CfnTaskDefinition
    );
    const taskSetLogicalId = this.getLogicalId(taskSet);
    const codedeployRole = new iam.Role(this, "Role", {
      assumedBy: new iam.ServicePrincipal("codedeploy.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AWSCodeDeployRoleForECS"),
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonEC2ContainerRegistryReadOnly"
        ),
      ],
    });

    // CloudFormation Hookの設定
    new CfnCodeDeployBlueGreenHook(this, "CodeDeployBlueGreenHook", {
      trafficRoutingConfig: {
        type: CfnTrafficRoutingType.TIME_BASED_CANARY,
        timeBasedCanary: {
          stepPercentage: 20,
          bakeTimeMins: 5,
        },
      },
      additionalOptions: {
        terminationWaitTimeInMinutes: 5,
      },
      serviceRole: this.getLogicalId(
        codedeployRole.node.defaultChild as iam.CfnRole
      ),
      applications: [
        {
          target: {
            type: service.cfnResourceType,
            logicalId: this.getLogicalId(service),
          },
          ecsAttributes: {
            taskDefinitions: [taskDefLogicalId, taskDefLogicalId + "Green"],
            taskSets: [taskSetLogicalId, taskSetLogicalId + "Green"],
            trafficRouting: {
              prodTrafficRoute: {
                type: elbv2.CfnListener.CFN_RESOURCE_TYPE_NAME,
                logicalId: this.getLogicalId(
                  listenerBlue.node.defaultChild as elbv2.CfnListener
                ),
              },
              testTrafficRoute: {
                type: elbv2.CfnListener.CFN_RESOURCE_TYPE_NAME,
                logicalId: this.getLogicalId(
                  listenerGreen.node.defaultChild as elbv2.CfnListener
                ),
              },
              targetGroups: [
                this.getLogicalId(
                  targetGroupBlue.node.defaultChild as elbv2.CfnTargetGroup
                ),
                this.getLogicalId(
                  targetGroupGreen.node.defaultChild as elbv2.CfnTargetGroup
                ),
              ],
            },
          },
        },
      ],
    });
  }
}
```

</details>

<details><summary>bin/ecs_bg.ts</summary>

```Typescript
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { EcsBgStack } from '../lib/ecs_bg-stack';

const app = new cdk.App();
new EcsBgStack(app, 'ECSBlueGreen');
```

</details>

## 挙動の詳細
### 0.スタック更新前の状態
まずスタック更新前の状態から見ていきましょう。
![2-0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/5b5cea0f-b53e-e793-b151-2bf41d06737f.png)

### ALB
- ListenerとTargetGroupが2つ存在しています
    - Listenerの片方は本番用、もう片方はテスト用です
- 両方のListenerがBlueのTargetGroupに紐づけられています
- GreenのTargetGroupはLisetnerと紐づけられていません
### ECS
- TaskSetが1つ存在し、BlueのTargetGroupに紐づけられています
    - TaskSetはECSの概念でサービスに紐づくTaskの集合です。TaskSetを利用することで、1つのサービスに複数のタスク定義を紐づけることができます
- タスク定義が1つ存在しています。

CloudFormation（以下、CFn）でECSのBlue/Greenをする場合、ECSやALBのリソースは事前に作成しておくことはできません。また、他のスタックで作成したものを使うこともできません。必ず`Transform`（後述します）が存在するCFnテンプレート内で定義する必要があります。

### 1.スタックの更新

CFnテンプレート内のタスク定義またはタスクセットを変更しスタックを更新します。ここではCFnテンプレートのタスク定義に変更を加え、Webページの背景色を水色から緑色にしたとします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/16acc603-142a-e262-d873-12c94df468d4.png)


### 2.デプロイの作成
スタックの更新に伴ってまずCodeDeployのデプロイが作成されます。これによりBlue/Greenデプロイの進捗がマネジメントコンソールから視覚的にわかるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/3031c405-71d9-3ab6-53ea-60337954033f.png)


### 3.タスク定義・TaskSetの作成
タスク定義の新しいリビジョンが新規作成され、それを利用したTaskSetも作成されます。

### 4.TargetGroupと紐づけ
新しく作成されたTaskSetのタスクが既存のGreenのTargetGroupに紐づきます。

### 5.テスト用Listenerのルーティング変更
テスト用Listenerに紐づくTargetGroupがBlueからGreenに変わります。これによって以降テスト用リスナー（HTTP:8080）経由でアクセスすると更新後の背景色が緑のWebページが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/feaba586-bc22-f238-cbcf-7f6956f667ce.png)

### 6.本番用Listenerのルーティング変更
本番用Listenerが紐づくTargetGroupもBlueからGreenに最終的に変わります。これによって以降本番用Listener（HTTP:80）経由でアクセスした場合も更新後の背景色が緑のWebページが確認できます。

なお、TargetGroupを切り替えるのが一気なのか徐々になのかはオプションで指定が可能です。

|Option|Description|
|:-|:-|
|ALL_AT_ONCE|一気に切り替え。|
|TIME_BASED_CANARY|二段階で切り替え。切り替え中は一定の割合でGreenの内容が表示される|
|TIME_BASED_LINEAR|複数段階で切り替え。切り替え中はGreenの内容が表示される割合が線形的に増加する|

それぞれのTargetGroupにどのくらいの割合でトラフィックが分配されているのかはGodeDeployのデプロイメントから確認することが可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/8b9768d6-370f-e93a-8ca2-f4576df2e70a.png)

なお、トラフィックの分配にはELBの加重ターゲットグループが利用されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/f4d6f266-490b-2e46-0e38-769b76e2df93.png)

### 7.タスクセットの削除・TargetGroupとの紐づけ解除
切り替えの完了からCFnテンプレート内の`TerminationWaitTimeInMinutes`で指定した時間が経過すると、もともとあったTaskSetは削除され、TargetGroupとの紐づけも解除されます。

### 8.タスク定義の登録解除
もともとあったタスク定義のリビジョンは登録が解除されます。

### 9.デプロの削除
CodeDeployのデプロイが削除されます。

### 10.スタック更新のレスポンス
スタックの更新に成功した旨が、クライアントに返ります。もし、スタックの更新を途中でキャンセルした場合はロールバックが行われます。

## CloudFormationのマクロ
前述の挙動の実現にはCloudFormationのマクロが使われています。CloudFormationのマクロを用いるとテンプレートの置換や全体の変換などの独自処理を実行できます。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-macros.html

CloudFormationでECSのBlue/Greenを行う場合、スタックの更新を実行する前にCloudFormationのテンプレートに`Transform`で始まる行を記述することになります。これがマクロです。

```yaml
Transform: AWS::CodeDeployBlueGreen
```

このマクロに渡すパラメータを続く`Hooks`のセクションで定義することになります。

```yaml
Hooks:
  CodeDeployBlueGreenHook:
    Type: AWS::CodeDeploy::BlueGreen
    Properties:
      ServiceRole: CodeDeployServiceRoleName
      Applications:
        - Target:
            Type: AWS::ECS::Service
            LogicalID: Service
          ECSAttributes:
            TaskDefinitions:
              - AWS::ECS::TaskDefinition Resource Logical ID (Blue)
              - AWS::ECS::TaskDefinition Resource Logical ID (Green)
            TaskSets:
              - AWS::ECS::TaskSet Resource Logical ID (Blue)
              - AWS::ECS::TaskSet Resource Logical ID (Green)
            TrafficRouting:
              ProdTrafficRoute:
                Type: AWS::ElasticLoadBalancingV2::Listener
                LogicalID: Resource Logical ID (Production)
              TestTrafficRoute:
                Type: AWS::ElasticLoadBalancingV2::Listener
                LogicalID: Resource Logical ID (Test)
              TargetGroups:
                - AWS::ElasticLoadBalancingV2::TargetGroup Resource Logical ID (Blue)
                - AWS::ElasticLoadBalancingV2::TargetGroup Resource Logical ID (Green)
      TrafficRoutingConfig: # ルーティングの設定
        Type: TimeBasedCanary # 20%のトラフィックを10分間Greenにルーティングして切り替える
        TimeBasedCanary:      
          StepPercentage: 20
          BakeTimeMins: 10
      AdditionalOptions:
        TerminationWaitTimeInMinutes: 30 # Blue環境削除までの時間
```

## 制約

以下に制約の記載がありますがそこそこ注意する必要があります。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/blue-green.html#blue-green-considerations

例えばCFnテンプレート内のECSサービスのタスクの数（DesiredCount）を変更してスタックを更新しようとするとエラーが出ます。この場合、CFnテンプレート内のHookとTransformのセクションをまるっとコメントアウトしてデプロイする必要があります。

```cmd
Failed to create ChangeSet cdk-deploy-change-set on ECSBlueGreen: FAILED, 'CodeDeployBlueGreenHook' of type AWS::CodeDeploy::BlueGreen failed with message: Additional resource diff other than ECS application related resource 
update is detected,CodeDeploy can't perform BlueGreen style update properly. Diff resource logical Ids: [Service]
```

利用にあたっては各運用手順について手順の詳細をよく確認する必要があるでしょう。

## まとめ
CloudFormationによるECSのBlue/Greenデプロイの挙動を図解してみました。
何かのお役に立てば幸いです。