🌲

AWS ECS Blue/Green デプロイハンズオンを CDK で書いてみた

2022/12/24に公開約18,600字

CDK の勉強のために AWS が提供しているこちらのハンズオンを CDK で書いてみました。
https://dcj71ciaiav4i.cloudfront.net/CF2469D0-FE58-11EC-96B1-AB890FC54FAE/introduction.html

きっかけになったのはこちらの記事です。
AWS CDKでECS Blue Green DeploymentのL2 Constructがキタ
CDK で Blue Green Deployment が簡単に記述できるようになったということで試しにやってみることにしました。

インフラは得意ではないので適切でない部分があれば教えていただけるとうれしいです。

コードはこちらです。

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.tsfrontend-service-stack.tsfrontend-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

ログインするとコメントできます