[アップデート] ECS のビルトインデプロイで線形デプロイとカナリアデプロイメントを利用できるようになりました

[アップデート] ECS のビルトインデプロイで線形デプロイとカナリアデプロイメントを利用できるようになりました

2025.11.04

概要

2025 年 7 月に ECS ビルトインデプロイで Blue/Green デプロイを選択できるようになりました。

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

従来ローリングアップデート以外の複雑なデプロイ方式を実現するためには CodeDeploy が必要でしたが、ビルトインデプロイの拡充でよりシンプルな設定が可能になっています。
今回のアップデートで Blue/Green デプロイに加えて CodeDeploy を利用しない形で線形デプロイとカナリアデプロイも実現可能になりました。

graph.png

https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-ecs-built-in-linear-canary-deployments/

線形デプロイ

ビルトイン Blue/Green デプロイでは、テストリスナー経由で新バージョンにトラフィックを流し始めてから一定時間経過後に本番リスナーのトラフィックも新バージョン側に流すようになります。
この際、トラフィックの切り替えはリスナールールでフォワードする際の重み付けを変更する形で行います。

  1. スタート: 旧バージョンにのみトラフィックが流れている状態

Untitled(51).png

  1. テストトラフィック切り替え: テストリスナーのみ新バージョンにトラフィックを流す

phase2.png

  1. 本番トラフィック切り替え: 本番リスナーも新バージョンにトラフィックを流す

phase3.png

※ 正確にはテストリスナーを設定しないことも可能ですが、Blue/Green デプロイでは多くの場合で設定されると考えているため、テストリスナーありで図示しています。

線形デプロイでは上のフローでは 3 つ目にあたる、本番トラフィック切り替え部分を複数ステップに分けて実施します。
この際、下記 3 つのパラメータが存在します。

パラメータ名 内容
リニアステップ割合 各ステップ数ごとに新たにどれだけのリクエストを新バージョンに流すか
リニアステップベイクタイム 各ステップ完了後にどれだけ待機するか
デプロイベイクタイム 全ステップが終わった後にどれだけ待機するか

linear.png

ステップごとにリニアステップの割合分だけ、新しいバージョン側にトラフィックを流します。
線形デプロイにおいては、ステップごとの待機時間としてリニアステップベイクタイムを指定できるようになっています。

また、テストトラフィックシフトやベイクタイムといった、Blue/Green デプロイで存在した仕組みは線形デプロイにも存在します。
Blue/Green デプロイの本番トラフィック切り替え部分を細かく制御できるモードと捉えても良さそうです。

カナリアデプロイ

カナリアデプロイでは、Canary パーセントと、Canary ベイクタイムが存在します。
最初に Canary パーセント分だけ、新しいバージョン側にトラフィックを流します。
Canary ベイクタイム分待機した後、残りのトラフィックも切り替えます。

パラメータ名 内容
Canary パーセント 最初に新しいバージョンにトラフィックを流す割合
Canary ベイク時間 少量のリクエストを流した後にどれだけ待機するか
デプロイベイク時間 全ステップが終わった後にどれだけ待機するか

canary.png

流れとしては下記のようになります。

注意点

線形デプロイ/カナリアデプロイ共通の注意点として、NLB には対応していません。
ALB もしくは ECS Service Connect で利用する必要があります。

試してみる

今回は線形デプロイを試してみます。
2025 年 11/4 時点では CDK は L1 コンストラクト含めて未対応でした (確認した最新バージョンは v2.221.1)。

https://github.com/aws/aws-cdk/releases

そのため、VPC、ECR、ALB、ターゲットグループ、ALB リスナーまでは CDK で作成して、ECS サービスのみ AWS CLI で作成する方針とします。

arch.png

マネジメントコンソール経由の作成も考えたのですが、テストリスナーの設定ができないため AWS CLI 経由としています。
※ テストリスナールールは設定できるのに、8080 番ポートなど別ポートのリスナーをテストリスナーとして指定できない...

