CI/CD — GitHub Actions + ECR + ECS
OIDC でアクセスキーのない GitHub Actions、ECR push、Task Definition の自動更新、ECS Service のローリングデプロイ、deployment circuit breaker と自動ロールバック、CodeDeploy blue/green まで。一度の git push で終わるデプロイの流れを整理します。
第22章 インフラの骨格 で ECS Service を手で立ち上げ、第23章 RDS 連携 で RDS とマイグレーションを手で回しました。本章は、そのすべての手作業を一度の git push にまとめる流れです。
4部の三番目の章として、扱う内容は次のとおりです。
- GitHub Actions ↔ AWS の認証をアクセスキーなしで — OIDC
- ビルド → ECR push → Task Definition 更新 → Service update → マイグレーションのワークフロー
- 自動ロールバック — Deployment Circuit Breaker
- 段階的デプロイ — CodeDeploy blue/green / canary
- CodePipeline との比較 — いつどちらか
大きな絵 #
git push (main)
│
▼
GitHub Actions
│
├─ 1) Test ← pytest / npm test
│
├─ 2) AWS OIDC assume-role ← アクセスキーなし
│
├─ 3) Build & push image ← <git-sha> タグ
│ ECR: blog-api:abc1234
│
├─ 4) Run migrations ← ecs run-task (blog-api-migrate)
│ 待機 → 終了コード検査
│
├─ 5) Update Task Definition ← 新 image で新 revision
│
├─ 6) Update Service ← rolling デプロイ
│
└─ 7) Wait services-stable ← 5~10 分
失敗時 circuit breaker 自動ロールバックこの流れが一度で回るようにすることが本章の目標です。
1) GitHub OIDC — アクセスキーのない認証 #
昔のパターンは、IAM ユーザーからアクセスキーを発行して GitHub Secrets に保存する方式でした。git history への露出、キーローテーションの義務、追跡の難しさといった危険がついてきます。
OIDC (OpenID Connect) パターンは、GitHub がワークフローの実行ごとに**短命トークン(15分)**を発行し、AWS IAM がそのトークンを信頼するようにします。
GitHub Actions Job 開始
│
▼
GitHub OIDC Provider が JWT 発行
{sub: "repo:myorg/blog-api:ref:refs/heads/main", aud: "sts.amazonaws.com"}
│
▼
aws-actions/configure-aws-credentials
├─ STS:AssumeRoleWithWebIdentity
├─ AWS が trust policy で sub claim 検証
▼
一時認証情報 (AccessKey / SecretKey / SessionToken) TTL: 1h一度だけ — IAM OIDC Provider の登録 #
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1Thumbprint は GitHub OIDC の SSL 証明書の SHA1 です。AWS コンソール GUI では自動で取得します。
IAM Role — Trust Policy #
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/blog-api:ref:refs/heads/main"
}
}
}]
}sub のパターンが核心です。
| パターン | 意味 |
|---|---|
repo:myorg/blog-api:ref:refs/heads/main | main ブランチのみ |
repo:myorg/blog-api:ref:refs/tags/* | タグプッシュのみ |
repo:myorg/blog-api:environment:production | environment ゲート通過のみ |
repo:myorg/blog-api:* | 危険 — すべての PR でもこの役割が使用可能 |
運用の推奨は environment ゲート + main/tag のみです。
Permissions Policy #
デプロイに必要なアクションだけ付与します。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ECR",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "*"
},
{
"Sid": "ECS",
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition",
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:RunTask",
"ecs:DescribeTasks",
"ecs:ListTasks"
],
"Resource": "*"
},
{
"Sid": "PassRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"arn:aws:iam::123456789012:role/blog-api-task-role"
]
}
]
}iam:PassRole が抜けると RegisterTaskDefinition が失敗します。Task Definition に IAM 役割を付与する行為は、その役割を「渡す」ことなので別途権限が必要です。
2) GitHub Actions ワークフロー #
name: Deploy to ECS
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write # OIDC トークン発行 — 必須
contents: read
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: blog-api
ECS_CLUSTER: blog-cluster
ECS_SERVICE: blog-api
TASK_FAMILY: blog-api
MIGRATE_FAMILY: blog-api-migrate
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.14" }
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest -q
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
# 1) AWS OIDC
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: ${{ env.AWS_REGION }}
# 2) ECR login
- name: Login to ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
# 3) Build & push
- name: Build and push
id: build
env:
REGISTRY: ${{ steps.ecr.outputs.registry }}
TAG: ${{ github.sha }}
run: |
docker build --platform=linux/amd64 \
-t $REGISTRY/$ECR_REPOSITORY:$TAG \
-t $REGISTRY/$ECR_REPOSITORY:latest .
docker push $REGISTRY/$ECR_REPOSITORY:$TAG
docker push $REGISTRY/$ECR_REPOSITORY:latest
echo "image=$REGISTRY/$ECR_REPOSITORY:$TAG" >> $GITHUB_OUTPUT
# 4) Migration RunTask
- name: Run DB migrations
env:
IMAGE: ${{ steps.build.outputs.image }}
run: |
# 新 image でマイグレーション task definition の新 revision を登録
DEF=$(aws ecs describe-task-definition --task-definition $MIGRATE_FAMILY \
--query 'taskDefinition' --output json)
NEW=$(echo "$DEF" | jq --arg I "$IMAGE" \
'.containerDefinitions[0].image=$I |
{family,taskRoleArn,executionRoleArn,networkMode,containerDefinitions,
volumes,placementConstraints,requiresCompatibilities,cpu,memory}')
NEW_ARN=$(aws ecs register-task-definition \
--cli-input-json "$NEW" \
--query 'taskDefinition.taskDefinitionArn' --output text)
# RunTask
TASK_ARN=$(aws ecs run-task --cluster $ECS_CLUSTER \
--task-definition $NEW_ARN --launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[${{ secrets.MIGRATE_SUBNET_ID }}],
securityGroups=[${{ secrets.FARGATE_SG_ID }}],
assignPublicIp=ENABLED
}" \
--started-by "deploy-${{ github.sha }}" \
--query 'tasks[0].taskArn' --output text)
echo "Migration task: $TASK_ARN"
aws ecs wait tasks-stopped --cluster $ECS_CLUSTER --tasks $TASK_ARN
# 終了コード検査 (0 でなければ fail)
EXIT=$(aws ecs describe-tasks --cluster $ECS_CLUSTER --tasks $TASK_ARN \
--query 'tasks[0].containers[0].exitCode' --output text)
if [ "$EXIT" != "0" ]; then
echo "Migration failed (exit=$EXIT)"
aws logs tail /ecs/blog-api-migrate --since 10m
exit 1
fi
# 5) Update Service Task Definition
- name: Render service task definition
id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ops/task-definition.json
container-name: api
image: ${{ steps.build.outputs.image }}
# 6) Deploy to ECS Service
- name: Deploy
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
wait-for-minutes: 15核となる段階を整理すると次のとおりです。
| 段階 | 意味 |
|---|---|
id-token: write | OIDC トークンの発行権限。外すと STS AssumeRole が 401 |
environment: production | GitHub environment ゲート — 手動承認、シークレット分離 |
aws-actions/amazon-ecs-render-task-definition | ベース JSON + 新 image → 新 JSON 生成 |
aws-actions/amazon-ecs-deploy-task-definition | RegisterTaskDefinition + UpdateService + wait |
wait-for-service-stability | 安定状態まで待機 — 失敗時に step fail |
3) Deployment Circuit Breaker — 自動ロールバック #
第22章 インフラの骨格 で少し扱った機能です。新しいデプロイが立ち上がらないと、自動で以前の task definition へ戻す機能です。
aws ecs update-service \
--cluster blog-cluster --service blog-api \
--deployment-configuration "
deploymentCircuitBreaker={enable=true,rollback=true},
maximumPercent=200,
minimumHealthyPercent=100"動作の仕方は次のとおりです。
- 新しい task が healthy 状態に入れないと ECS がカウントします。
- 一定の回数 / 時間以内に healthy へ到達できないとデプロイ失敗と判定します。
rollback=trueなら以前の task definition へ自動で復帰します。
GitHub Actions の段階では wait-for-service-stability が false を返すので、ワークフローも fail します。
直接ロールバック #
自動ロールバックができない、または事後調査が必要な場合です。
PREV=$(aws ecs describe-task-definition --task-definition blog-api:42 \
--query 'taskDefinition.taskDefinitionArn' --output text)
aws ecs update-service \
--cluster blog-cluster --service blog-api \
--task-definition $PREV \
--force-new-deployment4) 段階的デプロイ — Canary / Blue-Green #
基本の ECS rolling は、新しい task が healthy になると即座にトラフィックを受けます。より保守的な形が必要なら CodeDeploy が役割を果たします。
Blue/Green #
Blue (現在の運用) ←──── 100% トラフィック
│
▼
Green 新バージョンを立ち上げ (Blue はそのまま生きている)
│
▼
ALB Listener の Test traffic で Green を検証
│
▼
Listener の 100% トラフィック → Green
│
▼
Wait タイマー (10~60分) — 問題なければ Blue 終了
問題あれば Listener 一行で Blue 復帰 (即時ロールバック)利点は次のとおりです。
- 即時ロールバック — Listener だけ戻せば終わり
- 新バージョンを検証する時間を明示的に確保
欠点は次のとおりです。
- 二倍の資源(デプロイ中)
- ALB Listener パターンがやや複雑(Test listener + Production listener)
- ECS Rolling よりセットアップが重い
Canary #
Linear (10%ずつ5分間隔) — 50分で 100%
Canary (10% → 5分待機 → 90% を一度に)
AllAtOnce (即座に 100% — Blue/Green の最速の形)CodeDeploy の deployment configuration 名は次のとおりです。
CodeDeployDefault.ECSAllAtOnceCodeDeployDefault.ECSLinear10PercentEvery1MinutesCodeDeployDefault.ECSCanary10Percent5Minutes
どちらを使うか #
| 状況 | 推奨 |
|---|---|
| 小さな運用 / サイドプロジェクト | ECS Rolling + Circuit Breaker |
| トラフィックの大きな運用、危険な変更 | CodeDeploy Blue/Green Linear |
| ML 推論 / 大きなメモリモデル | Blue/Green(warmup 時間が必要) |
この本は ECS Rolling + Circuit Breaker を基本として前提にします。Blue/Green はトラフィックが大きくなったあとの話です。
5) CodePipeline との比較 #
GitHub Actions のほかに AWS ネイティブな CI/CD もあります。
| GitHub Actions | CodePipeline | |
|---|---|---|
| トリガー | push / PR / schedule | CodeCommit / GitHub / S3 / ECR push |
| ビルド | runners プール(ホスティッド/セルフホスティッド) | CodeBuild |
| デプロイ | 直接呼び出しまたは actions | CodeDeploy / ECS / CFN / Lambda |
| 価格 | ホスティッドは分あたり / セルフホスティッドは無料 | パイプラインあたり $1/月 + CodeBuild |
| 利点 | コードとワークフローが一箇所、エコシステムが豊富 | AWS ネイティブ統合、IAM が一貫 |
| 欠点 | OIDC セットアップ / シークレットを別途管理 | 外部サービス統合が弱い |
GitHub にコードがあるなら GitHub Actions が自然です。会社のセキュリティポリシーでコードが CodeCommit なら CodePipeline です。
6) 環境分離 — dev / staging / prod #
一つのワークフローで環境ごとに分岐します。
on:
push:
branches: [main, develop]
jobs:
deploy:
strategy:
matrix:
include:
- branch: develop
env: dev
cluster: blog-cluster-dev
role: arn:aws:iam::123456789012:role/github-actions-deploy-dev
- branch: main
env: prod
cluster: blog-cluster-prod
role: arn:aws:iam::123456789012:role/github-actions-deploy-prod
if: github.ref == format('refs/heads/{0}', matrix.branch)
environment: ${{ matrix.env }}GitHub environments(dev、prod)に別々の secret、required reviewers、wait timer をかけられます。運用 environment には 2人承認 + 5分待機タイマーをかけて誤操作を防ぎます。
7) シークレットと変数の管理 #
| どこに置くか | |
|---|---|
| AWS Account ID | GitHub vars |
| クラスター名 / サービス名 | GitHub vars またはワークフロー env |
| DB パスワード / API キー | AWS Secrets Manager(第23章) |
| GitHub deploy role ARN | GitHub vars |
| Slack webhook (CI 通知) | GitHub secrets |
原則は次のとおりです。アプリのシークレットは AWS Secrets Manager に置き、GitHub secrets には CI 自体に必要なトークンだけを置きます。
落とし穴 — CI/CD の流れでよく出会うもの #
1) aws sts get-caller-identity が 401
#
OIDC セットアップを疑います。点検の順序は次のとおりです。
permissions: id-token: writeの欠落- IAM Role の trust policy の
subパターンが実際のワークフローのrepo:org/repo:ref:...と正確に一致しているか - OIDC Provider の thumbprint が最新か
- Role の
aud条件がsts.amazonaws.comか
2) Migration が失敗しても Service が更新される #
run-task の終了コードを検査しないと、マイグレーションが失敗してもワークフローが次の段階へ進みます。必ず aws ecs describe-tasks の exitCode を確認し、non-zero なら exit 1 します。
3) latest タグでデプロイ
#
Task Definition の image が :latest なら、現在どのコードが動いているか追跡が不可能です。ECR の image digest(@sha256:...)まで明示するか、git SHA タグを使います。
4) Migration RunTask の IP 不足 #
Free Tier の default subnet は IP が小さいので、運用 task + migration task が同時に IP を掴もうとして失敗することがあります。マイグレーション専用の SG / subnet を分離するか、デプロイウィンドウに IP があるか確認します。
5) Circuit Breaker が正常なデプロイまでロールバック #
短すぎるヘルスチェックの grace period + 大きな起動時間だと、正常なデプロイが unhealthy と誤認されます。health-check-grace-period-seconds をアプリ起動 + 余裕(例: Django 90秒)に置きます。
6) GitHub Actions OIDC の audience キャッシュ #
sub または aud を変えたのに依然として古い値が入ってきます。ワークフローのキャッシュではなく、新しい job として再び開始しなければ新しいトークンが発行されません。
7) ecs-deploy-task-definition の stuck
#
wait-for-service-stability: true に置いて wait-for-minutes が短すぎると、正常なデプロイも失敗扱いされます。保守的に 15 ~ 20分に置きます。
練習問題 #
- OIDC が IAM ユーザーのアクセスキー方式より安全な理由を、§「GitHub OIDC」を根拠に二つ書いてみてください。Trust Policy の
subパターンをrepo:myorg/blog-api:*に置くとなぜ危険かも一行で説明してみてください。 - マイグレーション RunTask の段階で終了コードを検査しないとどんな事故が起きるかを §「落とし穴 2」を根拠に説明し、ワークフロー YAML のどの部分がこの検査を担うかを指してみてください。
- ECS Rolling + Circuit Breaker と CodeDeploy Blue/Green をそれぞれどんな状況で選ぶかを §「どちらを使うか」の表を根拠に整理してください。第25章 Terraform 入門 でこのデプロイ設定をコードに移すとき、
deployment_circuit_breakerブロックがどこに入るかを先に思い浮かべておくとよいです。
一行まとめ: GitHub Actions OIDC はアクセスキーなしで短命トークンにより AWS 役割を引き受け、Trust Policy の
subパターンでどのブランチ・環境だけをデプロイするか制限する。ワークフローは test → build/push → migration RunTask(終了コード検査) → Service deploy → wait-stable の順で、Deployment Circuit Breaker が失敗時に自動ロールバックする。アプリのシークレットは Secrets Manager、CI 自体のトークンだけ GitHub secrets に置く。
次の章 #
デプロイは自動化されました。しかしインフラ自体 — VPC / SG / RDS / ALB / ECS — は依然としてコンソールと CLI で手元に置いています。新しい環境をもう一度まったく同じに立ち上げられるでしょうか。次の 第25章 IaC — Terraform 入門 では、インフラをコードへ移します。provider / resource / state の形、S3 + DynamoDB backend、モジュールによる dev/prod 分離、そして22章のインフラを一行ずつコード化する流れまで扱います。