Zenn

AWS ECS Blue/GreenデプロイのCI/CD環境構築をCloudFormationで自動化してみた

2025/02/24に公開

AWSの手頃なCI/CD環境が欲しかったので公式ハンズオンを組み合わせて構築してみた。

最初は構成理解のために手動で構築を始めたが、時間課金のリソース(主にALB)が財布に厳しかったのと目的に沿って拡張できるように再現性が欲しかったので、必要な時だけ簡単に作り直せるようCloudFormation化してみた。

CloudFormationテンプレートのみを残すとインプットや手順の再現性がなくなってしまうので、備忘として作成過程の記録を残してみる。

構成概要

基本的な構成は練習題材としてよく参照されていると思われる、以下2つのハンズオン(JP Contents Hubより)をベースとしている。

まずは手動で構築したかった経緯から、前者のECS環境構築手順に後付けする形で、後者に含まれるCode Deploy Blue/GreenとCodePipeline構築手順の部分を組み合わせた構成にしている。

組み合わせに伴って、両者のハンズオンから大きく変更したポイントは以下の通り。

  • コンテナイメージはNext.jsを使用する
  • ソースリポジトリはCode CommitではなくGitHubを使用する

最終的に作成した構成は以下のイメージになる。(CloudFormationテンプレートをLLMにMermaid出力させたものなので詳細は割愛)

ECS環境の構築

以下の2点を除き、ひとつ目のハンズオンの手順(Amazon Elastic Container Service 入門 コンテナイメージを作って動かしてみよう)に従ってECS環境の構築を行う。

  • 02 コンテナイメージを作成するための、Cloud9環境を構築するは省略してローカル端末でビルドする
  • コンテナイメージはNext.jsを使用する

前者についてはCloud9環境の取扱コストを避けたかった以外に内容はないが、後者については手順周りを補足する。

1. Next.jsのコンテナイメージ作成

リファレンスよりNode.jsのRuntimeに載せる形でビルドすれば良いことがわかる。やや手抜きではあるが、サンプルのDockerfileをそのまま利用する形で作成した。

  • create-next-appコマンドでNext.jsアプリを作成する
  • 公式記載のClone our exampleより、リンク先リポジトリのDockerfileを、作成したアプリのルートに配置する
Dockerfile
Dockerfile
# syntax=docker.io/docker/dockerfile:1

FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT=3000

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

2. コンテナのポート番号周りの対応

Dockerfileより、作成したコンテナはポート番号3000で起動するので、80想定となっているハンズオンの手順を一部読み替える必要がある。(読み替える方が色々とわかることがあるという考え)

  • 06 VPCの作成
    • セキュリティグループのインバウンドルールにカスタムTCPでポート範囲3000を追加する
  • 07 ECSクラスター・タスク定義の作成
    • タスク定義のポートマッピングのコンテナポートに80番ではなく3000番を指定する

ECS環境のCloudFormation化

ハンズオンの06 VPCの作成07 ECSクラスター・タスク定義の作成の単位でそれぞれに対応したCloudFormationテンプレートを作成する。(ECRは作成コストがかからず放置した際のストレージコストも月額USD0.10/GBとそれほど問題にならないため対象外とした)

作成対象のリソースを特定できれば、CloudFormationのIaCジェネレーターを利用することでテンプレートの雛形を取得できるので、基本的な流れとしてはリソースの特定 -> IaCジェネレーターによるテンプレート雛形の生成 -> テンプレートの整形の手順とした。

Networkテンプレートの作成

リソースの特定はある程度機械的に行いたかったのでCloudTrailのイベント履歴を活用することにした。イベント名CreateVpcでリソース作成イベントが発生した時間を特定し、改めて前後数分を検索することでCreateXされているリソースをリストアップした。

次に、IaCジェネレーターでリストアップしたリソースをチェックする。この時関連リソースが提示されるが、リストしていなかったDHCPOptionsNetworkAclといったNW系の名称を持つリソースが数件存在していた。これらはVPC作成時にAWSがデフォルトで作成するリソースであるため、変更を加える意図がなければCloudFormationテンプレートに定義する必要はなかった。Security Groupはポート番号の読み替えの都合で変更していたのでテンプレートに含めた。

整形後のテンプレートは以下の通り。

network.yml
network.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Network Stack
Parameters:
  System:
    Description: System Name
    Type: String
  Env:
    Description: Environment
    Type: String