ecs-listener.png

利用した CDK のコードは下記です。
テスト用に余分な ALB リスナーを作成していることと、インフラストラクチャーロールが必要な点に注意が必要です。
インフラストラクチャーロールの権限としては Blue/Green デプロイ同様 AmazonECSInfrastructureRolePolicyForLoadBalancers をアタッチすれば問題ありません。

import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { Construct } from "constructs";
import * as ecr from "aws-cdk-lib/aws-ecr";

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

    // VPC
    const vpc = new ec2.Vpc(this, "Vpc", {
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "Public",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "Private",
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });

    // Security Group
    const albSg = new ec2.SecurityGroup(this, "AlbSg", {
      vpc: vpc,
      allowAllOutbound: true,
    });
    albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
    const appSg = new ec2.SecurityGroup(this, "AppSg", {
      vpc: vpc,
      allowAllOutbound: true,
    });
    appSg.addIngressRule(
      ec2.Peer.securityGroupId(albSg.securityGroupId),
      ec2.Port.tcp(80)
    );
    appSg.addIngressRule(
      ec2.Peer.securityGroupId(albSg.securityGroupId),
      ec2.Port.tcp(8080)
    );

    // ECR Repository
    const repository = new ecr.Repository(this, "Repository", {
      repositoryName: "sample-ecs-app",
    });

    // ECS Cluster
    new ecs.Cluster(this, "Cluster", {
      vpc: vpc,
    });

    // ECS Task Execution Role
    const taskExecutionRole = new iam.Role(this, "TaskExecutionRole", {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy"
        ),
      ],
    });

    // ECS Task Definition
    const taskDefinition = new ecs.FargateTaskDefinition(
      this,
      "TaskDefinition",
      {
        cpu: 256,
        memoryLimitMiB: 512,
        executionRole: taskExecutionRole,
      }
    );
    const appContainer = taskDefinition.addContainer("App", {
      containerName: "app",
      image: ecs.ContainerImage.fromEcrRepository(repository, "v1"),
      essential: true,
      logging: ecs.LogDriver.awsLogs({
        streamPrefix: "sample-ecs-app",
      }),
    });
    appContainer.addPortMappings({
      containerPort: 80,
    });

    // Infrastructure Role for ECS
    const infrastructureRole = new iam.Role(this, "InfrastructureRole", {
      assumedBy: new iam.ServicePrincipal("ecs.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonECSInfrastructureRolePolicyForLoadBalancers"
        ),
      ],
    });

    // ALB
    const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
      vpc: vpc,
      internetFacing: true,
      securityGroup: albSg,
      vpcSubnets: vpc.selectSubnets({
        subnetGroupName: "Public",
      }),
    });

    // ALB Target Group Blue
    const appTargetGroupBlue = new elbv2.ApplicationTargetGroup(
      this,
      "AppTargetGroupBlue",
      {
        targetGroupName: "AppTargetGroupBlue",
        vpc: vpc,
        protocol: elbv2.ApplicationProtocol.HTTP,
        targetType: elbv2.TargetType.IP,
        healthCheck: {
          path: "/",
          port: "80",
          protocol: elbv2.Protocol.HTTP,
        },
      }
    );

    // ALB Target Group Green
    const appTargetGroupGreen = new elbv2.ApplicationTargetGroup(
      this,
      "AppTargetGroupGreen",
      {
        targetGroupName: "AppTargetGroupGreen",
        vpc: vpc,
        protocol: elbv2.ApplicationProtocol.HTTP,
        targetType: elbv2.TargetType.IP,
        healthCheck: {
          path: "/",
          port: "80",
          protocol: elbv2.Protocol.HTTP,
        },
      }
    );

    // ALB Listener
    const albListener = alb.addListener("AlbListener", {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      defaultAction: elbv2.ListenerAction.fixedResponse(404, {
        contentType: "text/plain",
        messageBody: "Not Found",
      }),
    });
    new elbv2.ApplicationListenerRule(this, "AlbListenerRule", {
      listener: albListener,
      priority: 1,
      conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
      action: elbv2.ListenerAction.forward([appTargetGroupBlue]),
    });

    const albTestListener = alb.addListener("AlbTestListener", {
      port: 8080,
      protocol: elbv2.ApplicationProtocol.HTTP,
      defaultAction: elbv2.ListenerAction.fixedResponse(404, {
        contentType: "text/plain",
        messageBody: "Not Found",
      }),
    });
    new elbv2.ApplicationListenerRule(this, "AlbTestListenerRule", {
      listener: albTestListener,
      priority: 1,
      conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
      action: elbv2.ListenerAction.forward([appTargetGroupGreen]),
    });
  }
}

