Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

AWS ECS で実現するBlue/Green Deployment:運用を見据えたCDK実装例

始めに

バックエンドの com です。

テックタッチでは Blue Green Deployment 構成の ECS クラスタを、AWS CDK によるコードで管理しながら本番運用で使っています。

ECS Blue Green Deployment 構成の CDK コードは作るだけならそんなに難しくないかもしれません。しかし、運用を見据えた CDK 構成はアップデート頻度やスタック間依存を考慮したスタック分割に工夫がいるため試行錯誤が必要かつ、難易度が高いと感じています。

本記事では弊社内で培った運用経験を元に運用を見据えた AWS CDK によるサンプル実装を作成します。 これから AWS CDK を利用した ECS + Blue Green Deployment 構成を運用してみようとする方の参考になれば幸いです。

対象者

本記事は AWS の ECS 構成について基本的な知識のある人、CDK コードを作成したことがある人向けの内容です。

作成するアプリケーション構成

今回作成したサンプルコードは以下にあります。必要に応じて参照ください GitHub - ykomuro0719/ecs-bg-samples

なお本構成を作成するにあたり、aws-samples/aws-reinvent-trivia-gameの以下 IaC コードを参考にしています。 aws-reinvent-trivia-game/trivia-backend at master · aws-samples/aws-reinvent-trivia-game · GitHub

運用を見据えた構成とは

今回作成する IaC 構成で目指す「運用を見据えた構成」とは以下を目指すものとします

  • ソースコードリポジトリと連携した継続的デプロイができること
  • IaC コードの更新時、意図しないリソースが変更されないようにすること

後者についてですが、スタック構成のシンプルさを取るのであれば単一のスタックにすべてのリソースを定義することも可能です。しかしながら、運用を重ねる中でタスク定義、コンテナイメージとそのほかのリソースでは変更頻度や影響範囲に大きな違いがあると考えています。

そこでタスク定義、コンテナイメージの更新についてはスタックを分割するとともに継続的デプロイができるように CodePipeline 内で自動更新される形としています。

理想的には ALB 設定やそのほか ECS サービスも CodePipeline 実行することは可能ですが、本番環境でその辺含めて自動更新することの利便性とリスク管理踏まえて、自動更新されるものはタスク定義更新とコンテナイメージ更新までとしています。

構成概要

今回作成するアプリケーション構成は以下になります。

アプリケーション実行主体として ECS Fargate 構成を用い、Application LoadBalancer にてアプリケーション宛通信の Blue/Green Deployment 時のターゲットグループの切替を行う構成としています。

スタック分割、依存関係は以下の図になります。各スタックの詳細はのちほど解説します。

狙いとしてはAWS::ECS::Serviceのリソース定義では紐づくターゲットグループをコード内に定義することになります。CodeDeploy を利用したデプロイ下ではターゲットグループのアクティブ・スタンバイの切替が deploy のたびに発生するため実運用中はコードの定義と乖離することになります。

そのためAWS::ECS::Serviceを変更するスタックはほかのリソースと分離することで、スタック作成後に本スタックの更新なく運用できるようにしています。

スタック間依存関係図

また CodePipeline は前述した通り、タスク定義とコンテナイメージ更新のみ自動更新対象としているため CodePipeline によるタスク定義更新→ コンテナイメージ更新→CodeDeploy によるデプロイの構成としています。

CodePipeline 構成

ディレクトリ構成は以下のようになっています。 今回は IaC 定義、アプリケーションコードを同一リポジトリで管理する構成としており、Blue/Green 用のアプリケーションは/appに作成しています。 IaC コードは/infrastructureに作成しています。

ディレクトリ構成

.
├── README.md
├── app
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── infrastructure
    ├── README.md
    ├── bin
    │   └── infrastructure.ts
    ├── cdk.context.json
    ├── cdk.json
    ├── cdk.out
    ├── config
    │   └── index.ts
    ├── jest.config.js
    ├── lib
    │   ├── infrastructure-stack.ts - ①
        │   ├── ecr-stack.ts - ②
    │   ├── taskdefinition-stack.ts − ③
    │   ├── service-stack.ts - ④, ⑤
    │   └── pipelines-stack.ts - ⑥
    ├── node_modules
    ├── package-lock.json
    ├── package.json
    ├── test
    └── tsconfig.json

以降では①-⑥具体的なスタックの記述について触れていきます

各スタックの説明

① SampleInfrastructureStack

本スタックでは ECS サービス構築時に必要となる以下のリソースを作成しています

  • Application Loadbalancer
  • ECS,ALB 用セキュリティグループ
  • ECS 用ターゲットグループ(blue/green)
  • ECSService に必要なタスクロール、タスク実行ロール

ターゲットグループについては Blue/Green デプロイで新系・旧系の 2 つのターゲットグループを CodeDeploy にて切り替えて運用する形となるため 2 つ作成します。

またタスク実行ロールについてはタスク定義側の Construct 作成時に自動生成されるものを利用する方法もありますが、今回は手動で作成したタスクロールをタスク定義側で参照する形としています。