Resources:
  Vpc:
    Type: AWS::EC2::VPC
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      InstanceTenancy: default
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-vpc
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-igw
  VpcGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref Vpc
  PublicSubnetAz1:
    Type: AWS::EC2::Subnet
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      VpcId: !Ref Vpc
      MapPublicIpOnLaunch: false
      EnableDns64: false
      AvailabilityZoneId: apne1-az1
      PrivateDnsNameOptionsOnLaunch:
        EnableResourceNameDnsARecord: false
        HostnameType: ip-name
        EnableResourceNameDnsAAAARecord: false
      CidrBlock: 10.0.16.0/20
      Ipv6Native: false
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-subnet-public-endpoint-az1
  PublicSubnetAz4:
    Type: AWS::EC2::Subnet
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      VpcId: !Ref Vpc
      MapPublicIpOnLaunch: false
      EnableDns64: false
      AvailabilityZoneId: apne1-az4
      PrivateDnsNameOptionsOnLaunch:
        EnableResourceNameDnsARecord: false
        HostnameType: ip-name
        EnableResourceNameDnsAAAARecord: false
      CidrBlock: 10.0.0.0/20
      Ipv6Native: false
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-subnet-public-endpoint-az4
  SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      GroupName: !Sub ${System}-${Env}-sg-web
      GroupDescription: !Sub ${System}-${Env}-sg-web
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 80
          ToPort: 80
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 3000
          ToPort: 3000
        - IpProtocol: "-1"
          FromPort: -1
          SourceSecurityGroupId: !GetAtt Vpc.DefaultSecurityGroup
          ToPort: -1
      SecurityGroupEgress:
        - CidrIp: 0.0.0.0/0
          IpProtocol: "-1"
          FromPort: -1
          ToPort: -1
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-sg-web
  RouteTable:
    Type: AWS::EC2::RouteTable
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-rtb-public
  InternetGatewayRoute:
    Type: AWS::EC2::Route
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      RouteTableId: !Ref RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicSubnetAz1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      RouteTableId: !Ref RouteTable
      SubnetId: !Ref PublicSubnetAz1
  PublicSubnetAz4RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      RouteTableId: !Ref RouteTable
      SubnetId: !Ref PublicSubnetAz4
  # S3VpcEndpoint:
  #   Type: AWS::EC2::VPCEndpoint
  #   DeletionPolicy: Delete
  #   UpdateReplacePolicy: Delete
  #   Properties:
  #     PrivateDnsEnabled: false
  #     IpAddressType: not-specified
  #     DnsOptions:
  #       PrivateDnsOnlyForInboundResolverEndpoint: NotSpecified
  #       DnsRecordIpType: not-specified
  #     ResourceConfigurationArn: ""
  #     SecurityGroupIds: []
  #     SubnetIds: []
  #     ServiceNetworkArn: ""
  #     VpcId: !Ref Vpc
  #     RouteTableIds: []
  #     ServiceName: com.amazonaws.ap-northeast-1.s3
  #     PolicyDocument:
  #       Version: "2008-10-17"
  #       Statement:
  #         - Resource: "*"
  #           Action: "*"
  #           Effect: Allow
  #           Principal: "*"
  #     VpcEndpointType: Gateway
  #     Tags:
  #       - Key: Name
  #         Value: !Sub ${System}-${Env}-vpce-s3
Outputs:
  VpcId:
    Description: VPC ID
    Value: !Ref Vpc
    Export:
      Name: !Sub ${AWS::StackName}-vpc-id
  SecurityGroupId:
    Description: Security Group ID
    Value: !Ref SecurityGroup
    Export:
      Name: !Sub ${AWS::StackName}-sg-id
  PublicSubnetIds:
    Description: Public Subnet IDs
    Value: !Join [",", [!Ref PublicSubnetAz1, !Ref PublicSubnetAz4]]
    Export:
      Name: !Sub ${AWS::StackName}-public-subnet-ids

ECSテンプレートの作成

ECSのリソースは構築時にCloudFormationスタックが自動で作成される。これらのテンプレートにはECS ClusterECS Serviceが含まれているが、ECS タスク定義は含まれていない。不足を補うためにIaCジェネレーターでECS タスク定義をチェックし、関連リソースも提示されたものをすべて含める形でテンプレート生成を行った。

生成結果のなかで必要なものはECS タスク定義タスクロールのみでAWSServiceRoleForECSはAWSがデフォルトで作成するものであり、またその他はNetworkテンプレートでカバーできるものであった。

また、TargetGroup.Properties.HealthCheckPortは指定しなければデフォルトでtraffic-port=コンテナポートを向いてくれるようであるのでそのままにした。

If the protocol is HTTP, HTTPS, TCP, TLS, UDP, or TCP_UDP, the default is traffic-port, which is the port on which each target receives traffic from the load balancer.

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-elasticloadbalancingv2-targetgroup.html#cfn-elasticloadbalancingv2-targetgroup-healthcheckport

