GitHub ActionでDockerのビルドキャッシュを有効にしてAmazonECSへデプロイする

# やること
GitHub Actionを用いてDockerイメージをビルドし、Amazon ECRに保存し、Amazon ECSへデプロイします。

- ポイント
	- 本番運用を想定し、ブランチに[リリース](https://docs.github.com/ja/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository)を作成した場合にGitHub Actionが動作するようにする。
	- Dockerのビルドを高速化するために、ビルドキャッシュを有効にする

# 準備
- 保存先のECRを作成
- デプロイ先のECRを作成
- GitHub Actionで使用するためのAWS IAMユーザーを作成(ECRとECSの権限が必要です)

これらは作成済みとして進めます。

# 作成するワークフロー
下記のようなワークフローを作成し、レポジトリの`.github/workflows/`へ`deploy-to-ecs.yml`のような適当な名前で保存します。
mainブランチにtagをpushすることにより、このワークフローが動作し、ECRへDockerイメージがpushされ、ECSへデプロイします。
Githubの[リリース](https://docs.github.com/ja/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository)を用いることにより運用することを想定しています。

`.github/workflows/deploy-to-ecs.yml`

```yml
name: Deploy to ECS 
on:
  push:
    tags:
      - v*

env:
  ECR_REPOSITORY: your-repository-name
  ECS_SERVICE: your-service-name
  ECS_CLUSTER: your-cluster-name

jobs:
  deploy:
    name: Deploy to ECS
    if: github.event.base_ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Configure AWS Credentials # AWSアクセス権限設定
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR # ECRログイン処理
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Set Docker Tag Env # Docker Imageのバージョンをタグに合わせる
        run: echo "IMAGE_TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g")" >> $GITHUB_ENV

      - name: Build, tag, and push image to Amazon ECR # Docker イメージ Build&Push
        env:
          DOCKER_BUILDKIT: 1
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          docker build --cache-from=$ECR_REGISTRY/$ECR_REPOSITORY:latest --build-arg BUILDKIT_INLINE_CACHE=1 -f Dockerfile -t $ECR_REPOSITORY .
          docker tag $ECR_REPOSITORY:latest $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker tag $ECR_REPOSITORY:latest $ECR_REGISTRY/$ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

      - name: Render Amazon ECS task definition for app container # appコンテナのECSタスク定義ファイルレンダリング
        id: render-app-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: .aws/ecs/task-definition.json
          container-name: app
          image: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}

      - name: Deploy to Amazon ECS service # ECSサービスデプロイ
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-app-container.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: false
```


# 解説
ワークフローの動作について上から順に具体的に説明します。
## 1. ワークフロー名
```yml
name: Deploy to ECS 
```
ワークフローの名前を決めています。Githubのレポジトリ、Actionタブからワークフローを一覧で確認する際などにこの名前が表示されます。検証環境や本番環境で複数のワークフローを作成する場合は、区別できる分かりやすい名前に変更する事を推奨します。

## 2. ワークフローの動作条件
```yml
on:
  push:
    tags:
      - v*
```
`v1.0.0`のような`v`で始まるタグがpushされた場合に動作するようにしており、[リリース](https://docs.github.com/ja/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository)の作成によりこのワークフローが動作することを想定しています。
また、今回は他のブランチにタグがpushされてしまった場合に動作をしないようにjobs内に

```yml
if: github.event.base_ref == 'refs/heads/main'
```

のようにmainブランチではない場合には動作をしないようにしています。

特定のブランチにpushされた場合に動作するようにするには`if`の記述を無くし

```yml
on:
  push:
    branches:
      - target-branch
```

のように記述してください。(target-branchはデプロイ対象とするブランチに変更して下さい。)

### 補足
`on: push:`の記述方法で、特定のブランチにタグがpushされた場合動作するという事を実現するために

```yml
on:
  push:
    tags:
      - v*
     branches:
      - target-branch
```

のように記述すると、and条件ではなくor条件(タグかブランチのどちらかがpushされた場合)になってしまう為、`on: push:`で対象のタグを指定し、`if`で対象のブランチを指定しています。

## 3. 環境変数への代入
```yml
env:
  ECR_REPOSITORY: your-repository-name
  ECS_SERVICE: your-service-name
  ECS_CLUSTER: your-cluster-name
```
jobで何度も使う値の記述を減らすために環境変数へ代入しています。
`your-〇〇〇-name`は各自の環境のものへ変更して下さい

## 4. Jobsの実行開始
```yml
jobs:
  deploy:
    name: Deploy to ECS
    if: github.event.base_ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
```
`ubuntu-latest`の環境にて対象ブランチにcheckoutし、jobの実行を始めます。
(先程にも記述しましたが、タグがpushされた場合のmainブランチでのみ実行するように`if`行を記述しています。動作条件をブランチのpushに変更した場合は不要です。)

## 5. AWSへのログイン
```yml
- name: Configure AWS Credentials # AWSアクセス権限設定
    uses: aws-actions/configure-aws-credentials@v1
    with:
      aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
      aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      aws-region: ap-northeast-1
```
pushを行うECR、デプロイ先のECSを作成してあるAWS アカウントへログインをしています。
Githubの[シークレット](https://docs.github.com/ja/free-pro-team@latest/actions/reference/encrypted-secrets)を用いてデプロイを行うIAMのアクセスキーとシークレットキーを`AWS_ACCESS_KEY_ID`と`AWS_SECRET_ACCESS_KEY`へ登録してください。

## 6. ECRログイン
```yml
 - name: Login to Amazon ECR # ECRログイン処理
    id: login-ecr
    uses: aws-actions/amazon-ecr-login@v1
```
5.でログインしたAWSアカウントのECRへのログインを行います。

## 7. Dockerのイメージタグの設定
```yml
  - name: Set Docker Tag Env # Docker Imageのバージョンをタグに合わせる
    run: echo "IMAGE_TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g")" >> $GITHUB_ENV
```

Dockerのイメージタグがgitのtagと同じ値になるようにpushされたtagを`v1.0.0`のような形式で取り出し、環境変数へ代入しています。
ワークフローの動作条件にタグを用いない場合は、

```yml
run: echo "IMAGE_TAG=${{ github.sha }}" >> $GITHUB_ENV
```

のようにコミットハッシュを用いてイメージタグが一意になるようにすると良いと思います。

## 8. Dockerイメージのビルド&ECRへのPush
```yml
  - name: Build, tag, and push image to Amazon ECR # Docker イメージ Build&Push
    env:
      DOCKER_BUILDKIT: 1
      ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
    run: |
      docker build --cache-from=$ECR_REGISTRY/$ECR_REPOSITORY:latest --build-arg BUILDKIT_INLINE_CACHE=1 -f Dockerfile -t $ECR_REPOSITORY .
      docker tag $ECR_REPOSITORY:latest $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      docker tag $ECR_REPOSITORY:latest $ECR_REGISTRY/$ECR_REPOSITORY:latest
      docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
```
ローカルでDockerイメージをビルドする場合、前回にビルドしたイメージを用いてビルドが高速化されますが、GitHub Actionでは前回のビルド結果を保持していないため、毎回ビルドに時間が掛かってしまいます。
そこで、ここではDockerの`docker build --cache-from`を用いてビルドの高速化を行っています。
行っている内容としては、dockerイメージをビルドし、環境変数IMAGE_TAGに保存されているタグをpushしつつ、その値はワークフローの動作毎に動的に変化してしまい、前回ビルドした際の値を取得することが難しいためlatestタグもpushし、latestタグをキャシュに用いるようにしています。このようにすることにより、リリースタグと整合性を取りつつ、キャッシュを用いれます。
(`DOCKER_BUILDKIT`, `BUILDKIT_INLINE_CACHE`は`--cache-from`を用いるために必要なため記述しています。)
注意: Dockerのマルチステージビルドを用いている場合は、Dockerのイメージ中にビルドプロセスが全て含まれていないためこの方法ではキャッシュできません。その場合は、[このような記事](https://engineer.recruit-lifestyle.co.jp/techblog/2020-09-25-docker-build/)を参考にすると良いと思います。
## 9. ECSタスク定義の作成
```yml
- name: Render Amazon ECS task definition for app container # appコンテナのECSタスク定義ファイルレンダリング
    id: render-app-container
    uses: aws-actions/amazon-ecs-render-task-definition@v1
    with:
      task-definition: .aws/ecs/task-definition.json
      container-name: app
      image: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
```

ECSの実行にはタスク定義が必要になり、そこに使用するDockerイメージのレポジトリやタグの情報を記述する必要があります。
従って、DockerImageを更新した際はタスク定義の更新も必要になり、その処理を行っています。

今回はレポジトリへタスク定義ファイル`.aws/ecs/task-definition.json`を作成してあり、そのファイルを呼び出し、Dockerイメージの情報を更新しています。
`task-definition.json`の参考例)

```json
{
  "containerDefinitions": [
      "portMappings": [
        {
          "hostPort": 80,
          "protocol": "tcp",
          "containerPort": 80
        }
      ],
      "image": "your-image-name",
      "name": "app"
    }
  ],
  "cpu": "256",
  "executionRoleArn": "your-role-name",
  "family": "your-family",
  "memory": "512",
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "networkMode": "awsvpc"
}
```

[s3から読み出す事](https://kotamat.com/post/codepipeline-github-actions/)も可能のようなので各々お好きなタスク定義の管理をして下さい。

## 10. ECSへデプロイ
```yml
- name: Deploy to Amazon ECS service # ECSサービスデプロイ
    uses: aws-actions/amazon-ecs-deploy-task-definition@v1
    with:
      task-definition: ${{ steps.render-app-container.outputs.task-definition }}
      service: ${{ env.ECS_SERVICE }}
      cluster: ${{ env.ECS_CLUSTER }}
      wait-for-service-stability: false
```

9で作成したタスク定義をもとにECSへデプロイしています。
`wait-for-service-stability`を`true`にすることにより、デプロイが完了するまで実行完了を待機させ、デプロイ完了を通知すること等が可能ですが、Github Actionsの実行時間が増加してしまい、実行枠を越えてしまう等の可能性が存在します。
AWS Lambdaを用いてデプロイの完了をフックし、通知する事も可能なのでデプロイの多い環境ではそうする事を推奨します。

## 11. おわり
上記のフローが正しく動作すればデプロイ完了です。

## おまけ. task単体の実行
```yml
- name: Run Migrate # Migrationを実行
    env:
      CLUSTER_ARN: your_cluster_arn
      ECS_SUBNER_FIRST: your_subnet_first
      ECS_SUBNER_SECOND: your_subner_second
      ECS_SECURITY_GROUP: your_security_group
    run: |
      aws ecs run-task --launch-type FARGATE --cluster $ECS_CLUSTER --task-definition ${{ steps.put-migrate-task.outputs.render-app-container }} --network-configuration "awsvpcConfiguration={subnets=[$ECS_SUBNER_FIRST, $ECS_SUBNER_SECOND],securityGroups=[$ECS_SECURITY_GROUP],assignPublicIp=ENABLED}" > run-task.log
      TASK_ARN=$(jq -r '.tasks[0].taskArn' run-task.log)
      aws ecs wait tasks-stopped --cluster $CLUSTER_ARN --tasks $TASK_ARN
```
今回デプロイ前にDBのmigrationを行いたく、このようなjobを手順10のECSへデプロイ前に追加しtaskを単体で実行しました。
このようにすることにより、Github Actions内でtask単体を実行することも可能です。

# まとめ
ECSへのデプロイですとAWS CodeBuild、AWS CodePipelineを組み合わせるといった選択肢もあると思いますが、GitHub Actionを用いても簡単にデプロイすることが可能です。

# 参考記事
下記の記事を大変参考にさせて頂きました。

- [GitHub ActionsからAmazon ECSにアプリをデプロイ](https://qiita.com/momotaro98/items/a0791969172ac94cfe47)
- [https://dev.classmethod.jp/articles/github-action-ecr-push/](https://dev.classmethod.jp/articles/github-action-ecr-push/)
- [GithubActionsでのDocker BuildでCacheを効かせる方法メモ](https://qiita.com/mokemokechicken/items/39e3b04f3cbe09e5a601)
- [GitHub ActionsでのDockerビルドをキャッシュで高速化する](https://kotaroooo0-dev.hatenablog.com/entry/2020/07/07/004713)