Cloudformationを使ったFargateサービスのデプロイプロセスについて考えてみた

前回の記事「[Cloudformationを使ったFargateバッチのデプロイプロセスについて考えてみた](https://qiita.com/nyasba/items/3697a248c60151e11af6)」に続いて、今回は **Webアプリ** についてのデプロイプロセスについて考えてみた結果をまとめます。

## 前提とするWebアプリのアーキテクチャ

ALBの配下にFargateのサービスが紐づく一般的な構成です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/0aed4e90-e1f2-cc63-7411-2d2c6cb0e77a.png" width=50%>

## Webアプリについて

OpenJDK11で、SpringBootを利用して作成した簡易なWebアプリです。

コードはこちら。
https://github.com/nyasba/fargate-spring-web

[Jib](https://github.com/GoogleContainerTools/jib/tree/master/jib-gradle-plugin) というJavaプロジェクトをコンテナ化するgradle pluginを利用しており、個別にDockerfileは作成していません。Jibについてはいろんな記事が書かれていますので、興味のある方は参照してみてください。
ちなみに、**Jibを使ったビルドでは、dockerのインストールは不要です。**

> 公式 
https://github.com/GoogleContainerTools/jib/tree/master/jib-gradle-plugin
> ブログ
[jib を使って Java アプリケーションを超簡単にコンテナ化！](https://www.isoroot.jp/blog/1744/)


## アーキテクチャ設計方針

* ECRのDockerイメージのlatestというタグに対して、タスク定義(webapp:1)を作成して、サービスを立ち上げる形にする
* ECRへのイメージpushを行なってもサービスは更新されないため、サービスを強制更新する必要がある
* 複数バージョンのアプリを扱いたい場合はタスク定義の複数バージョンが必要になる
  - 複数バージョンはカナリアリリースやBlueGreenなど共存させる方法を検討する必要がある
  - ALBのTargetGroupをいじる必要がありそうなので、今回はそこまでは考えない。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/0f44331f-2797-921c-cb98-c3003e566f55.png" width=50%>

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/update-service.html
> 更新した Docker イメージがサービスに既存するタスク定義と同じタグを使用する場合 (my_image:latest など)、タスク定義の新しいリビジョンを作成する必要はありません。サービスの更新には、以下の手順を使用してサービスの現在の設定を維持し、[新しいデプロイの強制] を選択します。デプロイによって起動される新しいタスクは、リポジトリの開始時にリポジトリから現在のイメージあるいはタグを取得します。

## 初期構築

### 1. 権限周り（ロール作成）

#### Fargateタスク実行用のEcsTaskロール

Fargateの実行ロールです。名称は `ecsTaskExecutionRole` とします。
ロールの作成から、サービス`ElasticContainerService`、ユースケース`ElasticContainerServiceTask`を選択して、ポリシーとして`AmazonECSTaskExecutionRolePolicy`を付与します。

| 項目 | 設定値 |
|:--------------|:-----------------------|
| 信頼されたエンティティ | ecs-tasks.amazonaws.com |
| ポリシー       | AmazonECSTaskExecutionRolePolicy |

#### AutoScaling実行用ロール

AutoScalingの実行ロールです。名称は `ecsAutoscaleRole` とします。
ロールの作成から、サービス`Elastic Container Service`、ユースケース`Elastic Container Service Autoscale`を選択して、ポリシーとして`AmazonEC2ContainerServiceAutoscaleRole`を付与します。

| 項目 | 設定値 |
|:--------------|:-----------------------|
| 信頼されたエンティティ | application-autoscaling.amazonaws.com |
| ポリシー       | AmazonEC2ContainerServiceAutoscaleRole |


### 2. ECRリポジトリの作成

WebアプリのDockerイメージを管理するためのECRリポジトリを作成します。Fargateのタスク定義を作成する前にリポジトリに何かのイメージをpushしておきたいのでこのタイミングで作ります。ECRで管理できるイメージ数には上限がありますので、LifecyclePolicyも設定しておきます。

AWS CLIの例です

```bash
aws ecr create-repository --repository-name webapp
aws ecr put-lifecycle-policy --repository-name webapp --lifecycle-policy-text file://deploy/ecr-policy.json
```

タグ付けされていない最新の1イメージのみ残す設定としています。

```ecr-policy.json
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep only one untagged image, expire all others",
            "selection": {
                "tagStatus": "untagged",
                "countType": "imageCountMoreThan",
                "countNumber": 1
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
```

### 3. WebアプリのDockerイメージをpush 

#### 手動で実施

ECRのDockerRegistryにログインして、ビルドしたDockerイメージをpushします。
Jibを使っているのでDockerコマンドは利用していませんが、Dockerfileを書きたい方は[バッチの記事](https://qiita.com/nyasba/items/3697a248c60151e11af6#3-%E3%83%90%E3%83%83%E3%83%81%E3%81%AEdocker%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%82%92push)の方を参照ください。

JibでECRへログインする際に「[amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper)」を利用していますので、事前にインストールが必要です。

```
brew install docker-credential-helper-ecr
```

Jibの実行により、Dockerイメージが作成されて、ECRへのpushが実行されます。この際、ローカルにdockerコマンドは不要なようです。便利。

```bash
export ORG_GRADLE_PROJECT_ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...

./gradlew jib
```

Jibのgradle設定は以下の通りです。

```build.gradle(抜粋)
jib {
    from {
        image = 'openjdk:11'
    }
    to {
        // 認証情報は AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY という環境変数で指定しておく
        // ORG_GRADLE_PROJECT_ACCOUNT_ID でAWSアカウントIDを認証情報に設定する
        image = "${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/webapp"
        credHelper = 'ecr-login'
        tags = ['latest']
    }
}
```

#### CircleCIビルド

githubへのコードpushに伴うCircleCIでの自動化も検討してみました。が、

* Jibを使うためにamazon-ecr-credential-helperのインストールが必要で、
* amazon-ecr-credential-helperを使うためにGo言語のインストールが必要で・・

と前準備に手間がかかる結果になりました。
Dockerfileを記述してCircleCIのOrbでECRにpushした方がシンプルだったかもしれません。
興味がある方は「[CircleCIを使ってECRへイメージをpushする](https://qiita.com/nyasba/items/517c0d6b15600bf774b5)」を参照してください。

```.circleci/config.yml
version: 2.1
jobs:
  build:
    working_directory: ~/workspace
    docker:
      - image: circleci/openjdk:11-jdk
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "build.gradle" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-
      - run: gradle dependencies
      - save_cache:
          paths:
            - ~/.gradle
          key: v1-dependencies-{{ checksum "build.gradle" }} 
      - run:
          name: run test
          command: ./gradlew test
      - run:
          name: download go
          command: wget -O - "https://dl.google.com/go/go1.12.5.linux-amd64.tar.gz" | tar -zxvf - -C ./ 
      - run:
          name: get ecr-helper
          command: ./go/bin/go get -u github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
      - run:
          name: set go path
          command: echo 'export PATH=$HOME/go/bin:$PATH' >> $BASH_ENV 
      - run:
          name: push ecr
          command: ./gradlew jib
workflows:
  test_and_push_image:
    jobs:
      - build
```

### 4. Fargateのデプロイ

次にFargateでWebアプリを実行するために必要な、ALBやECSタスク定義、ECSクラスター、サービスなどをCloudformationで作ります。

```bash
export VPC=vpc-1111111
export SUBNET_A=subnet-aaaaaaa
export SUBNET_C=subnet-ccccccc

aws cloudformation create-stack --stack-name fargate-webapp \
  --template-body file://deploy/fargate-webapp.cf.yml \
  --parameters \
      ParameterKey=ProjectName,ParameterValue=fargate-webapp \
      ParameterKey=ECRRepositoryName,ParameterValue=webapp \
      ParameterKey=ECSTaskCPUUnit,ParameterValue=256 \
      ParameterKey=ECSTaskMemory,ParameterValue=512 \
      ParameterKey=ECSTaskDesiredCount,ParameterValue=1 \
      ParameterKey=TaskExecutionRoleName,ParameterValue=ecsTaskExecutionRole \
      ParameterKey=AutoscalingRoleName,ParameterValue=ecsAutoscaleRole \
      ParameterKey=VpcId,ParameterValue=${VPC} \
      ParameterKey=ALBSubnets,ParameterValue=\"${SUBNET_A},${SUBNET_C}\"
```

```fargate-webapp.cf.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: 'fargate web service'
Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Project Name Prefix"
        Parameters:
          - ProjectName
      - Label:
          default: "Fargate Configuration"
        Parameters:
          - ECRRepositoryName
          - ECSTaskCPUUnit
          - ECSTaskMemory
          - ECSTaskDesiredCount
          - TaskExecutionRoleName
          - AutoscalingRoleName
      - Label:
          default: "Netowork Configuration"
        Parameters:
          - VpcId
          - ALBSubnets

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------# 
Parameters:
  ProjectName:
    Type: String
    Default: "fargate-webapp"

#Network Configurations
  VpcId:
    Description : "VPC ID"
    Type: AWS::EC2::VPC::Id
  ALBSubnets:
    Description : "ALB Subnet"
    Type : "List<AWS::EC2::Subnet::Id>"

#ECSTask Configurations
  ECRRepositoryName:
    Type: String
    Default: "webapp"
  ECSTaskCPUUnit:
    AllowedValues: [ 256, 512, 1024, 2048, 4096  ]
    Type: String
    Default: "256"
  ECSTaskMemory:
    AllowedValues: [ 256, 512, 1024, 2048, 4096  ]
    Type: String
    Default: "512"
  ECSTaskDesiredCount:
    Type: Number
    Description: the number of instances
    Default: 1
  TaskExecutionRoleName:
    Type: String
    Description: A name of task execution role
    Default: 'ecsTaskExecutionRole'
  AutoscalingRoleName:
    Type: String
    Description: A name of AutoScaling role
    Default: 'ecsAutoscaleRole'

Resources:
# ------------------------------------------------------------#
#  SecurityGroup for ALB
# ------------------------------------------------------------#
  ALBSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: !Ref VpcId
      GroupName: !Sub "${ProjectName}-alb-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${ProjectName}-alb-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: "0.0.0.0/0"
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: "0.0.0.0/0"

# ------------------------------------------------------------#
#  SecurityGroup for ECS Service
# ------------------------------------------------------------#
  ECSSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: !Ref VpcId
      GroupName: !Sub "${ProjectName}-ecs-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${ProjectName}-ecs-sg"

  ECSSecurityGroupIngress: 
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties: 
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourceSecurityGroupId: !GetAtt [ALBSecurityGroup, GroupId]
      GroupId: !GetAtt [ECSSecurityGroup, GroupId]

# ------------------------------------------------------------#
#  Target Group
# ------------------------------------------------------------#
  TargetGroup:
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties:
      VpcId: !Ref VpcId
      Name: !Sub "${ProjectName}-tg"
      Protocol: HTTP
      Port: 80
      TargetType: ip
      HealthCheckPath: "/healthcheck"

# ------------------------------------------------------------#
#  Internet ALB
# ------------------------------------------------------------#
  InternetALB:
    Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
    Properties:
      Name: !Sub "${ProjectName}-alb"
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-alb"
      Scheme: "internet-facing"
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Subnets: !Ref ALBSubnets

  ALBListener:
    Type: "AWS::ElasticLoadBalancingV2::Listener"
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref InternetALB
      Port: 80
      Protocol: HTTP

# ------------------------------------------------------------#
# ECS Cluster
# ------------------------------------------------------------#
  ECSCluster:
    Type: "AWS::ECS::Cluster"
    Properties:
      ClusterName: !Sub "${ProjectName}-cluster"

# ------------------------------------------------------------#
#  ECS LogGroup
# ------------------------------------------------------------#
  ECSLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/${ProjectName}-ecs-group"

# ------------------------------------------------------------#
#  ECS TaskDefinition
# ------------------------------------------------------------#
  ECSTaskDefinition:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
      Cpu: !Ref ECSTaskCPUUnit
      ExecutionRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/${TaskExecutionRoleName}"
      Family: !Sub "${ProjectName}-task"
      Memory: !Ref ECSTaskMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ContainerDefinitions:
        - Name: !Sub "${ProjectName}-container"
          Image: !Sub "${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ECRRepositoryName}:latest"
          Environment: 
            - Name: "Key"
              Value: "Test"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref ECSLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: !Ref ProjectName
          MemoryReservation: 128
          PortMappings:
            - HostPort: 80
              Protocol: tcp
              ContainerPort: 80

# ------------------------------------------------------------#
#  ECS Service
# ------------------------------------------------------------#
  ECSService:
    Type: AWS::ECS::Service
    DependsOn: ALBListener
    Properties:
      Cluster: !Ref ECSCluster
      DesiredCount: !Ref ECSTaskDesiredCount
      LaunchType: FARGATE
      LoadBalancers:
        - TargetGroupArn: !Ref TargetGroup
          ContainerPort: 80
          ContainerName: !Sub "${ProjectName}-container"
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - !Ref ECSSecurityGroup
          Subnets: !Ref ALBSubnets
      ServiceName: !Sub "${ProjectName}-service"
      TaskDefinition: !Ref ECSTaskDefinition
      HealthCheckGracePeriodSeconds: 120 # wait for spring boot app starting

# ------------------------------------------------------------#
#  Auto Scaling
# ------------------------------------------------------------#
  AutoScalingTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: 3
      MinCapacity: 1
      ResourceId:
        Fn::Join:
        - "/"
        - - service
          - !Sub "${ProjectName}-cluster"
          - Fn::GetAtt:
            - ECSService
            - Name
      RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/${AutoscalingRoleName}"
      ScalableDimension: ecs:service:DesiredCount
      ServiceNamespace: ecs

  AutoScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: ECSScalingBlogPolicy
      PolicyType: TargetTrackingScaling 
      ScalingTargetId: !Ref AutoScalingTarget
      TargetTrackingScalingPolicyConfiguration:
        PredefinedMetricSpecification: 
          PredefinedMetricType: ECSServiceAverageCPUUtilization 
        DisableScaleIn: False
        ScaleInCooldown: 300
        ScaleOutCooldown: 300
        TargetValue: 80

Outputs:
  EndpointUrl:
    Description: "Endpoint url"
    Value: !GetAtt InternetALB.DNSName
```

Stackの出力タブを確認すると、ALBのエンドポイントが確認できます。このURLをブラウザで開いて、「Hello, テスト!」と返ってくれば成功です
![スクリーンショット 2019-05-15 20.01.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/ad012b21-0d11-3b78-f7e5-1303be63d8c2.png)

ECSでもサービスが立ち上がっているのが確認できます。
![スクリーンショット 2019-05-15 20.02.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/2e295c0d-2c59-fc6d-d42b-e2c4ef977c55.png)



#### (補足)FargateのAutoscalingPolicyについて

AutoScalingPolicyは以下の2種類存在します。
今回はシンプルなTargetTrackingScalingPolicyを使いましたが、採用するメトリクスの動きが激しい場合はスケールIN/OUTが頻繁に実施されてしまう可能性がありますので、StepScalingPolicyの方が使いやすいかもしれません。選択肢としては認識しておくとよいかと思います

| ポリシー名 | 仕様 | Cloudformationの設定リファレンス |
|:--|:--|:--|
| [TargetTrackingScalingPolicy](https://docs.aws.amazon.com/ja_jp/autoscaling/application/userguide/application-auto-scaling-target-tracking.html)  | 指定されたメトリクス(CPU使用率やメモリ使用量など)が指定された値に近づくように自動的にスケールIN/OUTを行うポリシー。  | [AWSドキュメント](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration.html) |
| [StepScalingPolicy](https://docs.aws.amazon.com/ja_jp/autoscaling/application/userguide/application-auto-scaling-step-scaling-policies.html)  | 条件に合致した際に段階的にスケールさせるポリシー。スケールIN/OUTそれぞれで定義する。 | [AWSドキュメント](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-applicationautoscaling-scalingpolicy-stepscalingpolicyconfiguration.html) |


## アプリ修正に伴うデプロイ

### 1. Dockerイメージの再push

初期構築の3の手順と同じです。
手動、もしくはコードpushに伴うCircleCIのビルドにて、latestタグのイメージを置き換える形になります。

### 2. サービスの強制更新

Fargateのサービスを強制更新することで稼働しているアプリが置き換わります。

```bash
aws ecs update-service --cluster fargate-webapp-cluster --service fargate-webapp-service --force-new-deployment
```

## 使ってみた感想

### :o: アプリのデプロイは楽

サービスの強制更新を行うだけでデプロイが可能な点は楽です。イメージpushも自動化できているため、通常のWebアプリでは十分使えるものだと感じました。

### :question: Blue/Greenやカナリアリリースなどを行う場合は手間がかかる

この場合はALBのTargetGroupをいじる必要があるため、少々面倒そうです。調べているとまさに！という記事を見つけましたので興味のある方は、以下の記事を見ていただければと思います。

[AmazonECS / Fargate 本番運用のための構築とデプロイ方法まとめ](https://qiita.com/HiromuMasuda0228/items/2170032142ae1c33adcc)

いろいろ考えると、ElasticBeanstalkという選択肢も捨てきれないというのがモヤモヤします:thinking:


## まとめ

今回はCloudformationを使うメリットが出たのでよかったです。
実際に商用で使ってみてはまることも多そうなので、これから何かで使ってみて知見をためようと思います！

＃Jibを導入したことでFargateとは違うところに脱線しまくりでした。失敗。