自動作成されたスタックのテンプレートとIaCジェネレーターで生成したテンプレートをマージして整形したテンプレートは以下の通り。

ecs.yml
ecs.yml
AWSTemplateFormatVersion: 2010-09-09
Description: ECS Stack
Parameters:
  System:
    Description: System Name
    Type: String
  Env:
    Description: Environment
    Type: String
  ImageUri:
    Description: Image URI
    Type: String
Resources:
  EcsLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      FieldIndexPolicies: []
      LogGroupClass: STANDARD
      LogGroupName: !Sub /ecs/${System}-${Env}-ecs-td
      DataProtectionPolicy: {}
  EcsCluster:
    Type: AWS::ECS::Cluster
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ClusterName: !Sub ${System}-${Env}-ecs-cluster
      CapacityProviders:
        - FARGATE
        - FARGATE_SPOT
      ClusterSettings:
        - Name: containerInsights
          Value: disabled
      ServiceConnectDefaults:
        Namespace: !Sub ${System}-${Env}
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-ecs-cluster
  EcsTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ExecutionRoleArn:
        Fn::GetAtt:
          - "EcsTaskExecutionRole"
          - "Arn"
      RuntimePlatform:
        OperatingSystemFamily: "LINUX"
        CpuArchitecture: "X86_64"
      Volumes: []
      InferenceAccelerators: []
      Memory: "1024"
      PlacementConstraints: []
      ContainerDefinitions:
        - ExtraHosts: []
          Secrets: []
          VolumesFrom: []
          Cpu: 0
          EntryPoint: []
          DnsServers: []
          Image: !Ref ImageUri
          Essential: true
          LogConfiguration:
            SecretOptions: []
            Options:
              awslogs-group: !Ref EcsLogGroup
              mode: "non-blocking"
              max-buffer-size: "25m"
              awslogs-create-group: "true"
              awslogs-region: "ap-northeast-1"
              awslogs-stream-prefix: "ecs"
            LogDriver: "awslogs"
          ResourceRequirements: []
          EnvironmentFiles: []
          Name: !Sub ecs-container
          MountPoints: []
          DependsOn: []
          DockerLabels: {}
          PortMappings:
            - ContainerPort: 3000
              AppProtocol: "http"
              Protocol: "tcp"
              HostPort: 3000
              Name: !Sub ${Env}--${System}--container--3000--tcp
          DockerSecurityOptions: []
          SystemControls: []
          Command: []
          DnsSearchDomains: []
          Environment: []
          Links: []
          CredentialSpecs: []
          Ulimits: []
      Cpu: "512"
      RequiresCompatibilities:
        - "FARGATE"
      Family: !Sub ${System}-${Env}-ecs-td
      NetworkMode: "awsvpc"
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-ecs-td
  EcsTaskExecutionRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Path: "/"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
      MaxSessionDuration: 3600
      RoleName: !Sub ${System}-${Env}-role-ecs-task-execution
      AssumeRolePolicyDocument:
        Version: "2008-10-17"
        Statement:
          - Action: "sts:AssumeRole"
            Effect: "Allow"
            Principal:
              Service: "ecs-tasks.amazonaws.com"
            Sid: ""
  EcsService:
    Type: AWS::ECS::Service
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Cluster: !Ref EcsCluster
      CapacityProviderStrategy:
        - CapacityProvider: FARGATE
          Base: 0
          Weight: 1
      TaskDefinition: !Ref EcsTaskDefinition
      ServiceName: !Sub ${System}-${Env}-ecs-sv
      SchedulingStrategy: REPLICA
      DesiredCount: 1
      AvailabilityZoneRebalancing: ENABLED
      LoadBalancers:
        - ContainerName: !Sub ecs-container
          ContainerPort: 3000
          LoadBalancerName: !Ref AWS::NoValue
          TargetGroupArn: !Ref TargetGroup
      HealthCheckGracePeriodSeconds: "30"
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-sg-id
          Subnets:
            Fn::Split:
              - ","
              - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-public-subnet-ids
      PlatformVersion: LATEST
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 100
        DeploymentCircuitBreaker:
          Enable: true
          Rollback: true
      DeploymentController:
        Type: ECS
      ServiceConnectConfiguration:
        Enabled: false
      Tags: []
      EnableECSManagedTags: true
    DependsOn:
      - Listener
  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Type: application
      Name: !Sub ${System}-${Env}-alb-web
      SecurityGroups:
        - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-sg-id
      Subnets:
        Fn::Split:
          - ","
          - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-public-subnet-ids
  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      HealthCheckPath: /
      Name: !Sub ${System}-${Env}-elb-tg-web
      Port: 3000
      Protocol: HTTP
      TargetType: ip
      HealthCheckProtocol: HTTP
      VpcId:
        Fn::ImportValue: !Sub ${System}-${Env}-network-stack-vpc-id
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: "300"
  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 80
      Protocol: HTTP

