CloudFormation による ECS 環境の構築

## 想定読者

* aws の基礎知識がある
* docker の基礎知識がある

## 前準備

### ① aws cli のインストール

   https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-chap-install.html

### ② IAM ユーザの作成
   CloudFormation でリソースを構築できる IAM ユーザーの作成。以下の点に注意して作成します。

   * CloudFormation を実行できる権限があるか（admin 権限がついていれば作成可能）
   * プログラムによりアクセスを許可する
   * IAM ユーザー発行時に発行される、**アクセスキー ID**、**シークレットアクセスキー**をメモしておく
  
   IAMユーザーの作成では下記のサイトが参考になります。

   * https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_users_create.html#id_users_create_console

   * https://qiita.com/kzykmyzw/items/ca0c3276dfebb401f7d8

   必要に応じて IAM ユーザーの権限を絞ってください。一般的に最低限の権限を与えることが良いとされています。

   > IAM ポリシーを作成する場合、*最小限のアクセス権*を付与するという標準的なセキュリティアドバイスに従うか、タスクの実行に必要なアクセス許可のみ付与します。

   出典：https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/best-practices.html#grant-least-privilege

### ③ 上記で作成したユーザーの **アクセスキー ID**、**シークレットアクセスキー** を aws cli の credentials に設定

   以下を ~/.aws/credentials に追記

   ```:~/.aws/credentials
   [cloudformation_sample]
   aws_access_key_id=${アクセスキー ID}
   aws_secret_access_key=${シークレットアクセスキー}
   ```

   以下を ~/.aws/config に追記

   ```:~/.aws/config
   [profile cloudformation_sample]
   region=ap-northeast-1
   ```

### ④ docker の install

   https://docs.docker.com/get-docker/

### ⑤ Dockerfile の作成

   本記事では以下の `Dockerfile` を使います。あまり意味のない `Dockerfile` ですが、build して image を作成したいのでこのようにしています。

   ```dockerfile:Dockerfie
   FROM nginx
   ```

