【AWS】エンタープライズレベル ECS CI/CD の作り方③（GitHub Actions）

## 1. **はじめに**
ECSのCICD構成について、エンタープライズレベルでの構築方法をご紹介します。
単にコンテナをデプロイするだけでなく、セキュリティ、運用保守性などエンタープライズレベルの要件を満たすための構成を目指します。

本記事は3回目/全3回です。

初回、及び2回目記事はこちらです。
[【AWS】エンタープライズレベル ECS CI/CD の作り方①（GitHub Actions）](https://qiita.com/tatsufumi_haneda/items/4834ac0f413779dce638)
[【AWS】エンタープライズレベル ECS CI/CD の作り方②（GitHub Actions）](https://qiita.com/tatsufumi_haneda/items/3996631382a104ab3a46)

Github Actionsワークフローファイルのコードは、初回記事に記載しています。

## 2. **設計ポイント**
3回目の記事で紹介する設計ポイントは以下の（★）の項目です。

* セキュリティ対策
    * ウィルススキャンの実施
    * 脆弱性スキャンの実施　
    * S3上の環境変数ファイルからECSタスクへの機微情報の注入
        * Github上に機微情報を配置しない
    * アクセスキーではなく、IAMロールでのAssumeRoleを利用
    * IAMポリシーの最小限の権限付与
* 運用保守性
    * （★）単一のワークフローのみで複数環境へ選択的にデプロイ
    * （★）単一のワークフローのみで複数のイメージをまとめてビルドしてデプロイ
    * （★）デプロイ実行後のCodeDeploy URL等を表示し、デプロイ結果を迅速に確認
    * （★）コンテナイメージのタグにコミットハッシュを使用、アプリケーションのデプロイバージョンを一意に識別

## 3. **運用保守性**
### 3-1. **単一のワークフローのみで複数環境へ選択的にデプロイ**
GithubActionsのワークフローファイルの改修やメンテナンスが発生した際に、複数のワークフローファイルを管理していると手間がかかります。
このため、単一のワークフローファイルのみで複数環境へ選択的にデプロイするするようにしています。
ワークフローを実行する際に、プルダウンメニューから環境を選択し、その環境に対してデプロイを実行できるようにします。

![画像2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3336189/6940e626-d355-580b-9cf1-8930539c21a4.png)

実装にあたってのポイントは以下になります。
* AWS環境のECS Task, ECS Cluster, ECS Serviceなどの関連AWSリソースの名称に環境略称`dev`, `prod`などを付与
* appspecファイル名にも同様に環境略称`dev`, `prod`などを付与
    * 例：`appspec_dev.yml`, `appspec_prod.yml`, 
* [inputsコンテキスト](https://docs.github.com/ja/enterprise-cloud@latest/actions/writing-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs)で`choice`型を利用し、環境略称などの変数をワークフローの実行時に選択可能に
* AWSリソースの名称をenvコンテキストで指定
    * このとき、inputsで指定された変数の値を利用し、envコンテキストの変数の値に代入
    * 例：`ECS_TASKNAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-task`



<details><summary>コードはコチラ！</summary>

```yml
name: Deploy ECS Service
on:
  workflow_dispatch:
    inputs:
      Environment:
        description: '対象の環境名称を選択'
        required: true
        type: choice
        options: # ここに記載した値がプルダウンメニューに出てきます。
          - dev
          - prod
      PJ_NAME:
        description: '案件略称（固定）'
        required: true
        type: choice
        default: sample
        options:
          - sample
  ～省略～

env:
  AWS_DEFAULT_REGION: ap-northeast-1
  PJ_NAME: ${{ inputs.PJ_NAME }}
  ENV: ${{ inputs.Environment }}
  ～省略～
  CD_APPSPEC_FILE: .github/workflows/aws/appspec_${{ inputs.Environment }}.yml
  ～省略～
  # AWSリソースの名称
  ECS_CLUSTER: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-cluster
  ECS_SERVICE: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-service
  ECS_TASKNAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-task
```

</details>

また、ワークフローを実行した後にどの環境に対してデプロイしたのかがわからなくなるためGithub Actionsのサマリーにどの環境に対してデプロイしたのかを表示してあげるとなおよいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3336189/14e0c585-ddd0-e5bf-a100-0b47caffac2f.png)

該当のコードは以下です。

```yml
  deploymentenv:
    runs-on: ubuntu-latest
    steps:
      - name: Set environment summary
        run: echo "## このワークフローは${{ inputs.Environment }}環境に対して実行されました。" >> "${GITHUB_STEP_SUMMARY}"
```

### 3-2. **単一のワークフローのみで複数のイメージをまとめてビルドしてデプロイ**
ECSタスク内にApache, NginxなどのWebサーバー、tomcatなどのアプリケーションサーバーなど複数のコンテナイメージを含む場合があります。
このような場合、複数のコンテナイメージをまとめてビルドしてデプロイするようにします。

デプロイにあたっては、まず最新のtask-definition.jsonを取得し、そのtask-definition.json内のコンテナイメージタグを書き換えます。
具体的には、`aws ecs describe-task-definition`コマンドで最新のtask-definition.jsonを取得した後、`aws-actions/amazon-ecs-render-task-definition`アクションを利用して、task-definition.json内のコンテナイメージタグを書き換えます。

実装にあたってのポイントは以下になります。

1. まずwebサーバーのコンテナイメージをビルドした後、`aws-actions/amazon-ecs-render-task-definition`アクションを利用して、task-definition.json内のwebサーバーのコンテナイメージタグを書き換えます。
2. さらに、アプリケーションサーバーのコンテナイメージをビルドした後、直前のstepで書き換えたtask-definition.jsonを、再度`aws-actions/amazon-ecs-render-task-definition`アクションを利用して、task-definition.json内のアプリケーションサーバーのコンテナイメージタグを書き換えます。

ここでは、以下のコードの`task-definition: ${{ steps.render-web-container.outputs.task-definition }}`の部分が肝になります。
step`render-web-container`で書き換えたtask-definition.jsonを、次のstep`render-app-container`で再度書き換えるために`${{ steps.render-web-container.outputs.task-definition }}`で直前のstepの出力を参照しています。

<details><summary>コードはコチラ！</summary>

```yml
# deploy        
      - name: Download task definition
        # 最新（revision番号値が最大）のtask-definition.jsonを取得
        # warningが出ないようにjqでtaskDefinitionArn等の不要な要素を削除
        run: |
          aws ecs describe-task-definition --task-definition $ECS_TASKNAME --query taskDefinition --output json | jq -r 'del(
            .taskDefinitionArn,
            .requiresAttributes,
            .compatibilities,
            .revision,
            .status,
            .registeredAt,
            .registeredBy
          )' > task-definition.json

      - name: Web Render Amazon ECS task definition
        id: render-web-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: ${{ env.APACHE_CONTAINER_NAME }}
          image: ${{ steps.build-web.outputs.image }}

      - name: App Render Amazon ECS task definition
        id: render-app-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ steps.render-web-container.outputs.task-definition }} # 直前のstepで書換たtask-definition
          container-name: ${{ env.TOMCAT_CONTAINER_NAME }} 
          image: ${{ steps.build-app.outputs.image }}
```

</details>

以下のコードではstep`render-app-container`で書き換えたtask-definition.jsonを、次のstep`deploy`で``${{ steps.render-app-container.outputs.task-definition }}`で参照しています。

<details><summary>コードはコチラ！</summary>

```yml
      - name: Deploy to Amazon ECS service
        id: deploy
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render-app-container.outputs.task-definition }} # 直前のstepで書換たtask-definition
          service: ${{ env.ECS_SERVICE }} 
          cluster: ${{ env.ECS_CLUSTER }} 
          codedeploy-appspec: ${{ env.CD_APPSPEC_FILE }}
          codedeploy-application: ${{ env.CD_APPNAME }}
          codedeploy-deployment-group: ${{ env.CD_DEPLOYNAME }}
          wait-for-service-stability: false # true:ecs servce上でtaskのヘルスチェック完了まで待つ。5分程度かかる。B/G deployを使うならfalse。