CI/CDパイプラインの構築

以下の4点を除き、ふたつ目のハンズオンの手順(AWS CI/CD for Amazon ECS ハンズオン)のP63〜を参照してECS ServiceをDeployType = Blue/Greenで作り直したうえで、Blue/Greenデプロイに対応したCI/CDパイプラインの構築を行なう。

1. CodeConnectionsでGitHubと接続

CodeCommitは残念ながら今後の発展が見込めないため、将来性の観点からSource ProviderはGitHubへの置き換えを試すことにした。

After careful consideration, we have made the decision to close new customer access to AWS CodeCommit, effective July 25, 2024. AWS CodeCommit existing customers can continue to use the service as normal. AWS continues to invest in security, availability, and performance improvements for AWS CodeCommit, but we do not plan to introduce new features.

https://aws.amazon.com/jp/blogs/devops/how-to-migrate-your-aws-codecommit-repository-to-another-git-provider/

こちらの記事を参考にしてGitHubと接続するためのCodeConnectionsのリソースを作成した。

https://blog.serverworks.co.jp/create-codeconnections

CodePipelineのソースプロバイダーはGitHub (バージョン 2)を指定して道なりに設定を行う。

The current GitHub (via GitHub App) action is the version 2 source action for GitHub.

https://docs.aws.amazon.com/codepipeline/latest/userguide/integrations-action-type.html

2. buildspec.ymlの最新化

ハンズオンのbuildspec.ymlのままであると、こちらの記事で言及されている通りECRへのログインコマンドが旧いため実行に失敗してしまう。

https://qiita.com/mksamba/items/ffbdbd0a8ca25c3e060e

記事の内容を念頭に、ECS Web Application ハンズオンで提供されているbuildspec.ymlをベースにして最新化を行なった。

一部の環境変数を外出しにしているので、CodeBuild側で設定を行う。

buildspec.yml
buildspec.yml
version: 0.2

phases:
  pre_build:
    commands:
      - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
      - REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${REPOSITORY_NAME}
      - IMAGE_TAG=$(echo ${CODEBUILD_RESOLVED_SOURCE_VERSION} | cut -c 1-7)
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com
      - $(aws ecs describe-task-definition --task-definition ${ECS_TASK_DEFINITION_ARN} --query taskDefinition | jq '.containerDefinitions[0].image="<IMAGE1_NAME>"' > taskdef.json)

  build:
    commands:
      - docker build -t ${REPOSITORY_URI}:latest .
      - docker tag ${REPOSITORY_URI}:latest ${REPOSITORY_URI}:${IMAGE_TAG}

  post_build:
    commands:
      - docker push ${REPOSITORY_URI}:${IMAGE_TAG}
      - printf '{"Version":"1.0","ImageURI":"%s"}' $REPOSITORY_URI:$IMAGE_TAG > imageDetail.json

artifacts:
  files:
    - imageDetail.json
    - taskdef.json

3. taskdef.jsonをビルド時に生成

増築している背景もあるが、ハンズオンの構成だとECS タスク定義をECSテンプレートと静的に二重管理することになり忌避感があったので、こちらの記事を参考にしてtaskdef.jsonをビルド時に動的に生成することにした。(どちらで管理すべきかは別途検討が必要だと思うが暫定的に)

上述のbuildspec.ymlに反映している。

https://qiita.com/RikuKomiya/items/206558a38d3a2b52c421

4. appspec.ymlのコンテナポート番号を変更

Next.jsのコンテナイメージに対応するため、コンテナポート番号を80 -> 3000に変更した。

appspec.yml
appspec.yml
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "<TASK_DEFINITION>"
        LoadBalancerInfo:
          ContainerName: ecs-container
          ContainerPort: 3000

CI/CDパイプラインのCloudFormation化

すでに作成したECSテンプレートのBlue/Greenデプロイ拡張版、CodeConnections、CI/CDの単位でテンプレートを作成する。基本的にはECSテンプレートの作成手順と同様になるが、CodeConnectionsやCodePipelineはIaCジェネレーターの対象外サービスとなっているので別途ベースを探す必要があった。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resource-import-supported-resources.html

ECS Blue/Greenテンプレートの作成

ECSテンプレート作成時と同様に、ECS ServiceのDeployType = Blue/Greenへの作り直しを行うと対応するCloudFormationスタックが新たに自動で作成される。作成済みのECSテンプレートとの差分リソースがシンプルにBlue/Greenデプロイに関連したものになるのでその分を更新すれば良い。
差分をマージして整形したテンプレートは以下の通り。