### ⑥ Route53 でドメインの取得（オプション）

   なくても大丈夫です。CloudFormation を使った [Route53 の設定](#route53-の設定)もしたい方は必要です。



## 概要

CloudFormation を使って下図で示す構成を構築します。

![システム構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/582722/8891c797-b826-40b1-5793-6e558e750035.png)


ECR に登録された docker image をもとに、Fargate を使ってコンテナを起動します。コンテナへのアクセスはロードバランサを使って制御します。コンテナのログは CloudWatch へ、ロードバランサへのログは S3 に残します。また、コンテナの cpu 使用率が閾値を超えた際に自動でスケールするように、AutoScaling を設定していきます。

## CloudFormation で作るメリット

簡単に同じ環境を構築、削除できることです。

例えば、検証用のテスト環境の構築を任されたとします。もちろん、AWS上のコンソールでポチポチして作ることも可能ですが、本記事の用に多くのリソースを作成する場合は大変です。また、人間なので必ずミスも起きます。作成のみならず削除も大変です。料金がかかるサービスもあるので、削除が必要ですが、削除漏れや、誤って必要なリソースを削除してしまう可能性があります。

一方で、CloudFormation  を使ってコードでインフラを定義していれば、コマンド一つで検証環境の構築・削除ができます。工数も削減できますし、100%再現できます。削除漏れもありません。

## システム構築方法

* 下記ファイルをカレントディレクトリに用意する
  * [Dockerfile](#-dockerfile-の作成)
  * [parametes.ecr.json](#parametersecrjson)
  * [parameters.ecs.json ](#parametersecsjson)
  * [template.ecr.yml](#templateecryml)
  * [template.ecs.yml](#templateecsyml)
 
  以下のようになっていればOK

  ```sh
  ❯ ls
  Dockerfile parameters.ecr.json parameters.ecs.json template.ecr.yml template.ecs.yml
  ```

* ECR の作成

  ```sh
  ❯ aws cloudformation create-stack \
  --profile cloudformation_sample \
  --stack-name cloudformation-sample-ecr \
  --capabilities CAPABILITY_IAM \
  --template-body file://$(pwd)/template.ecr.yml \
  --parameters file://$(pwd)/parameters.ecr.json
  
  ## output
  {
      "StackId": "arn:aws:cloudformation:ap-northeast-1:${account_id}:stack/cloudformation-sample-ecr/${id}"
  }
  ```

  `account_id` は後で使うのでメモしておく

* ECR に docker image を登録

  ```sh
  ❯ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${account_id}.dkr.ecr.ap-northeast-1.amazonaws.com
  ❯ docker build -t cloudformation-sample-ecr .
  ❯ docker tag cloudformation-sample-ecr ${account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/cloudformation-sample-ecr:latest
  ❯ docker push ${account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/cloudformation-sample-ecr:latest
  ```

* ECS の作成

  ```sh
  ❯ aws cloudformation create-stack \
  --profile cloudformation_sample \
  --stack-name cloudformation-sample-ecs \
  --capabilities CAPABILITY_IAM \
  --template-body file://$(pwd)/template.ecs.yml \
  --parameters file://$(pwd)/parameters.ecs.json
  ```

* 確認

  ALB の DNS 名にアクセスして nginx の画面が映れば OK

![alb console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/582722/d760d948-f15e-d7e1-49d7-74cbc1608b2a.png)



  ![nginx http.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/582722/1e366f29-1bcc-9815-006f-8c6c90c50c6c.png)

## パラメーターの作成
以下のような理由でパラメーターを別ファイルで定義しています。
パラメーターをテンプレートファイルから分離していると、再利用性が高まる。例えば、パラメータファイルをコピーして vpc や subnet の id を書き換えるだけで全く同じ環境を dev と　prod に簡単に構築できる。
また、github 等にソースコードをアップロードすることを考えても template に id などをハードコーディングしていると誰からでも id が見えてセキュリテイィー的によろしくないと思われます。

### parameters.ecr.json
```json:parameters.ecr.json
[
  {
    "ParameterKey": "ProjectName",
    "ParameterValue": "cloudformation_sample"
  }
]
```


### parameters.ecs.json
* `${VPC ID}`, `${Private Subnet IDs}`, `${Public Subnet IDs}` はご自身のものに書き換えてください
* ALB の CIDR が `0.0.0.0/0` とフルオープンになっているので必要に応じて変更してください


```json:parameters.ecs.json
[
  {
    "ParameterKey": "ProjectName",
    "ParameterValue": "cloudformation_sample"
  },
  {
    "ParameterKey": "VpcId",
    "ParameterValue": "${VPC ID}"
  },
  {
    "ParameterKey": "PrivateSubnetIds",
    "ParameterValue": "${Private Subnet IDs}" // ","区切りで書く
  },
  {
    "ParameterKey": "PublicSubnetIds",　// ","区切りで書く
    "ParameterValue": "${Public Subnet IDs}"
  },
  {
    "ParameterKey": "ALBAllowCidrIp",
    "ParameterValue": "0.0.0.0/0"
  },
  {
    "ParameterKey": "ECSDesiredCapacity",
    "ParameterValue": "1"
  },
  {
    "ParameterKey": "ECSMaxCapacity",
    "ParameterValue": "2"
  },
  {
    "ParameterKey": "ECSContainerPort",
    "ParameterValue": "80"
  },
  {
    "ParameterKey": "ECSContainerLogsRetentionInDays",
    "ParameterValue": "30"
  },
  {
    "ParameterKey": "ECSTaskCpuMB",
    "ParameterValue": "512"
  },
  {
    "ParameterKey": "ECSTaskMemory",
    "ParameterValue": "2048"
  },
  {
    "ParameterKey": "TargetGroupHealthCheckPath",
    "ParameterValue": "/"
  }
]
```

## テンプレートの作成
### template.ecr.yml
```yaml:template.ecr.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Create ECR

Parameters:
  ProjectName:
    Type: String

Resources:
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${ProjectName}-ecr

Outputs:
  ECR:
    Value: !GetAtt ECR.RepositoryUri
    Export:
      Name: !Sub ${ProjectName}-ecr-repository-uri
```
### template.ecs.yml
```yaml:template.ecs.yml
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  ProjectName:
    Type: String
    Description: Project name.
  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: Select a VPC that allows instances access to the Internet.
  PrivateSubnetIds:
    Type: List<AWS::EC2::Subnet::Id>
    Description: Select at public subnets in your selected VPC.
  PublicSubnetIds:
    Type: List<AWS::EC2::Subnet::Id>
    Description: Select at public subnets in your selected VPC.
  ALBAllowCidrIp:
    Type: String
    Description: CIDR IP of VPN.
  ECSDesiredCapacity:
    Type: Number
    Description: Number of instances to launch in your ECS cluster.
  ECSMaxCapacity:
    Type: Number
    Description: Maximum number of instances that can be launched in your ECS cluster.
  ECSContainerPort:
    Type: Number
    Description: Port Number of container created by the task.
  ECSContainerLogsRetentionInDays:
    Type: Number
    Description: Number of days to retain the log events of container created by the task.
  ECSTaskCpuMB:
    Type: Number
    Description: Number of cpu units used by the task.
  ECSTaskMemory:
    Type: Number
    Description: Amount of memory used by the task.
  TargetGroupHealthCheckPath:
    Type: String
    Description: Destination for health checks on the targets.
Mappings:
  S3Config:
    us-east-1:
      BucketPrincipal: '127311923021'
    us-east-2:
      BucketPrincipal: '033677994240'
    us-west-1:
      BucketPrincipal: '027434742980'
    us-west-2:
      BucketPrincipal: '797873946194'
    ca-central-1:
      BucketPrincipal: '985666609251'
    eu-west-1:
      BucketPrincipal: '156460612806'
    eu-central-1:
      BucketPrincipal: '054676820928'
    eu-west-2:
      BucketPrincipal: '652711504416'
    ap-northeast-1:
      BucketPrincipal: '582318560864'
    ap-northeast-2:
      BucketPrincipal: '600734575887'
    ap-southeast-1:
      BucketPrincipal: '114774131450'
    ap-southeast-2:
      BucketPrincipal: '783225319266'
    ap-south-1:
      BucketPrincipal: '718504428378'
    sa-east-1:
      BucketPrincipal: '507241528517'
    us-gov-west-1:
      BucketPrincipal: '048591011584'
    cn-north-1:
      BucketPrincipal: '638102146993'

Resources:
  ###################################################
  # IAM
  ###################################################
  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ecsTaskExecutionPolicy
          PolicyDocument:
            Statement:
              Effect: Allow
              Action:
                - ecr:GetAuthorizationToken
                - ecr:BatchCheckLayerAvailability
                - ecr:GetDownloadUrlForLayer
                - ecr:BatchGetImage
                - logs:CreateLogStream
                - logs:PutLogEvents
              Resource: '*'

  AutoScalingRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - application-autoscaling.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: ecsAutoScalingPolicy
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: 
                  - application-autoscaling:*
                  - cloudwatch:DescribeAlarms
                  - cloudwatch:PutMetricAlarm
                  - ecs:DescribeServices
                  - ecs:UpdateService
                Resource: '*'


  ###################################################
  # SecurityGroup
  ###################################################
  ECSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    DependsOn: ALBSecurityGroup
    Properties:
      GroupDescription: ECS Security Group
      VpcId: !Ref VpcId
  ECSSecurityGroupHTTPinbound:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      Description: !Sub ${ProjectName}-alb
      GroupId: !Ref ECSSecurityGroup
      IpProtocol: tcp
      FromPort: !Ref ECSContainerPort
      ToPort: !Ref ECSContainerPort
      SourceSecurityGroupId: !Ref ALBSecurityGroup

  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ECS Security Group
      VpcId: !Ref VpcId
  ALBSecurityGroupHTTPinbound:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      Description: vpn ip
      GroupId: !Ref ALBSecurityGroup
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      CidrIp: !Ref ALBAllowCidrIp


  ###################################################
  # ECS
  ###################################################
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub ${ProjectName}-cluster
  ECSService:
    Type: AWS::ECS::Service
    DependsOn:
      - ALBHTTPListener
    Properties:
      ServiceName: !Sub ${ProjectName}-service
      Cluster: !Ref ECSCluster
      DesiredCount: !Ref ECSDesiredCapacity
      LaunchType: FARGATE
      LoadBalancers:
        - ContainerName: !Ref ProjectName
          ContainerPort: !Ref ECSContainerPort
          TargetGroupArn: !Ref TargetGroup
      TaskDefinition: !Ref ECSTaskDefinition
      NetworkConfiguration:
        AwsvpcConfiguration:
          SecurityGroups:
            - !Ref ECSSecurityGroup
          Subnets: !Ref PrivateSubnetIds
  ECSTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub ${ProjectName}
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      ContainerDefinitions:
        - Name: !Ref ProjectName
          PortMappings:
            - ContainerPort: !Ref ECSContainerPort
              HostPort: !Ref ECSContainerPort
              Protocol: tcp
          Image:
            Fn::ImportValue:
              !Sub ${ProjectName}-ecr-repository-uri
      Cpu: !Ref ECSTaskCpuMB
      Memory: !Ref ECSTaskMemory
      TaskRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      RequiresCompatibilities: 
        - FARGATE
      NetworkMode: awsvpc


  ###################################################
  # ALB
  ###################################################
  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub ${ProjectName}-alb
      Scheme: internet-facing
      LoadBalancerAttributes:
        - Key: idle_timeout.timeout_seconds
          Value: '30'
        - Key: access_logs.s3.enabled
          Value: 'true'
        - Key: access_logs.s3.bucket
          Value: !Sub ${ProjectName}-alb-logs
      Subnets: !Ref PublicSubnetIds
      SecurityGroups:
        - !Ref ALBSecurityGroup
  ALBHTTPListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup
      LoadBalancerArn: !Ref ALB
  ALBHTTPListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup
      Conditions:
        - Field: path-pattern
          Values:
            - '*'
      ListenerArn: !Ref ALBHTTPListener
      Priority: 1
  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DependsOn: ALB
    Properties:
      HealthCheckIntervalSeconds: 60
      HealthCheckPath: !Ref TargetGroupHealthCheckPath
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      Name: !Sub ${ProjectName}-tg
      Port: 80
      Protocol: HTTP
      UnhealthyThresholdCount: 2
      TargetType: ip
      VpcId: !Ref VpcId


  ###################################################
  # AutoScale
  ###################################################
  AutoScalingTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: !Ref ECSMaxCapacity
      MinCapacity: 1
      ResourceId:
        Fn::Join:
          - ''
          - - service/
            - !Ref ECSCluster
            - /
            - !GetAtt ECSService.Name
      RoleARN: !GetAtt AutoScalingRole.Arn
      ScalableDimension: ecs:service:DesiredCount
      ServiceNamespace: ecs
  AutoScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: !Sub ${ProjectName}-target-tracking-policy
      PolicyType: TargetTrackingScaling
      ScalingTargetId: !Ref AutoScalingTarget
      TargetTrackingScalingPolicyConfiguration:
        PredefinedMetricSpecification: 
          PredefinedMetricType: ECSServiceAverageCPUUtilization 
        ScaleInCooldown: 300
        ScaleOutCooldown: 300
        TargetValue: 70


  ###################################################
  # CloudWatch
  ###################################################
  CloudwatchLogsGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /ecs/${ProjectName}
      RetentionInDays: !Ref ECSContainerLogsRetentionInDays


  ###################################################
  # S3
  ###################################################
  ALBLogBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${ProjectName}-alb-logs
  BucketPolicyELBLogBucket:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref ALBLogBucket
      PolicyDocument:
        Id: !Sub ${ProjectName}-alb-logs-policy
        Version: '2012-10-17'
        Statement:
          - Action:
              - s3:PutObject
            Effect: Allow
            Resource:
              Fn::Join:
                - ''
                - - 'arn:aws:s3:::'
                  - !Ref ALBLogBucket
                  - /AWSLogs/
                  - !Ref AWS::AccountId
                  - /*
            Principal:
              AWS:
                Fn::FindInMap:
                  - S3Config
                  - !Ref AWS::Region
                  - BucketPrincipal
```

## Route53 の設定

### 設定を追記
* `${ssl 証明書の arn}`, `${Domain の hostzone ID}`, `${Domain}` はご自身のものに書き換えてください

  ```json:parameters.ecs.json
  [
    // 追記
    {
      "ParameterKey": "ACMArn",
      "ParameterValue": "${ssl 証明書の arn}"
    },
    {
      "ParameterKey": "HostZoneId",
      "ParameterValue": "${Domain の hostzone ID}"
    },
    {
      "ParameterKey": "Domain",
      "ParameterValue": "${Domain}"
    }
  ]
  ```

  ```yaml:template.ecs.yml
  Parameters:
  
    （中略）
    
    ACMArn:
      Type: String
      Description: Select Arn for ssl/tls certificate.
    HostZoneId:
      Type: AWS::Route53::HostedZone::Id
      Description: ID of the hosted zone.
    Domain:
      Type: String
      Description: Name of domain. 
   
    （中略）
  
   Resources:
   
   	（中略）
   	
    ECSService:
        Type: AWS::ECS::Service
        DependsOn:
          - ALBHTTPListener
          - ALBHTTPSListener //追記
          
    （中略）
    
    ALBSecurityGroupHTTPSinbound:
      Type: AWS::EC2::SecurityGroupIngress
      Properties:
        Description: vpn ip
        GroupId: !Ref ALBSecurityGroup
        IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: !Ref ALBAllowCidrIp
  
    ALBHTTPSListener:
      Type: AWS::ElasticLoadBalancingV2::Listener
      Properties:
        Port: 443
        Protocol: HTTPS
        Certificates:
          - CertificateArn: !Ref ACMArn
        DefaultActions:
          - TargetGroupArn: !Ref TargetGroup
            Type: forward
        LoadBalancerArn: !Ref ALB
    ALBHTTPSListenerRule:
      Type: AWS::ElasticLoadBalancingV2::ListenerRule
      Properties:
        Actions:
          - Type: forward
            TargetGroupArn: !Ref TargetGroup
        Conditions:
          - Field: path-pattern
            Values:
              - '*'
        ListenerArn: !Ref ALBHTTPSListener
        Priority: 1
  
    DNSRecordSet:
      Type: AWS::Route53::RecordSetGroup
      Properties:
        HostedZoneId: !Ref HostZoneId
        RecordSets:
          - Name: !Ref Domain
            Type: A
            AliasTarget:
              HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID
              DNSName:
                Fn::Join:
                  - '.'
                  - - dualstack
                    - !GetAtt ALB.DNSName
  ```

### Stack をアップデート

  ```sh
  ❯ aws cloudformation update-stack \
  --profile cloudformation_sample \
  --stack-name cloudformation-sample-ecs \
  --capabilities CAPABILITY_IAM \
  --template-body file://$(pwd)/template.ecs.yml \
  --parameters file://$(pwd)/parameters.ecs.json
  ```

### 確認

  指定したドメインにアクセスすると、nginx の画面が表示されればOK

  ![nginx https.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/582722/1acb817d-6877-05e4-aa0f-584b87168798.png)


## 参考

* https://qiita.com/yasuhiroki/items/8463eed1c78123313a6f
* https://dev.classmethod.jp/articles/cloudformation-fargate/
* https://qiita.com/tomokyu/items/d341ba1f4a1ad1149fe4
* https://note.com/dd_techblog/n/n109b59faa5f5
* https://aws.amazon.com/jp/blogs/devops/passing-parameters-to-cloudformation-stacks-with-the-aws-cli-and-powershell/