// infrastructure-stack.ts
import {
  CfnOutput,
  Stack,
  type StackProps,
  aws_ec2 as ec2,
  aws_ecs as ecs,
  aws_elasticloadbalancingv2 as elbv2,
  aws_iam as iam,
} from 'aws-cdk-lib'
import type { Construct } from 'constructs'
import type { Variables } from '../config'

export interface SampleInfrastructureStackProps extends StackProps {
  variables: Variables
}

export class SampleInfrastructureStack extends Stack {
  constructor(
    parent: Construct,
    name: string,
    props: SampleInfrastructureStackProps,
  ) {
    super(parent, name, props)
    const {
      variables: { vpcId, publicSubnetIds, clusterName },
    } = props
    const vpc = ec2.Vpc.fromLookup(this, 'VPC', { vpcId })

    const cluster = new ecs.Cluster(this, 'Cluster', {
      vpc,
      clusterName,
    })

    const serviceSG = new ec2.SecurityGroup(this, 'serviceSG', {
      securityGroupName: 'sample-api-v2-service-sg',
      vpc,
      allowAllOutbound: true,
    })

    const primaryPort = 1323
    const albSecurityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', {
      securityGroupName: 'sample-service-lbv2-sg',
      description: 'Security Group for Service ALBv2',
      vpc,
    })
    const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'ServiceLB', {
      vpc,
      internetFacing: true,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PUBLIC,
        subnetFilters: [ec2.SubnetFilter.byIds(publicSubnetIds)],
      },
      securityGroup: albSecurityGroup,
    })

    serviceSG.connections.allowFrom(loadBalancer, ec2.Port.tcp(primaryPort))
    // First target group for blue fleet
    const healthCheck: elbv2.HealthCheck = {
      path: '/health',
      healthyHttpCodes: '200',
    }
    const tg1 = new elbv2.ApplicationTargetGroup(this, 'BlueTargetGroup', {
      vpc,
      targetGroupName: 'sample-blue-tg',
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
      port: primaryPort,
      healthCheck,
    })

    // Second target group for green fleet
    const tg2 = new elbv2.ApplicationTargetGroup(this, 'GreenTargetGroup', {
      vpc,
      targetGroupName: 'sample-green-tg',
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
      port: primaryPort,
      healthCheck,
    })

    const listener = loadBalancer.addListener('PublicListener', {
      protocol: elbv2.ApplicationProtocol.HTTP,
      open: true,
      defaultTargetGroups: [tg1],
    })

    const taskExecutionRole = new iam.Role(this, 'TaskExecutionRole', {
      roleName: 'sample-task-execution-role',
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AmazonECSTaskExecutionRolePolicy',
        ),
      ],
    })

    taskExecutionRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['logs:CreateLogGroup'],
        resources: ['*'],
      }),
    )

    const taskRole = new iam.Role(this, 'TaskRole', {
      roleName: 'sample-task-role',
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
    })

    // Export values to use in other stacks
    new CfnOutput(this, 'LoadBalancerOutput', {
      value: loadBalancer.loadBalancerArn,
      exportName: `${this.stackName}LoadBalancerArn`,
    })
    new CfnOutput(this, 'LoadBalancerEndpoint', {
      value: loadBalancer.loadBalancerDnsName,
      exportName: `${this.stackName}LoadBalancerEndpoint`,
    })
    new CfnOutput(this, 'LoadBalancerSecurityGroup', {
      value: albSecurityGroup.securityGroupId,
      exportName: `${this.stackName}LoadBalancerSecurityGroup`,
    })
    new CfnOutput(this, 'ServiceSecurityGroupOutput', {
      value: serviceSG.securityGroupId,
      exportName: `${this.stackName}ServiceSecurityGroup`,
    })
    new CfnOutput(this, 'BlueTargetGroupOutput', {
      value: tg1.targetGroupArn,
      exportName: `${this.stackName}BlueTargetGroup`,
    })
    new CfnOutput(this, 'GreenTargetGroupOutput', {
      value: tg2.targetGroupArn,
      exportName: `${this.stackName}GreenTargetGroup`,
    })
    new CfnOutput(this, 'ProdTrafficListenerOutput', {
      value: listener.listenerArn,
      exportName: `${this.stackName}ProdTrafficListener`,
    })
    new CfnOutput(this, 'TaskExecutionRoleOutput', {
      value: taskExecutionRole.roleArn,
      exportName: `${this.stackName}TaskExecutionRole`,
    })
    new CfnOutput(this, 'TaskRoleOutput', {
      value: taskRole.roleArn,
      exportName: `${this.stackName}TaskRole`,
    })
  }
}

② SampleContainerRepositoryStack

作成したイメージ管理用の private repository を作成するスタックです。 コメントに記載の通り、初回実行時に限り手動でイメージを push する運用としています

