🌲
AWS ECS Blue/Green デプロイハンズオンを CDK で書いてみた
CDK の勉強のために AWS が提供しているこちらのハンズオンを CDK で書いてみました。
きっかけになったのはこちらの記事です。
AWS CDKでECS Blue Green DeploymentのL2 Constructがキタ
CDK で Blue Green Deployment が簡単に記述できるようになったということで試しにやってみることにしました。
インフラは得意ではないので適切でない部分があれば教えていただけるとうれしいです。
コードはこちらです。
- CDK
- CodePipeline のパートで使われる Rails アプリケーション
- shshimamo/handsonuser-ecsdemo-frontend
- (ハンズオンでは CodeCommit を使っていますが今回は GitHub を使いました)
Stack は以下のように分けています。
- infrastructure-stack.ts
- VPC、ターゲットグループ、ロググループなど
- frontend-service-stack.ts
- ECS サービス。フロントエンドとなる Rails のデモアプリ。 Blue/Green デプロイ。
- backend-service-crystal-stack.ts
- ECS サービス。バックエンドとなる Crystal のデモアプリ
- backend-service-nodejs-stack.ts
- ECS サービス。バックエンドとなる Node.js のデモアプリ
- ecr-stack.ts
- フロントエンドサービス(Rails)のイメージを保存する ECR
- frontend-pipeline-stack.ts
- フロントエンドサービス(Rails)のための CodePipeline
内容についてはハンズオンの資料を見ていただくのが良いと思うので割愛します。
CDK 化を行うにあたり、以下の記事を参考にさせていただきました。ありがとうございます。
AWS CDKでECS Blue Green DeploymentのL2 Constructがキタ
AWS CDK の歩き方 〜 ECS 環境構築編
最後に CodeDeploy に関連する infrastructure-stack.ts
、frontend-service-stack.ts
、frontend-pipeline-stack.ts
のコードを載せておきます。
infrastructure-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 iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as servicediscovery from 'aws-cdk-lib/aws-servicediscovery';
import { Construct } from 'constructs';
import { Context } from './common/context'
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
export class InfrastructureStack extends cdk.Stack {
public readonly cluster: ecs.Cluster;
public readonly frontendServiceSG: ec2.SecurityGroup;
public readonly backendServiceSG: ec2.SecurityGroup;
public readonly cloudmapNamespace: servicediscovery.PrivateDnsNamespace;
public readonly frontendTaskRole: iam.Role;
public readonly backendCrystalTaskRole: iam.Role;
public readonly backendNodejsTaskRole: iam.Role;
public readonly TaskExecutionRole: iam.Role;
public readonly frontendLogGroup: logs.LogGroup;
public readonly backendCrystalLogGroup: logs.LogGroup;
public readonly backendNodejsLogGroup: logs.LogGroup;
public readonly frontendBuildProjectLogGroup: logs.LogGroup;
public readonly blueTargetGroup: elbv2.ApplicationTargetGroup;
public readonly greenTargetGroup: elbv2.ApplicationTargetGroup;
public readonly frontListener: elbv2.ApplicationListener;
public readonly frontTestListener: elbv2.ApplicationListener;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// create a VPC
const vpc = new ec2.Vpc(this, 'VPC', {
cidr: '10.0.0.0/16',
maxAzs: 3,
subnetConfiguration: [
{
// PublicSubnet
cidrMask: 24,
name: 'ingress',
subnetType: ec2.SubnetType.PUBLIC,
},
],
});
// セキュリティグループ(ALB)
const albSG = new ec2.SecurityGroup(this, 'ALBSG', {
vpc,
securityGroupName: `${Context.ID_PREFIX}-ALBSG`,
})
albSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80))
albSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(9000))
// セキュリティグループ(ECSサービス フロントエンド)
this.frontendServiceSG = new ec2.SecurityGroup(this, 'FrontendServiceSG',
{
securityGroupName: `${Context.ID_PREFIX}-FrontendServiceSG`,
vpc: vpc,
}
);
this.frontendServiceSG.addIngressRule(albSG, ec2.Port.allTcp());
// セキュリティグループ(ECSサービス バックエンド)
this.backendServiceSG = new ec2.SecurityGroup(this, 'BackendServiceSG',
{
securityGroupName: `${Context.ID_PREFIX}-BackendServiceSG`,
vpc: vpc,
}
);
this.backendServiceSG.addIngressRule(this.frontendServiceSG, ec2.Port.allTcp());
// クラウドマップ
this.cloudmapNamespace = new servicediscovery.PrivateDnsNamespace(this, 'Namespace',
{
name: `${Context.ID_PREFIX}-service`,
vpc: vpc,
}
);
// ポリシー
const ECSExecPolicyStatement = new iam.PolicyStatement({
sid: `${Context.ID_PREFIX}AllowECSExec`,
resources: ['*'],
actions: [
'ssmmessages:CreateControlChannel', // for ECS Exec
'ssmmessages:CreateDataChannel', // for ECS Exec
'ssmmessages:OpenControlChannel', // for ECS Exec
'ssmmessages:OpenDataChannel', // for ECS Exec
'logs:CreateLogStream',
'logs:DescribeLogGroups',
'logs:DescribeLogStreams',
'logs:PutLogEvents',
],
});
this.frontendTaskRole = new iam.Role(this, 'FrontendTaskRole', {
roleName: `${Context.ID_PREFIX}-FrontendTaskRole`,
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
});
this.frontendTaskRole.addToPolicy(ECSExecPolicyStatement);
this.backendCrystalTaskRole = new iam.Role(this, 'BackendCrystalTaskRole', {
roleName: `${Context.ID_PREFIX}-BackendCrystalTaskRole`,
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
});
this.backendCrystalTaskRole.addToPolicy(ECSExecPolicyStatement);
this.backendNodejsTaskRole = new iam.Role(this, 'BackendNodejsTaskRole', {
roleName: `${Context.ID_PREFIX}-BackendNodejsTaskRole`,
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
});
this.backendNodejsTaskRole.addToPolicy(ECSExecPolicyStatement);
this.TaskExecutionRole = new iam.Role(this, 'TaskExecutionRole', {
roleName: `${Context.ID_PREFIX}-TaskExecutionRole`,
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
managedPolicies: [
{
managedPolicyArn:
'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy',
},
],
});
// ロググループ
this.frontendLogGroup = new logs.LogGroup(this, 'frontendLogGroup', {
logGroupName: `${Context.ID_PREFIX}-frontend-service`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
this.backendCrystalLogGroup = new logs.LogGroup(this, 'BackendCrystalLogGroup', {
logGroupName: `${Context.ID_PREFIX}-backend-crystal-service`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
this.backendNodejsLogGroup = new logs.LogGroup(this, 'BackendNodejsLogGroup', {
logGroupName: `${Context.ID_PREFIX}-backend-nodejs-service`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
this.frontendBuildProjectLogGroup = new logs.LogGroup(this, 'frontendBuildProjectLogGroup', {
logGroupName: `${Context.ID_PREFIX}-frontend-build-project`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// Application Load Balancer
const ecsAlb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
vpc,
securityGroup: albSG,
internetFacing: true,
loadBalancerName: `${Context.ID_PREFIX}-ALB`,
vpcSubnets: { subnets: vpc.publicSubnets },
})
// Blue リスナー
this.frontListener = ecsAlb.addListener('Front-Listener', {
port: 80,
open: true,
})
// Blue TG
this.blueTargetGroup = new elbv2.ApplicationTargetGroup(this, 'Blue-TargetGroup', {
vpc,
targetGroupName: `${Context.ID_PREFIX}-Blue-TargetGroup`,
protocol: elbv2.ApplicationProtocol.HTTP,
port: 3000,
healthCheck: {
path: '/health',
},
targetType: elbv2.TargetType.IP,
})
this.frontListener.addTargetGroups('Add-Blue-TargetGroup', {
targetGroups: [this.blueTargetGroup],
})
// Green リスナー
this.frontTestListener = ecsAlb.addListener('FrontTest-Listener', {
protocol: elbv2.ApplicationProtocol.HTTP,
port: 9000,
open: true,
})
// Green TG
this.greenTargetGroup = new elbv2.ApplicationTargetGroup(this, 'Green-TargetGroup', {
vpc,
targetGroupName: `${Context.ID_PREFIX}-Green-TargetGroup`,
protocol: elbv2.ApplicationProtocol.HTTP,
port: 3000,
healthCheck: {
path: '/health',
},
targetType: elbv2.TargetType.IP,
})
this.frontTestListener.addTargetGroups('Add-Green-TargetGroup', {
targetGroups: [this.greenTargetGroup],
})
// ECS cluster
this.cluster = new ecs.Cluster(this, 'ECSCluster', {
vpc: vpc,
clusterName: `${Context.ID_PREFIX}-ECSCluster`,
containerInsights: true,
});
}
}
frontend-service-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as codedeploy from 'aws-cdk-lib/aws-codedeploy';
import * as ecr from 'aws-cdk-lib/aws-ecr';
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 logs from 'aws-cdk-lib/aws-logs';
import * as servicediscovery from 'aws-cdk-lib/aws-servicediscovery';
import { Construct } from 'constructs';
import { Context } from './common/context'
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
interface FrontendServiceStackProps extends cdk.StackProps {
cluster: ecs.Cluster,
frontendServiceSG: ec2.SecurityGroup,
frontendTaskRole: iam.Role,
frontendTaskExecutionRole: iam.Role,
frontendLogGroup: logs.LogGroup,
cloudmapNamespace: servicediscovery.PrivateDnsNamespace,
blueTargetGroup: elbv2.ApplicationTargetGroup,
greenTargetGroup: elbv2.ApplicationTargetGroup,
frontListener: elbv2.ApplicationListener,
frontTestListener: elbv2.ApplicationListener
}
export class FrontendServiceStack extends cdk.Stack {
public readonly ecsDeploymentGroup: codedeploy.EcsDeploymentGroup
constructor(scope: Construct, id: string, props: FrontendServiceStackProps) {
super(scope, id, props);
// ECS タスク定義
const frontTaskDefinition = new ecs.FargateTaskDefinition(this, "FrontendTaskDef", {
memoryLimitMiB: 512,
cpu: 256,
executionRole: props.frontendTaskExecutionRole,
taskRole: props.frontendTaskRole,
runtimePlatform: {
operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
cpuArchitecture: ecs.CpuArchitecture.X86_64,
},
});
// ECR リポジトリ
const frontendRepo = ecr.Repository.fromRepositoryArn(
this,
`${Context.ID_PREFIX}-FrontendRepository`,
"arn:aws:ecr:ap-northeast-1:449974608116:repository/devday2019-ecsdemo-frontend"
)
// コンテナ
frontTaskDefinition.addContainer('FrontendContainer', {
containerName: 'ecsdemo-frontend',
image: ecs.ContainerImage.fromEcrRepository(frontendRepo),
memoryLimitMiB: 512,
cpu: 256,
logging: ecs.LogDrivers.awsLogs({
streamPrefix: `${Context.ID_PREFIX}-FrontendStream`,
logGroup: props.frontendLogGroup
}),
portMappings: [
{
containerPort: 3000,
hostPort: 3000,
protocol: ecs.Protocol.TCP,
},
],
environment: {
'CRYSTAL_URL': `http://${Context.USER_NAME}-ecsdemo-crystal.${props.cloudmapNamespace.namespaceName}:3000/crystal`,
'NODEJS_URL': `http://${Context.USER_NAME}-ecsdemo-nodejs.${props.cloudmapNamespace.namespaceName}:3000`
},
});
// ECS サービス
const frontendService = new ecs.FargateService(this, 'FrontendService', {
serviceName: `${Context.USER_NAME}-ecsdemo-frontend`,
cluster: props.cluster,
desiredCount: 3,
assignPublicIp: true,
taskDefinition: frontTaskDefinition,
enableExecuteCommand: true,
cloudMapOptions: {
name: `${Context.USER_NAME}-ecsdemo-frontend`,
cloudMapNamespace: props.cloudmapNamespace,
dnsRecordType: servicediscovery.DnsRecordType.A,
dnsTtl: cdk.Duration.seconds(60)
},
securityGroups: [props.frontendServiceSG],
deploymentController: {
type: ecs.DeploymentControllerType.CODE_DEPLOY,
}
});
// サービスをターゲットグループに追加
props.blueTargetGroup.addTarget(frontendService);
// CodeDeploy の ECS アプリケーションを作成
const ecsApplication = new codedeploy.EcsApplication(this, 'EcsApplication', {
applicationName: `${Context.ID_PREFIX}FrontendApplication`,
});
// デプロイグループ
this.ecsDeploymentGroup = new codedeploy.EcsDeploymentGroup(this, 'EcsDeploymentGroup', {
blueGreenDeploymentConfig: { // ターゲットグループやリスナー
blueTargetGroup: props.blueTargetGroup,
greenTargetGroup: props.greenTargetGroup,
listener: props.frontListener,
testListener: props.frontTestListener,
deploymentApprovalWaitTime: cdk.Duration.minutes(10),
terminationWaitTime: cdk.Duration.minutes(10),
},
autoRollback: { // ロールバックの設定
failedDeployment: true
},
service: frontendService, // ECSサービス
application: ecsApplication, // ECSアプリケーション
deploymentConfig: codedeploy.EcsDeploymentConfig.ALL_AT_ONCE, // デプロイの方式
deploymentGroupName: `${Context.ID_PREFIX}FrontendDepGrp`,
})
}
}
frontend-pipeline-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codedeploy from 'aws-cdk-lib/aws-codedeploy';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as codepipeline_actions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import { Context } from './common/context'
interface FrontendPipelineStackProps extends cdk.StackProps {
ecsDeploymentGroup: codedeploy.EcsDeploymentGroup,
buildProjectLogGroup: logs.LogGroup
}
export class FrontendPipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: FrontendPipelineStackProps) {
super(scope, id, props);
// パイプライン
const pipeline = new codepipeline.Pipeline(this, 'FrontendPipeline');
// Source アクション
const sourceOutput = new codepipeline.Artifact();
const sourceAction = new codepipeline_actions.GitHubSourceAction ({
actionName: 'GitHub_Source',
owner: 'shshimamo',
repo: 'handsonuser-ecsdemo-frontend',
branch: 'main',
oauthToken: cdk.SecretValue.secretsManager('my-github-token'), // SecretsManager は CDK ではなくコンソールから登録しました
trigger: codepipeline_actions.GitHubTrigger.WEBHOOK,
output: sourceOutput,
});
pipeline.addStage({
stageName: 'Source',
actions: [sourceAction],
});
// ビルドプロジェクト
const buildProject = new codebuild.PipelineProject(this, 'BuildProject', {
projectName: `${Context.ID_PREFIX}-frontend-build`,
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_6_0,
privileged: true,
},
logging: {
cloudWatch: {
logGroup: props.buildProjectLogGroup,
}
}
});
buildProject.addToRolePolicy(
new iam.PolicyStatement({
resources: ['*'],
actions: ["ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:GetRepositoryPolicy",
"ecr:DescribeRepositories",
"ecr:ListImages",
"ecr:DescribeImages",
"ecr:BatchGetImage",
"ecr:GetLifecyclePolicy",
"ecr:GetLifecyclePolicyPreview",
"ecr:ListTagsForResource",
"ecr:DescribeImageScanFindings",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage"]
})
)
// ビルドアクション
const buildOutput = new codepipeline.Artifact();
const buildAction = new codepipeline_actions.CodeBuildAction({
actionName: `${Context.ID_PREFIX}-frontend-build`,
project: buildProject,
input: sourceOutput,
outputs: [buildOutput]
});
pipeline.addStage({
stageName: 'Build',
actions: [buildAction],
});
// デプロイアクション
const deployAction = new codepipeline_actions.CodeDeployEcsDeployAction({
actionName: `${Context.ID_PREFIX}-frontend-deploy`,
deploymentGroup: props.ecsDeploymentGroup,
taskDefinitionTemplateInput: sourceOutput, // タスク定義
appSpecTemplateInput: sourceOutput, // AppSpecファイル
containerImageInputs: [{
input: buildOutput,
taskDefinitionPlaceholder: 'IMAGE1_NAME',
}],
variablesNamespace: 'DeployVariables'
})
pipeline.addStage({
stageName: 'Deploy',
actions: [deployAction],
});
}
}
Discussion