CloudFormationを使用してECS Fargateを構築してみた

2024.02.29

CloudFormationでECS Fargateを構築する機会があったのでブログに残します。

やること

タイトルの通りCloudFormationを使用してECS Fargateを動かせる環境を作成します。
ECS Fargateで使用するコンテナイメージはCloudShellで作成してECRへpushします。
ECRへのアクセスなどはVPCエンドポイントを使用する構成とします。
簡単にはなりますが構成図は以下となります。

AWSリソースの作成

まずはECSを動かすネットワーク周りのリソースとECRを作成します。
作成は以下のCloudFormationテンプレートで行いました。

CloudFormationテンプレート (ここをクリックしてください)
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
# ------------------------------------------------------------#
# Parameters
# ------------------------------------------------------------# 
  VPCCIDR:
    Default: 10.0.0.0/16
    Type: String

  PublicSubnet01CIDR:
    Default: 10.0.0.0/24
    Type: String

  PublicSubnet02CIDR:
    Default: 10.0.1.0/24
    Type: String

  PrivateSubnet01CIDR:
    Default: 10.0.2.0/24
    Type: String

  PrivateSubnet02CIDR:
    Default: 10.0.3.0/24
    Type: String

Resources:
# ------------------------------------------------------------#
# VPC
# ------------------------------------------------------------# 
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: 
        - Key: Name
          Value: ecs-test-vpc

# ------------------------------------------------------------#
# Subnet
# ------------------------------------------------------------# 
  PublicSubnet01:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: !Ref PublicSubnet01CIDR
      Tags: 
        - Key: Name
          Value: ecs-test-public-01
      VpcId: !Ref VPC

  PublicSubnet02:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Ref PublicSubnet02CIDR
      Tags: 
        - Key: Name
          Value: ecs-test-public-02
      VpcId: !Ref VPC

  PrivateSubnet01:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: !Ref PrivateSubnet01CIDR
      Tags: 
        - Key: Name
          Value: ecs-test-private-01
      VpcId: !Ref VPC

  PrivateSubnet02:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Ref PrivateSubnet02CIDR
      Tags: 
        - Key: Name
          Value: ecs-test-private-02
      VpcId: !Ref VPC

# ------------------------------------------------------------#
# InternetGateway
# ------------------------------------------------------------# 
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags: 
        - Key: Name
          Value: !Sub ecs-test-igw

  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC

# ------------------------------------------------------------#
# RouteTable
# ------------------------------------------------------------# 
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: ecs-test-public-rtb

  PublicRouteTableRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
      RouteTableId: !Ref PublicRouteTable

  PublicRtAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet01

  PublicRtAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet02

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: ecs-test-private-rtb

  PrivateRtAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet01

  PrivateRtAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet02

# ------------------------------------------------------------#
# Security Group
# ------------------------------------------------------------# 
  ALBSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for alb
      GroupName: ecs-test-sg-alb
      SecurityGroupIngress:
        - FromPort: 80
          IpProtocol: tcp
          CidrIp: 0.0.0.0/0
          ToPort: 80
      Tags: 
        - Key: Name
          Value: ecs-test-sg-alb
      VpcId: !Ref VPC

  ECSSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for ecs
      GroupName: test-sg-ecs
      SecurityGroupIngress:
        - FromPort: 80
          IpProtocol: tcp
          SourceSecurityGroupId: !Ref ALBSG
          ToPort: 80
      Tags: 
        - Key: Name
          Value: ecs-test-sg-ecs
      VpcId: !Ref VPC

  VPCEndpointSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for VPC Endpoint
      GroupName: ecs-test-vpc-endpoint-sg
      SecurityGroupEgress: 
        - CidrIp: 0.0.0.0/0
          FromPort: -1
          IpProtocol: -1
          ToPort: -1
      SecurityGroupIngress: 
        - SourceSecurityGroupId: !Ref ECSSG
          FromPort: 443
          IpProtocol: tcp
          ToPort: 443
      Tags: 
        - Key: Name
          Value: ecs-test-vpc-endpoint-sg
      VpcId: !Ref VPC