// ecr-stack.ts
import {
  CfnOutput,
  RemovalPolicy,
  Stack,
  type StackProps,
  aws_ecr as ecr,
} from 'aws-cdk-lib'
import type { Construct } from 'constructs'

export interface SampleContainerRepositoryStackProps extends StackProps {}

/* after deploy this stack、push initial image to ECR
commands
---bash
cd app && docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/sample-api:latest .
aws ecr get-login-password [--profile <AWS_PROFILE>] | docker login --username AWS --password-stdin '<AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com' \
docker push <AWS_ACCOUNT_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/sample-api:latest
---
*/
export class SampleContainerRepositoryStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: SampleContainerRepositoryStackProps,
  ) {
    super(scope, id, props)
    const repository = new ecr.Repository(this, 'Repository', {
      repositoryName: 'sample-api',
      removalPolicy: RemovalPolicy.DESTROY,
      lifecycleRules: [
        { rulePriority: 1, maxImageCount: 10, tagStatus: ecr.TagStatus.ANY },
      ],
    })

    new CfnOutput(this, 'ContainerRepositoryOutput', {
      value: repository.repositoryName,
      exportName: `${this.stackName}ContainerRepository`,
    })
  }
}

③ SampleTaskDefinitionStack

タスク定義管理用スタックとなります。①、②スタックで作成された各種ロールやrepositoryをCfnImportによるクロススタック間参照を用いて利用する形としています。

今回タスク定義は1種類しか作成していませんが、実運用する際には本番や開発といった環境ごとにリソースや環境変数が異なるため分岐して使う形になるかと思います。

// taskdefinition-stack.ts
import {
  CfnOutput,
  Fn,
  Stack,
  type StackProps,
  aws_ecr as ecr,
  aws_ecs as ecs,
  aws_iam as iam,
  aws_logs as logs,
} from 'aws-cdk-lib'
import { Construct } from 'constructs'

export interface SampleTaskDefinitionStackProps extends StackProps {
  registryStackName: string
  infrastructureStackName: string
}
export class SampleTaskDefinitionStack extends Stack {
  public readonly taskDefinition: ecs.FargateTaskDefinition
  constructor(
    scope: Construct,
    id: string,
    props: SampleTaskDefinitionStackProps,
  ) {
    super(scope, id, props)
    const { registryStackName, infrastructureStackName } = props
    const repository = ecr.Repository.fromRepositoryName(
      this,
      'repository',
      Fn.importValue(`${registryStackName}ContainerRepository`),
    )

    const taskRole = iam.Role.fromRoleArn(
      this,
      'taskRole',
      Fn.importValue(`${infrastructureStackName}TaskRole`),
      {
        mutable: false,
      },
    )
    const executionRole = iam.Role.fromRoleArn(
      this,
      'taskExecutionRole',
      Fn.importValue(`${infrastructureStackName}TaskExecutionRole`),
      {
        mutable: false,
      },
    )

    const { taskDefinition } = new SampleTaskDefinition(
      this,
      'TaskDefinition',
      {
        repository,
        taskRole,
        executionRole,
      },
    )
    this.taskDefinition = taskDefinition

    new CfnOutput(this, 'TaskDefinitionFamily', {
      value: taskDefinition.family,
      exportName: `${this.stackName}TaskDefinitionFamily`,
    })
    new CfnOutput(this, 'TaskDefinitionDefaultContainerName', {
      value: taskDefinition.defaultContainer?.containerName || '',
      exportName: `${this.stackName}TaskDefinitionDefaultContainerName`,
    })
  }
}

export interface SampleTaskDefinitionProps {
  repository: ecr.IRepository
  taskRole: iam.IRole
  executionRole: iam.IRole
}

export class SampleTaskDefinition extends Construct {
  readonly taskDefinition: ecs.FargateTaskDefinition

  constructor(scope: Construct, id: string, props: SampleTaskDefinitionProps) {
    super(scope, id)
    const { repository, taskRole, executionRole } = props

    const taskDefinition = new ecs.FargateTaskDefinition(
      this,
      'FargateTaskDefinition',
      {
        family: 'sample-api',
        cpu: 256,
        memoryLimitMiB: 512,
        executionRole,
        taskRole,
      },
    )

    taskDefinition.addContainer('sample-api', {
      image: ecs.ContainerImage.fromEcrRepository(repository, 'latest'),
      portMappings: [{ containerPort: 1323, hostPort: 1323 }],
      logging: new ecs.AwsLogDriver({
        streamPrefix: 'ecs/sample-api',
        logRetention: logs.RetentionDays.ONE_WEEK,
      }),
      essential: true,
      environment: {
        HEALTH_RATE: '1.0',
      },
    })

    this.taskDefinition = taskDefinition
  }
}

④ SampleServiceStack

