CloudFormation使ってみた 

こんにちは！株式会社ナイトレイで働くインフラエンジニアです。
今回は、CloudFormationを用いた実装の一部を紹介します！

## 背景

aws opsworksサービス終了に伴い、これまでchefで管理していたwebサイトのインフラリソースを、Cloud Formationで作り直すことに！ついでに、ec2インスタンスからECSに乗り換えることにしたので、一部共有します。

## Cloud Formationとは

AWSのリソースをコードで管理できるサービスです。テンプレートと呼ばれるテキストファイル（YAML/JSON）を読み込むと、自動でAWSの環境を作ってくれます。料金は、利用しているリソース分支払えば良いだけで、Cloud Formation自体の利用は無料です。チュートリアルも用意されているのでご参考までに📕

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/CHAP_Using.html

## EC2からECSへの移行について

ECS（Elastic Container Service）とは、Dockerコンテナのデプロイや運用管理を簡単に行うための、AWSのサービスです。コンテナベースでサービスを管理できるため、EC2と比べ、運用を簡素化、また効率化することができます。

## プロジェクト構成

```markdown
.
├── Makefile
├── README.md
└── {service_name}/
　  └── cf/
       ├── {env}/ 環境固有のファイル群
       │  ├── .params
       │  └── main.yml
       └── templates/
          ├── app/
          │　 ├── task.yml
          │　 ├── ecr.yml
          │　 └── ecs.yml
          └── lb/
           　 └── alb.yml
```

実際に作成したものから、一部抜粋します。

サービス単位でディレクトリを切り、テンプレートとスタック作成のファイルを分離しました。

また、パラメータをgithubなどで管理したくないので、.paramsをs3に置き、実行時にshellでダウンロードしてくる仕様にしました。

（他にもネットワークやセキュリティなどなど作りましたが、本記事では割愛します。）

## テンプレート作成

公式リファレンスを見ながら作っていきます。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-reference.html

ecr.yml

```yaml
Parameters:
  Env:
    Type: String
  ServiceName:
    Type: String

Resources:
  # ECR
  EcrNginx:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${ServiceName}-${Env}-nginx
      ImageTagMutability: MUTABLE
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256
      LifecyclePolicy:
          LifecyclePolicyText: >
            {
              "rules": [
                {
                  "action": {
                    "type": "expire"
                  },
                  "selection": {
                    "countType": "imageCountMoreThan",
                    "countNumber": 4,
                    "tagStatus": "any"
                  },
                  "description": "Delete more than 4 images",
                  "rulePriority": 1
                }
              ]
            }
          RegistryId: !Ref AWS::AccountId

  EcrPhp:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${ServiceName}-${Env}-php
      ImageTagMutability: MUTABLE
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256
      LifecyclePolicy:
          LifecyclePolicyText: >
            {
              "rules": [
                {
                  "action": {
                    "type": "expire"
                  },
                  "selection": {
                    "countType": "imageCountMoreThan",
                    "countNumber": 4,
                    "tagStatus": "any"
                  },
                  "description": "Delete more than 4 images",
                  "rulePriority": 1
                }
              ]
            }
          RegistryId: !Ref AWS::AccountId
```

微々たるものですが、コストかかるのでイメージは3世代管理に。

task.yml

```yaml
Parameters:
  Env:
    Type: String
  ServiceName:
    Type: String
  AccountId:
    Type: String
  WebCpu:
    Type: Number
  WebMemory:
    Type: Number
  NginxLatestTag:
    Type: String
  PhpLatestTag:
    Type: String

Resources:
  # Task Definition
  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub ${ServiceName}-${Env}-web
      Cpu: !Ref WebCpu
      Memory: !Ref WebMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !Sub arn:aws:iam::${AccountId}:role/ecsTaskExecutionRole
      TaskRoleArn: !Sub arn:aws:iam::${AccountId}:role/ecsTaskRole
      ContainerDefinitions:
        - Name: !Sub ${ServiceName}-${Env}-nginx-container
          Image: !Sub ${AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ServiceName}-${Env}-nginx:${NginxLatestTag}
          Essential: true
          PortMappings:
            - HostPort: 80
              ContainerPort: 80
              Protocol: tcp
          LinuxParameters:
            initProcessEnabled: true
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Sub /ecs/${ServiceName}-${Env}-nginx
              awslogs-region: ap-northeast-1
              awslogs-stream-prefix: "ecs"
              awslogs-datetime-format: "%Y-%m-%d %H:%M:%S"
        - Name: !Sub ${ServiceName}-${Env}-php-container
          Image: !Sub ${AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ServiceName}-${Env}-php:${PhpLatestTag}
          EnvironmentFiles:
            - Value: !Sub arn:aws:s3:::${ServiceName}-env/ecs/${Env}.env
              Type: s3
          PortMappings:
            - HostPort: 9000
              ContainerPort: 9000
              Protocol: tcp
          LinuxParameters:
            initProcessEnabled: true
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Sub /ecs/${ServiceName}-${Env}-php
              awslogs-region: ap-northeast-1
              awslogs-stream-prefix: "ecs"
              awslogs-datetime-format: "%Y-%m-%d %H:%M:%S"

Outputs:
  TaskDefinition:
    Value: !Ref TaskDefinition
```

