CDKで構築したECS Fargateのデプロイをecspressoに移行してみた(RemovalPolicy.RETAINパターン)
CDKで構築したECS Fargateサービスを、ecspressoでデプロイする構成に移行してみました。CDKのRemovalPolicy.RETAINを使って、稼働中のサービスを止めずにecspressoへ引き継ぐ手順を紹介します。
背景
CDKでECSをデプロイしている場合、タスク定義の更新時にCloudFormationのクロススタック参照エラーが出ることがあります。ECSのデプロイ部分をecspressoに切り出すことで、この問題を回避しつつ速くデプロイできます。
前提
- AWS CDK(TypeScript)でECS Fargateを構築した経験がある
- ecspresso v2
- サンプルアプリ: nginx(最小構成)
- CDKの2スタック構成(InfraStack + EcsServiceStack)から移行します
CDKでECS Fargate環境を構築する
CDK TypeScriptプロジェクトを作成して、2スタック構成でデプロイします。
- InfraStack: VPC、ALB、ECSクラスター、IAMロールなど(移行後もCDKでの管理を継続)
- EcsServiceStack: タスク定義とFargateService(ecspressoに移行する対象)
bin/app.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { InfraStack } from "../lib/infra-stack";
import { EcsServiceStack } from "../lib/ecs-service-stack";
const app = new cdk.App();
const infra = new InfraStack(app, "EcspressoDemoInfraStack", {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: "ap-northeast-1",
},
});
new EcsServiceStack(app, "EcspressoDemoEcsServiceStack", {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: "ap-northeast-1",
},
cluster: infra.cluster,
blueTargetGroup: infra.blueTargetGroup,
greenTargetGroup: infra.greenTargetGroup,
prodListenerRule: infra.prodListenerRule,
blueGreenRole: infra.blueGreenRole,
taskExecutionRole: infra.taskExecutionRole,
taskRole: infra.taskRole,
logGroup: infra.logGroup,
containerSecurityGroup: infra.containerSecurityGroup,
privateSubnets: infra.privateSubnets,
});
lib/infra-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
export class InfraStack extends cdk.Stack {
public readonly cluster: ecs.Cluster;
public readonly blueTargetGroup: elbv2.ApplicationTargetGroup;
public readonly greenTargetGroup: elbv2.ApplicationTargetGroup;
public readonly prodListenerRule: elbv2.ApplicationListenerRule;
public readonly blueGreenRole: iam.Role;
public readonly taskExecutionRole: iam.Role;
public readonly taskRole: iam.Role;
public readonly logGroup: logs.LogGroup;
public readonly containerSecurityGroup: ec2.SecurityGroup;
public readonly privateSubnets: ec2.ISubnet[];
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// VPC
const vpc = new ec2.Vpc(this, "Vpc", {
maxAzs: 2,
natGateways: 1,
});
// ECS Cluster
this.cluster = new ecs.Cluster(this, "Cluster", {
vpc,
clusterName: "ecspresso-demo-cluster",
});
// Security Groups
const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSg", {
vpc,
description: "ALB Security Group",
});
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(80),
"Allow HTTP"
);
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(8080),
"Allow test listener"
);
this.containerSecurityGroup = new ec2.SecurityGroup(
this,
"ContainerSg",
{
vpc,
description: "ECS Container Security Group",
}
);
this.containerSecurityGroup.addIngressRule(
albSecurityGroup,
ec2.Port.tcp(80),
"Allow from ALB"
);
// ALB
const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
vpc,
internetFacing: true,
securityGroup: albSecurityGroup,
});
// ブルー/グリーン用ターゲットグループ
this.blueTargetGroup = new elbv2.ApplicationTargetGroup(this, "BlueTg", {
vpc,
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: "/",
interval: cdk.Duration.seconds(5),
timeout: cdk.Duration.seconds(2),
healthyThresholdCount: 2,
},
deregistrationDelay: cdk.Duration.seconds(5),
});
this.greenTargetGroup = new elbv2.ApplicationTargetGroup(this, "GreenTg", {
vpc,
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: "/",
interval: cdk.Duration.seconds(5),
timeout: cdk.Duration.seconds(2),
healthyThresholdCount: 2,
},
deregistrationDelay: cdk.Duration.seconds(5),
});
// 本番用リスナー・リスナールール
const prodListener = alb.addListener("ProdListener", {
port: 80,
defaultAction: elbv2.ListenerAction.fixedResponse(404),
});
this.prodListenerRule = new elbv2.ApplicationListenerRule(this, "ProdRule", {
listener: prodListener,
priority: 1,
conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
targetGroups: [this.blueTargetGroup],
});
// テスト用リスナー・リスナールール
const testListener = alb.addListener("TestListener", {
port: 8080,
defaultAction: elbv2.ListenerAction.fixedResponse(404),
});
new elbv2.ApplicationListenerRule(this, "TestRule", {
listener: testListener,
priority: 1,
conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
targetGroups: [this.blueTargetGroup],
});
// IAM Roles
this.taskExecutionRole = new iam.Role(this, "TaskExecutionRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AmazonECSTaskExecutionRolePolicy"
),
],
});
this.taskRole = new iam.Role(this, "TaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
});
// B/Gデプロイ用ロール(ECSがリスナールールのターゲットグループを切り替える際に使用)
this.blueGreenRole = new iam.Role(this, "AlternateTargetRole", {
assumedBy: new iam.ServicePrincipal("ecs.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"AmazonECSInfrastructureRolePolicyForLoadBalancers"
),
],
});
// CloudWatch Logs
this.logGroup = new logs.LogGroup(this, "LogGroup", {
logGroupName: "/ecs/ecspresso-demo",
removalPolicy: cdk.RemovalPolicy.DESTROY,
retention: logs.RetentionDays.ONE_WEEK,
});
this.privateSubnets = vpc.privateSubnets;
// Outputs(ecspressoのcloudformationプラグインから参照する)
new cdk.CfnOutput(this, "ClusterName", {
value: this.cluster.clusterName,
exportName: "ecspresso-demo-cluster-name",
});
new cdk.CfnOutput(this, "PrivateSubnet1Id", {
value: vpc.privateSubnets[0].subnetId,
exportName: "ecspresso-demo-private-subnet-1-id",
});
new cdk.CfnOutput(this, "PrivateSubnet2Id", {
value: vpc.privateSubnets[1].subnetId,
exportName: "ecspresso-demo-private-subnet-2-id",
});
new cdk.CfnOutput(this, "ContainerSecurityGroupId", {
value: this.containerSecurityGroup.securityGroupId,
exportName: "ecspresso-demo-container-sg-id",
});
new cdk.CfnOutput(this, "TaskExecutionRoleArn", {
value: this.taskExecutionRole.roleArn,
exportName: "ecspresso-demo-task-execution-role-arn",
});
new cdk.CfnOutput(this, "TaskRoleArn", {
value: this.taskRole.roleArn,
exportName: "ecspresso-demo-task-role-arn",
});
new cdk.CfnOutput(this, "LogGroupName", {
value: this.logGroup.logGroupName,
exportName: "ecspresso-demo-log-group-name",
});
new cdk.CfnOutput(this, "BlueTargetGroupArn", {
value: this.blueTargetGroup.targetGroupArn,
exportName: "ecspresso-demo-blue-tg-arn",
});
new cdk.CfnOutput(this, "GreenTargetGroupArn", {
value: this.greenTargetGroup.targetGroupArn,
exportName: "ecspresso-demo-green-tg-arn",
});
new cdk.CfnOutput(this, "ProdListenerRuleArn", {
value: this.prodListenerRule.listenerRuleArn,
exportName: "ecspresso-demo-prod-listener-rule-arn",
});
new cdk.CfnOutput(this, "BlueGreenRoleArn", {
value: this.blueGreenRole.roleArn,
exportName: "ecspresso-demo-blue-green-role-arn",
});
new cdk.CfnOutput(this, "AlbDnsName", {
value: alb.loadBalancerDnsName,
});
}
}
lib/ecs-service-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
interface EcsServiceStackProps extends cdk.StackProps {
cluster: ecs.Cluster;
blueTargetGroup: elbv2.ApplicationTargetGroup;
greenTargetGroup: elbv2.ApplicationTargetGroup;
prodListenerRule: elbv2.ApplicationListenerRule;
blueGreenRole: iam.Role;
taskExecutionRole: iam.Role;
taskRole: iam.Role;
logGroup: logs.LogGroup;
containerSecurityGroup: ec2.SecurityGroup;
privateSubnets: ec2.ISubnet[];
}
export class EcsServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: EcsServiceStackProps) {
super(scope, id, props);
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
cpu: 256,
memoryLimitMiB: 512,
executionRole: props.taskExecutionRole,
taskRole: props.taskRole,
family: "ecspresso-demo",
});
taskDefinition.addVolume({ name: "nginx-cache" });
taskDefinition.addVolume({ name: "nginx-run" });
taskDefinition.addVolume({ name: "tmp" });
const container = taskDefinition.addContainer("nginx", {
image: ecs.ContainerImage.fromRegistry(
"public.ecr.aws/nginx/nginx:1.27"
),
portMappings: [{ containerPort: 80 }],
logging: ecs.LogDrivers.awsLogs({
streamPrefix: "nginx",
logGroup: props.logGroup,
}),
readonlyRootFilesystem: true,
});
container.addMountPoints(
{ sourceVolume: "nginx-cache", containerPath: "/var/cache/nginx", readOnly: false },
{ sourceVolume: "nginx-run", containerPath: "/var/run", readOnly: false },
{ sourceVolume: "tmp", containerPath: "/tmp", readOnly: false },
);
const service = new ecs.FargateService(this, "Service", {
cluster: props.cluster,
taskDefinition,
desiredCount: 1,
serviceName: "ecspresso-demo-service",
assignPublicIp: false,
securityGroups: [props.containerSecurityGroup],
vpcSubnets: { subnets: props.privateSubnets },
deploymentStrategy: ecs.DeploymentStrategy.BLUE_GREEN,
bakeTime: cdk.Duration.minutes(15),
});
const target = service.loadBalancerTarget({
containerName: "nginx",
containerPort: 80,
protocol: ecs.Protocol.TCP,
alternateTarget: new ecs.AlternateTarget("AlternateTarget", {
alternateTargetGroup: props.greenTargetGroup,
productionListener:
ecs.ListenerRuleConfiguration.applicationListenerRule(
props.prodListenerRule
),
role: props.blueGreenRole,
}),
});
target.attachToApplicationTargetGroup(props.blueTargetGroup);
}
}
デプロイ
cdk deploy --all
デプロイ完了後に、CfnOutputに表示されるALBのDNS名にアクセスするとnginxのデフォルトページが表示されます。