ECS サービス作成用のスタックとなります。 ここでのポイントは

  1. deploymentController として CODE_DEPLOY を指定すること

    これを選択することにより ECS 標準のローリングアップデートではなく CodeDeploy によるデプロイ管理が可能になります。

  2. ECS サービス内で参照するタスク定義の指定を revision なしの TaskFamily 名で指定すること

    通常の L2 Construct で ECS サービスを定義する場合、タスク定義の指定にはecs.TaskDefinitionを指定する必要があります。この場合クロススタック間参照でタスク定義が参照されることになりますが、その際にタスク定義が revision 込で参照されるか否か不明瞭(私も初回構築時この辺曖昧なまま L2 Construct で構築してしまってたまたま問題なく動いていました)になるということがありました。

    ECS サービス定義自体複雑なパラメータがあまりないため今回は ECS サービスを L1 Construct で定義して、タスク定義指定も revision なしの TaskFamily 名の形で指定することで依存関係が明確になるようにしました。

// service-stack.ts(前半部分)
import {
  CfnOutput,
  Duration,
  Fn,
  Stack,
  type StackProps,
  aws_applicationautoscaling as applicationautoscaling,
  aws_ec2 as ec2,
  aws_ecs as ecs,
  aws_iam as iam,
} from 'aws-cdk-lib'
import type { Construct } from 'constructs'
import type { Variables } from '../config'
export interface SampleServiceStackProps extends StackProps {
  infrastructureStackName: string
  taskDefinitionStackName: string
  variables: Variables
}

export class SampleServiceStack extends Stack {
  constructor(scope: Construct, id: string, props: SampleServiceStackProps) {
    super(scope, id, props)
    const {
      taskDefinitionStackName,
      infrastructureStackName,
      variables: { vpcId, privateSubnetIds, clusterName },
    } = props

    const serviceSG = ec2.SecurityGroup.fromSecurityGroupId(
      this,
      'ServiceSecurityGroup',
      Fn.importValue(`${infrastructureStackName}ServiceSecurityGroup`),
    )
    const vpc = ec2.Vpc.fromLookup(this, 'VPC', { vpcId })
    const cluster = ecs.Cluster.fromClusterAttributes(this, 'cluster', {
      clusterName,
      vpc,
      securityGroups: [serviceSG],
    })

    const service = new ecs.CfnService(this, 'Service', {
      cluster: clusterName,
      serviceName: 'sample-service',
      deploymentConfiguration: {
        maximumPercent: 200,
      },
      deploymentController: {
        type: 'CODE_DEPLOY',
      },
      healthCheckGracePeriodSeconds: 10,
      launchType: 'FARGATE',
      loadBalancers: [
        {
          containerName: Fn.importValue(
            `${taskDefinitionStackName}TaskDefinitionDefaultContainerName`,
          ),
          containerPort: 1323,
          targetGroupArn: Fn.importValue(
            `${infrastructureStackName}BlueTargetGroup`,
          ),
        },
      ],
      networkConfiguration: {
        awsvpcConfiguration: {
          assignPublicIp: 'DISABLED',
          securityGroups: [serviceSG.securityGroupId],
          subnets: privateSubnetIds,
        },
      },
      taskDefinition: Fn.importValue(
        `${taskDefinitionStackName}TaskDefinitionFamily`,
      ),
    })

    new CfnOutput(this, 'ServiceOutput', {
      value: service.attrServiceArn,
      exportName: `${this.stackName}ServiceOutput`,
    })
  }
}

⑤ SampleServicePreferenceStack

ECSService 周りの周辺パラメータ。ここではオートスケーリング設定の定義を行うスタックです。

ECS サービスの運用として変更する可能性が高いパラメータですがリソースとしてAWS::ECS::Serviceとは分かれているのでスタックとして分離することで後から変更しやすいような構成としています。

// service-stack.ts(後半部分)
export interface SampleServicePreferenceStackProps extends StackProps {
  serviceStackName: string
  variables: Variables
}
export class SampleServicePreferenceStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: SampleServicePreferenceStackProps,
  ) {
    super(scope, id, props)
    const {
      serviceStackName,
      variables: { clusterName, minCapacity = 1, maxCapacity = 2 },
    } = props

    const service = ecs.FargateService.fromFargateServiceArn(
      this,
      'Service',
      Fn.importValue(`${serviceStackName}ServiceOutput`),
    )
    const scalableTaskCount = new ecs.ScalableTaskCount(
      this,
      'ScalableTaskCount',
      {
        serviceNamespace: applicationautoscaling.ServiceNamespace.ECS,
        resourceId: `service/${clusterName}/${service.serviceName}`,
        minCapacity,
        maxCapacity,
        dimension: 'ecs:service:DesiredCount',
        role: iam.Role.fromRoleName(
          this,
          'Role',
          'AWSServiceRoleForApplicationAutoScaling_ECSService',
        ),
      },
    )
    scalableTaskCount.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 60,
      scaleInCooldown: Duration.minutes(1),
      scaleOutCooldown: Duration.minutes(1),
    })
    scalableTaskCount.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 60,
      scaleInCooldown: Duration.minutes(1),
      scaleOutCooldown: Duration.minutes(1),
    })
  }
}