アプリケーションとしてはシンプルなものを Express.js で作成しています。

import express, { Request, Response } from "express";

const app = express();

app.get("/", (request: Request, response: Response) => {
  response.status(200).send("Hello World v1");
});

app
  .listen(80, () => {
    console.log("Server running at PORT: ", 80);
  })
  .on("error", (error) => {
    throw new Error(error.message);
  });

Dockerfile は下記を利用しました。

FROM node:22-bookworm

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

EXPOSE 80
CMD ["npm", "run", "start"]

デプロイ中にリクエストを投げた際、古いバージョンと新バージョンの割合を確認したいので、テスト用スクリプトとして下記を利用します。
雑な作りですが、旧バージョンは Hello World v1 と返却させ、新バージョンは Hello World v2 と返却させた上で includes 関数でどちらのバージョンかを判定します。

const http = require("http");

const url = process.argv[2];

if (!url) {
  console.error("使用方法: node check.js <URL>");
  console.error("例: node check.js http://example.com");
  process.exit(1);
}

const requestCount = 20;
let v1count = 0;
let v2count = 0;
let done = 0;

for (let i = 0; i < requestCount; i++) {
  http
    .get(url, (res) => {
      let data = "";

      res.on("data", (chunk) => (data += chunk));
      res.on("end", () => {
        const hasV1 = data.includes("v1");
        const hasV2 = data.includes("v2");

        if (hasV1 && !hasV2) v1count++;
        if (hasV2 && !hasV1) v2count++;

        done++;

        if (done === requestCount) {
          const total = v1count + v2count;
          console.log(`Result:`);
          console.log(
            `  v1: ${v1count} (${((v1count / total) * 100).toFixed(1)}%)`
          );
          console.log(
            `  v2: ${v2count} (${((v2count / total) * 100).toFixed(1)}%)`
          );
        }
      });
    })
    .on("error", (err) => {
      console.error("Request error:", err);
    });
}

ECS サービスを作成します。
組み込みデプロイを利用する際、deployment controller は ECS になります。
deployment-configuration 側でデプロイ戦略(ローリングアップデート、Blue/Gree、線形など)を指定します。

aws ecs create-service \
  --cluster $CLUSTER_NAME \
  --service-name sample-ecs-app \
  --task-definition $TASK_DEFINITION \
  --desired-count 1 \
  --launch-type FARGATE \
  --platform-version 1.4.0 \
  --scheduling-strategy REPLICA \
  --network-configuration "awsvpcConfiguration={
      subnets=[$SUBNET1,$SUBNET2],
      securityGroups=[$SECURITY_GROUPS],
      assignPublicIp=DISABLED
  }" \
  --deployment-controller '{"type":"ECS"}' \
  --deployment-configuration '{
      "strategy": "LINEAR",
      "bakeTimeInMinutes": 5,
      "linearConfiguration": {
        "stepPercent": 50,
        "stepBakeTimeInMinutes": 5
      }
  }' \
  --load-balancers "targetGroupArn=$TARGET_GROUP_BLUE,containerName=app,containerPort=80,advancedConfiguration={alternateTargetGroupArn=$TARGET_GROUP_GREEN,productionListenerRule=$LISTENER_RULE_BLUE,testListenerRule=$LISTENER_RULE_GREEN,roleArn=$ROLE_ARN}"