スタンダードなnginxとphpコンテナ構成。

ecs.yml

```yaml
Parameters:
  Env:
    Type: String
  ServiceName:
    Type: String
  SgWebId:
    Type: AWS::EC2::SecurityGroup::Id
  TaskDefinition:
    Type: String
  PrivateSubnetCId:
    Type: AWS::EC2::Subnet::Id
  PrivateSubnetDId:
    Type: AWS::EC2::Subnet::Id
  TargetGroup:
    Type: String

Resources:
  # ECS Cluster
  EcsCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub ${ServiceName}-${Env}-cluster

  # ECS Service
  EcsService:
    Type: AWS::ECS::Service
    Properties:
      PlatformVersion: 1.4.0
      ServiceName: !Sub ${ServiceName}-${Env}-service
      Cluster: !Ref EcsCluster
      TaskDefinition: !Ref TaskDefinition
      DesiredCount: 2
      LaunchType: FARGATE
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 100
        DeploymentCircuitBreaker:
          Enable: true
          Rollback: true
      EnableExecuteCommand: true
      HealthCheckGracePeriodSeconds: 120
      PropagateTags: TASK_DEFINITION
      NetworkConfiguration:
        AwsvpcConfiguration:
          Subnets:
            - !Ref PrivateSubnetCId
            - !Ref PrivateSubnetDId
          SecurityGroups:
            - !Ref SgWebId
          AssignPublicIp: DISABLED
      LoadBalancers:
        - TargetGroupArn: !Ref TargetGroup
          ContainerName: !Sub ${ServiceName}-${Env}-nginx-container
          ContainerPort: 80

Outputs:
  EcsCluster:
    Value: !Ref EcsCluster
  EcsService:
    Value: !GetAtt EcsService.Name
```

パラメータのtypeはstringとリソースタイプでばらつきがありますが、まぁ良しとします（本当は、リソースタイプを指定すべきですね…）。

alb.yml

```yaml
Parameters:
  Env:
    Type: String
  AccountId:
    Type: String
  ServiceName:
    Type: String
  Domain:
    Type: String
  Certificates:
    Type: String
  SgAlbId:
    Type: AWS::EC2::SecurityGroup::Id
  VpcId:
    Type: String
  PublicSubnetCId:
    Type: AWS::EC2::Subnet::Id
  PublicSubnetDId:
    Type: AWS::EC2::Subnet::Id

Resources:
  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub ${ServiceName}-${Env}-alb
      Scheme: internet-facing
      Type: application
      IpAddressType: ipv4
      Subnets:
        - !Ref PublicSubnetCId
        - !Ref PublicSubnetDId
      SecurityGroups: 
        - !Ref SgAlbId
      LoadBalancerAttributes:
        - Key: idle_timeout.timeout_seconds
          Value: 120

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub ${ServiceName}-${Env}-tg
      Port: 80
      Protocol: HTTP
      TargetType: ip
      VpcId: !Ref VpcId
      HealthCheckEnabled: true
      HealthyThresholdCount: 2
      HealthCheckTimeoutSeconds: 5
      HealthCheckProtocol: HTTP
      HealthCheckPort: 80
      HealthCheckPath: /healthcheck
      HealthCheckIntervalSeconds: 30
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: 300
        - Key: stickiness.enabled
          Value: true
        - Key: stickiness.type
          Value: lb_cookie
        - Key: stickiness.lb_cookie.duration_seconds
          Value: 86400

  DefaultListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Alb
      Port: 443
      Protocol: HTTPS
      Certificates:
        - CertificateArn: !Sub arn:aws:acm:ap-northeast-1:${AWS::AccountId}:certificate/${Certificates}
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      DefaultActions:
        - Type: fixed-response
          FixedResponseConfig:
            ContentType: text/plain
            StatusCode: 403
  
  CustomListener:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Priority: 100
      ListenerArn: !Ref DefaultListener
      Actions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup
      Conditions: 
        - Field: path-pattern
          PathPatternConfig:
            Values: 
              - "/*"
        - Field: host-header
          HostHeaderConfig: 
            Values: 
              - !Ref Domain

Outputs:
  Alb:
    Value: !Ref Alb
  TargetGroup:
    Value: !Ref TargetGroup
```