⑥ SamplePipelineStack

今回一番の肝となる CodePipeline に関するスタックです。ここではタスク定義を更新する CDK Pipelines と通常のイメージ更新の Pipeline を統合した以下のフローを作成しています。

本スタックのうちポイントとなるのは以下の点です。

  • ImadeBuild ステージで前ステージにて更新済みの最新のタスク定義を取得し、CodeDeploy で利用可能な形式に整形して渡すことで想定したタスク定義とコンテナイメージの組み合わせでデプロイされるようになる。

    本構成では AWS CDK によるタスク定義更新、CodeDeploy のコンテナイメージ差し替えによるタスク定義更新の計 2 回更新が発生します。そのためタスク定義の revision は 2 つ上がる挙動となります。

  • CodeDeploy のアラート条件に CloudWatch Metrics を用いた以下のアラームを作成する
    1. 5xx レートが指定の割合を超える
    2. unhealthy ホストが指定の数値を上回る この条件のアラートが発行した場合に自動でロールバックするようになる。

アラート条件については運用次第で適切な設定が異なるかと思うので自身の環境に応じたものを最終的には適用いただくのがよいかと思います。

// pipelines-stack.ts
import {
  Duration,
  Fn,
  RemovalPolicy,
  Stack,
  type StackProps,
  Stage,
  type StageProps,
  aws_codepipeline_actions as actions,
  aws_cloudwatch as cloudwatch,
  aws_codebuild as codebuild,
  aws_codedeploy as codedeploy,
  aws_codepipeline as codepipeline,
  aws_codestarconnections as codestarconnections,
  aws_ec2 as ec2,
  aws_ecr as ecr,
  aws_ecs as ecs,
  aws_elasticloadbalancingv2 as elbv2,
  aws_iam as iam,
  pipelines,
  aws_s3 as s3,
} from 'aws-cdk-lib'

import { Construct } from 'constructs'
import type { Variables } from '../config'
import { SampleTaskDefinitionStack } from './taskdefinition-stack'

interface ECSDeploymentProps {
  blueTG: elbv2.IApplicationTargetGroup
  greenTG: elbv2.IApplicationTargetGroup
  prodListener: elbv2.IApplicationListener
  service: ecs.IBaseService
  variables: Variables
}

export class ECSDeployment extends Construct {
  public readonly ecsApplication: codedeploy.EcsApplication
  public readonly ecsDeploymentGroup: codedeploy.EcsDeploymentGroup
  constructor(scope: Construct, id: string, props: ECSDeploymentProps) {
    super(scope, id)
    const {
      blueTG,
      greenTG,
      service,
      prodListener,
      variables: {
        deploymentConfig = codedeploy.EcsDeploymentConfig
          .CANARY_10PERCENT_5MINUTES,
        terminationWaitMinutes = 0,
      },
    } = props

    const blueTGUnhealthyHosts = new cloudwatch.Alarm(
      this,
      'TargetGroupUnhealthyHosts',
      {
        alarmName: `${Stack.of(this).stackName}-Unhealthy-Hosts-Blue`,
        metric: new cloudwatch.MathExpression({
          expression: 'FILL(m1, 0)',
          usingMetrics: {
            m1: blueTG.metrics.unhealthyHostCount({
              statistic: 'sum',
              period: Duration.minutes(1),
            }),
          },
          period: Duration.minutes(5),
        }),
        threshold: 2,
        evaluationPeriods: 1,
      },
    )

    // Alarm if 5xx in target group exceeds 20% of total
    const blueTGApiFailure = new cloudwatch.Alarm(this, 'TargetGroup15xx', {
      alarmName: `${Stack.of(this).stackName}-Http-500percentage-Blue`,
      metric: new cloudwatch.MathExpression({
        label: '5xx-rate',
        expression: 'm1/m2',
        usingMetrics: {
          m1: new cloudwatch.MathExpression({
            expression: 'FILL(m11, 0)',
            usingMetrics: {
              m11: blueTG.metrics.httpCodeTarget(
                elbv2.HttpCodeTarget.TARGET_5XX_COUNT,
                {
                  period: Duration.minutes(1),
                  statistic: 'sum',
                },
              ),
            },
          }),
          m2: new cloudwatch.MathExpression({
            expression: 'FILL(m12, 1)',
            usingMetrics: {
              m12: blueTG.metrics.requestCount({
                period: Duration.minutes(1),
                statistic: 'sum',
              }),
            },
          }),
        },
        period: Duration.minutes(1),
      }),
      threshold: 0.2,
      evaluationPeriods: 1,
    })

    const greenTGUnhealthyHosts = new cloudwatch.Alarm(
      this,
      'TargetGroup2UnhealthyHosts',
      {
        alarmName: `${Stack.of(this).stackName}-Unhealthy-Hosts-Green`,
        metric: new cloudwatch.MathExpression({
          expression: 'FILL(m1, 0)',
          usingMetrics: {
            m1: greenTG.metrics.unhealthyHostCount({
              period: Duration.minutes(1),
              statistic: 'sum',
            }),
          },
          period: Duration.minutes(5),
        }),
        threshold: 2,
        evaluationPeriods: 1,
      },
    )

    // Alarm if 5xx in target group exceeds 20% of total
    const greenTGApiFailure = new cloudwatch.Alarm(this, 'TargetGroup25xx', {
      alarmName: `${Stack.of(this).stackName}-Http-500percentage-Green`,
      metric: new cloudwatch.MathExpression({
        label: '5xxrate',
        expression: 'm1/m2',
        usingMetrics: {
          m1: new cloudwatch.MathExpression({
            expression: 'FILL(m11, 0)',
            usingMetrics: {
              m11: greenTG.metrics.httpCodeTarget(
                elbv2.HttpCodeTarget.TARGET_5XX_COUNT,
                {
                  period: Duration.minutes(1),
                  statistic: 'sum',
                },
              ),
            },
          }),
          m2: new cloudwatch.MathExpression({
            expression: 'FILL(m12, 1)',
            usingMetrics: {
              m12: greenTG.metrics.requestCount({
                period: Duration.minutes(1),
                statistic: 'sum',
              }),
            },
          }),
        },
        period: Duration.minutes(1),
      }),
      threshold: 0.2,
      evaluationPeriods: 1,
    })

    // CodeDeploy Resources
    const ecsApp = new codedeploy.EcsApplication(
      this,
      'CodeDeployApplication',
      {
        applicationName: `AppECS-${Stack.of(this).stackName}`,
      },
    )

    const deploymentGroup = new codedeploy.EcsDeploymentGroup(
      this,
      'DeploymentGroup',
      {
        application: ecsApp,
        deploymentGroupName: `DgpECS-${Stack.of(this)}`,
        deploymentConfig,
        alarms: [
          blueTGUnhealthyHosts,
          blueTGApiFailure,
          greenTGUnhealthyHosts,
          greenTGApiFailure,
        ],
        service,
        blueGreenDeploymentConfig: {
          blueTargetGroup: blueTG,
          greenTargetGroup: greenTG,
          listener: prodListener,
          terminationWaitTime: Duration.minutes(terminationWaitMinutes),
        },
        autoRollback: {
          stoppedDeployment: true,
        },
      },
    )

    this.ecsApplication = ecsApp
    this.ecsDeploymentGroup = deploymentGroup
  }
}