ecs-bluegreen.yml
ecs-bluegreen.yml
AWSTemplateFormatVersion: 2010-09-09
Description: ECS Fargate with Blue/Green Deploy Stack
Parameters:
  System:
    Description: System Name
    Type: String
  Env:
    Description: Environment
    Type: String
  ImageUri:
    Description: Image URI
    Type: String
Resources:
  EcsLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      FieldIndexPolicies: []
      LogGroupClass: STANDARD
      LogGroupName: !Sub /ecs/${System}-${Env}-ecs-td
      DataProtectionPolicy: {}
  EcsCluster:
    Type: AWS::ECS::Cluster
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ClusterName: !Sub ${System}-${Env}-ecs-cluster
      CapacityProviders:
        - FARGATE
        - FARGATE_SPOT
      ClusterSettings:
        - Name: containerInsights
          Value: disabled
      ServiceConnectDefaults:
        Namespace: !Sub ${System}-${Env}
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-ecs-cluster
  EcsTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ExecutionRoleArn:
        Fn::GetAtt:
          - "EcsTaskExecutionRole"
          - "Arn"
      RuntimePlatform:
        OperatingSystemFamily: "LINUX"
        CpuArchitecture: "X86_64"
      Volumes: []
      InferenceAccelerators: []
      Memory: "1024"
      PlacementConstraints: []
      ContainerDefinitions:
        - ExtraHosts: []
          Secrets: []
          VolumesFrom: []
          Cpu: 0
          EntryPoint: []
          DnsServers: []
          Image: !Ref ImageUri
          Essential: true
          LogConfiguration:
            SecretOptions: []
            Options:
              awslogs-group: !Ref EcsLogGroup
              mode: "non-blocking"
              max-buffer-size: "25m"
              awslogs-create-group: "true"
              awslogs-region: "ap-northeast-1"
              awslogs-stream-prefix: "ecs"
            LogDriver: "awslogs"
          ResourceRequirements: []
          EnvironmentFiles: []
          Name: ecs-container
          MountPoints: []
          DependsOn: []
          DockerLabels: {}
          PortMappings:
            - ContainerPort: 3000
              AppProtocol: "http"
              Protocol: "tcp"
              HostPort: 3000
              Name: ecs-container-3000-tcp
          DockerSecurityOptions: []
          SystemControls: []
          Command: []
          DnsSearchDomains: []
          Environment: []
          Links: []
          CredentialSpecs: []
          Ulimits: []
      Cpu: "512"
      RequiresCompatibilities:
        - "FARGATE"
      Family: !Sub ${System}-${Env}-ecs-td
      NetworkMode: "awsvpc"
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-ecs-td
  EcsTaskExecutionRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
      MaxSessionDuration: 3600
      RoleName: !Sub ${System}-${Env}-role-ecs-task-execution
      AssumeRolePolicyDocument:
        Version: 2008-10-17
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
  EcsService:
    Type: AWS::ECS::Service
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Cluster: !Ref EcsCluster
      CapacityProviderStrategy:
        - CapacityProvider: FARGATE
          Base: 0
          Weight: 1
      TaskDefinition: !Ref EcsTaskDefinition
      ServiceName: !Sub ${System}-${Env}-ecs-sv
      SchedulingStrategy: REPLICA
      DesiredCount: 1
      AvailabilityZoneRebalancing: ENABLED
      LoadBalancers:
        - ContainerName: ecs-container
          ContainerPort: 3000
          LoadBalancerName: !Ref AWS::NoValue
          TargetGroupArn: !Ref TargetGroup1
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-sg-id
          Subnets:
            Fn::Split:
              - ","
              - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-public-subnet-ids
      PlatformVersion: LATEST
      DeploymentController:
        Type: CODE_DEPLOY
      ServiceConnectConfiguration:
        Enabled: false
      Tags: []
      EnableECSManagedTags: true
    DependsOn:
      - Listener
  TargetGroup1:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      HealthCheckPath: /
      Name: !Sub ${System}-${Env}-elb-tg-web-1
      Port: 3000
      Protocol: HTTP
      TargetType: ip
      HealthCheckProtocol: HTTP
      VpcId:
        Fn::ImportValue: !Sub ${System}-${Env}-network-stack-vpc-id
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: "300"
  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup1
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 80
      Protocol: HTTP
  TargetGroup2:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      HealthCheckPath: /
      Name: !Sub ${System}-${Env}-elb-tg-web-2
      Port: 3000
      Protocol: HTTP
      TargetType: ip
      HealthCheckProtocol: HTTP
      VpcId:
        Fn::ImportValue: !Sub ${System}-${Env}-network-stack-vpc-id
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: "300"
  CodeDeployRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS
      MaxSessionDuration: 3600
      RoleName: !Sub ${System}-${Env}-role-cd
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: codedeploy.amazonaws.com
  CodeDeployDeploymentGroup:
    Type: AWS::CodeDeploy::DeploymentGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ApplicationName: !Ref CodeDeployApplication
      DeploymentGroupName: !Sub ${System}-${Env}-cd-group
      DeploymentConfigName: CodeDeployDefault.ECSAllAtOnce
      AutoRollbackConfiguration:
        Enabled: true
        Events:
          - DEPLOYMENT_FAILURE
          - DEPLOYMENT_STOP_ON_REQUEST
      BlueGreenDeploymentConfiguration:
        DeploymentReadyOption:
          ActionOnTimeout: CONTINUE_DEPLOYMENT
          WaitTimeInMinutes: 0
        TerminateBlueInstancesOnDeploymentSuccess:
          Action: TERMINATE
          TerminationWaitTimeInMinutes: 60
      DeploymentStyle:
        DeploymentOption: WITH_TRAFFIC_CONTROL
        DeploymentType: BLUE_GREEN
      LoadBalancerInfo:
        TargetGroupPairInfoList:
          - ProdTrafficRoute:
              ListenerArns:
                - !GetAtt Listener.ListenerArn
            TargetGroups:
              - Name: !GetAtt TargetGroup1.TargetGroupName
              - Name: !GetAtt TargetGroup2.TargetGroupName
      ServiceRoleArn: !GetAtt CodeDeployRole.Arn
      ECSServices:
        - ClusterName: !Sub ${System}-${Env}-ecs-cluster
          ServiceName: !Sub ${System}-${Env}-ecs-sv
      Tags: []
    DependsOn:
      - CodeDeployApplication
  CodeDeployApplication:
    Type: AWS::CodeDeploy::Application
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ApplicationName: !Sub ${System}-${Env}-cd-app
      ComputePlatform: ECS
      Tags: []
    DependsOn:
      - EcsService
  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Type: application
      Name: !Sub ${System}-${Env}-alb-web
      SecurityGroups:
        - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-sg-id
      Subnets:
        Fn::Split:
          - ","
          - Fn::ImportValue: !Sub ${System}-${Env}-network-stack-public-subnet-ids
