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

## 1. **はじめに**
(株)電通総研 XI本部 羽根田と申します。インフラ構築運用サービス「DMAP」を運営しています。
ECSのCICD構成について、エンタープライズレベルでの構築方法をご紹介します。
単にコンテナをデプロイするだけでなく、セキュリティ、運用保守性などエンタープライズレベルの要件を満たすための構成を目指します。

全3回に分けてご紹介する予定です。

第2回/第3回はコチラ
[【AWS】エンタープライズレベル ECS CI/CD の作り方②（GitHub Actions）](https://qiita.com/tatsufumi_haneda/items/3996631382a104ab3a46)
[【AWS】エンタープライズレベル ECS CI/CD の作り方③（GitHub Actions）](https://qiita.com/tatsufumi_haneda/items/db472c5529973dda267a)

## 2. **設計ポイント**
セキュリティ対策やアプリケーションチームの求める運用保守性を考慮し、以下の設計ポイントを採用しています。
本記事では以下のそれぞれのポイントについて順次ご紹介していきます。

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

## 3. **構成図**
* 全体構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3336189/07276a5e-7771-089e-0a63-32aeaba8cb52.png)

ポイントは以下です。

* 開発者はGitHubにコードを、環境変数ファイルをS3に配置するだけでデプロイ準備完了
* GithubActionsに処理を集約し、単一のワークフローで複数環境へ選択的にデプロイ可能
* 脆弱性管理だけでなく、ウィルススキャンも実施し一般的なセキュリティ要件に対応


## 4. **ワークフローファイル**

* 動作条件
    * ApacheコンテナとTomcatコンテナの2つをビルドし、ECSにデプロイします。
    * それぞれのDockerfileは`deploy/apache`と`deploy/tomcat`に配置します。（envコンテキストで適時変更可）
    * appspecファイルは`.github/workflows/aws`に配置します。（envコンテキストで適時変更可）
    * ECSタスク定義等のAWSリソース名称は、envコンテキストに記述していますので、適宜変更してください。

<details><summary>GithubActionsワークフロー全体はコチラ！</summary>

```yml
name: Deploy ECS Service
on:
  workflow_dispatch:
    inputs:
      Environment:
        description: '対象の環境名称を選択'
        required: true
        type: choice
        options:
          - dev
          - pre
          - stg
          - mnt
          - trn
          - prod
      PJ_NAME:
        description: '案件略称（固定）'
        required: true
        type: choice
        default: sample
        options:
          - sample

permissions: 
  id-token: write
  contents: read

env: #workflowファイルのjobs内で利用する環境変数
  AWS_DEFAULT_REGION: ap-northeast-1
  PJ_NAME: ${{ inputs.PJ_NAME }}
  ENV: ${{ inputs.Environment }}
  # githubリポジトリ内のファイルパス
  APACHE_DOCKERFILE_PATH: deploy/apache
  TOMCAT_DOCKERFILE_PATH: deploy/tomcat
  CD_APPSPEC_FILE: .github/workflows/aws/appspec_${{ inputs.Environment }}.yml
  # AWSリソースの名称
  APACHE_CONTAINER_NAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-apache
  APACHE_ECR_REPOSITORY: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-apache
  CD_APPNAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-cluster-service-CodeDeployAppName
  CD_DEPLOYNAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-cluster-service-CodeDeployDeploymentGroupName
  ECS_CLUSTER: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-cluster
  ECS_SERVICE: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-service
  ECS_TASKNAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-task
  TOMCAT_CONTAINER_NAME: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-tomcat
  TOMCAT_ECR_REPOSITORY: ${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-tomcat
  AWS_ROLE_ARN: arn:aws:iam::xxxxxxxxxxxxx:role/${{ inputs.PJ_NAME }}-${{ inputs.Environment }}-ghhostedrunner
  # TMAS API キー
  TMAS_API_KEY: ${{ secrets.TMAS_API_KEY }}

jobs:

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

  ecsdeploy:
    runs-on: ubuntu-latest
# checkout
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: 'true'  
          clean: false
          fetch-depth: 1 # <= https://github.com/actions/checkout/issues/265

      - name: Checkout LFS objects
        run: git lfs checkout # https://stackoverflow.com/questions/61463578/github-actions-actions-checkoutv2-lfs-true-flag-not-converting-pointers-to-act

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ env.AWS_ROLE_ARN }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
        with:
          mask-password: 'true'
          
      - name: Get short commit hash
        id: commit
        run: echo "IMAGE_TAG=$(git rev-parse --short=7 HEAD)" >> $GITHUB_ENV

# build
      - name: Download tmas-cli # tmas-cliをダウンロード
        id: download-tmas-cli
        run: |
          mkdir tmas && cd tmas
          wget https://cli.artifactscan.cloudone.trendmicro.com/tmas-cli/latest/tmas-cli_Linux_x86_64.tar.gz
          tar -zxvf tmas-cli_Linux_x86_64.tar.gz
          TMAS_PATH=$(pwd)
          echo "PATH=$TMAS_PATH:$PATH" >> $GITHUB_OUTPUT
      # build web image
      - name: Web Build, tag, and push image to Amazon ECR # ECRイメージPush, Dockerfileの配置パスは[CONTEXTに記載]
        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
          SCAN_REPORT=$(tmas scan docker:$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -M -r ap-northeast-1 | jq -r '.malware')
          SCAN_RESULT=$(echo $SCAN_REPORT | jq -r '.scanResult')
          if [ $SCAN_RESULT -ne 0 ]; then
            echo "malware detected"
            echo "## イメージスキャン時のマルウェア検出結果(Web)" >> "${GITHUB_STEP_SUMMARY}"
            echo "$SCAN_REPORT" >> "${GITHUB_STEP_SUMMARY}"
            exit 1
          fi
          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
        
      # build app image
      - name: App Build, tag, and push image to Amazon ECR # ECRイメージPush, Dockerfileの配置パスは[CONTEXTに記載]
        id: build-app
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ env.TOMCAT_ECR_REPOSITORY }}
          CONTEXT: ${{ env.TOMCAT_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
          SCAN_REPORT=$(tmas scan docker:$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -M -r ap-northeast-1 | jq -r '.malware')
          SCAN_RESULT=$(echo $SCAN_REPORT | jq -r '.scanResult')
          if [ $SCAN_RESULT -ne 0 ]; then
            echo "malware detected"
            echo "## イメージスキャン時のマルウェア検出結果(App)" >> "${GITHUB_STEP_SUMMARY}"
            echo "$SCAN_REPORT" >> "${GITHUB_STEP_SUMMARY}"
            exit 1
          fi
          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
          
# deploy        
      - name: Download task definition # 最新（revision番号値が最大）のtask-definition.jsonを取得
        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 # ECSタスク定義ファイルを修正
        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 }} # task-definition.json内のコンテナ名
          image: ${{ steps.build-web.outputs.image }}

      - name: App Render Amazon ECS task definition # ECSタスク定義ファイルを修正
        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 }} # task-definition.json内のコンテナ名
          image: ${{ steps.build-app.outputs.image }}
          
      - 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 }} # task-definition.jsonを利用して起動するタスクの配置先サービス
          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。

      - name: Logout of Amazon ECR
        if: always()
        run: docker logout ${{ steps.login-ecr.outputs.registry }}

# 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>

## 5. **セキュリティ対策**
### 5.1. ウィルススキャン
* Trend Micro Artifact Scannerを利用し、コンテナイメージにウィルススキャンを実施します。
* Trend Micro Artifact Scanner (以降TMAS)はCLIツールを任意の環境に実装して実行します。仕組みとしては、スキャン対象のコンテナイメージのSBOMを作成し、SaaSとして存在するバックエンドにSBOMを送信してスキャンをするようです。CLIツールとして実装するので、CI/CDパイプラインや開発時の任意タイミングでのスキャンが可能です。
    * [Trend Micro Artifact Scannerについて](https://docs.trendmicro.com/ja-jp/documentation/article/trend-vision-one-artifact-scanner-tmas)
    

* 事前準備としてTMAS用のAPIキーを発行し、GithubSecretsに登録します。
    * ここでは、`TMAS_API_KEY`という名前で登録しています。
    * APIキーの発行方法は、クラスメソッド様の記事がわかりやすいです。
        * [Trend Vision One Container Securityでイメージスキャンしてみた](https://dev.classmethod.jp/articles/202312-v1-cs-artifactscanner-01/)

* GithubHostedランナー上にTMAS-CLIをダウンロードするstepを記述します。

```yml
      - name: Download tmas-cli # tmas-cliをダウンロード
        id: download-tmas-cli
        run: |
          mkdir tmas && cd tmas
          wget https://cli.artifactscan.cloudone.trendmicro.com/tmas-cli/latest/tmas-cli_Linux_x86_64.tar.gz
          tar -zxvf tmas-cli_Linux_x86_64.tar.gz
          TMAS_PATH=$(pwd)
          echo "PATH=$TMAS_PATH:$PATH" >> $GITHUB_OUTPUT
```

* コンテナイメージのビルド後、TMASを実行してスキャン結果を取得します。
    * マルウェアが検出された場合は、スキャン結果を出力し、ステップを失敗させます。


```yml
      - name: Web Build, tag, and push image to Amazon ECR
        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
          SCAN_REPORT=$(tmas scan docker:$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -M -r ap-northeast-1 | jq -r '.malware')
          SCAN_RESULT=$(echo $SCAN_REPORT | jq -r '.scanResult')
          if [ $SCAN_RESULT -ne 0 ]; then
            echo "malware detected"
            echo "## イメージスキャン時のマルウェア検出結果(Web)" >> "${GITHUB_STEP_SUMMARY}"
            echo "$SCAN_REPORT" >> "${GITHUB_STEP_SUMMARY}"
            exit 1
          fi
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
```

### 5.2. 脆弱性スキャン
* 本項目の内容は以下の第2回 記事にて記載しております。


第2回/第3回はコチラ
[【AWS】エンタープライズレベル ECS CI/CD の作り方②（GitHub Actions）](https://qiita.com/tatsufumi_haneda/items/3996631382a104ab3a46)
[【AWS】エンタープライズレベル ECS CI/CD の作り方③（GitHub Actions）](https://qiita.com/tatsufumi_haneda/items/db472c5529973dda267a)

## おわりに

今回の記事では、エンタープライズレベルでのECS CI/CDの構築における基本的な方針と、セキュリティや運用保守性を高めるための設計ポイントについて紹介しました。
特に、GitHub Actionsを活用したセキュアで効率的なパイプライン設計や、ウィルススキャンを含むセキュリティ対策の重要性に焦点を当てています。

エンタープライズレベルのシステムでは、単なるデプロイ機能以上に、信頼性や保守性、セキュリティを担保することが求められます。
本記事の内容が、そのような要件を満たすための一助となれば幸いです。

次回の記事では、[2. 設計ポイント]で紹介したポイントの内、脆弱性スキャン以降の内容について詳しく解説します。

引き続き、実務での活用や改善提案などがあれば、ぜひコメントやフィードバックをお寄せください。一緒により良いCI/CDを作り上げていきましょう！