interface SampleTaskdefinitionStageProps extends StageProps {
  registryStackName: string
  infrastructureStackName: string
}

class SampleTaskdefinitionStage extends Stage {
  constructor(
    scope: Construct,
    id: string,
    props: SampleTaskdefinitionStageProps,
  ) {
    super(scope, id, props)
    const { registryStackName, infrastructureStackName } = props
    new SampleTaskDefinitionStack(this, 'SampleTaskdefinitionStack', {
      env: { account: this.account, region: this.region },
      stackName: 'SampleTaskDefinitionStack',
      registryStackName: registryStackName,
      infrastructureStackName,
    })
  }
}

export interface SamplePipelineStackProps extends StackProps {
  infrastructureStackName: string
  serviceStackName: string
  containerRegistryStackName: string
  variables: Variables
}

export class SamplePipelineStack extends Stack {
  constructor(scope: Construct, id: string, props: SamplePipelineStackProps) {
    super(scope, id, props)
    const {
      containerRegistryStackName,
      infrastructureStackName,
      serviceStackName,
      variables,
    } = props
    const { owner, repository: repo, branch, clusterName, vpcId } = variables
    const repository = ecr.Repository.fromRepositoryName(
      this,
      'Repository',
      Fn.importValue(`${containerRegistryStackName}ContainerRepository`),
    )
    const githubOutput = codepipeline.Artifact.artifact('MyApp')
    const buildOutput = codepipeline.Artifact.artifact('MyAppBuild')

    const artifactBucket = new s3.Bucket(this, 'ArtifactBucket', {
      bucketName:
        `${Stack.of(this).account}-${Stack.of(this).stackName.toLowerCase()}-artifact-bucket`.substring(
          0,
          63,
        ),
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      encryption: s3.BucketEncryption.S3_MANAGED,
    })
    /**
     * Manually refresh Pending connections if SourceAction fails after pipeline deployment
     * https://docs.aws.amazon.com/dtconsole/latest/userguide/connections-update.html
     * **/
    const connection = new codestarconnections.CfnConnection(
      this,
      'GithubConnection',
      {
        connectionName: 'SampleGitHubConnection',
        providerType: 'GitHub',
      },
    )

    const sourceAction = new actions.CodeStarConnectionsSourceAction({
      actionName: 'Source',
      codeBuildCloneOutput: false,
      connectionArn: connection.attrConnectionArn,
      output: githubOutput,
      owner,
      repo,
      runOrder: 1,
      branch,
    })
    const pp = new codepipeline.Pipeline(this, 'SamplePipeline', {
      pipelineName: 'sample-pipeline',
      artifactBucket,
      stages: [
        {
          stageName: 'Source',
          actions: [sourceAction],
        },
      ],
    })

    const pipeline = new pipelines.CodePipeline(this, 'CodePipeline', {
      selfMutation: false,
      synth: new pipelines.CodeBuildStep('Synth', {
        input: pipelines.CodePipelineFileSet.fromArtifact(githubOutput),
        installCommands: ['cd infrastructure', 'npm ci'],
        commands: [`npx cdk synth ${id}`],
        primaryOutputDirectory: 'infrastructure/cdk.out',
        env: {
          privileged: 'true',
        },
        rolePolicyStatements: [
          new iam.PolicyStatement({
            actions: ['sts:AssumeRole'],
            resources: [
              `arn:aws:iam::${this.account}:role/cdk-hnb659fds-lookup-role-${this.account}-${this.region}`,
            ],
            conditions: {
              StringEquals: {
                'iam:ResourceTag/aws-cdk:bootstrap-role': ['lookup'],
              },
            },
          }),
        ],
      }),
      codePipeline: pp,
    })

    const taskdefinitionStage = new SampleTaskdefinitionStage(
      this,
      'TaskDefinitionStage',
      {
        env: { account: this.account, region: this.region },
        stageName: 'UpdateTaskDefinition',
        registryStackName: containerRegistryStackName,
        infrastructureStackName,
      },
    )

    pipeline.addStage(taskdefinitionStage)
    pipeline.buildPipeline()

    const vpc = ec2.Vpc.fromLookup(this, 'VPC', { vpcId: vpcId })
    const { ecsDeploymentGroup } = new ECSDeployment(this, 'ECSDeployment', {
      blueTG: elbv2.ApplicationTargetGroup.fromTargetGroupAttributes(
        this,
        'BlueTG',
        {
          targetGroupArn: Fn.importValue(
            `${infrastructureStackName}BlueTargetGroup`,
          ),
          loadBalancerArns: Fn.importValue(
            `${infrastructureStackName}LoadBalancerArn`,
          ),
        },
      ),
      greenTG: elbv2.ApplicationTargetGroup.fromTargetGroupAttributes(
        this,
        'GreenTG',
        {
          targetGroupArn: Fn.importValue(
            `${infrastructureStackName}GreenTargetGroup`,
          ),
          loadBalancerArns: Fn.importValue(
            `${infrastructureStackName}LoadBalancerArn`,
          ),
        },
      ),
      prodListener: elbv2.ApplicationListener.fromApplicationListenerAttributes(
        this,
        'ProdRoute',
        {
          listenerArn: Fn.importValue(
            `${infrastructureStackName}ProdTrafficListener`,
          ),
          securityGroup: ec2.SecurityGroup.fromSecurityGroupId(
            this,
            'LBSecurityGroup',
            Fn.importValue(
              `${infrastructureStackName}LoadBalancerSecurityGroup`,
            ),
            { allowAllOutbound: true },
          ),
        },
      ),
      service: ecs.FargateService.fromFargateServiceAttributes(
        this,
        'FargateService',
        {
          cluster: ecs.Cluster.fromClusterAttributes(this, 'cluster', {
            clusterName: clusterName,
            vpc,
          }),
          serviceArn: Fn.importValue(`${serviceStackName}ServiceOutput`),
        },
      ),
      variables,
    })

    const stages = [
      this._createImageBuildStage({
        repository,
        input: githubOutput,
        output: buildOutput,
      }),
      this._createDeployStage({
        input: buildOutput,
        ecsDeploymentGroup,
      }),
    ]

    stages.forEach((__stage) => pipeline.pipeline.addStage(__stage))
  }