Outputs:
  EcsTaskDefinitionArn:
    Description: ECS Task Definition Arn
    Value: !Ref EcsTaskDefinition
    Export:
      Name: !Sub ${AWS::StackName}-ecs-td-arn
  CodeDeployAppName:
    Description: CodeDeploy Application Name
    Value: !Ref CodeDeployApplication
    Export:
      Name: !Sub ${AWS::StackName}-cd-app-name
  CodeDeployDGName:
    Description: CodeDeploy Deployment Group Name
    Value: !Ref CodeDeployDeploymentGroup
    Export:
      Name: !Sub ${AWS::StackName}-cd-group-name

CodeConnectionsテンプレートの作成

前述の通り、CodeConnectionsはIaCジェネレーターの対象外であるためテンプレートのベースを別途参照する必要があった。ぱっと見ではネット上に参考になりそうな記事がなかったため、LLMに生成してもらいつつ仕様を確認する形で作成した。

また、以下の引用にある通り、CloudFormationでリソースを作成した後に別途コンソールでステータスを更新する手動対応は残ってしまう。

Note: A connection created through AWS CloudFormation, the CLI, or the SDK is in PENDING status by default. You can make its status AVAILABLE by updating the connection in the console.

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-codeconnections-connection.html

connections.yml
connections.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Code Connections Stack
Parameters:
  System:
    Description: System Name
    Type: String
  Env:
    Description: Environment
    Type: String
Resources:
  CodeConnections:
    Type: AWS::CodeConnections::Connection
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ConnectionName: !Sub ${System}-${Env}-connections
      ProviderType: GitHub
      Tags:
        - Key: Name
          Value: !Sub ${System}-${Env}-connections
Outputs:
  CodeConnections:
    Description: Code Connections
    Value: !Ref CodeConnections
    Export:
      Name: !Sub ${AWS::StackName}-connections-arn

CI/CDテンプレートの作成

CodePipelineもIaCジェネレーターの対象外であるため、別途テンプレートのベースを探す必要があった。CodePipelineのパイプラインを作成する -> Category = Deployment -> Template = Deploy to ECS Fargateを選択していくとサンプルのCloudFomationテンプレートが得られるが、これはBlue/Greenデプロイには対応していなかった。形式自体は公式になると思われるのでこちらを一定ベースとしつつも、中核部分に関してはこちらの記事のplaceholder-codepipeline.ymlを参考にさせてもらった。

