ECS ServiceのCD PipelineをGitHub Actionsで構築

DockerイメージのビルドからECS Serviceの更新までのCD PipelineをGitHub Actionsで構築したいと思います。

※ イメージタグの管理方法、それからECS Task DefinitionとServiceの更新はIaCのツールによって変わるので具体的なコマンドは省略します。

# フロー

PlantUMLでCD Pipelineのフローを可視化したのが以下です。

![plantuml (13).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173064/6239c602-2402-29a2-5e60-69c303da7954.png)

BuildとDeploy Stageに分けている理由はいくつかあります。
GitHub Actionsではneedsに含まれているジョブがスキップされたら、そのジョブもスキップされてしまいます。
なのでECS更新ジョブでDockerイメージのビルド・プッシュジョブがneedsに含めてしまうと、ECRにイメージが存在していたらECS更新ジョブもスキップされてしまいます。
だとするならば、ECRにイメージが存在しているかチェックするのを止めて毎回新イメージをビルド・プッシュすればいいですが、イメージ以外のパラメータを更新をしたい場合に不便です。
このような理由から2つのStageに分けます。

# CD Pipeline on GitHub Actions

## 前提

- Dockerイメージ名(タグを含む)をファイルで管理している

## Workflow

### Build Stage

```yaml
name: Build Stage

on:
  push:
    tags:
      - 'v*'

# 複数のイメージをBulid・Pushする場合のために、共通パラメータはworkflowレベルで環境変数化
# secretsを環境変数化してもログ上ではマスキングされるので問題なし
env:
  DOCKER_BUILDKIT: 1
  SLACK_CHANNEL: '#random'
  AWS_DEFAULT_REGION: ap-northeast-1
  AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  precheck:
    name: Check image existence
    runs-on: ubuntu-20.04
    outputs:
      # ECRに存在していればTrue、存在していなければFalse
      sample: ${{ steps.sample.outputs.existence }}
    steps:
      - uses: actions/checkout@v2

      - name: Check sample image
        id: sample
        run: # ファイルで管理しているイメージがECRに存在しているかチェックする処理

  sample:
    name: Push sample image
    runs-on: ubuntu-20.04
    needs: [precheck]
    # ECRに存在していなければ実行
    if: ${{ ! needs.precheck.outputs.sample }}
    steps:
      - uses: actions/checkout@v2

      - name: Get image name
        id: getter
        # $(...)でファイルからイメージ名(タグを含む)を取得
        # 具体的な処理の記述は省略
        run: echo "::set-output name=image::$(...)"

      - name: Build image
        env:
          DOCKER_CONTENT_TRUST: 1
        run: docker build -t ${{ steps.getter.outputs.image }} .

      # Trivyによってイメージをスキャン
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ steps.getter.outputs.image }}
          severity: 'HIGH,CRITICAL'
          exit-code: '1'
          ignore-unfixed: true

      - uses: hands-lab/push-ecr-action@v1.1
        with:
          image: ${{ steps.getter.outputs.image }}
          aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_DEFAULT_REGION }}
          aws-account-id: ${{ env.AWS_ACCOUNT_ID }}

      # 成否をSlackへ通知
      - uses: lazy-actions/slatify@master
        if: ${{ always() }}
        with:
          type: ${{ job.status }}
          job_name: ':ecr: *Push sample image to ECR*'
          channel: ${{ env.SLACK_CHANNEL }}
          url: ${{ secrets.CRM_SLACK_WEBHOOK }}
```

precheckジョブでECRに同じイメージ名(タグを含む)が存在しているかチェックします。
そのサンプルスクリプトを一応書いておきます。
\$repoにECRのリポジトリ名、\$tagにイメージタグを代入すれば :ok:
GitHub Actionsは空文字であればFalse、それ以外はTrueになります。以下のスクリプトの場合、ECRに該当するイメージがなければ空文字になるので上記のworkflowでもそのまま使えます。

```bash
aws ecr batch-get-image \
    --repository-name "$repo" \
    --image-ids "imageTag=$tag" \
    --query 'images[].imageId.imageTag' \
    --output text
```

sampleジョブはprecheckジョブの出力次第でスキップされます。
ECRに存在していないタグであれば実行します。
あとはイメージをビルドしてプッシュしているだけです。
しいて言えば、trivyの公式GitHub Actionでイメージスキャンをしています。`exit-code: '1'`をセットして脆弱性があればプッシュしない = workflow失敗というようにしています。

### Deploy Stage

```yaml
name: Deploy Stage

on:
  workflow_run:
    # Build Stage完了後にDeploy Stageを実行
    workflows:
      - Build Stage
    types:
      - completed

env:
  DOCKER_BUILDKIT: 1
  SLACK_CHANNEL: '#random'
  AWS_DEFAULT_REGION: ap-northeast-1
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  deploy:
    name: Deployment
    runs-on: ubuntu-20.04
    # Build Stageが成功している場合のみ実行
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - uses: actions/checkout@v2
        with:
          # Build Stageと同じコミットSHAにチェックアウト
          # refの指定がなければデフォルトブランチにチェックアウトします
          ref: ${{ github.event.workflow_run.head_sha }}

      - name: Update ECS Task Definition and Service
        run: # 具体的な処理は省略

      # 成否をSlackへ通知
      - uses: lazy-actions/slatify@master
        if: ${{ always() }}
        with:
          type: ${{ job.status }}
          job_name: ':ecs: *Deploy*'
          channel: ${{ env.SLACK_CHANNEL }}
          url: ${{ secrets.CRM_SLACK_WEBHOOK }}
```

Build Stage完了後にDeploy Stageを呼び出すために、`workflow_run`というイベントトリガーを使用します。
workflow_runは依存元のworkflowの成否によらず発火するのでDeploy StageでBuild Stageの成否を明示的にチェックする必要があります。それが `if: ${{ github.event.workflow_run.conclusion == 'success' }}` になります。

# Reference

- https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#workflow_run
- https://github.com/aquasecurity/trivy-action