上手く設定できました。

スクリーンショット 2025-11-02 22.31.20.png

(あまり複雑にしてもわかり辛いかなと思ってシンプルにしたのですが、50% だと 2 ステップしか無いのでカナリアデプロイで良いって話はありますね...)

アプリケーションを更新していきます。
Hello World v2 と返すように更新して、イメージ push 後タスク定義更新します。

スクリーンショット 2025-11-02 22.36.40.png

ECS サービスを更新します。

スクリーンショット 2025-11-02 22.37.03.png

無事デプロイメントが始まりました。

mask1.png

最初の段階では、本番リスナーもテストリスナーも v1 アプリケーションのみにトラフィックを流します。

% node check.js http://iacsta-alb16-ysj8pxeqlffw-1778688955.ap-northeast-1.elb.amazonaws.com:80
Result:
  v1: 20 (100.0%)
  v2: 0 (0.0%)
% node check.js http://iacsta-alb16-ysj8pxeqlffw-1778688955.ap-northeast-1.elb.amazonaws.com:8080
Result:
  v1: 20 (100.0%)
  v2: 0 (0.0%)

テストトラフィック移行フェーズに移ります。

mask2.png

まだ本番リスナーは v1 のみにトラフィックを流しています。

% node check.js http://iacsta-alb16-ysj8pxeqlffw-1778688955.ap-northeast-1.elb.amazonaws.com:80
Result:
  v1: 20 (100.0%)
  v2: 0 (0.0%)

テストリスナーは v2 側にトラフィックを流します。
この部分の挙動は Blue/Green デプロイと同じですね。

% node check.js http://iacsta-alb16-ysj8pxeqlffw-1778688955.ap-northeast-1.elb.amazonaws.com:8080
Result:
  v1: 0 (0.0%)
  v2: 20 (100.0%)

本番トラフィック移行フェーズに入ります。

mask3.png

本番リスナーで 50% ずつのリプライが返ってくるようになり、カナリアデプロイメントが効いていることがわかります。

% node check.js http://iacsta-alb16-ysj8pxeqlffw-1778688955.ap-northeast-1.elb.amazonaws.com:80
Result:
  v1: 10 (50.0%)
  v2: 10 (50.0%)

ターゲットグループのアクションを確認すると、各ターゲットグループへフォワードする重み付けが 500:500 になっていました。

スクリーンショット 2025-11-02 22.41.47.png

リニアステップベイクタイムで設定した 5 分経過後、再度確認すると v2 側にすべてのトラフィックをルーティングしていることを確認できました。

% node check.js http://iacsta-alb16-ysj8pxeqlffw-1778688955.ap-northeast-1.elb.amazonaws.com:80
Result:
  v1: 0 (0.0%)
  v2: 20 (100.0%)

この間はずっと「本番トラフィック移行」フェーズになるので、ライフサイクルフックは挟めないようです。
とはいえ、ライフサイクルフックはテストトラフィック移行後に挟めば良いと思います。
CloudTrail 側でも挙動を確認してみます。

スクリーンショット 2025-11-02 22.47.04.png

テストトラフィック移行も本番トラフィック移行も ECS が ModifyRule アクションを実行して、ターゲットにフォワードする重み付けを変更して実現していることがわかります。
テストリスナー側の切り替えの 42 秒後に 1 回目の本番トラフィック切り替え (50%) が実行され、リニアステップベイクタイム経過後に 2 回目の本番トラフィック切り替え (50%) が実行されています。
リニアステップベイクタイムは 5 分だったのに対して、2 回目の Modify Rule と 3 回目の Modify Rule の間は 5 分 30 秒程度でした。
リニアステップベイクタイムはそこまで厳密なものでは無く、最低限この時間は待機しますくらいに捉えておくと良さそうです。

この記事をシェアする

FacebookHatena blogX

関連記事