3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-03-29

はじめに

こんにちは。

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

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

図解

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

hvIIJW606R8oBLw7S1gg1711700424-1711700725.gif

検証に使ったコード (AWS CDK)

を参考で以下に置いておきます。↑の図の構成を実現できます。

lib/ecs-bg-stack.ts
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
                ),
              ],
            },
          },
        },
      ],
    });
  }
}
bin/ecs_bg.ts
#!/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');

挙動の詳細

0.スタック更新前の状態

まずスタック更新前の状態から見ていきましょう。
2-0.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

2.デプロイの作成

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

image.png

3.タスク定義・TaskSetの作成

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

4.TargetGroupと紐づけ

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

5.テスト用Listenerのルーティング変更

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

image.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

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

image.png

7.タスクセットの削除・TargetGroupとの紐づけ解除

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

8.タスク定義の登録解除

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

9.デプロの削除

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

10.スタック更新のレスポンス

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

CloudFormationのマクロ

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

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

Transform: AWS::CodeDeployBlueGreen

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

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環境削除までの時間

制約

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

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

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デプロイの挙動を図解してみました。
何かのお役に立てば幸いです。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?