0
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?

ECSネイティブBlue/Green構成のServiceをCDKで書いてみる

Posted at

ECS built-in blue/green deployments

従来、ECSでBlue/Greenデプロイメントを実現するにはCodeDeployやオレオレ実装が必要でしたが、
ECSサービス内にBlue/Greenデプロイが組み込まれました!🎉🎉🎉

従来のCodeDeployとIaC(特にCDK)の相性が以下の点で悪いと個人的に感じていました。

  • CodeDeploy自体がリソースを変更するためドリフトが発生する
    • CloudFormation(CFn)にはTerraformの ignore_changes 相当の機能がない
  • ServiceのネットワークまわりがCodeDeploy(appspec.yml)経由での変更が必要で、CDKから変更できなくなる
    • CFnには AWS::CodeDeploy::BlueGreen Hook などがありましたが、CDKから扱いにくい

様々な黒魔術で対応していた方が多かったと思いますが、今回のアップデートでもしかして!と思い、CDK × ECS Blue/Greenを試してみた次第です。

参考資料/記事

Let's CDK

built-in blue/green 対応状況(2025/07/20現在)

というわけで、L2で作りつつescape hatchを利用してCDKで実装してみることにしました。

要件

  • ALB × ECS(Fargate)でHTTP経由のHello World

Code

早速ですが、コードはこんな感じです。

#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";

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

    const vpc = new ec2.Vpc(this, "VPC", {
      maxAzs: 2,
      natGateways: 0,
      ipAddresses: ec2.IpAddresses.cidr("10.128.0.0/16"),
      restrictDefaultSecurityGroup: false,
    });

    const cluster = new ecs.Cluster(this, "Cluster", {
      vpc,
    });

    const taskDef = new ecs.FargateTaskDefinition(this, "TaskDef");
    taskDef.addContainer("web", {
      image: ecs.ContainerImage.fromRegistry("nginxdemos/hello"),
      cpu: 256,
      memoryLimitMiB: 512,
      portMappings: [{ containerPort: 80 }],
    });

    const serviceSg = new ec2.SecurityGroup(this, "ServiceSG", {
      vpc,
      allowAllOutbound: true,
    });
    serviceSg.addIngressRule(
      ec2.Peer.ipv4("10.128.0.0/16"),
      ec2.Port.tcp(80),
      "Allow HTTP traffic"
    );
    const service = new ecs.FargateService(this, "Service", {
      cluster,
      taskDefinition: taskDef,
      deploymentController: {
        type: ecs.DeploymentControllerType.ECS,
      },
      securityGroups: [serviceSg],
      assignPublicIp: true,
      enableExecuteCommand: true, // for debugging
    });

    const targetGroupA = new elbv2.ApplicationTargetGroup(
      this,
      "TargetGroupA",
      {
        vpc,
        protocol: elbv2.ApplicationProtocol.HTTP,
        port: 80,
        targetType: elbv2.TargetType.IP,
      }
    );

    const targetGroupB = new elbv2.ApplicationTargetGroup(
      this,
      "TargetGroupB",
      {
        vpc,
        protocol: elbv2.ApplicationProtocol.HTTP,
        port: 80,
        targetType: elbv2.TargetType.IP,
      }
    );

    const albSg = new ec2.SecurityGroup(this, "AlbSG", {
      vpc,
      allowAllOutbound: true,
    });
    albSg.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(80),
      "Allow HTTP Prod traffic"
    );
    albSg.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(8080),
      "Allow HTTP Test traffic"
    );
    const alb = new elbv2.ApplicationLoadBalancer(this, "ALB", {
      vpc,
      internetFacing: true,
      securityGroup: albSg,
    });
    const prodListener = alb.addListener("ProdListener", {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      open: true,
      defaultAction: elbv2.ListenerAction.fixedResponse(500),
    });
    const prodListenerRule = new elbv2.ApplicationListenerRule(
      this,
      "ProdListenerRule",
      {
        listener: prodListener,
        priority: 1,
        conditions: [elbv2.ListenerCondition.pathPatterns(["/*"])],
        // NOTE: 以下はECS Deploymentによって変更されるためドリフトする点に留意
        //       CustomResourceなどで初回以降はCFnの管理外に置いて、ECS Deployment側の変更を尊重した方が安全かも
        action: elbv2.ListenerAction.weightedForward([
          { targetGroup: targetGroupA, weight: 1 },
          { targetGroup: targetGroupB, weight: 0 },
        ]),
      }
    );

    const testListener = alb.addListener("TestListener", {
      port: 8080,
      protocol: elbv2.ApplicationProtocol.HTTP,
      open: true,
      defaultAction: elbv2.ListenerAction.fixedResponse(500),
    });
    const testListenerRule = new elbv2.ApplicationListenerRule(
      this,
      "TestListenerRule",
      {
        listener: testListener,
        priority: 1,
        conditions: [elbv2.ListenerCondition.pathPatterns(["/*"])],
        // NOTE: 以下はECS Deploymentによって変更されるためドリフトする点に留意
        //       CustomResourceなどで初回以降はCFnの管理外に置いて、ECS Deployment側の変更を尊重した方が安全かも
        action: elbv2.ListenerAction.weightedForward([
          { targetGroup: targetGroupA, weight: 1 },
          { targetGroup: targetGroupB, weight: 0 },
        ]),
      }
    );

    // escape hatch for ECS Service
    // ref. https://github.com/aws/aws-cdk/issues/35010
    // ref. https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/cfn-layer.html
    const serviceCf = service.node.defaultChild as ecs.CfnService;
    serviceCf.loadBalancers = [
      {
        targetGroupArn: targetGroupA.targetGroupArn,
        containerName: "web",
        containerPort: 80,
        advancedConfiguration: {
          alternateTargetGroupArn: targetGroupB.targetGroupArn,

          // ALBの場合はlistenerではなく、listenerRuleを指定する点に留意
          productionListenerRule: prodListenerRule.listenerRuleArn,
          testListenerRule: testListenerRule.listenerRuleArn,

          // ref. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AmazonECSInfrastructureRolePolicyForLoadBalancers.html
          roleArn: new iam.Role(this, "BlueGreenRole", {
            assumedBy: new iam.ServicePrincipal("ecs.amazonaws.com"),
            managedPolicies: [
              iam.ManagedPolicy.fromAwsManagedPolicyName(
                "AmazonECSInfrastructureRolePolicyForLoadBalancers"
              ),
            ],
            // ドキュメントに記載が無いが以下ポリシーも必要
            inlinePolicies: {
              DescribeTargets: new iam.PolicyDocument({
                statements: [
                  new iam.PolicyStatement({
                    actions: [
                      "elasticloadbalancing:DescribeTargetGroups",
                      "elasticloadbalancing:DescribeTargetHealth",
                    ],
                    resources: ["*"],
                  }),
                ],
              }),
              ModifyTargetGroup: new iam.PolicyDocument({
                statements: [
                  new iam.PolicyStatement({
                    actions: [
                      "elasticloadbalancing:RegisterTargets",
                      "elasticloadbalancing:DeregisterTargets",
                    ],
                    resources: [
                      targetGroupA.targetGroupArn,
                      targetGroupB.targetGroupArn,
                    ],
                  }),
                ],
              }),
            },
          }).roleArn,
        },
      },
    ];
    serviceCf.deploymentConfiguration = {
      strategy: "BLUE_GREEN",
    };
  }
}

