
CircleCI から GitHub Actions を実行して ECS (Fargate) にデプロイしてみる
はじめに
デプロイパイプラインを構築する際、タイトルのようにCIツールを敢えて複数組み合わせる機会は少ないとは思います。
しかし、実際のプロジェクトでは既存の CircleCI パイプラインが存在する、CircleCIの特定機能に現状依存している、段階的な移行が必要などといった理由から、このような状況に直面することがあると思います。
このような背景から、本稿では CircleCIとGitHub Actionsとを使ったデプロイを検証しました。
やりたいこと
- GitHubリポジトリA でアプリ更新、CircleCI でDockerイメージをビルドしてECRにプッシュ
- GitHubリポジトリT の GitHub Actions を使ってECSのタスク定義を更新
- GitHubリポジトリT の GitHub Actions でTerraformを使用してECSにデプロイ
なお、本稿では、PRの前にデプロイしてしまっていますが、環境に応じて plan のみにするなどの調整をすると良いと思います。
イメージ
環境
- node v22.14.0
- npm 10.9.2
- Terraform v1.11.2
前提
GitHubの設定
- アプリケーションリポジトリ(as Aリポジトリ)
- Terraformリポジトリ (as Tリポジトリ)
- GitHub Actions のOIDC設定
- 環境変数の設定
AWSの設定
- ECRリポジトリの作成
- ECSクラスターの作成
CircleCIの設定
- プロジェクト・パイプラインの設定
- CircleCI のOIDC設定
- context/環境変数の設定
- AWS_ACCOUNT_ID
- AWS_REGION
- AWS_ROLE_ARN
- ECR_REPOSITORY_NAME
- GITHUB_TOKEN (Fine-grained personal access token)
- GITHUB_URL
必要な設定
Aリポジトリ
アプリコードは、本稿の目的外なので省略します。(シンプルな Node Express です。)
.circleci/config.yml
version: 2.1
orbs:
node: circleci/node@7.1.0
aws-cli: circleci/aws-cli@5.2.0
jobs:
test-node:
executor: node/default
steps:
- checkout
- node/install-packages:
pkg-manager: npm
- run:
name: Run tests
command: echo "No test specified in package.json"
build-and-push-image:
machine:
image: ubuntu-2204:current
steps:
- checkout
- aws-cli/setup:
role_arn: ${AWS_ROLE_ARN}
region: ${AWS_REGION}
- run:
name: Set image tag
command: |
echo "export IMAGE_TAG=${CIRCLE_SHA1}-$(date +%Y%m%d-%H%M%S)" >> $BASH_ENV
source $BASH_ENV
- run:
name: Login to Amazon ECR
command: aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
- run:
name: Build and push Docker image
command: |
docker build -t ${ECR_REPOSITORY_NAME}:${IMAGE_TAG} .
docker tag ${ECR_REPOSITORY_NAME}:${IMAGE_TAG} ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}:${IMAGE_TAG}
docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}:${IMAGE_TAG}
- run:
name: "Execute GitHub Actions"
command: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
${GITHUB_URL} \
-d "{
\"event_type\": \"update-ecs-image\",
\"client_payload\": {
\"image_tag\": \"${IMAGE_TAG}\",
\"sha\": \"${CIRCLE_SHA1}\"
}
}"
workflows:
build-test-deploy:
jobs:
- test-node
- build-and-push-image:
requires:
- test-node
context: aws
ここでの肝は、 create a repository dispatch event
のAPIを叩くことだけです。
下記例に沿って実行します。
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer <YOUR-TOKEN>" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/OWNER/REPO/dispatches \
-d '{"event_type":"on-demand-test","client_payload":{"unit":false,"integration":true}}'
Tリポジトリ
ECSを構築するTFのコードは、本稿の目的外なので関連箇所以外は省略します。
locals.tf
locals {
container_name = "app"
container_port = 3000
image_tag = "hogehoge"
ecr_repository_name = "sample-express-app"
}
task_definition.json
[
{
"name": "${container_name}",
"image": "${account_id}.dkr.ecr.${region}.amazonaws.com/${ecr_repository_name}:${image_tag}",
"portMappings": [
{
"containerPort": ${container_port},
"protocol": "tcp"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/app",
"awslogs-region": "${region}",
"awslogs-stream-prefix": "ecs"
}
}
}
]
.github/workflows/update-ecs.yml
name: "Update ECS Task Definition"
on:
repository_dispatch:
types: [update-ecs-image]
env:
IMAGE_TAG: ${{ github.event.client_payload.image_tag }}
SHA: ${{ github.event.client_payload.sha }}
BRANCH_NAME: feature/update_ecs_image_${{ github.event.client_payload.sha }}
jobs:
update-ecs:
name: "Dispatch"
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Update Image Tag in locals.tf
run: |
# locals.tfのパスを指定
LOCALS_FILE="${GITHUB_WORKSPACE}/locals.tf"
# image_tagの値を更新
sed -i "s/image_tag *= *\".*\"/image_tag = \"$IMAGE_TAG\"/" "$LOCALS_FILE"
echo "=== After Update ==="
cat "$LOCALS_FILE"
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: "ap-northeast-1"
role-to-assume: "arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-for-ecs-deploy-role"
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: terraform plan -out=tfplan
# 環境に応じてplanのみにするなど検討
- name: Terraform Apply
run: terraform apply tfplan
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
title: "Update ECS Task"
commit-message: Update ECS Task Definition Image Tag
body: |
Updates the ECS task definition with new image tag:
- Tag: ${{ env.IMAGE_TAG }}
- Auto-generated by [create-pull-request][1]
[1]: https://github.com/peter-evans/create-pull-request
branch: ${{ env.BRANCH_NAME }}
base: main
labels: |
automated pr
ecs-update
Webhook でリクエストされたクライアントペイロードを受け取って GitHub Actions のworkflow を実行します。
下記例に沿って記述します。
on:
repository_dispatch:
types: [test_result]
jobs:
run_if_failure:
if: ${{ !github.event.client_payload.passed }}
runs-on: ubuntu-latest
steps:
- env:
MESSAGE: ${{ github.event.client_payload.message }}
run: echo $MESSAGE
本稿では、PRを出すのに、Create Pull Request Actions を利用しました。
実行してみる
CircleCI Pipeline
GHA workflows
GitHub PR
ECS タスク
複数環境におけるデプロイフローの例
続けて、下図のようなデプロイフローで対応してみます。
以下のような要件とします。
- アプリの
main
ブランチへの merge はterraform apply
でデプロイされる- インフラリポジトリの
main
から派生されたブランチは自動で merge される
- インフラリポジトリの
- アプリのタグリリースでは
terraform plan
される- 指定したインフラリポジトリのブランチから派生されたブランチで自動 PR できる
① 〜 ⑨ は簡単な流れの順を示しています。
(アプリ) CircleCIのTrigger
タグリリースで発火できるように Trigger 設定を追加します。
また、Why do I see "No workflow" when I push a commit or a tag – CircleCI Support Center で記載されている通り、 config.yml
にも tag filter を追記します。
Terraform で apply するか plan するかの判別のために、以下のようにタグプッシュか main ブランチへのマージかの is_deploy
、およびインフラで利用するブランチ branch
をペイロードに追加させます。
...省略
if [[ -n "${CIRCLE_TAG}" ]]; then
echo "export IS_DEPLOY=false" >> $BASH_ENV
else
echo "export IS_DEPLOY=true" >> $BASH_ENV
fi
source $BASH_ENV
...省略
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
${GITHUB_URL} \
-d "{
\"event_type\": \"update-ecs-image\",
\"client_payload\": {
\"image_tag\": \"${IMAGE_TAG}\",
\"sha\": \"${CIRCLE_SHA1}\",
\"branch\": \"${INFRA_TARGET_BRANCH}\",
\"is_deploy\": \"${IS_DEPLOY}\"
}
}"
上記を踏まえて .circleci/config.yml
は以下のようになりました。
version: 2.1
orbs:
node: circleci/node@7.1.0
aws-cli: circleci/aws-cli@5.2.0
jobs:
test-node:
executor: node/default
steps:
- checkout
- node/install-packages:
pkg-manager: npm
- run:
name: Run tests
command: echo "No test specified in package.json"
build-and-push-image:
machine:
image: ubuntu-2204:current
steps:
- checkout
- aws-cli/setup:
role_arn: ${AWS_ROLE_ARN}
region: ${AWS_REGION}
- run:
name: Set image tag
command: |
echo "export IMAGE_TAG=${CIRCLE_SHA1}-$(date +%Y%m%d-%H%M%S)" >> $BASH_ENV
# タグプッシュかmainブランチかでDEPLOYかどうか判断
if [[ -n "${CIRCLE_TAG}" ]]; then
echo "export IS_DEPLOY=false" >> $BASH_ENV
else
echo "export IS_DEPLOY=true" >> $BASH_ENV
fi
source $BASH_ENV
- run:
name: Login to Amazon ECR
command: aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
- run:
name: Build and push Docker image
command: |
docker build -t ${ECR_REPOSITORY_NAME}:${IMAGE_TAG} .
docker tag ${ECR_REPOSITORY_NAME}:${IMAGE_TAG} ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}:${IMAGE_TAG}
docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}:${IMAGE_TAG}
- run:
name: "Execute GitHub Actions"
environment:
# default main ブランチ
INFRA_TARGET_BRANCH: "main"
command: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
${GITHUB_URL} \
-d "{
\"event_type\": \"update-ecs-image\",
\"client_payload\": {
\"image_tag\": \"${IMAGE_TAG}\",
\"sha\": \"${CIRCLE_SHA1}\",
\"branch\": \"${INFRA_TARGET_BRANCH}\",
\"is_deploy\": \"${IS_DEPLOY}\"
}
}"
workflows:
build-test-deploy:
jobs:
- test-node:
filters:
tags:
only: /.*/
- build-and-push-image:
requires:
- test-node
context: aws
filters:
tags:
only: /.*/
(インフラ) 自動マージについて
peter-evans/create-pull-request
を利用しているときの注意点として GitHub Actionsで作成したプルリクエストが他のActionsをトリガーしない(on:pull_request) というものがありました。
Authenticating with GitHub App generated tokens で
紹介されている通り、GitHub Apps で対応します。
APP_ID
と APP_PRIVATE_KEY
は GitHub Actions の secret に設定します。
なお、 GitHub Apps と secrets.GITHUB_TOKEN
とを併用することによって、同じユーザー(GITHUB_TOKEN を含む)が作成した PR を自身で approve することを防ぐ Pull request authors can’t approve their own pull request
を避けることができます。
上記を踏まえて、インフラの GHA は以下のような構成になりました。
.github
├── actions
│ └── terraform-execute
│ └── action.yml
└── workflows
├── branches-for-env.yml
├── fire-update-ecs-workflow.yml
└── update-common-process.yml
それぞれの役割については箇条書きで説明しておきます。
.github/workflows/fire-update-ecs-workflow.yml
- エントリーポイントとなるワークフロー
- CircleCIからのrepository_dispatchイベントを受け取る
- is_deployの値に応じて、デプロイ(true)または計画(false)を分岐
- 必要な権限(permissions)を設定
- branches-for-env.yml を呼び出す
name: "Fire Update ECS Workflow"
on:
repository_dispatch:
types: [update-ecs-image]
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
deploy:
if: github.event.client_payload.is_deploy == 'true'
uses: ./.github/workflows/branches-for-env.yml
with:
is_deploy: true
secrets: inherit
plan:
if: github.event.client_payload.is_deploy == 'false'
uses: ./.github/workflows/branches-for-env.yml
with:
is_deploy: false
secrets: inherit
.github/workflows/branches-for-env.yml
- 環境(dev/stg/prd)ごとの処理を分岐
is_deploy
に基づいて実行環境を決定:- true: devのみ実行
- false: stgとprdを実行(prdはstg完了後)
- 各環境のAWSアカウントIDを variables から渡す
- update-common-process.yml を呼び出す
name: "Branches For Env"
on:
workflow_call:
inputs:
is_deploy:
required: true
type: boolean
jobs:
dev:
if: inputs.is_deploy
uses: ./.github/workflows/update-common-process.yml
with:
aws_account_id: ${{ vars.AWS_DEV_ACCOUNT_ID }}
is_deploy: true
secrets: inherit
stg:
if: "!inputs.is_deploy"
uses: ./.github/workflows/update-common-process.yml
with:
aws_account_id: ${{ vars.AWS_STG_ACCOUNT_ID }}
is_deploy: false
secrets: inherit
prd:
if: "!inputs.is_deploy"
needs: stg
uses: ./.github/workflows/update-common-process.yml
with:
aws_account_id: ${{ vars.AWS_PRD_ACCOUNT_ID }}
is_deploy: false
secrets: inherit
.github/workflows/update-common-process.yml
- ECS タスク更新処理を実行
- GitHub App トークンの生成
- イメージタグの更新
- Terraform 実行(terraform-execute action を使用)
- GitHub App トークンによる自動 PR 作成
- GitHub トークンによる自動マージ(dev環境の場合)
- 環境変数の設定と管理
name: "ECS Common Process"
on:
workflow_call:
inputs:
aws_account_id:
required: true
type: string
is_deploy:
required: true
type: boolean
secrets:
APP_ID:
required: true
APP_PRIVATE_KEY:
required: true
env:
IMAGE_TAG: ${{ github.event.client_payload.image_tag }}
SHA: ${{ github.event.client_payload.sha }}
BRANCH_NAME: feature/update_ecs_image_${{ github.event.client_payload.sha }}
TARGET_BRANCH: ${{ github.event.client_payload.branch }}
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: generate-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_BRANCH }}
- name: Update Image Tag in locals.tf
run: |
LOCALS_FILE="${GITHUB_WORKSPACE}/locals.tf"
sed -i "s/image_tag *= *\".*\"/image_tag = \"${{ env.IMAGE_TAG }}\"/" "$LOCALS_FILE"
echo "=== After Update ==="
cat "$LOCALS_FILE"
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Execute Terraform
uses: ./.github/actions/terraform-execute
with:
aws_account_id: ${{ inputs.aws_account_id }}
is_deploy: ${{ inputs.is_deploy }}
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v7
with:
token: ${{ steps.generate-token.outputs.token }}
title: "Update ECS Task"
body: |
Updates the ECS task definition with new image tag:
- Tag: ${{ env.IMAGE_TAG }}
- Auto-generated by [create-pull-request][1]
[1]: https://github.com/peter-evans/create-pull-request
branch: ${{ env.BRANCH_NAME }}
base: ${{ env.TARGET_BRANCH }}
labels: |
automated pr
ecs-update
- name: Auto Approve and Merge PR
if: inputs.is_deploy && steps.cpr.outputs.pull-request-number
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Processing PR #${{ steps.cpr.outputs.pull-request-number }}"
# PRを承認(PR番号を指定)
gh pr review ${{ steps.cpr.outputs.pull-request-number }} --approve
# PRをマージ(PR番号を指定)
gh pr merge ${{ steps.cpr.outputs.pull-request-number }} --merge --auto --delete-branch
.github/actions/terraform-execute/action.yml
- Terraform の共通処理をカスタムアクションとして実装
name: "Terraform Execute"
description: "Execute Terraform commands"
inputs:
aws_account_id:
description: "AWS Account ID for assuming role"
required: true
is_deploy:
description: "Whether to deploy or not"
required: true
runs:
using: "composite"
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: "ap-northeast-1"
role-to-assume: "arn:aws:iam::${{ inputs.aws_account_id }}:role/github-actions-for-ecs-deploy-role"
- name: Terraform Init
shell: bash
run: terraform init
- name: Terraform Plan
shell: bash
run: terraform plan -out=tfplan
- name: Terraform Apply
if: inputs.is_deploy == 'true'
shell: bash
run: terraform apply tfplan
さいごに
本記事では、CircleCI と GitHub Actions を組み合わせたECS (Fargate) へのデプロイパイプラインを構築してみました。
なお、ロールバックについては検討の上、準備しておく必要があると考えます。
完全自動のロールバックなのか、タグベースの手動ロールバックなのか、チームの開発事情に応じた適切な方法を選択して、明確な手順とフローを共有しておくことが重要だと考えます。