```

</details>

### 3-3. **デプロイ実行後のCodeDeploy URL等を表示し、デプロイ結果を迅速に確認**
Blue/Greenデプロイメントを行う場合、CodeDeployのデプロイのステータスのURLを表示し、デプロイ結果を迅速に確認できるようにします。

具体的には以下の3つを表示します。
* CodeDeployのデプロイのステータス確認のURL
* CloudShellのURL、及びコンテナログインコマンド（デプロイされたECSタスクIDを引数に指定済み）
* コンテナのログを確認するためのロググループのURL

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3336189/ae3f3704-c6fc-ead4-d9ef-08a020a3753a.png)

<details><summary>コードはコチラ！</summary>

```yml

# Summary
      - name: ECS Task Deploy Control url summary
        env:
          CODEDEPLOY_URL: https://ap-northeast-1.console.aws.amazon.com/codesuite/codedeploy/deployments/
          DEPLOYMENT_ID: ${{ steps.deploy.outputs.codedeploy-deployment-id }}
        run: |
          echo "## AWS Console URL for B/G Deploy Control" >> "${GITHUB_STEP_SUMMARY}"
          echo "- ${CODEDEPLOY_URL}${DEPLOYMENT_ID}?region=ap-northeast-1"  >> "${GITHUB_STEP_SUMMARY}"
      

  containerlogin: # アプリTがコンテナログインやログ参照可能なようにURLとコマンドを出力
    needs: ecsdeploy # ecsdeploy jobの正常完了後、本ジョブスタート
    runs-on: ubuntu-latest
    steps:
      - name: Configure AWS Credentials # AWSアクセス権限設定,github secretsから取得
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ env.AWS_ROLE_ARN }}

      - name: ECS Container login cmd, Logs url summary
        env:
          CLOUDSHELL_URL: https://ap-northeast-1.console.aws.amazon.com/cloudshell/home
          CLOUDWATCHLOGS_URL: https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home#logsV2:log-groups
        run: |
          echo "## AWS CloudShell and Login ECS container cmd" >> "${GITHUB_STEP_SUMMARY}"
          echo "### コンテナログイン用terminal (AWS CloudShell URL)" >> "${GITHUB_STEP_SUMMARY}"
          echo "- ${CLOUDSHELL_URL}?region=ap-northeast-1"  >> "${GITHUB_STEP_SUMMARY}"   
          echo "### タスク一覧と各コンテナログインコマンド" >> "${GITHUB_STEP_SUMMARY}"
          LATEST_REVISION=$(aws ecs describe-task-definition --task-definition "$ECS_TASKNAME" --query 'taskDefinition.revision' --output text)
          echo "LATEST_REVISION=$LATEST_REVISION" >> "${GITHUB_STEP_SUMMARY}"  
          echo "please wait for 60 sec... tasks starting up " >> "${GITHUB_STEP_SUMMARY}"  
          sleep 60
          # タスク定義の ARN を取得
          TASK_DEFINITION_ARN=$(aws ecs list-task-definitions --family-prefix "$ECS_TASKNAME" --query "taskDefinitionArns[?ends_with(@,':$LATEST_REVISION')]" --output text)
          
          # タスク定義を使用して実行されているタスクのリストを取得
          TASK_ARNS=$(aws ecs list-tasks --cluster "$ECS_CLUSTER" --query "taskArns[]" --output text)
       
          i=0
          for task_arn in $TASK_ARNS; do
            task_definition=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER" --tasks "$task_arn" --query "tasks[0].taskDefinitionArn" --output text)
            if [ "$task_definition" == "$TASK_DEFINITION_ARN" ]; then
              task_id=$(echo "$task_arn" | cut -d/ -f3)
              echo "- タスクID${i+1}: $task_id" >> "${GITHUB_STEP_SUMMARY}"  
              echo "  - apacheログインコマンド: " >> "${GITHUB_STEP_SUMMARY}"  
              echo "    - aws ecs execute-command --cluster ${ECS_CLUSTER} --task $task_id --container ${APACHE_CONTAINER_NAME}  --interactive --command bash" >> "${GITHUB_STEP_SUMMARY}"  
              echo "  - tomcatログインコマンド: " >> "${GITHUB_STEP_SUMMARY}"  
              echo "    - aws ecs execute-command --cluster ${ECS_CLUSTER} --task $task_id --container ${TOMCAT_CONTAINER_NAME}  --interactive --command bash" >> "${GITHUB_STEP_SUMMARY}"  
              i=$((i+1))
            fi
          done
          echo "## コンテナの標準出力ログとファイル出力ログ" >> "${GITHUB_STEP_SUMMARY}"
          echo "- ${CLOUDWATCHLOGS_URL}\$3FlogGroupNameFilter\$3D${ECS_TASKNAME}" >> "${GITHUB_STEP_SUMMARY}"