https://dev.classmethod.jp/articles/2pattern-cfn-fargate-blue-green-deployment-codepipeline/#toc--bluegreen-

こちらのテンプレートではソースプロバイダをCodeCommitとしており、CodeConnectionsへの読み替えが必要であったため、以下の変更を行なっている。

1. CloudWatchEvent(EventBridge)関連リソースの除去

CodePipelineの実行トリガーとしてEventBridgeを定義しているが、CodeConnectionsの場合は独自に変更を検知してトリガーしてくれるので不要であった。

The pipeline defaults to detect changes on code push to the connection source repository.

https://docs.aws.amazon.com/codepipeline/latest/userguide/connections-github.html

https://docs.aws.amazon.com/codepipeline/latest/userguide/change-detection-methods.html

2. SourceActionの変更

リファレンスを参照し、ProviderをCodeCommitからCodeStarSourceConnectionへ変更した。

https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodestarConnectionSource.html

3. CodePipeline用のIAMロールにCodeConnectionsへのアクセス権限を付与

CodeCommitへのアクセス権限の代わりにCodeConnectionsへのアクセス権限が必要であった。CodePipeline用のIAMロールについては手動構築時に作成されたリソースの許可ポリシー・信頼関係をコンソールで確認し裏付けを行なった。

https://dev.classmethod.jp/articles/tsnote-codepipeline-github-source-action-in-codepipeline-says-you-dont-have-permission-to-access/

整形後のテンプレートは以下の通り。

cicd.yml
cicd.yml
AWSTemplateFormatVersion: 2010-09-09
Description: CodePipeline For ECS Fargate with Blue/Green Deploy
Parameters:
  System:
    Description: System Name
    Type: String
  Env:
    Description: Environment
    Type: String
  EcrRepositoryName:
    Description: ECR Repository Name used in buildspec.yml
    Type: String
  GithubFullRepositoryId:
    Description: Github Full Repository Id (e.g. <owner_name>/<repository_name>)
    Type: String