# ------------------------------------------------------------#
# VPC Endpoint
# ------------------------------------------------------------# 
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      RouteTableIds: 
        - !Ref PrivateRouteTable
      ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
      VpcEndpointType: Gateway
      VpcId: !Ref VPC

  ECRdkrEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.dkr
      VpcId: !Ref VPC
      SubnetIds:
        - !Ref PrivateSubnet01
      SecurityGroupIds:
        - !Ref VPCEndpointSG

  ECRapiEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.api
      VpcId: !Ref VPC
      SubnetIds:
        - !Ref PrivateSubnet01
      SecurityGroupIds:
        - !Ref VPCEndpointSG

  LogsEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub com.amazonaws.${AWS::Region}.logs
      VpcId: !Ref VPC
      SubnetIds:
        - !Ref PrivateSubnet01
      SecurityGroupIds:
        - !Ref VPCEndpointSG

  SsmMessagesEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssmmessages
      VpcId: !Ref VPC
      SubnetIds:
        - !Ref PrivateSubnet01
      SecurityGroupIds:
        - !Ref VPCEndpointSG

# ------------------------------------------------------------#
# ALB
# ------------------------------------------------------------# 
  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      IpAddressType: ipv4
      Name: alb
      Scheme: internet-facing
      SecurityGroups:
        - !Ref ALBSG
      Subnets: 
        - !Ref PublicSubnet01
        - !Ref PublicSubnet02
      Tags: 
        - Key: Name
          Value: ecs-test-alb
      Type: application

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      HealthCheckEnabled: true
      HealthCheckIntervalSeconds: 30
      HealthCheckPath: /
      HealthCheckPort: 80
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 5
      IpAddressType: ipv4
      Matcher:
        HttpCode: 200
      Name: ecs-test-tg
      Port: 80
      Protocol: HTTP
      ProtocolVersion: HTTP1
      Tags: 
        - Key: Name
          Value: ecs-test-tg
      TargetType: ip
      UnhealthyThresholdCount: 2
      VpcId: !Ref VPC

  ALBHTTPListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref ALB
      Port: 80
      Protocol: HTTP

# ------------------------------------------------------------#
# ECR
# ------------------------------------------------------------# 
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      EmptyOnDelete: true
      EncryptionConfiguration:
        EncryptionType: AES256
      RepositoryName: ecs-test-ecr

VPCやサブネット周りの詳細な説明は省かせていただきますが、31行目から201行目でVPC、サブネット、インターネットゲートウェイ、ルートテーブル、セキュリティグループを作成しています。
206行目~249行目でVPCエンドポイントを作成しています。
作成しているのはECRのVPCエンドポイント、S3のVPCエンドポイント、CloudWatch LogsのVPCエンドポイント、SystemsManagerのVPCエンドポイントとなります。
ECRにアクセスするのに最低限必要なVPCエンドポイントは以下のドキュメントに記載されています。
Amazon ECR インターフェイス VPC エンドポイント (AWS PrivateLink)

254行目~302行目でタスクを紐づけるALBを作成しています。
307行目~313行目でECRの作成を行っています。

デプロイは以下のコマンドを実行します。
VPCやサブネットのCIDRを変更したい場合は「--parameters」オプションでパラメータを指定してください。

aws cloudformation create-stack --stack-name CloudFormationスタック名 --template-body file://CloudFormationテンプレートファイル名

デプロイが完了したらCloudShellへアクセスします。
アクセス方法は以下のドキュメントをご確認ください。
AWS CloudShell の使用を開始するには?

アクセスができたら、ECS Fargateで動かすコンテナイメージを作成していきます。
まずは以下のDockerfileを作成してください。
内容はシンプルでApacheのコンテナイメージを作成するものとなっています。

FROM httpd:2.4

RUN echo "ecs-test" > /usr/local/apache2/htdocs/index.html

Dockerfileを作成したら以下のコマンドでコンテナイメージを作成します。
「docker images」を実行すると作成されたイメージが確認できます。

docker build -t ecs-test .
docker images

イメージが作成できたら以下のコマンドでタグを付けます。

docker tag ecs-test:latest AWSアカウントID.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-test-ecr:latest

タグの設定後、以下のコマンドでECRに対して認証を行いコンテナイメージをpushします。

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin AWSアカウントID.dkr.ecr.ap-northeast-1.amazonaws.com
docker push AWSアカウントID.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-test-ecr:latest

pushが成功するとECRの画面から以下のようにイメージが確認できます。

