GitHub ActionsによるECSのセキュアなCI/CDワークフローの構築

最近、GitHub ActionsでECS on FargateのCI/CDワークフローを構築する機会がありました。
このプロセスで、運用保守やセキュリティに関して工夫した点をまとめました。

# ワークフロー全容
以下がECS on FargateのCI/CDワークフローの全容です。このワークフローは、コードのチェックアウト、ECRリポジトリへのログイン、Dockerイメージのビルドとプッシュ、セキュリティスキャン、そしてECSへのデプロイを含んでいます。説明のため、テストやリントなどのステップは省略していますが、実際のワークフローには含めることを推奨します。

```yml:deploy-ecs.yml
name: ECS Fargate CI/CD

on:
  push:
    branches: [main, develop]
    paths:
      - "backend/**"
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  build-and-push:
    if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set ECR repository URI based on branch
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then
            echo "REPOSITORY_URI=************.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-dev" >> $GITHUB_ENV
          else
            echo "REPOSITORY_URI=************.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-prod" >> $GITHUB_ENV
          fi

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ secrets.AWS_IAM_ROLE_TO_ASSUME }}
          role-session-name: GitHubActions
          role-duration-seconds: 3600

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build Docker Image
        run: docker build -t ${{ env.REPOSITORY_URI }}:${{ github.sha }} -f ./backend/Dockerfile.ecs ./backend

      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REPOSITORY_URI }}:${{ github.sha }}
          format: "table"
          severity: "CRITICAL,HIGH"
          exit-code: 1

      - name: Check Docker best practices with Dockle
        uses: erzz/dockle-action@v1
        with:
          image: ${{ env.REPOSITORY_URI }}:${{ github.sha }}
          failure-threshold: fatal
          exit-code: 1

      - name: Push to ECR
        if: success()
        run: docker push ${{ env.REPOSITORY_URI }}:${{ github.sha }}

      - name: Notify Slack on success
        if: success()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
          SLACK_COLOR: "#36A64F"
          SLACK_MESSAGE: "Security scans have completed successfully. All checks passed."
          SLACK_TITLE: "Security Scan Completed"

      - name: Notify Slack on failure
        if: failure()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
          SLACK_COLOR: "danger"
          SLACK_MESSAGE: "A critical error has occurred in the build or security scan process. Please check the GitHub Actions logs for more details."
          SLACK_TITLE: "Build or Security Scan Failed"

  deploy:
    if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ secrets.AWS_IAM_ROLE_TO_ASSUME }}
          role-session-name: GitHubActions
          role-duration-seconds: 3600

      - name: Download ecspresso
        uses: kayac/ecspresso@v2
        with:
          version: v2.3.3

      - name: Setup environment
        run: |
          echo "IMAGE_TAG=${{ github.sha }}" >> $GITHUB_ENV
          if [[ ${{ github.ref }} == 'refs/heads/develop' ]]; then
            echo "working_directory=./backend/ecspresso/dev" >> $GITHUB_ENV
            echo "ENV=${{ secrets.ENV_DEV }}" >> $GITHUB_ENV
            echo "COGNITO_USER_POOL_ID=${{ secrets.COGNITO_USER_POOL_ID_DEV }}" >> $GITHUB_ENV
            echo "S3_URL=${{ secrets.S3_URL_DEV }}" >> $GITHUB_ENV
            echo "SLACK_MENTIONS=" >> $GITHUB_ENV
            echo "SLACK_TITLE_PREFIX=Develop" >> $GITHUB_ENV
          else
            echo "working_directory=./backend/ecspresso/prod" >> $GITHUB_ENV
            echo "ENV=${{ secrets.ENV_PROD }}" >> $GITHUB_ENV
            echo "COGNITO_USER_POOL_ID=${{ secrets.COGNITO_USER_POOL_ID_PROD }}" >> $GITHUB_ENV
            echo "S3_URL=${{ secrets.S3_URL_PROD }}" >> $GITHUB_ENV
            echo "SLACK_MENTIONS=<@***********>" >> $GITHUB_ENV
            echo "SLACK_TITLE_PREFIX=Production" >> $GITHUB_ENV
          fi

      - name: Deploy to ECS service
        run: ecspresso deploy --config ecspresso.yml
        working-directory: ${{ env.working_directory }}
        env:
          ENV: ${{ env.ENV }}
          COGNITO_USER_POOL_ID: ${{ env.COGNITO_USER_POOL_ID }}
          S3_URL: ${{ env.S3_URL }}
          IMAGE_TAG: ${{ env.IMAGE_TAG }}

      - name: Set Slack message and title on success
        if: success()
        run: |
          echo "SLACK_COLOR=good" >> $GITHUB_ENV
          echo "SLACK_TITLE_SUFFIX=(${{ github.ref_name }}) on ECS Fargate Deployment Success" >> $GITHUB_ENV

      - name: Set Slack message and title on failure
        if: failure()
        run: |
          echo "SLACK_COLOR=danger" >> $GITHUB_ENV
          echo "SLACK_TITLE_SUFFIX=(${{ github.ref_name }}) on ECS Fargate Deployment Failure" >> $GITHUB_ENV

      - name: Notify Slack about deployment status
        if: always()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
          SLACK_COLOR: ${{ env.SLACK_COLOR }}
          SLACK_MESSAGE: ${{ env.SLACK_MENTIONS }}
          SLACK_TITLE: ${{ env.SLACK_TITLE_PREFIX }} ${{ env.SLACK_TITLE_SUFFIX }}
```

