ECSとCodePipelineのブルー/グリーンデプロイ構成をCDKで実装してみた

2023.09.30

こんにちは、つくぼし(tsukuboshi0755)です!

以前以下のブログで、CDK(TypeScript)でECSとECRのコンテナ構成を実装しました。

今回はこの構成に対して、CodeCommit及びCodeBuildを含むCodePipelineを追加し、ECSのブルー/グリーンデプロイ構成を実現するCDKコードを作ってみたいと思います!

前提条件

今回は以下の通り、CDKv2を使ってコードを書いていきます。

$ cdk version
2.83.1 (build 006b542)

またDockerクライアントとしては、Rancher Desktopを使用します。

$ rdctl version
rdctl client version: 1.1.0, targeting server version: v1

全体構成

今回はVPC+ALB+ECS(Fargate)+ECRに加えて、CodeCommit+CodeBuild+CodeDeploy+CodePipelineのCI/CD構成を追加で作成し、nginxコンテナをパブリック公開します。

リポジトリ

コード全体については、以下のリポジトリに格納していますのでご参照ください。

tsukuboshi/cdk-microservices-bluegreendeployment-template

コード解説

CDKコードの中核となるlib/cdk-microservices-bluegreendeployment-template-stack.ts について説明します。

なお以前のECSとECRのコンテナ構成をCDKで実装してみた | DevelopersIOで説明した箇所は省略します。

また今回ブルー/グリーンデプロイで必要なビルド仕様ファイル及びイメージ定義ファイルについて触れますが、該当ファイルの詳細は以下のブログをご参照ください。

ECSのデプロイ設定

    // Create ALB and ECS Fargate Service
    const service = new ecs_patterns.ApplicationLoadBalancedFargateService(
      this,
      "FargateService",
      {
        loadBalancerName: `${resourceName}-lb`,
        publicLoadBalancer: true,
        cluster: cluster,
        serviceName: `${resourceName}-service`,
        cpu: 256,
        desiredCount: 2,
        memoryLimitMiB: 512,
        assignPublicIp: true,
        taskSubnets: { subnetType: ec2.SubnetType.PUBLIC },
        taskImageOptions: {
          family: `${resourceName}-taskdef`,
          containerName: `${resourceName}-container`,
          image: ecs.ContainerImage.fromEcrRepository(ecrRepository, "latest"),
          logDriver: new ecs.AwsLogDriver({
            streamPrefix: `container`,
            logGroup: logGroup,
          }),
        },
        deploymentController: {
          type: ecs.DeploymentControllerType.CODE_DEPLOY,
        },
      }
    );

deployControllerのtypeにDeploymentControllerType.CODEDEPLOYを設定し、CodeDeployがデプロイを制御するように設定します。

この設定により、ECSタスクがブルー/グリーンデプロイでデプロイされるようになります。

2つ目のALBリスナー/ターゲットグループの作成

    // Create Second Target Group
    const targetGroup2 = new elbv2.ApplicationTargetGroup(this, "TargetGroup", {
      vpc: vpc,
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
    });

...
(中略)
...

    // Create Second Listener
    const listener2 = service.loadBalancer.addListener("Listener2", {
      port: 8080,
      open: true,
      defaultTargetGroups: [targetGroup2],
    });

色々調査してみたものの、残念ながら現状のecs_patterns.ApplicationLoadBalancedFargateServiceには、ブルー/グリーンデプロイに必要な追加のALBリスナー及びターゲットグループを作成する機能が見当たりませんでした。

そのため個別でALBリスナー及びターゲットグループを作成し、作成したロードバランサーに追加しておきます。

(この辺について、もしより良い作成方法をご存知な方がいらっしゃればぜひお伺いしたいです...!)

CodeCommitリポジトリの作成

    // Create CodeCommit Repository
    const codeCommitRepository = new codecommit.Repository(
      this,
      "CodeCommitRepo",
      {
        repositoryName: `${resourceName}-codecommit-repo`,
      }
    );

CodePipelineのソースステージに指定するCodeCommitリポジトリを作成します。