ターゲットグループはデフォルト403で返すようにして、Hostが指定したドメインの場合のみECSコンテナにアクセスさせるようにします。

定番のアクセス制限ですね。

余談ですが、サブネットにaがない理由はNatゲータウェイ作成時に下記エラーが出たためです。

“Nat Gateway is not available in this availability zone.”

長年AWS使っていて初めて遭遇しました…こんな事があるんですね。

調べたところAZには新旧あるようで、古いアカウントだと発生しうるようです。

何か対応すれば使えるようになるという類のものではなさそうなので、今回aは使用しないことにしました。

参考：

https://blog.serverworks.co.jp/tech/2019/10/02/apne1-az3/

## スタック作成

main.yml

```yaml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  Env:
    Type: String
  VpcId:
    Type: String
  IgwId:
    Type: String
  ServiceName:
    Type: String
  ServiceDirName:
    Type: String
  Domain:
    Type: String
  Certificates:
    Type: String
  TemplateBucketName:
    Type: String
  WebCpu:
    Type: Number
  WebMemory:
    Type: Number
  PublicCidrC:
    Type: String
  PublicCidrD:
    Type: String
  PrivateCidrC:
    Type: String
  PrivateCidrD:
    Type: String
  NginxLatestTag:
    Type: String
  PhpLatestTag:
    Type: String
    
Resources:
  #---ネットワーク割愛---

  Ecr:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        Env: !Ref Env
        ServiceName: !Ref ServiceName
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/ecr.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}
  Alb:
    Type: AWS::CloudFormation::Stack
    DependsOn: Sg
    Properties:
      Parameters:
        Env: !Ref Env
        AccountId: !Ref "AWS::AccountId"
        ServiceName: !Ref ServiceName
        Domain: !Ref Domain
        Certificates: !Ref Certificates
        SgAlbId: !GetAtt Sg.Outputs.SgAlbId
        VpcId: !Ref VpcId
        PublicSubnetCId: !GetAtt Subnet.Outputs.PublicSubnetC
        PublicSubnetDId: !GetAtt Subnet.Outputs.PublicSubnetD
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/lb/alb.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}
  EcsTask:
    Type: AWS::CloudFormation::Stack
    DependsOn: Alb
    Properties:
      Parameters:
        Env: !Ref Env
        AccountId: !Ref "AWS::AccountId"
        ServiceName: !Ref ServiceName
        WebCpu: !Ref WebCpu
        WebMemory: !Ref WebMemory
        NginxLatestTag: !Ref NginxLatestTag
        PhpLatestTag: !Ref PhpLatestTag
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/task.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}
  Ecs:
    Type: AWS::CloudFormation::Stack
    DependsOn: EcsTask
    Properties:
      Parameters:
        Env: !Ref Env
        ServiceName: !Ref ServiceName
        SgWebId: !GetAtt Sg.Outputs.SgWebId
        TaskDefinition: !GetAtt EcsTask.Outputs.TaskDefinition
        PrivateSubnetCId: !GetAtt Subnet.Outputs.PrivateSubnetC
        PrivateSubnetDId: !GetAtt Subnet.Outputs.PrivateSubnetD
        TargetGroup: !GetAtt Alb.Outputs.TargetGroup
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/ecs.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}

  #---バッチ、スケーリング設定割愛
```

## 変更セット作成

CloudFormationには、create-stackコマンドが用意されていますが、今回はterraformっぽくdeployを使います。

```bash
aws cloudformation deploy \
            --stack-name "$STACK_NAME" \
            --template-file "./cf/$ENV/main.yaml" \
            --parameter-overrides $(cat ./cf/$ENV/.params) \
            --no-execute-changeset \
            --profile "$PROFILE"
```

--no-execute-changesetを付与すると、変更セットの作成にとどめてくれます。

### デプロイ

```bash
aws cloudformation deploy \
            --stack-name "$STACK_NAME" \
            --template-file "./cf/$ENV/main.yaml" \
            --parameter-overrides $(cat ./cf/$ENV/.params) \
            --profile "$PROFILE"
```

--no-execute-changesetを外しただけです。

ちなみに初回のデプロイに失敗した場合、削除せずに変更セットを再作成しようとするとエラーになります。デプロイ成功後2回目以降は削除不要で変更セットが作成できます。

### 削除

```bash
aws cloudformation delete-stack \
            --stack-name "$STACK_NAME" \
            --profile "$PROFILE"
```

## 感想

ymlファイルは非常にシンプルで作成しやすいです（CloudFromationはJsonでも可）。

しかし、変更セットはterraformに比べると差分がわかりにくかったです。システム規模が大きくなるとボトルネックになりそう…。

また、今回は、stacksetsを使わなかったので次回は使ってみたいです！