  _createImageBuildStage(props: {
    repository: ecr.IRepository
    input: codepipeline.Artifact
    output: codepipeline.Artifact
  }): codepipeline.StageOptions {
    const { repository, input, output } = props
    const taskDefinitionName = 'sample-api'
    const containerName = 'sample-api'
    const appSpec: any = {
      version: 0.0,
      Resources: [
        {
          TargetService: {
            Type: 'AWS::ECS::Service',
            Properties: {
              TaskDefinition: '<TASK_DEFINITION>',
              LoadBalancerInfo: {
                ContainerName: containerName,
                containerPort: 1323,
              },
            },
          },
        },
      ],
    }

    const project = new codebuild.PipelineProject(this, 'SampleProject', {
      projectName: 'SampleProject',
      environment: {
        privileged: true,
        buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
      },
      environmentVariables: {
        REPOSITORY_URI: {
          type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,
          value: repository.repositoryUri,
        },
      },
      timeout: Duration.minutes(30),
      buildSpec: codebuild.BuildSpec.fromObjectToYaml({
        version: '0.2',
        env: {
          variables: {
            DOCKER_BUILDKIT: '1',
          },
        },
        phases: {
          pre_build: {
            commands: [
              'echo Logging in to Amazon ECR...',
              `aws ecr get-login-password --region ${this.region} | docker login --username AWS --password-stdin ${this.account}.dkr.ecr.${this.region}.amazonaws.com`,
              'IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)',
            ],
          },
          build: {
            commands: [
              'echo Building the Docker image....',
              'cd app && docker build -t $REPOSITORY_URI:latest . --build-arg VERSION=$IMAGE_TAG -f Dockerfile && cd ..',
              'docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG',
            ],
          },
          post_build: {
            commands: [
              'echo Build completed on `date`',
              'echo Pushing the Docker images...',
              'docker push $REPOSITORY_URI:latest',
              'docker push $REPOSITORY_URI:$IMAGE_TAG',
              'echo Writing image detail file...',
              'echo "{\\"ImageURI\\":\\"${REPOSITORY_URI}:${IMAGE_TAG}\\"}" | tee imageDetail.json',
              `aws ecs describe-task-definition --task-definition ${taskDefinitionName} | jq '.taskDefinition | del (.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities)' | jq --arg image '<IMAGE1_NAME>' '.containerDefinitions[] |= if .name == "${containerName}" then .image = $image else . end' > taskdef.json`,
              `echo '${JSON.stringify(appSpec)}' | tee appspec.json`,
            ],
          },
        },
        artifacts: {
          files: ['imageDetail.json', 'taskdef.json', 'appspec.json'],
        },
      }),
    })
    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['ecs:DescribeTaskDefinition'],
        resources: ['*'],
      }),
    )
    repository.grantPullPush(project)

    return {
      stageName: 'ImageBuild',
      actions: [
        new actions.CodeBuildAction({
          runOrder: 1,
          actionName: 'ImageBuild',
          input: input,
          outputs: [output],
          project: project,
        }),
      ],
    }
  }

  _createDeployStage(props: {
    ecsDeploymentGroup: codedeploy.EcsDeploymentGroup
    input: codepipeline.Artifact
  }): codepipeline.StageOptions {
    const { ecsDeploymentGroup, input } = props

    const deployAction = new actions.CodeDeployEcsDeployAction({
      runOrder: 1,
      actionName: 'deploy',
      appSpecTemplateFile: input.atPath('appspec.json'),
      taskDefinitionTemplateInput: input,
      containerImageInputs: [
        {
          input,
          taskDefinitionPlaceholder: 'IMAGE1_NAME',
        },
      ],
      deploymentGroup: ecsDeploymentGroup,
    })

    return {
      stageName: 'Deploy',
      actions: [deployAction],
    }
  }
}