なお今回は記述していませんが、以下のようにcodeにディレクトリパスを指定する事で、CodeCommitリポジトリが作成された後に、特定のディレクトリに含まれる初期コードをすぐにプッシュできます。

      {
        repositoryName: `${resourceName}-codecommit-repo`,
        code: codecommit.Code.fromDirectory(
          path.join(__dirname, "..", "app"),
          "main"
        ),
    }

CodeBuildプロジェクトの作成

    // Create CloudWatch Log Group
    const buildLogGroup = new logs.LogGroup(this, "BuildLogGroup", {
      logGroupName: `/aws/codebuild/${resourceName}`,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // Create CodeBuild Project
    const codeBuildProject = new codebuild.Project(this, "CodeBuildProject", {
      projectName: `${resourceName}-codebuild-project`,
      source: codebuild.Source.codeCommit({
        repository: codeCommitRepository,
      }),
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        computeType: codebuild.ComputeType.SMALL,
        privileged: true,
        environmentVariables: {
          AWS_ACCOUNT_ID: {
            value: accountId,
          },
          REPOSITORY_URI: {
            value: ecrRepository.repositoryUri,
          },
          CONTAINER_BUILD_PATH: {
            value: ".",
          },
          CONTAINER_NAME: {
            value: service.taskDefinition.defaultContainer?.containerName,
          },
          TASK_FAMILY: {
            value: service.taskDefinition.family,
          },
          TASK_ROLE_ARN: {
            value: service.taskDefinition.taskRole?.roleArn,
          },
          EXECUTION_ROLE_ARN: {
            value: service.taskDefinition.executionRole?.roleArn,
          },
          CW_LOG_GROUP: {
            value: logGroup.logGroupName,
          },
          CW_LOG_STREAM_PREFIX: {
            value: "container",
          },
        },
      },
      logging: {
        cloudWatch: {
          logGroup: buildLogGroup,
        },
      },
      // buildSpec: codebuild.BuildSpec.fromSourceFilename('buildspec.yml'),
      buildSpec: codebuild.BuildSpec.fromObject({
        version: "0.2",
        phases: {
          pre_build: {
            commands: [
              "echo Logging in to Amazon ECR...",
              "aws --version",
              "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",
              "COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)",
              "IMAGE_TAG=${COMMIT_HASH:=latest}",
            ],
          },
          build: {
            commands: [
              "echo Build started on `date`",
              "echo Building the Docker image...",
              "docker build -t $REPOSITORY_URI:latest $CONTAINER_BUILD_PATH",
              "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:$IMAGE_TAG",
              "docker push $REPOSITORY_URI:latest",
              "echo Writing image detail file...",
              'printf \'{"ImageURI":"%s"}\' $REPOSITORY_URI:$IMAGE_TAG > imageDetail.json',
              "echo Rewriting task definitions file...",
              'sed -i -e "s##$TASK_FAMILY#" taskdef.json',
              'sed -i -e "s##$TASK_ROLE_ARN#" taskdef.json',
              'sed -i -e "s##$EXECUTION_ROLE_ARN#" taskdef.json',
              'sed -i -e "s##$CONTAINER_NAME#" taskdef.json',
              'sed -i -e "s##$CW_LOG_GROUP#" taskdef.json',
              'sed -i -e "s##$AWS_DEFAULT_REGION#" taskdef.json',
              'sed -i -e "s##$CW_LOG_STREAM_PREFIX#" taskdef.json',
              "echo Rewriting appspec file...",
              'sed -i -e "s##$CONTAINER_NAME#" appspec.yml',
            ],
          },
        },
        artifacts: {
          files: ["imageDetail.json", "taskdef.json", "appspec.yml"],
        },
      }),
    });

CodePipelineのビルドステージに指定するCodeBuildプロジェクトを作成します。

今回はbuildSpecBuildSpec.fromObjectを設定し、ビルド仕様をコマンド形式で書く形にしています。

この形にする事で、ビルド仕様をCDKコード内に記述でき、CDKの機能を利用しやすくなります。

参考:CodeBuildのbuildspecはCDKのBuildSpec.fromObjectで作成するようにした - mazyu36の日記

なおコマンド形式ではなく、ソースに存在するビルド仕様ファイル(buildspec.yml)を読み込む形にしたい場合は、fromSourceFilenameを用いる事で実現できます。

その場合はBuildSpecの箇所を以下の内容に書き換えた上で、CodeCommitリポジトリにビルド仕様ファイルをプッシュしてください。

        buildSpec: codebuild.BuildSpec.fromSourceFilename('./app/buildspec.yml'),

CodeBuildサービスロールへのECRアクセス権付与

    // Create ECR Access Policy
    const ecrAccessPolicy = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: [
        "ecr:BatchCheckLayerAvailability",
        "ecr:CompleteLayerUpload",
        "ecr:GetAuthorizationToken",
        "ecr:InitiateLayerUpload",
        "ecr:PutImage",
        "ecr:UploadLayerPart",
      ],
      resources: ["*"],
    });

    // Add ECR Access Policy to CodeBuild Project
    codeBuildProject.addToRolePolicy(ecrAccessPolicy);