CDKだけでECSを管理する場合の課題
VPCやIAMロールなどのインフラリソースは、一度構築したらあまり変更しません。一方、ECSにデプロイするアプリケーションは日常的に更新します。この2つはライフサイクルが異なりますが、CDKで一括管理するとその違いが見えにくくなります。
イメージタグを変えるだけでもCloudFormationスタックの更新が走り、数分かかります。ecspressoでECSのデプロイを分離すると、ECS APIを直接呼ぶため速くデプロイできます。

ecspressoの紹介とインストール
ecspressoはKayac社製のECSデプロイツールです。ECSのサービスとタスク定義をJSON/YAMLで管理し、ECS APIを直接呼んでデプロイします。
CDKでインフラ基盤(VPC、ALB、IAMロールなど)を管理して、ecspressoでタスク定義とサービスのデプロイを担当する構成になります。
インストールはHomebrewで行います。
brew install kayac/tap/ecspresso
ecspresso init で設定を自動生成
ecspressoには、既存のECSサービスから設定ファイルを自動生成する機能があります。
mkdir ecspresso && cd ecspresso
ecspresso init \
--cluster ecspresso-demo-cluster \
--service ecspresso-demo-service \
--region ap-northeast-1
3つのファイルが生成されます。
ecspresso.yml: クラスター名・サービス名・リージョンなどの基本設定ecs-service-def.json: サービス定義(ネットワーク設定、デプロイ設定、ターゲットグループ)ecs-task-def.json: タスク定義(コンテナ定義、CPU/メモリ、IAMロール)
cloudformationプラグインの設定
生成された設定ファイルにはサブネットIDやセキュリティグループIDがハードコードされています。CDKのCfnOutputから動的に取得するよう、cloudformationプラグインを設定します。
ecspresso.ymlにプラグインを追加します。timeoutはデフォルトの10分ではB/GデプロイのBakeフェーズ(15分)中にタイムアウトするため、25分に変更しています。
region: ap-northeast-1
cluster: ecspresso-demo-cluster
service: ecspresso-demo-service
service_definition: ecs-service-def.json
task_definition: ecs-task-def.json
timeout: "25m0s"
+plugins:
+ - name: cloudformation
ecs-service-def.jsonのハードコード値をプラグインのテンプレートに置き換えます。
B/G構成ではloadBalancersにadvancedConfigurationが含まれます。ハードコード値をcloudformationプラグインのテンプレートに置き換えます。
"loadBalancers": [
{
"advancedConfiguration": {
- "alternateTargetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:targetgroup/green-...",
+ "alternateTargetGroupArn": "{{ cfn_output `EcspressoDemoInfraStack` `GreenTargetGroupArn` }}",
- "productionListenerRule": "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:listener-rule/...",
+ "productionListenerRule": "{{ cfn_output `EcspressoDemoInfraStack` `ProdListenerRuleArn` }}",
- "roleArn": "arn:aws:iam::123456789012:role/EcspressoDemoEcsServiceSt-ServiceAlternateTargetRol-..."
+ "roleArn": "{{ cfn_output `EcspressoDemoInfraStack` `BlueGreenRoleArn` }}"
},
"containerName": "nginx",
"containerPort": 80,
- "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:targetgroup/blue-..."
+ "targetGroupArn": "{{ cfn_output `EcspressoDemoInfraStack` `BlueTargetGroupArn` }}"
}
],
"networkConfiguration": {
"awsvpcConfiguration": {
"assignPublicIp": "DISABLED",
"securityGroups": [
- "sg-0123456789abcdef0"
+ "{{ cfn_output `EcspressoDemoInfraStack` `ContainerSecurityGroupId` }}"
],
"subnets": [
- "subnet-0123456789abcdef0",
- "subnet-0123456789abcdef1"
+ "{{ cfn_output `EcspressoDemoInfraStack` `PrivateSubnet1Id` }}",
+ "{{ cfn_output `EcspressoDemoInfraStack` `PrivateSubnet2Id` }}"
]
}
},
ecs-task-def.jsonも同様に書き換えます。
"logConfiguration": {
"logDriver": "awslogs",
"options": {
- "awslogs-group": "/ecs/ecspresso-demo",
+ "awslogs-group": "{{ cfn_output `EcspressoDemoInfraStack` `LogGroupName` }}",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "nginx"
}
},
// ...省略...
- "executionRoleArn": "arn:aws:iam::123456789012:role/EcspressoDemoInfraStack-TaskExecutionRole...",
+ "executionRoleArn": "{{ cfn_output `EcspressoDemoInfraStack` `TaskExecutionRoleArn` }}",
"family": "ecspresso-demo",
// ...省略...
- "taskRoleArn": "arn:aws:iam::123456789012:role/EcspressoDemoInfraStack-TaskRole..."
+ "taskRoleArn": "{{ cfn_output `EcspressoDemoInfraStack` `TaskRoleArn` }}",
ecspressoの基本操作
設定ファイルを準備したら、ecspressoの基本的なコマンドを試してみます。
差分確認
ecspresso diff
CloudFormationスタックから値を取得し、現在のECSサービスとの差分を表示します。プラグイン設定が正しければ差分はなく、INFO行のみ出力されます。
2026-04-13T17:19:32.086+09:00 [INFO] ecspresso version [version:v2.8.0]
デプロイ
イメージタグをnginx:1.27からnginx:1.26に変えてデプロイしてみます。ecs-task-def.jsonのimageを書き換えてecspresso diffを実行すると、変更箇所が表示されます。
$ ecspresso diff
--- arn:aws:ecs:ap-northeast-1:123456789012:task-definition/ecspresso-demo:1
+++ ecs-task-def.json
@@ -4,7 +4,7 @@
"cpu": 0,
"dockerLabels": {},
"essential": true,
- "image": "public.ecr.aws/nginx/nginx:1.27",
+ "image": "public.ecr.aws/nginx/nginx:1.26",
"logConfiguration": {
"logDriver": "awslogs",
"options": {
差分を確認したらデプロイします。B/Gデプロイではbakeフェーズ(15分)があるため、--no-waitでデプロイを投入します。
ecspresso deploy --no-wait
デプロイ後にecspresso diffを実行して差分がなければ、設定通りに反映されています。
ロールバック
ecspresso rollback
直前のタスク定義リビジョンに戻します。nginx:1.27に戻ったことを確認できます。
状態確認
ecspresso status
サービスのrunningCount、desiredCount、最新のデプロイメント状態などを確認できます。
ecspresso verify
サービスの設定が正しいかを検証します。ターゲットグループのヘルスチェックやIAMロールの権限なども確認されます。
CDKからecspressoへの移行(DeletionPolicy=Retainパターン)
稼働中のECSサービスを止めずに、CDK管理からecspresso管理へ切り替えます。
移行は3ステップで行います。
CDKでRemovalPolicy.RETAINを設定する
ecs-service-stack.tsのタスク定義とサービスにRemovalPolicy.RETAINを設定します。
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
cpu: 256,
memoryLimitMiB: 512,
executionRole: props.taskExecutionRole,
taskRole: props.taskRole,
family: "ecspresso-demo",
});
+taskDefinition.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
taskDefinition.addVolume({ name: "nginx-cache" });
// ...省略...
const service = new ecs.FargateService(this, "Service", {
// ...省略...
deploymentStrategy: ecs.DeploymentStrategy.BLUE_GREEN,
});
+service.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
const target = service.loadBalancerTarget({
// ...省略...
});
デプロイします。
cdk deploy EcspressoDemoEcsServiceStack
このデプロイではリソースの動作に変更はありません。CloudFormationテンプレートのDeletionPolicyがDeleteからRetainに変わるだけです。
CDKからタスク定義とサービスを削除する
ecs-service-stack.tsからタスク定義とFargateServiceの記述を削除します。
export class EcsServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: EcsServiceStackProps) {
super(scope, id, props);
- const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
- // ...タスク定義の全体を削除...
- });
-
- // ...コンテナ、マウントポイント、サービスの定義をすべて削除...
-
- service.attachToApplicationTargetGroup(props.targetGroup);
+ // タスク定義とサービスを削除済み
+ // ecspressoで管理する
}
}
デプロイします。
cdk deploy EcspressoDemoEcsServiceStack
CloudFormationはスタックからタスク定義とサービスを削除しますが、Retainが設定されているため実リソースは削除されません。ALB経由でnginxにアクセスできることを確認します。
curl http://<ALBのDNS名>/
nginxのレスポンスが返れば、リソースが残っていることが確認できます。
ecspressoでデプロイする
CloudFormationの管理から外れたECSサービスを、ecspressoで引き継ぎます。
ecspresso diff
ecspressoが既存のサービスとタスク定義を認識していることを確認します。
イメージタグを変えてデプロイしてみます。ecs-task-def.jsonのimageをpublic.ecr.aws/nginx/nginx:1.26に変更し、デプロイします。
ecspresso deploy --no-wait
ecspresso diffで差分がなくなれば、ecspressoでの管理に移行できています。
ecspresso run でワンオフタスク実行
ecspressoにはワンオフタスク実行の機能もあります。DBマイグレーションやバッチ処理で使います。
overridesをJSONファイルに切り出しておくと便利です。
{
"containerOverrides": [
{
"name": "nginx",
"command": ["sh", "-c", "echo Migration started && nginx -v && echo Done"]
}
]
}
ecspresso run --overrides-file overrides-demo.json --watch-container nginx
--watch-containerを指定すると、タスクのログがリアルタイムに表示されます。タスクが完了すると終了コードが返ります(成功=0、失敗=非0)。
ネットワーク設定(サブネット、セキュリティグループ)はサービス定義のnetworkConfigurationがそのまま適用されます。常駐サービスとは違い、コマンド実行後にタスクは自動で停止します。CIからDBマイグレーションを実行するような用途で便利です。
クリーンアップ
検証が終わったらリソースを削除します。
# ECSサービスを停止・削除
ecspresso scale --tasks 0
ecspresso delete --force
# インフラリソースを削除
cdk destroy --all
おわりに
CDKでインフラ基盤を管理しつつ、ECSのデプロイはecspressoに任せる構成にすると、クロススタック参照の問題を回避できます。RemovalPolicy.RETAINパターンで、稼働中のサービスを止めずに移行できました。