動作確認

正常にデプロイが完了する場合のCodeDeployの挙動

まずは正常動作時の挙動から確認していきます。

今回はデプロイ戦略としてECSCanary10Percent5Minutesを選択したため、最初の 5 分は 10%のトラフィックを新系に流して問題がなければ全体を切り替える形で動作します。

同時間帯のターゲットグループのトラフィックは以下です。CodeDeploy がターゲットグループへの流入の割合を制御して Blue/Green デプロイを実現していることが見てとれます。

正常に切替完了すると以下のように置換が 100%となります。

リスナに紐づくターゲットグループのトラフィックも想定通り新系が 100%になりました。

ロールバックが発生する場合のCodeDeployの動作

今度は異常が発生しロールバックする場合の挙動について確認していきます。

今回 CodeDeploy の Blue/Green デプロイ機能を確認するために、ターゲットグループの healthcheck endpoint は 200 を返しつつも、そのほかの API は 50%の確率で 5xx 応答を返すロジックを入れたアプリケーションをデプロイし、API に対して定期的にリクエストを送り続ける形で検証してみました。

その結果、きちんと CodeDeploy の Alarm 条件<SamplePipelineStack-Http500percentage-Blue>が発行してロールバックしたことが確認できました。

念の為 CloudWatch Alarm の画面にて作成した Alarm の状態を確認します。

メトリクスの推移として 5xx レートが 0.6-0.8 あたりを推移しておりこれらは 5xx-rate ≥0.2を満たしたため発行したことが確認できました。

終わりに

今回は「運用を見据えた」をテーマに ECS Fargate の Blue/Green 構成および CodePipeline での自動更新のサンプルを作成してみました。実際にテックタッチではこれに近い構成の IaC 構成で 1 年以上大きな問題もなく運用されています。

一方で改善の余地があることも認識しています。それはタスク定義更新とイメージビルドを直列にしたために Pipeline 全体の実行時間が増加したことです。この辺に関しては Pipeline 内で並列実行可能な箇所があるので並列化すればよいのですがまだ最適化にはいたっていない状態です。

そのほか、今回は触れませんでしたが CodeDeploy にはテストリスナ機能があり、Blue/Green による切替前にテストリスナを通じて新系を検証する機能があります。こちらは本番系で運用可能な検証プロセスがイメージできなかったため導入を見送りましたが、適切な検証プロセスが構築可能なタイミングあれば検証、導入を検討してみたいと思っています。