先程のProjectではCodeBuildサービスロールも自動的に作成されますが、デフォルトではECRへのPush権限がついていません。

そのため必要なECR権限を、CodeBuildサービスロールに付与します。

参考:CodeBuild のDocker サンプル - AWS CodeBuild

CodeDeployアプリケーション/デプロイグループの作成

    // Create CodeDeploy Application
    const codeDeployapplication = new codedeploy.EcsApplication(
      this,
      "CodeDeployApplication",
      {
        applicationName: `${resourceName}-codedeploy-application`,
      }
    );

    // Create CodeDeploy Group
    const codeDeployGroup = new codedeploy.EcsDeploymentGroup(
      this,
      "BlueGreenDG",
      {
        service: service.service,
        application: codeDeployapplication,
        deploymentGroupName: `${resourceName}-codedeploy-group`,
        blueGreenDeploymentConfig: {
          deploymentApprovalWaitTime: Duration.minutes(60),
          terminationWaitTime: Duration.minutes(5),
          blueTargetGroup: service.targetGroup,
          greenTargetGroup: targetGroup2,
          listener: service.listener,
          testListener: listener2,
        },
        deploymentConfig: codedeploy.EcsDeploymentConfig.ALL_AT_ONCE,
      }
    );

CodePipelineのデプロイステージに指定するCodeDeployのデプロイグループを作成します。

まずEcsDeploymentGroup.blueGreenDeploymentConfigにあるblueTargetGroup及びlistenerに対して、ecs_patterns.ApplicationLoadBalancedFargateServiceで自動的に作成されるALBリスナー及びターゲットグループを指定します。

一方でgreenTargetTargetGroup及びtestlistnerに対して、先程追加で作成した2つ目のALBリスナー及びターゲットグループを指定します。

またdeploymentApprovalWaitTimeを設定する事で、CodeDeployが自動でトラフィックを再ルーティングするまでの待機時間を変更できます。

さらにterminationWaitTimeを変更する事で、CodeDeployが自動で元のリビジョンを終了するまでの待機時間を変更できます。

なおCodeDeployのサービスロールは、EcsDeploymentGroupで自動的に作成され、デフォルトでAWSCodeDeployRoleForECSマネージドポリシーが付与されます。