# 運用保守まわりの工夫点

## 各環境のワークフローを1つのファイルに統一
GitFlowを使用しているため、developブランチとmainブランチへのPRマージをトリガーに、ワークフローが実行されるようにしました(説明のため、記載のサンプルコードからstagingブランチは除いています)。

以前のワークフローでは環境ごとにファイルがわかれていたのですが、保守しやすくするため、1つのファイルにまとめました。以下のコードは、ブランチに応じてECRリポジトリURIを設定する部分です。

```yml
      - name: Set ECR repository URI based on branch
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then
            echo "REPOSITORY_URI=************.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-dev" >> $GITHUB_ENV
          else
            echo "REPOSITORY_URI=************.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-prod" >> $GITHUB_ENV
          fi
```

## コンテナイメージのタグにコミットハッシュを使用
ECRでコンテナイメージを管理する際に`latest`タグを使用するのではなく、アプリケーションコードとの対応関係がわかりやすいようにコミットハッシュをタグとして使用しました。これにより、どのコミットからビルドされたイメージかを容易に追跡できます。

```yml
- name: Push to ECR
        if: success()
        run: docker push ${{ env.REPOSITORY_URI }}:${{ github.sha }}
```

## ecspressoによるタスク定義とサービスのコード管理
ECSのタスク定義やサービスのコード管理には、専用のツールである`ecspresso`を使用しました。Terraformを使用することも検討しましたが、デプロイのたびに更新が発生し、差分管理が複雑になるため、`ecspresso`を選択しました。

日本の有名企業でも結構使われているみたいだったので、今回導入してみることにしました。

https://github.com/kayac/ecspresso

以下のコードは、`ecspresso`を使用してタスク定義とサービスをデプロイする方法を示しています。`working-directory`で`ecspresso.yml`ファイルのディレクトリを指定し、環境変数を渡してデプロイを実行します。

```yml
      - name: Deploy to ECS service
        run: ecspresso deploy --config ecspresso.yml
        working-directory: ${{ env.working_directory }}
        env:
          ENV: ${{ env.ENV }}
          COGNITO_USER_POOL_ID: ${{ env.COGNITO_USER_POOL_ID }}
          S3_URL: ${{ env.S3_URL }}
          IMAGE_TAG: ${{ env.IMAGE_TAG }}
```

タスク定義の管理ファイル`ecs-task-def.json`では、環境変数を以下のように読み込みます。

```json:ecs-task-def.json
{
  "containerDefinitions": [
    {
      "cpu": 256,
      "environment": [
        {
          "name": "TZ",
          "value": "Asia/Tokyo"
        },
        {
          "name": "ENV",
          "value": "{{ must_env `ENV` }}"
        },
        {
          "name": "COGNITO_USER_POOL_ID",
          "value": "{{ must_env `COGNITO_USER_POOL_ID` }}"
        },
        {
          "name": "S3_URL",
          "value": "{{ must_env `S3_URL` }}"
        },
        {
          "name": "REGION",
          "value": "{{ must_env `REGION` }}"
        }
      ],
      "essential": true,
      "image": "************.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-dev:{{ must_env `IMAGE_TAG` }}",
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-create-group": "true",
          "awslogs-group": "/ecs/task-dev",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "memory": 512,
      "memoryReservation": 512,
      "name": "container",
      "portMappings": [
        {
          "appProtocol": "http",
          "containerPort": 8080,
          "hostPort": 8080,
          "name": "container-8080-tcp",
          "protocol": "tcp"
        }
      ]
    }
  ],
  "cpu": "256",
  "executionRoleArn": "arn:aws:iam::************:role/ecsTaskExecutionRole",
  "family": "task-dev",
  "ipcMode": "",
  "memory": "512",
  "networkMode": "awsvpc",
  "pidMode": "",
  "requiresCompatibilities": ["FARGATE"],
  "tags": [
    {
      "key": "Environment",
      "value": "dev"
    }
  ],
  "taskRoleArn": "arn:aws:iam::************:role/ecsTaskRole"
}
```