const app = new cdk.App();
new ECSBlueGreenSampleStack(app, 'ECSBlueGreenSampleStack', {});

実装時の課題

1. CFnの制限への対応

課題: CFnではListenerのdefault actionのARNが取得できない

対処法: そのためdefault actionは500エラーを返すように設定し、別途Listener Ruleを作成して処理する形を取りました。

2. リソースのドリフト問題

課題: Listener RuleがECSデプロイメント時に変更され、CDKコードとドリフトする

  • ECS Deployment側が変更したListener RuleをCDKから変更してしまうと、タイミングによってはターゲットがいなくなる障害が発生する可能性。
  • IaC理念的に気持ち悪さあり。

対処法(案):

  • CustomResourceなどで初回以外は変更できなくするほうが安全?
    • ECS Deployment側の変更を尊重した方が良いと思う

それでもネットワークまわりの設定が宣言できるようになったのはめちゃくちゃありがたい!

3. IAMロールの追加権限

課題: ドキュメントにはマネージドポリシー AmazonECSInfrastructureRolePolicyForLoadBalancers を指定すれば行けるような書き方だが、実際には追加でポリシーが必要

必要Action (いずれもリソース指定不可のActionです)

  • elasticloadbalancing:DescribeTargetGroups
  • elasticloadbalancing:DescribeTargetHealth
  • elasticloadbalancing:RegisterTargets
  • elasticloadbalancing:DeregisterTargets

(めちゃくちゃハマったんですがt-kikucさんの記事に助けられました、ありがとうございます🙏)

まとめ

ドリフトの課題は完全に解消されなかったものの、今まで試したCDK × ECS Blue/Greenで最もシンプルに書けたのは間違いないです。

従来のCodeDeployベースの実装と比較して

  • 設定がめちゃくちゃシンプルになった、知るべき概念が減った
  • CDKでネットワーク設定を宣言的に管理できる
  • ECS内で完結するため、運用の複雑さが軽減
    • CI/CDもシンプルに構成できそう

個人的にはAWS 2025年一番の神アップデートでした!

Deployment Lifecycle Hook

今回は極力シンプルにするためDeployment Lifecycle Hookを利用していませんが、かなりプリミティブなAPIなので、組織のルール・文化に合わせた柔軟なデプロイフローを組むなんて事もできると思います!

感想

正式にL2が出るまではプロダクションで使うのは少々抵抗がありますが、それでも今からCodeDeployで構成する手はもう無いかな、という所感です。改めて神アプデ!

同じ悩みをお持ちの方がいれば是非一度試してみてほしいです!

それでは、良いCDKライフを!

0
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
0
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?