ECS ネイティブの Blue/Green デプロイを CDK の L2 コンストラクトで実装してみた
先日、CodeDeploy を必要としない ECS ネイティブの Blue/Green デプロイ機能を利用できるようになりました。
ECS Service Connect と Blue/Green デプロイを併用できることや、構成が複雑になりがちだった Blue/Green デプロイをシンプルに定義できることがメリットとして挙げられます。
そんな ECS の Blue/Green デプロイが CDK の L2 コンストラクトを利用して実装できるようになっていたので、試してみました。
書き方は、CDK リポジトリの README.md に記載されています。
import * as lambda from "aws-cdk-lib/aws-lambda";
declare const cluster: ecs.Cluster;
declare const taskDefinition: ecs.TaskDefinition;
declare const lambdaHook: lambda.Function;
declare const blueTargetGroup: elbv2.ApplicationTargetGroup;
declare const greenTargetGroup: elbv2.ApplicationTargetGroup;
declare const prodListenerRule: elbv2.ApplicationListenerRule;
const service = new ecs.FargateService(this, "Service", {
cluster,
taskDefinition,
deploymentStrategy: ecs.DeploymentStrategy.BLUE_GREEN,
});
service.addLifecycleHook(
new ecs.DeploymentLifecycleLambdaTarget(lambdaHook, "PreScaleHook", {
lifecycleStages: [ecs.DeploymentLifecycleStage.PRE_SCALE_UP],
})
);
const target = service.loadBalancerTarget({
containerName: "nginx",
containerPort: 80,
protocol: ecs.Protocol.TCP,
alternateTarget: new ecs.AlternateTarget("AlternateTarget", {
alternateTargetGroup: greenTargetGroup,
productionListener:
ecs.ListenerRuleConfiguration.applicationListenerRule(prodListenerRule),
}),
});
target.attachToApplicationTargetGroup(blueTargetGroup);
ECS Service の定義で deploymentStrategy
として BLUE_GREEN
を選択しつつ、service.loadBalancerTarget
を利用してターゲットグループに追加する際に、テスト用のリスナーや追加のターゲットグループを指定します。
ターゲットグループを 2 つ用意することで、各リスナーからフォワーディングするターゲットを ECS が良い感じに切り替えてくれます。
そのため、AWS マネージドポリシーである AmazonECSInfrastructureRolePolicyForLoadBalancers を付与したインフラストラクチャロールを用意する必要があることも注意が必要です。
上記 README.md 記載のコード例は簡略化されているので、付随するリソース定義も含めて実装してみたのが下記リポジトリになります。
CDK は下記構成を作成できるようにしています。
Synth して生成された CloudFormation は下記のようになります。
TargetGroupArn
として Blue 側のターゲットグループが、AdvancedConfiguration.AlternateTargetGroupArn
として Green 側のターゲットグループがきちんと指定されていますね。
ServiceD69D759B:
Type: AWS::ECS::Service
Properties:
Cluster:
Ref: ClusterEB0386A7
DeploymentConfiguration:
BakeTimeInMinutes: 2
MaximumPercent: 200
MinimumHealthyPercent: 50
Strategy: BLUE_GREEN
DesiredCount: 1
EnableECSManagedTags: false
HealthCheckGracePeriodSeconds: 60
LaunchType: FARGATE
LoadBalancers:
- AdvancedConfiguration:
AlternateTargetGroupArn:
Ref: AppTargetGroupGreen5462D905
ProductionListenerRule:
Ref: AlbListenerRuleE27FB4D8
RoleArn:
Fn::GetAtt:
- InfrastructureRoleB0C76DC8
- Arn
TestListenerRule:
Ref: AlbTestListenerRule5FCB082B
ContainerName: app
ContainerPort: 80
TargetGroupArn:
Ref: AppTargetGroupBlueE8C741C5
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: DISABLED
SecurityGroups:
- Fn::GetAtt:
- AppSg898BCA4E
- GroupId
Subnets:
- Ref: VpcPrivateSubnet1Subnet536B997A
- Ref: VpcPrivateSubnet2Subnet3788AAA1
PlatformVersion: 1.4.0
TaskDefinition:
Ref: TaskDefinitionB36D86D9
CloudFormation 実装については AWS 公式ドキュメントにもリファレンスがあり、同じ設定になっていることも確認できました。
Blue/Green デプロイの流れ
公式ドキュメント記載の図がわかりやすいので引用します。
Amazon ECS blue/green service deployments workflow
下記のような流れになります。
- 本番リスナーから Blue 側 (左側) のターゲットグループにトラフィックを流している状態
- Green 側 (右側) のターゲットグループに新しいバージョンのタスクを登録して、テストリスナー経由でリクエストを受けれるようにする
- 一定時間経過後に本番リスナー側も Green 側にトラフィックを流すように変更
これらの切り替えを ECS 側が Modify Rule API を叩きながら良い感じにやってくれます。
常に 2 つのターゲットグループが存在しますが、デプロイ開始時にどちらのターゲットグループにリクエストが流れているかはデプロイする度に交互に入れ替わります。
また、POST_TEST_TRAFFIC_SHIFT (テストリスナー経由で新しいバージョンのアプリケーションにアクセスできるようになった直後) などに Lambda 関数を利用して、アプリケーションの正常性テストを仕込むことが可能です。
デプロイしてみる
ではやってみます。
CDK デプロイ後にリクエストを投げると、下記のように返ってきます。
% curl IacSta-Alb16-c6mbkOEOUI88-826201051.ap-northeast-1.elb.amazonaws.com
Hello World v1
このタイミングでは Blue 側のターゲットグループにのみ ECS タスクがターゲットとして登録されています。
ターゲットグループ (Blue)
ターゲットグループ (Green)
リスナーはどちらも Blue 側のターゲットグループにトラフィックを流しています。
本番リスナー
テストリスナー
アプリケーションを書き換えてデプロイします。
ルートパスにアクセスして返却される文字列だけを書き換えます。
ContainerImage.fromAsset を利用しているので、アプリケーションコードを変更した上で、cdk deploy を実行すればコンテナイメージがビルドされた上でデプロイされます。
CDK 上の差分としては下記でデプロイします。
Stack IacStack
Resources
[~] AWS::ECS::TaskDefinition TaskDefinition TaskDefinitionB36D86D9 replace
└─ [~] ContainerDefinitions (requires replacement)
└─ @@ -2,7 +2,7 @@
[ ] {
[ ] "Essential": true,
[ ] "Image": {
[-] "Fn::Sub": "${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-${AWS::AccountId}-${AWS::Region}:b79076cbf77fc246e7a63db98ef2b6a8c75caefed5303da257407479c9aa9cbe"
[+] "Fn::Sub": "${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-${AWS::AccountId}-${AWS::Region}:241dd8d12ad62a4f35c4783300f1c93b7f51cbda58e4adce7c3e0b55c972ffbc"
[ ] },
[ ] "LogConfiguration": {
[ ] "LogDriver": "awslogs",
デプロイ後しばらくすると、テストリスナー経由で新しいバージョンにアクセスできるようになります。
% curl http://IacSta-Alb16-c6mbkOEOUI88-826201051.ap-northeast-1.elb.amazonaws.com:8080
Hello World v2
デプロイ段階でいうと、「テストトラフィック移行後」になった段階です。
CloudTrail を見ると ecs.amazonaws.com
が Modify Rule API を利用して、リスナールールが各ターゲットグループにトラフィックを流す際の重みを変更していることがわかります。
{
"eventVersion": "1.11",
"userIdentity": {
"type": "AssumedRole",
"principalId": "XXXXXXX:ECSNetworkingWithELB",
"arn": "arn:aws:sts::XXXXXXX:assumed-role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6/ECSNetworkingWithELB",
"accountId": "XXXXXXX",
"accessKeyId": "XXXXXXX",
"sessionContext": {
"sessionIssuer": {
"type": "Role",
"principalId": "XXXXXXX",
"arn": "arn:aws:iam::XXXXXXX:role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6",
"accountId": "XXXXXXX",
"userName": "IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6"
},
"attributes": {
"creationDate": "2025-08-30T05:35:19Z",
"mfaAuthenticated": "false"
}
},
"invokedBy": "ecs.amazonaws.com"
},
"eventTime": "2025-08-30T05:35:19Z",
"eventSource": "elasticloadbalancing.amazonaws.com",
"eventName": "ModifyRule",
"awsRegion": "ap-northeast-1",
"sourceIPAddress": "ecs.amazonaws.com",
"userAgent": "ecs.amazonaws.com",
"requestParameters": {
"ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/ef249d1afd29053c/8e3689f373a18f80",
"actions": [
{
"type": "forward",
"forwardConfig": {
"targetGroups": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
"weight": 0
},
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
"weight": 100
}
],
"targetGroupStickinessConfig": {
"enabled": false
}
}
}
]
},
"responseElements": {
"rules": [
{
"ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/ef249d1afd29053c/8e3689f373a18f80",
"priority": "1",
"conditions": [
{
"field": "path-pattern",
"values": ["*"],
"pathPatternConfig": {
"values": ["*"]
}
}
],
"actions": [
{
"type": "forward",
"forwardConfig": {
"targetGroups": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
"weight": 100
},
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
"weight": 0
}
],
"targetGroupStickinessConfig": {
"enabled": false
}
}
}
],
"isDefault": false
}
]
},
"requestID": "f26ab8db-ed22-4531-9a13-056dc5c63e99",
"eventID": "3306d1b5-7699-42d6-9667-de0e0947d15c",
"readOnly": false,
"eventType": "AwsApiCall",
"apiVersion": "2015-12-01",
"managementEvent": true,
"recipientAccountId": "XXXXXXX",
"eventCategory": "Management"
}
先にテストリスナーだけが Green 側のターゲットグループにトラフィックを流すようになっていました。
少しすると、本番側へもリクエストが通るようになります。
% curl http://IacSta-Alb16-c6mbkOEOUI88-826201051.ap-northeast-1.elb.amazonaws.com
Hello World v2
本番リスナーの設定を確認すると、こちらも Green 側のターゲットグループにトラフィックを流すようになっていました。
CloudTrail を見れば、 ecs.amazonaws.com
が同様に Modify Rule API を叩いていることを確認できます。
{
"eventVersion": "1.11",
"userIdentity": {
"type": "AssumedRole",
"principalId": "XXXXXXX:ECSNetworkingWithELB",
"arn": "arn:aws:sts::XXXXXXX:assumed-role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6/ECSNetworkingWithELB",
"accountId": "XXXXXXX",
"accessKeyId": "XXXXXXX",
"sessionContext": {
"sessionIssuer": {
"type": "Role",
"principalId": "XXXXXXX",
"arn": "arn:aws:iam::XXXXXXX:role/IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6",
"accountId": "XXXXXXX",
"userName": "IacStack-InfrastructureRoleB0C76DC8-RVjhoJmLJ3N6"
},
"attributes": {
"creationDate": "2025-08-30T05:36:00Z",
"mfaAuthenticated": "false"
}
},
"invokedBy": "ecs.amazonaws.com"
},
"eventTime": "2025-08-30T05:36:00Z",
"eventSource": "elasticloadbalancing.amazonaws.com",
"eventName": "ModifyRule",
"awsRegion": "ap-northeast-1",
"sourceIPAddress": "ecs.amazonaws.com",
"userAgent": "ecs.amazonaws.com",
"requestParameters": {
"ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/1d61e03731b12062/001283748f5a49e2",
"actions": [
{
"type": "forward",
"forwardConfig": {
"targetGroups": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
"weight": 0
},
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
"weight": 100
}
],
"targetGroupStickinessConfig": {
"enabled": false
}
}
}
]
},
"responseElements": {
"rules": [
{
"ruleArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:listener-rule/app/IacSta-Alb16-c6mbkOEOUI88/58c0dd8224412063/1d61e03731b12062/001283748f5a49e2",
"priority": "1",
"conditions": [
{
"field": "path-pattern",
"values": ["*"],
"pathPatternConfig": {
"values": ["*"]
}
}
],
"actions": [
{
"type": "forward",
"forwardConfig": {
"targetGroups": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupGreen/7617b6ca7679d5fa",
"weight": 100
},
{
"targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXX:targetgroup/AppTargetGroupBlue/d5a510d367b855d6",
"weight": 0
}
],
"targetGroupStickinessConfig": {
"enabled": false
}
}
}
],
"isDefault": false
}
]
},
"requestID": "1056961e-b202-41ce-aca9-90e51585b878",
"eventID": "6431fa6d-572d-4196-b45a-9cba6f8405ce",
"readOnly": false,
"eventType": "AwsApiCall",
"apiVersion": "2015-12-01",
"managementEvent": true,
"recipientAccountId": "XXXXXXX",
"eventCategory": "Management"
}
テストリスナーと本番リスナーで 2 回分 ModifyRule が実行されましたが、この間の時間差は 40 秒程度でした。
そして切り替え時の時間差をカスタマイズする設定がないことには注意が必要です。
ECS ネイティブの Blue/Green デプロイでは「ベイク時間」と呼ばれる待ち時間を設定可能ですが、これはトラフィック切り替え後に既存バージョンのタスクを残す時間であり、本番リスナーの切り替えを待ってくれる時間ではないです。
ベイク時間 – 本番トラフィックが移行した後に、ブルーおよびグリーンのサービスリビジョンの両方が同時に実行される期間。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/deployment-type-blue-green.html
そのため、テストリスナーだけ切り替わった状態で何らかのテストを行うためには、Lambda を用意してライフサイクルフックとして設定する必要があります。
Blue/Green デプロイにおいて切り戻しするかどうかの判断フローは予め定めておくべきで、それを Lambda として実装すれば良いのですが、単純に CodeDeploy で設定できた部分ができなくなっているので注意が必要です。
ロールバック
設定したベイク時間の間は旧タスクも生き続けるので、リスナー設定の変更のみで切り戻しできます。
「ロールバック」を実行すれば、デプロイ同様 ECS が Modify Rule を実行してくれる形です。
とはいえ、ロールバックを実行してからリスナーが書き換わるまでに 30 秒弱かかっていたので、テストリスナーだけが切り替わったタイミングで可能な限りきちんとテストを組みたいですね。