ECRへのpushまで完了したらECSを作成していきます。
ECSは以下のCloudFormationテンプレートで作成しました。

CloudFormationテンプレート (ここをクリックしてください)
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
# ------------------------------------------------------------#
# Parameters
# ------------------------------------------------------------# 
  TargetGroupArn:
    Type: String

  SecurityGroupID:
    Type: String

  PrivateSubnet01ID:
    Type: String

  PrivateSubnet02ID:
    Type: String

Resources:
# ------------------------------------------------------------#
# CloudWatch Logs
# ------------------------------------------------------------# 
  ECSLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/ecs-test-log"

# ------------------------------------------------------------#
# IAM
# ------------------------------------------------------------# 
  TaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
      RoleName: ecs-test-tast-execution-role

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      RoleName: ecs-test-tast-role

  TaskRolePolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: ecs-test-task-role-policy
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action: 
              - "ssmmessages:CreateControlChannel"
              - "ssmmessages:CreateDataChannel"
              - "ssmmessages:OpenControlChannel"
              - "ssmmessages:OpenDataChannel"
            Resource: '*'
      Roles:
        - !Ref TaskRole

# ------------------------------------------------------------#
# ECS
# ------------------------------------------------------------# 
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      CapacityProviders:
        - FARGATE
      ClusterName: ecs-test-cluster
      DefaultCapacityProviderStrategy:
        - CapacityProvider: FARGATE
          Weight: 1

  ECSTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      ContainerDefinitions:
        - Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/ecs-test-ecr:latest
          LogConfiguration:
            LogDriver: awslogs
            Options: 
              awslogs-group: "/ecs/logs/ecs-test-log"
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: "ecs-test-log"
          Name: ecs-test-task
          PortMappings:
            - ContainerPort: 80
              HostPort: 80
      Cpu: 256
      ExecutionRoleArn: !Ref TaskExecutionRole
      Family: ecs-test-task-def
      Memory: 512
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      TaskRoleArn: !Ref TaskRole

  ECSService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !Ref ECSCluster
      DesiredCount: 1
      EnableExecuteCommand: true
      LoadBalancers:
        - ContainerName: ecs-test-task
          ContainerPort: 80
          TargetGroupArn: !Ref TargetGroupArn
      NetworkConfiguration:
        AwsvpcConfiguration:
          SecurityGroups:
            - !Ref SecurityGroupID
          Subnets:
            - !Ref PrivateSubnet01ID
            - !Ref PrivateSubnet02ID
      ServiceName: ecs-test-service
      TaskDefinition: !Ref ECSTaskDef

上記のCloudFormationテンプレートではCloudWatch LogsロググループやECSの使用するタスクロール、タスク実行ロールの作成とECS周りのリソース作成を行っています。
47行目~76行目でECSのタスクロールを作成しています。
このタスクロールではECS Execでコンテナ接続に必要なIAMポリシーを設定するようにしています。
ECS Execは120行目の「EnableExecuteCommand」というもので有効化しています。
今回はタスクが起動できるところまでを確認したかったのでApplication AutoScalingの設定は入れていません。
サービスのオートスケーリング

デプロイは以下のコマンドを実行します。

aws cloudformation create-stack --stack-name CloudFormationスタック名 --template-body file://CloudFormationテンプレートファイル名 --parameters ParameterKey=TargetGroupArn,ParameterValue=ECSタスクを紐づけるターゲットグループのARN ParameterKey=SecurityGroupID,ParameterValue=ECSタスクが使用するセキュリティグループのID ParameterKey=PrivateSubnet01ID,ParameterValue=1つ目のプライベートサブネットのID ParameterKey=PrivateSubnet02ID,ParameterValue=2つ目のプライベートサブネットのID --capabilities CAPABILITY_NAMED_IAM

デプロイ完了後、ALBのDNS名にブラウザからアクセスすると「ecs-test」という文字列が表示されることを確認できます。
また、ECSクラスターの画面からタスクが1つ起動していることが確認できます。

タスクにECS Execで接続するには以下のコマンドを実行します。

aws ecs execute-command --cluster ecs-test-cluster --task タスクのID --container ecs-test-task --interactive --command "/bin/sh"

さいごに

今回はApplication AutoScalingの設定までは入れませんでしたが必要であれば以下のドキュメントのリソースを使用して作成することが可能です。
Application Auto Scaling resource type reference