S3アーティファクトバケットの作成

    // Create Artifact Bucket for CodePipeline
    const artifactBucket = new s3.Bucket(this, "ArtifactBucket", {
      bucketName: `${resourceName}-artifact-bucket-${accountId}`,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

CodePipelineで使用するS3アーティファクトバケットを作成します。

今回のS3バケットは検証用のため、autoDeleteObjectsをtrueで設定し、バケットにオブジェクトが存在する場合でも削除できるようにしています。

CodePipelineの作成

    // Create CodePipeline
    const pipeline = new codepipeline.Pipeline(this, "CodePipeline", {
      artifactBucket: artifactBucket,
      pipelineName: `${resourceName}-pipeline`,
    });

    // Add Source Stage to Pipeline
    const sourceOutput = new codepipeline.Artifact(`${resourceName}-source`);
    const sourceAction = new codepipeline_actions.CodeCommitSourceAction({
      actionName: "Source",
      repository: codeCommitRepository,
      output: sourceOutput,
      branch: "main",
      trigger: codepipeline_actions.CodeCommitTrigger.EVENTS,
    });
    pipeline.addStage({
      stageName: "Source",
      actions: [sourceAction],
    });

    // Add Build Stage to Pipeline
    const buildOutput = new codepipeline.Artifact(`${resourceName}-build`);
    const buildAction = new codepipeline_actions.CodeBuildAction({
      actionName: "Build",
      project: codeBuildProject,
      input: sourceOutput,
      outputs: [buildOutput],
    });
    pipeline.addStage({
      stageName: "Build",
      actions: [buildAction],
    });

    // Add Deploy Stage to Pipeline
    const deployAction = new codepipeline_actions.CodeDeployEcsDeployAction({
      actionName: "Deploy",
      deploymentGroup: codeDeployGroup,
      taskDefinitionTemplateFile: buildOutput.atPath("taskdef.json"),
      appSpecTemplateFile: buildOutput.atPath("appspec.yml"),
      containerImageInputs: [
        {
          input: buildOutput,
          taskDefinitionPlaceholder: "IMAGE1_NAME",
        },
      ],
    });
    pipeline.addStage({
      stageName: "Deploy",
      actions: [deployAction],
    });

CodePipelineを作成し、ソースステージ/ビルドステージ/デプロイステージを各々設定します。

CodeCommitSourceActiontriggerCodeCommitTrigger.EVENTSを設定する事で、CodePipeline用のEventBridgeルールが合わせて作成され、CodeCommitリポジトリにプッシュされると自動的にパイプラインが起動するようになります。

またCodeDeployEcsDeployActiontaskDefinitionTemplateFile及びappSpecTemplateFileには、ビルドステージで作成したタスク定義ファイル(taskdef.json)及びアプリケーション仕様ファイル(appspec.yml)を指定します。

さらにcontainerImageInputsにあるtaskDefinitionPlaceholderには、タスク定義ファイル(taskdef.json)のコンテナイメージを置き換えるためのプレースホルダとしてIMAGE1_NAMEを指定します。

動作確認

CDKコードのデプロイ、及びECSタスクの起動確認については、ECSとECRのコンテナ構成をCDKで実装してみた | DevelopersIOをご参照ください。

ここではCodePipelineによるブルー/グリーンデプロイの動作確認を実施します。

CDKコードをデプロイした後、パイプラインが以下の通り作成されている事を確認します。

続いてパイプラインを稼働させるため、コンソール上で作成されたCodeCommitリポジトリをクリックし、"ファイルのアップロード"をクリックします。

"Choose File"をクリックし、例としてGitHubリポジトリのapp/Dockerfileを選択してください。

また今回は検証のため、"作成者名"及び"Eメールアドレス"は何でも構いません。

項目を入力し終えたら、"変更のコミット"をクリックします。

さらに今回はブルー/グリーンデプロイにapp/taskdef.json及びapp/appspec.ymlが必要になるため、これらのファイルもCodeCommitリポジトリに繰り返しコミットしてください。

コミット完了後、正常に設定されていればパイプラインが稼働します。

数分後、Deployステージで停止するので、"詳細"をクリックしてください。

デプロイの内容に問題がなければ、"トラフィックの再ルーティング"をクリックし、新しいタスクセットへの再ルーティングを実施してください。

再ルーティング完了後、"元のタスクセットの終了"をクリックし、古いタスクセットを削除してください。

元のタスクセットが終了し、パイプラインのDeployステージまで成功していればブルー/グリーンデプロイの動作確認は完了です!

最後に

今回はCodeCommit及びCodeBuildを含むCodePipelineを追加し、ECSのブルー/グリーンデプロイ構成を実現するCDKコードを作ってみました。

ぜひCDKでECSとCodePipelineのブルー/グリーンデプロイ構成を実装する際に、参考にしてみてください。

なおブルー/グリーンデプロイ構成と対をなす、ローリングアップデート構成については、以下のブログで解説していますので、必要に応じてご参照ください。

以上、つくぼし(tsukuboshi0755)でした!