Resources:
  CodeBuildServiceRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      RoleName: !Sub ${System}-${Env}-pipeline-role-cb
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
      Policies:
        - PolicyName: PipelineCodeBuildAccess
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Resource: "*"
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
              - Effect: Allow
                Resource: !Sub arn:aws:s3:::${CodePipelineArtifactsBucket}/*
                Action:
                  - s3:PutObject
                  - s3:GetObject
                  - s3:GetObjectVersion
                  - s3:GetBucketAcl
                  - s3:GetBucketLocation
              - Effect: Allow
                Resource: "*"
                Action:
                  - codebuild:CreateReportGroup
                  - codebuild:CreateReport
                  - codebuild:UpdateReport
                  - codebuild:BatchPutTestCases
                  - codebuild:BatchPutCodeCoverages
              - Effect: Allow
                Resource: "*"
                Action:
                  - ecs:DescribeTaskDefinition

  CodePipelineServiceRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      RoleName: !Sub ${System}-${Env}-role-cp
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: codepipeline.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: PipelineAccess
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Action:
                  - iam:PassRole
                Resource: "*"
                Effect: Allow
                Condition:
                  StringEqualsIfExists:
                    iam:PassedToService:
                      - ecs-tasks.amazonaws.com
              - Resource:
                  - !Sub arn:aws:s3:::${CodePipelineArtifactsBucket}/*
                Effect: Allow
                Action:
                  - s3:PutObject
                  - s3:GetObject
                  - s3:GetObjectVersion
                  - s3:GetBucketVersioning
              - Action:
                  - codedeploy:CreateDeployment
                  - codedeploy:GetApplication
                  - codedeploy:GetApplicationRevision
                  - codedeploy:GetDeployment
                  - codedeploy:GetDeploymentConfig
                  - codedeploy:RegisterApplicationRevision
                  - codedeploy:*
                Resource: "*"
                Effect: Allow
              - Action:
                  - elasticbeanstalk:*
                  - ec2:*
                  - elasticloadbalancing:*
                  - autoscaling:*
                  - cloudwatch:*
                  - sns:*
                  - cloudformation:*
                  - rds:*
                  - sqs:*
                  - ecs:*
                Resource: "*"
                Effect: Allow
              - Action:
                  - codebuild:BatchGetBuilds
                  - codebuild:StartBuild
                  - codebuild:BatchGetBuildBatches
                  - codebuild:StartBuildBatch
                Resource: "*"
                Effect: Allow
              - Action:
                  - codestar-connections:UseConnection
                Resource:
                  - Fn::ImportValue: !Sub ${System}-${Env}-connections-stack-connections-arn
                Effect: Allow

  CodePipelineArtifactsBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  CodePipelineArtifactsBucketPolicy:
    Type: AWS::S3::BucketPolicy
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Bucket: !Ref CodePipelineArtifactsBucket
      PolicyDocument:
        Statement:
          - Action: s3:*
            Condition:
              Bool:
                aws:SecureTransport: false
            Effect: Deny
            Principal:
              AWS: "*"
            Resource:
              - !GetAtt CodePipelineArtifactsBucket.Arn
              - !Join
                - ""
                - - !GetAtt CodePipelineArtifactsBucket.Arn
                  - /*
        Version: 2012-10-17

  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      ServiceRole: !Ref CodeBuildServiceRole
      Artifacts:
        Type: CODEPIPELINE
      Source:
        Type: CODEPIPELINE
        BuildSpec: buildspec.yml
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0
        ImagePullCredentialsType: CODEBUILD
        PrivilegedMode: false
        Type: LINUX_CONTAINER
        EnvironmentVariables:
          - Name: REPOSITORY_NAME
            Value: !Ref EcrRepositoryName
          - Name: ECS_TASK_DEFINITION_ARN
            Value:
              Fn::ImportValue: !Sub ${System}-${Env}-ecs-bluegreen-stack-ecs-td-arn
      Name: !Sub ${System}-${Env}-cb-build

  CodePipeline:
    Type: AWS::CodePipeline::Pipeline
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      RoleArn: !GetAtt CodePipelineServiceRole.Arn
      Name: !Sub ${System}-${Env}-cp
      PipelineType: V2
      ExecutionMode: QUEUED
      ArtifactStore:
        Type: S3
        Location: !Ref CodePipelineArtifactsBucket
      Stages:
        - Name: Source
          Actions:
            - Name: SourceAction
              ActionTypeId:
                Category: Source
                Owner: AWS
                Version: "1"
                Provider: CodeStarSourceConnection
              Configuration:
                ConnectionArn:
                  Fn::ImportValue: !Sub ${System}-${Env}-connections-stack-connections-arn
                FullRepositoryId: !Ref GithubFullRepositoryId
                BranchName: main
                OutputArtifactFormat: CODE_ZIP
              RunOrder: 1
              OutputArtifacts:
                - Name: SourceArtifact
        - Name: Build
          Actions:
            - Name: Build
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: "1"
                Provider: CodeBuild
              Configuration:
                ProjectName: !Ref CodeBuildProject
              RunOrder: 1
              InputArtifacts:
                - Name: SourceArtifact
              OutputArtifacts:
                - Name: BuildArtifact
        - Name: Deploy
          Actions:
            - Name: Deploy
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: "1"
                Provider: CodeDeployToECS
              Configuration:
                AppSpecTemplateArtifact: SourceArtifact
                AppSpecTemplatePath: appspec.yml
                TaskDefinitionTemplateArtifact: BuildArtifact
                TaskDefinitionTemplatePath: taskdef.json
                ApplicationName:
                  Fn::ImportValue: !Sub ${System}-${Env}-ecs-bluegreen-stack-cd-app-name
                DeploymentGroupName:
                  Fn::ImportValue: !Sub ${System}-${Env}-ecs-bluegreen-stack-cd-group-name
                Image1ArtifactName: BuildArtifact
                Image1ContainerName: IMAGE1_NAME
              RunOrder: 1
              InputArtifacts:
                - Name: SourceArtifact
                - Name: BuildArtifact
              Region: !Ref AWS::Region

その他特記事項

CloudFormationテンプレートに定義するリソース名の命名規則

こちらの記事を参考にさせてもらった。

https://dev.classmethod.jp/articles/aws-resource-naming-rule-2024/

CloudFormationスタック作成時に注意すること

  • OutputsとImportValueが整合するようにスタック名とParameterの指定に気をつける
  • スタック名は以下の指定を想定している
    • ${System}-${Env}-network-stack
    • ${System}-${Env}-ecs-stack
    • ${System}-${Env}-ecs-bluegreen-stack
    • ${System}-${Env}-connections-stack
    • ${System}-${Env}-cicd-stack

CodePipeline実行時に気をつけること

すでに参照した記事でも言及されているが、根本的にCodeBuildの実行で落ちる可能性がある。回避策は特段取っていないので、エラーになった場合は気長に再実行する想定としている。

https://dev.classmethod.jp/articles/codebuild-has-to-use-dockerhub-login-to-avoid-ip-gacha/

Discussion

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