## デプロイ通知
脆弱性スキャンやデプロイ完了の通知には`rtCamp/action-slack-notify`を使用しました。

https://github.com/rtCamp/action-slack-notify

本番環境へのデプロイ時には、運用者のSlackメンバーIDを環境変数に設定し(`echo "SLACK_MENTIONS=<@***********>" >> $GITHUB_ENV`)、通知時にメンションがつくようにしました。これにより、デプロイの状況をチーム全体で即座に把握できます。

![スクリーンショット 2024-05-18 22.04.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/14de5983-4fe2-1d90-f46b-21c3a0e83b18.png)

メンバーIDはSlackの以下の画面からコピーできます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/deef149d-5c0e-fe1e-2cfc-2917330feb1c.png" width="300">

# セキュリティまわりの工夫点

## OpenID ConnectによるアクセスキーレスなAssumeRole
GitHub ActionsでECRや他のAWSリソースにアクセスする際に、クレデンシャル(アクセスキー、シークレットアクセスキー)を使わずにAssumeRoleできるようにOpenID Connectを使用しました。これにより、長期間有効な静的クレデンシャルを使用する必要がなくなり、セキュリティが大幅に向上します。

詳細な実装方法については、以前書いた記事をご参照ください。

https://qiita.com/suzuki0430/items/c1fbde1550950ca41f7f

## TrivyとDockleによる脆弱性スキャン
コンテナイメージのビルド後、セキュリティのベストプラクティスに従って脆弱性スキャンを行うようにしました。

### Trivy
Trivyは、コンテナイメージ内の既知の脆弱性をチェックします。OSパッケージやアプリケーションライブラリに対する脆弱性スキャンを行い、`HIGH`または`CRITICAL`な脆弱性が検出された場合、ワークフローを失敗させます。

### Dockle
Dockleは、`Dockerfile`のベストプラクティスに従っているかを確認します。ルートユーザーで実行されていないか、不必要なファイルやディレクトリが含まれていないかなどをチェックします。

以下は、TrivyとDockleを使用して脆弱性スキャンを行い、スキャンが成功した場合のみECRにプッシュし、Slackに通知するステップです。

```yml
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REPOSITORY_URI }}:${{ github.sha }}
          format: "table"
          severity: "CRITICAL,HIGH"
          exit-code: 1

      - name: Check Docker best practices with Dockle
        uses: erzz/dockle-action@v1
        with:
          image: ${{ env.REPOSITORY_URI }}:${{ github.sha }}
          failure-threshold: fatal
          exit-code: 1

      - name: Push to ECR
        if: success()
        run: docker push ${{ env.REPOSITORY_URI }}:${{ github.sha }}

      - name: Notify Slack on success
        if: success()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
          SLACK_COLOR: "#36A64F"
          SLACK_MESSAGE: "Security scans have completed successfully. All checks passed."
          SLACK_TITLE: "Security Scan Completed"

      - name: Notify Slack on failure
        if: failure()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
          SLACK_COLOR: "danger"
          SLACK_MESSAGE: "A critical error has occurred in the build or security scan process. Please check the GitHub Actions logs for more details."
          SLACK_TITLE: "Build or Security Scan Failed"
```

TrivyとDockleのアクションは以下のリポジトリから使用しました。

https://github.com/aquasecurity/trivy-action

https://github.com/erzz/dockle-action

## マルチステージビルド
デプロイイメージに不要なライブラリが含まれないように、マルチステージビルドを行いました。これにより、ビルド環境と実行環境を分離し、最終イメージのサイズを小さく保つことができます。また、セキュリティの観点からも、不要なツールやライブラリが含まれないため、攻撃対象の範囲が減少し、脆弱性のリスクを下げられます。最終イメージのベースには、より軽量な`slim`バージョンを選択しました。

```Dockerfile:Dockerfile.ecs
FROM node:18.20.2 AS builder

WORKDIR /app

COPY package*.json ./
COPY yarn.lock ./

RUN yarn install --frozen-lockfile --ignore-scripts

COPY . .

RUN yarn build

FROM node:18.20.2-slim

WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

EXPOSE 8080

CMD ["node", "dist/main"]
```

マルチステージビルドについても以前記事を書いたので、よろしければご覧ください。

https://qiita.com/suzuki0430/items/e48764af3e714e1b63e0

# おわりに
今回紹介したECS on FargateのCI/CDワークフローでは、セキュリティと効率性を高めるためにさまざまな工夫を取り入れました。今後は、テストステップの追加やパフォーマンスモニタリングツールとの統合なども考えていきたいと思います。