ECSのCI/CDを運用&セキュリティ面でいろいろ工夫したよ
こんにちは、すずきです。
最近、GitHub ActionsでECS on FargateのCI/CDワークフローを構築する機会があったので、運用保守やセキュリティに関して工夫した点をまとめました。
英語版の記事もあるので、日本語が苦手な方はこちらをご覧ください。割と反響がありました(1週間で12,000 View超えた)。
ワークフロー全容
以下がECS on FargateのCI/CDワークフローの全容です。このワークフローは、コードのチェックアウト、ECRリポジトリへのログイン、Dockerイメージのビルドとプッシュ、セキュリティスキャン、そしてECSへのデプロイを含んでいます。説明のため、テストやリントなどのステップは省略していますが、実際のワークフローには含めることを推奨します。
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を設定する部分です。
- 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タグを使用するのではなく、アプリケーションコードとの対応関係がわかりやすいようにコミットハッシュをタグとして使用しました。これにより、どのコミットからビルドされたイメージかを容易に追跡できます。
- name: Push to ECR
if: success()
run: docker push ${{ env.REPOSITORY_URI }}:${{ github.sha }}
ecspressoによるタスク定義とサービスのコード管理
ECSのタスク定義やサービスのコード管理には、専用のツールであるecspressoを使用しました。Terraformを使用することも検討しましたが、デプロイのたびに更新が発生し、差分管理が複雑になるため、ecspressoを選択しました。
日本の有名企業でも結構使われているみたいだったので、今回導入してみることにしました。
以下のコードは、ecspressoを使用してタスク定義とサービスをデプロイする方法を示しています。working-directoryでecspresso.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では、環境変数を以下のように読み込みます。
{
"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を使用しました。
本番環境へのデプロイ時には、運用者のSlackメンバーIDを環境変数に設定し(echo "SLACK_MENTIONS=<@***********>" >> $GITHUB_ENV)、通知時にメンションがつくようにしました。これにより、デプロイの状況をチーム全体で即座に把握できます。
メンバーIDはSlackの以下の画面からコピーできます。
セキュリティまわりの工夫点
OpenID ConnectによるアクセスキーレスなAssumeRole
GitHub ActionsでECRや他のAWSリソースにアクセスする際に、クレデンシャル(アクセスキー、シークレットアクセスキー)を使わずにAssumeRoleできるようにOpenID Connectを使用しました。これにより、長期間有効な静的クレデンシャルを使用する必要がなくなり、セキュリティが大幅に向上します。
詳細な実装方法については、以前書いた記事をご参照ください。
TrivyとDockleによる脆弱性スキャン
コンテナイメージのビルド後、セキュリティのベストプラクティスに従って脆弱性スキャンを行うようにしました。
Trivy
Trivyは、コンテナイメージ内の既知の脆弱性をチェックします。OSパッケージやアプリケーションライブラリに対する脆弱性スキャンを行い、HIGHまたはCRITICALな脆弱性が検出された場合、ワークフローを失敗させます。
Dockle
Dockleは、Dockerfileのベストプラクティスに従っているかを確認します。ルートユーザーで実行されていないか、不必要なファイルやディレクトリが含まれていないかなどをチェックします。
以下は、TrivyとDockleを使用して脆弱性スキャンを行い、スキャンが成功した場合のみECRにプッシュし、Slackに通知するステップです。
- 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のアクションは以下のリポジトリから使用しました。
マルチステージビルド
デプロイイメージに不要なライブラリが含まれないように、マルチステージビルドを行いました。これにより、ビルド環境と実行環境を分離し、最終イメージのサイズを小さく保つことができます。また、セキュリティの観点からも、不要なツールやライブラリが含まれないため、攻撃対象の範囲が減少し、脆弱性のリスクを下げられます。最終イメージのベースには、より軽量なslimバージョンを選択しました。
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"]
マルチステージビルドについても以前記事を書いたので、よろしければご覧ください。
おわりに
U-NEXTのレンタル期間が48時間とは露知らず、マクロスⅡのOPを観ただけで220円が溶けました..
採用情報
この記事が気に入ったらサポートをしてみませんか?