はじめに
少し遅いですが、Amazon ECS で組み込みのブルー/グリーンデプロイが利用可能になったとのことですので試してみたいと思います。
そもそも今更になったというのも、今回以下の条件でやってみたいというのがありました。
- AWS CDK で構築したい
- 本番稼働を想定してインフラとアプリケーションを分離させたい
- アプリケーションは、CI/CD によるデプロイをしたい(今回インフラは CI/CD にしませんが、間違って本番稼働へデプロイしてしまうことをミスを考慮すると、CI/CD にした方が安全かと思います)
構成図
準備する IaC コード
./lib/ecs-application-stack.ts
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 elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from 'constructs';
export interface EcsApplicationStackProps extends cdk.StackProps {
readonly containerImage?: string;
}
export class EcsApplicationStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
public readonly ecsSg: ec2.SecurityGroup;
public readonly albSg: ec2.SecurityGroup;
public readonly cluster: ecs.Cluster;
public readonly service: ecs.FargateService;
public readonly taskDefinition: ecs.FargateTaskDefinition;
public readonly alb: elbv2.ApplicationLoadBalancer;
public readonly blueTargetGroup: elbv2.ApplicationTargetGroup;
public readonly greenTargetGroup: elbv2.ApplicationTargetGroup;
constructor(scope: Construct, id: string, props?: EcsApplicationStackProps) {
super(scope, id, props);
// VPC, サブネット
this.vpc = new ec2.Vpc(this, 'ApacheVpc', {
vpcName: 'apache-vpc',
ipAddresses: ec2.IpAddresses.cidr('10.100.0.0/16'),
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
name: 'apache-public',
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
},
{
name: 'apache-private-ecs',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
{
name: 'apache-private-alb',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
{
name: 'apache-private-client',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
],
});
// ECS クラスター
this.cluster = new ecs.Cluster(this, 'ApacheCluster', {
clusterName: 'apache-cluster',
defaultCloudMapNamespace: {
name: 'apache.local',
vpc: this.vpc,
},
vpc: this.vpc,
});
// ECS タスク実行ロール
const taskExecutionRole = new iam.Role(this, "ApacheEcsTaskExecutionRole", {
roleName: "apache-ecs-task-execution-role",
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AmazonECSTaskExecutionRolePolicy"
),
],
});
// タスク定義
this.taskDefinition = new ecs.FargateTaskDefinition(this, 'ApacheTaskDefinition', {
cpu: 256,
memoryLimitMiB: 512,
executionRole: taskExecutionRole,
});
const containerImage = props?.containerImage || 'public.ecr.aws/docker/library/httpd:2.4';
this.taskDefinition.addContainer('ApacheContainer', {
containerName: 'apache-container',
image: ecs.ContainerImage.fromRegistry(containerImage),
essential: true,
logging: ecs.LogDrivers.awsLogs({
streamPrefix: 'apache',
}),
portMappings: [
{
containerPort: 80,
protocol: ecs.Protocol.TCP,
},
],
});
// セキュリティグループ (ECS)
this.ecsSg = new ec2.SecurityGroup(this, 'ApacheEcsSg', {
securityGroupName: 'apache-ecs-sg',
description: 'Security group for ECS',
vpc: this.vpc,
allowAllOutbound: true,
});
// インフラストラクチャロール
const infrastructureRole = new iam.Role(this, "InfrastructureEcsRole", {
roleName: "infrastructure-ecs-role",
assumedBy: new iam.ServicePrincipal("ecs.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"AmazonECSInfrastructureRolePolicyForLoadBalancers"
),
],
});
// ECS サービス
this.service = new ecs.FargateService(this, 'ApacheService', {
serviceName: 'apache-service',
cluster: this.cluster,
taskDefinition: this.taskDefinition,
platformVersion: ecs.FargatePlatformVersion.LATEST,
desiredCount: 1,
assignPublicIp: false,
securityGroups: [this.ecsSg],
vpcSubnets: this.vpc.selectSubnets({
subnetGroupName: "apache-private-ecs",
}),
deploymentStrategy: ecs.DeploymentStrategy.BLUE_GREEN,
bakeTime: cdk.Duration.minutes(5),
});
// セキュリティグループ(ALB)
this.albSg = new ec2.SecurityGroup(this, "ApacheAlbSg", {
securityGroupName: "apache-alb-sg",
description: "Security group for ALB",
vpc: this.vpc,
allowAllOutbound: true,
disableInlineRules: true,
});
new ec2.CfnSecurityGroupIngress(this, 'AlbSgIngress80', {
groupId: this.albSg.securityGroupId,
ipProtocol: 'tcp',
fromPort: 80,
toPort: 80,
cidrIp: '10.100.0.0/16',
});
new ec2.CfnSecurityGroupIngress(this, 'AlbSgIngress8080', {
groupId: this.albSg.securityGroupId,
ipProtocol: 'tcp',
fromPort: 8080,
toPort: 8080,
cidrIp: '10.100.0.0/16',
});
// ALB
this.alb = new elbv2.ApplicationLoadBalancer(this, 'ApacheAlb', {
loadBalancerName: 'apache-alb',
vpc: this.vpc,
internetFacing: false,
securityGroup: this.albSg,
vpcSubnets: this.vpc.selectSubnets({
subnetGroupName: "apache-private-alb",
}),
});
// ターゲットグループ (Blue)
this.blueTargetGroup = new elbv2.ApplicationTargetGroup(this, 'ApacheBlueTargetGroup', {
targetGroupName: 'apache-blue-target-group',
vpc: this.vpc,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: '/',
port: "80",
protocol: elbv2.Protocol.HTTP,
},
});
// ターゲットグループ (Green)
this.greenTargetGroup = new elbv2.ApplicationTargetGroup(this, 'ApacheGreenTargetGroup', {
targetGroupName: 'apache-green-target-group',
vpc: this.vpc,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.IP,
healthCheck: {
path: '/',
port: "80",
protocol: elbv2.Protocol.HTTP,
},
});
// リスナー
const listener = this.alb.addListener('ApacheListener', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
open: false,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: "text/plain",
messageBody: "Not Found",
}),
});
const listenerRule = new elbv2.ApplicationListenerRule(this,"AlbListenerRule",
{
listener: listener,
priority: 1,
conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
action: elbv2.ListenerAction.forward([this.blueTargetGroup]),
}
);
const testListener = this.alb.addListener("ApacheTestListener", {
port: 8080,
protocol: elbv2.ApplicationProtocol.HTTP,
open: false,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: "text/plain",
messageBody: "Not Found",
}),
});
const testListenerRule = new elbv2.ApplicationListenerRule(this,"AlbTestListenerRule",
{
listener: testListener,
priority: 1,
conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
action: elbv2.ListenerAction.forward([this.greenTargetGroup]),
}
);
const target = this.service.loadBalancerTarget({
containerName: 'apache-container',
containerPort: 80,
protocol: ecs.Protocol.TCP,
alternateTarget: new ecs.AlternateTarget("AlternateTarget", {
alternateTargetGroup: this.greenTargetGroup,
productionListener:
ecs.ListenerRuleConfiguration.applicationListenerRule(
listenerRule
),
testListener:
ecs.ListenerRuleConfiguration.applicationListenerRule(
testListenerRule
),
role: infrastructureRole,
}),
});
target.attachToApplicationTargetGroup(this.blueTargetGroup);
// ALB Outputs
new cdk.CfnOutput(this, 'LoadBalancerDNS', {
value: this.alb.loadBalancerDnsName,
description: 'DNS name of the Application Load Balancer',
exportName: `${this.stackName}-LoadBalancerDNS`,
});
}
}
./lib/ecs-cicd-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as codepipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import { Construct } from 'constructs';
export interface EcsCicdStackProps extends cdk.StackProps {
readonly ecsService: ecs.FargateService;
readonly ecrRepository?: ecr.Repository;
readonly githubOwner: string;
readonly githubRepo: string;
readonly githubBranch?: string;
readonly codestarConnectionArn: string;
}
export class EcsCicdStack extends cdk.Stack {
public readonly pipeline: codepipeline.Pipeline;
public readonly ecrRepository: ecr.Repository;
constructor(scope: Construct, id: string, props: EcsCicdStackProps) {
super(scope, id, props);
// ECR リポジトリ
this.ecrRepository = props.ecrRepository || new ecr.Repository(this, 'ApacheRepository', {
repositoryName: 'apache-repository',
imageTagMutability: ecr.TagMutability.IMMUTABLE,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// CodeBuild プロジェクト
const buildProject = new codebuild.PipelineProject(this, 'ApacheBuildProject', {
projectName: 'apache-build-project',
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
privileged: true,
computeType: codebuild.ComputeType.SMALL,
},
buildSpec: codebuild.BuildSpec.fromSourceFilename('buildspec.yml'),
environmentVariables: {
AWS_DEFAULT_REGION: {
value: this.region,
},
AWS_ACCOUNT_ID: {
value: this.account,
},
IMAGE_REPO_NAME: {
value: this.ecrRepository.repositoryName,
},
},
});
// CodeBuild に ECR へのプッシュ権限を付与
this.ecrRepository.grantPullPush(buildProject);
// CodePipeline アーティファクト
const sourceOutput = new codepipeline.Artifact('SourceOutput');
const buildOutput = new codepipeline.Artifact('BuildOutput');
// CodePipeline
this.pipeline = new codepipeline.Pipeline(this, 'ApachePipeline', {
pipelineName: 'apache-pipeline',
pipelineType: codepipeline.PipelineType.V2,
restartExecutionOnUpdate: false,
stages: [
{
stageName: 'Source',
actions: [
new codepipelineActions.CodeStarConnectionsSourceAction({
actionName: 'Source',
owner: props.githubOwner,
repo: props.githubRepo,
branch: props.githubBranch || 'main',
connectionArn: props.codestarConnectionArn,
output: sourceOutput,
}),
],
},
{
stageName: 'Build',
actions: [
new codepipelineActions.CodeBuildAction({
actionName: 'CodeBuild',
project: buildProject,
input: sourceOutput,
outputs: [buildOutput],
}),
],
},
{
stageName: 'Deploy',
actions: [
new codepipelineActions.EcsDeployAction({
actionName: 'ApacheEcsDeploy',
service: props.ecsService,
deploymentTimeout: cdk.Duration.minutes(15),
input: buildOutput,
}),
],
},
],
});
}
}
./bin/ecs-blue-green-deployment.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { EcsApplicationStack } from '../lib/ecs-application-stack';
import { EcsCicdStack } from '../lib/ecs-cicd-stack';
const app = new cdk.App();
// デフォルトの環境設定
const env = {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
};
// GitHub リポジトリ情報 (Context から取得、なければデフォルト値)
const githubOwner = app.node.tryGetContext('githubOwner') || '<GitHub のユーザー名>';
const githubRepo = app.node.tryGetContext('githubRepo') || 'ecs-blue-green-deployment';
const githubBranch = app.node.tryGetContext('githubBranch') || 'main';
// CodeStar Connection ARN (Context から取得、必須)
const codestarConnectionArn = app.node.tryGetContext('codestarConnectionArn') || '<CodeStar Connection の ARN>';
if (!codestarConnectionArn) {
throw new Error('codestarConnectionArn context value is required. Please set it using: cdk deploy -c codestarConnectionArn=arn:aws:codestar-connections:region:account:connection/connection-id');
}
// Application スタック
const appStack = new EcsApplicationStack(app, 'EcsApplicationStack', {
env,
description: 'ECS Blue/Green Deployment - Application Infrastructure',
});
// CI/CD Pipeline スタック
const cicdStack = new EcsCicdStack(app, 'EcsCicdStack', {
env,
description: 'ECS Blue/Green Deployment - CI/CD Pipeline',
ecsService: appStack.service,
githubOwner,
githubRepo,
githubBranch,
codestarConnectionArn,
});
// CI/CD スタックはアプリケーションスタックに依存
cicdStack.addDependency(appStack);
// Add tags to all resources
cdk.Tags.of(app).add('Project', 'apache-ecs');
cdk.Tags.of(app).add('Environment', app.node.tryGetContext('environment') || 'dev');
Dockerfile
FROM public.ecr.aws/docker/library/httpd:2.4
RUN echo '<html><body><h1>Apache Blue/Green Deployment - Version 2.0</h1><p>This is running with automatic pipeline trigger! </p></body></html>' > /usr/local/apache2/htdocs/index.html
EXPOSE 80
CMD ["httpd-foreground"]
buildspec.yml
version: 0.2
phases:
pre_build:
commands:
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
- REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH:=latest}
- echo Repository URI is $REPOSITORY_URI
- echo Image tag is $IMAGE_TAG
build:
commands:
- echo Build started on `date`
- echo Building the Docker image...
- docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG .
- docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $REPOSITORY_URI:$IMAGE_TAG
post_build:
commands:
- echo Build completed on `date`
- echo Pushing the Docker image...
- docker push $REPOSITORY_URI:$IMAGE_TAG
- echo Writing image definitions file for ECS built-in Blue/Green deployment...
- printf '[{"name":"apache-container","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json
- cat imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
name: BuildArtifact
cache:
paths:
- '/root/.docker/**/*'
1. アプリケーション環境の構築と動作確認
1-1. アプリケーションスタックのデプロイ
cdk deploy EcsApplicationStack
1-2. アプリケーションスタックの動作確認
デプロイされたら、一旦ここまで接続を確認しておきます。
CloudFormation の画面から ALB の DNS 名を確認し、メモしておきます。
クライアントは、CloudShell を使います。「Creat VPC environment(max 2)」をクリックします。
適当な「Name」、VPC は、「apache-vpc」、Subnet は「apache-private-clientSubnet1」、Security Group は「default」を選択し、「Create」をクリックします。
default のセキュリティグループのアウトバウンドで、送信先:0.0.0.0/0 へのすべてのトラフィックを許可するようにしておきます。
先ほどメモした ALB の DNS 名に CloudShell からアクセスできるか確認しておきます。
1-3. デプロイ内容の確認
まず、ECS からイベントを確認してみました。すると、最初は apache-blue-target-group
にデプロイされると思ったのですが、apache-green-target-group
にデプロイされていました。
次にリスナーを確認してみます。apache-green-target-group
に 100 % 転送するようなっています。
1-4. 変更を加えてデプロイ
アプリケーションの中身が変わるわけではないですが、 ./lib/ecs-application-stack.ts
の
const containerImage = props?.containerImage || 'public.ecr.aws/docker/library/httpd:2.4';
を
const containerImage = props?.containerImage || 'public.ecr.aws/docker/library/httpd:latest';
に変えて再度デプロイしてみます。
cdk deploy EcsApplicationStack
1-5. 変更を加えてデプロイ後の内容確認
ECS からイベントを確認してみます。最初は apache-green-target-group
から apache-blue-target-group
に切り替わってデプロイされていることが確認できます。
次にリスナーを確認してみます。apache-blue-target-group
に 100 % 転送するようなっています。
今回、ALB のアクセスログを有効にしておかなかったため、8080 番ポートのリスナーでテスト後、80 番ポートのリスナーへ切り替わっている様子は確認できませんでしたが、実際に詳しく動作を確認されたい方は、ALB のアクセスログを有効にしておくか、実際にトラフィックを流して確認してみると良いかと思われます。
2. CI/CD 環境の構築と動作確認
2-1. GitHub への接続の作成
以下、AWS ドキュメントを参考に GitHub への接続を作成しておきます。 ARN は次の手順で利用するのでコピペなどをして控えておきます。
2-2. CI/CD スタックのデプロイ
cdk deploy EcsCicdStack --context githubOwner=<GitHub アカウント名> --context githubRepo=ecs-blue-green-deployment --context codestarConnectionArn=<【2-1. GitHub への接続の作成】で作成した接続の Arn> --require-approval never
2-3. push してパイプラインを実行する
git add .
git commit -m "Update ECS Blue/Green deployment with CI/CD pipeline"
git push origin main
当たり前ですが、Amazon ECS で組み込みのブルー/グリーンデプロイされるため、詳細な確認は、Amazon ECS 側で確認することとなるようです。
2-4. パイプライン実行後の動作確認と内容確認
ECS からイベントを確認してみます。最初は apache-green-target-group
から apache-blue-target-group
に切り替わってデプロイされていることが確認できます。
Amazon ECS で組み込みのブルー/グリーンデプロイで実行しているため、基本的に【1-5. 変更を加えてデプロイ後の内容確認】と変わらないようです。
最後に
Amazon ECS で組み込みのブルー/グリーンデプロイを AWS CDK で CI/CD 化してみました。
今回実施はしませんでしたが気になる点として 2 つ。
- 単純にブルーグリーンでデプロイしましたが最終まで完了した後、どう切り戻しを実施するか
- AWS ブログでも紹介されているデプロイライフサイクルフックを利用することで、認証ページが突破できた場合のみ切り替えるようなことはできるか
時間に余裕が出た時に深掘りしてみたいと思います。