```

</details>

### 3-4. **コンテナイメージのタグにコミットハッシュを使用、アプリケーションのデプロイバージョンを一意に識別**

コンテナイメージのタグにコミットハッシュを使用することで、アプリケーションのデプロイバージョンを一意に識別します。
デプロイした後に不具合が見つかった場合などに、どのコミットのコードによって不具合が発生したのかを特定しやすくなります。

具体的なコードは以下になります。

<details><summary>コードはコチラ！</summary>

```yml
      - name: Get short commit hash
        id: commit
        run: echo "IMAGE_TAG=$(git rev-parse --short=7 HEAD)" >> $GITHUB_ENV

      # build web image
      - name: Web Build, tag, and push image to Amazon ECR
        id: build-web
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ env.APACHE_ECR_REPOSITORY }}
          CONTEXT: ${{ env.APACHE_DOCKERFILE_PATH }}
          DOCKERFILE_NAME: Dockerfile
          PATH: ${{ steps.download-tmas-cli.outputs.PATH }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f $CONTEXT/$DOCKERFILE_NAME $CONTEXT
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          ～省略～
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
```

</details>

## おわりに
以上で、エンタープライズレベルのECS CI/CD構成の作り方について、3回にわたりご紹介いたしました。
ここまでお付き合いいただきありがとうございました。

セキュリティ対策、運用保守性の観点から、エンタープライズレベルの要件を満たすための設計ポイントをご紹介しました。
本記事を参考にエンタープライズレベルのECS CI/CD構成を構築し、より安全で運用保守性の高いシステムを構築していただければ幸いです。