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

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

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

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

※ 正確にはテストリスナーを設定しないことも可能ですが、Blue/Green デプロイでは多くの場合で設定されると考えているため、テストリスナーありで図示しています。
線形デプロイでは上のフローでは 3 つ目にあたる、本番トラフィック切り替え部分を複数ステップに分けて実施します。
この際、下記 3 つのパラメータが存在します。
| パラメータ名 | 内容 |
|---|---|
| リニアステップ割合 | 各ステップ数ごとに新たにどれだけのリクエストを新バージョンに流すか |
| リニアステップベイクタイム | 各ステップ完了後にどれだけ待機するか |
| デプロイベイクタイム | 全ステップが終わった後にどれだけ待機するか |

ステップごとにリニアステップの割合分だけ、新しいバージョン側にトラフィックを流します。
線形デプロイにおいては、ステップごとの待機時間としてリニアステップベイクタイムを指定できるようになっています。
また、テストトラフィックシフトやベイクタイムといった、Blue/Green デプロイで存在した仕組みは線形デプロイにも存在します。
Blue/Green デプロイの本番トラフィック切り替え部分を細かく制御できるモードと捉えても良さそうです。
カナリアデプロイ
カナリアデプロイでは、Canary パーセントと、Canary ベイクタイムが存在します。
最初に Canary パーセント分だけ、新しいバージョン側にトラフィックを流します。
Canary ベイクタイム分待機した後、残りのトラフィックも切り替えます。
| パラメータ名 | 内容 |
|---|---|
| Canary パーセント | 最初に新しいバージョンにトラフィックを流す割合 |
| Canary ベイク時間 | 少量のリクエストを流した後にどれだけ待機するか |
| デプロイベイク時間 | 全ステップが終わった後にどれだけ待機するか |

流れとしては下記のようになります。
注意点
線形デプロイ/カナリアデプロイ共通の注意点として、NLB には対応していません。
ALB もしくは ECS Service Connect で利用する必要があります。
試してみる
今回は線形デプロイを試してみます。
2025 年 11/4 時点では CDK は L1 コンストラクト含めて未対応でした (確認した最新バージョンは v2.221.1)。
そのため、VPC、ECR、ALB、ターゲットグループ、ALB リスナーまでは CDK で作成して、ECS サービスのみ AWS CLI で作成する方針とします。

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

利用した 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}"
上手く設定できました。

(あまり複雑にしてもわかり辛いかなと思ってシンプルにしたのですが、50% だと 2 ステップしか無いのでカナリアデプロイで良いって話はありますね...)
アプリケーションを更新していきます。
Hello World v2 と返すように更新して、イメージ push 後タスク定義更新します。

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

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

最初の段階では、本番リスナーもテストリスナーも 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%)
テストトラフィック移行フェーズに移ります。

まだ本番リスナーは 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%)
本番トラフィック移行フェーズに入ります。

本番リスナーで 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 になっていました。

リニアステップベイクタイムで設定した 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 側でも挙動を確認してみます。

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






