AWS実践 #3 CI/CD — GitHub Actions + ECR + ECS

読了 7分

#1 で ECS Service を手動で立ち上げ、#2 で RDS とマイグレーションを手動で回しました。今回はその全ての手作業を 1 回の git push で束ねます。

扱うこと:

  • GitHub Actions ↔ AWS の認証をアクセスキーなしで — OIDC
  • ビルド → ECR push → Task Definition 更新 → Service update → マイグレーション のワークフロー
  • 自動ロールバック — Deployment Circuit Breaker
  • 段階的デプロイ — CodeDeploy blue/green / canary を軽く
  • CodePipeline との比較 — どちらをいつ

全体像 #

git push 1 回で完結する流れ
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 回で回せるようにするのが今回の目標。

1) GitHub OIDC — アクセスキーなしの認証 #

昔のパターンは IAM ユーザー → アクセスキー発行 → GitHub Secrets に保存。危険 — git history に漏れる / キーローテーションの義務 / 追跡が難しい。

OIDC (OpenID Connect) パターンは GitHub がワークフロー実行ごとに 短命トークン (15 分) を発行し、AWS IAM がそのトークンを信頼する形にします。

OIDC の形
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

1 度だけ — IAM OIDC Provider を登録 #

OIDC Provider
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

Thumbprint は GitHub OIDC の SSL 証明書 SHA1。AWS コンソール GUI で自動取得。

IAM Role — Trust Policy #

github-actions-deploy ロールの 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/mainmain ブランチのみ
repo:myorg/blog-api:ref:refs/tags/*タグ push のみ
repo:myorg/blog-api:environment:productionenvironment ゲート通過時のみ
repo:myorg/blog-api:*危険 — どの PR でもこのロールが使えてしまう

本番推奨: environment ゲート + main/tag のみ

Permissions Policy #

デプロイに必要なアクションだけ:

github-actions-deploy 権限
{
  "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 ワークフロー #

.github/workflows/deploy.yml
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: writeOIDC トークン発行権限。抜けると STS AssumeRole が 401
environment: productionGitHub environment ゲート — 手動承認、シークレット分離
aws-actions/amazon-ecs-render-task-definitionベース JSON + 新 image → 新 JSON を生成
aws-actions/amazon-ecs-deploy-task-definitionRegisterTaskDefinition + UpdateService + wait
wait-for-service-stability安定状態まで待機 — 失敗するとステップが fail

3) Deployment Circuit Breaker — 自動ロールバック #

#1 で軽く触れた仕組み。新デプロイが立ち上がらないと 自動的に前の task definition に戻す 機能。

Service に Circuit Breaker を有効化
aws ecs update-service \
  --cluster blog-cluster --service blog-api \
  --deployment-configuration "
    deploymentCircuitBreaker={enable=true,rollback=true},
    maximumPercent=200,
    minimumHealthyPercent=100"

動作の流れ:

  1. 新 task が healthy にならないと ECS がカウント
  2. 一定回数 / 時間内に healthy に到達しないと → デプロイ失敗の判定
  3. rollback=true なら前の task definition に自動復帰

GitHub Actions のステップでは wait-for-service-stability が false を返すのでワークフローも fail。

直接ロールバック #

自動ロールバックが効かないか、事後調査が必要なケース:

前の revision に手動ロールバック
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-deployment

4) 段階的デプロイ — Canary / Blue-Green #

ECS のデフォルトのローリングは 新 task が healthy になると即座にトラフィック を受けます。もう少し保守的な形が要るなら CodeDeploy の出番。

Blue/Green #

Blue/Green の形
Blue (現運用)  ←──── 100% トラフィック
Green の新バージョンを立てる (Blue はそのまま生存)
ALB Listener の Test traffic で Green を検証
Listener の 100% トラフィックを Green に切り替え
Wait タイマー (10~60分) — 問題なければ Blue を終了
                       問題があれば Listener を 1 行で Blue に戻す (即時ロールバック)

利点:

  • 即時ロールバック — Listener を戻すだけ
  • 新バージョンを検証する時間を明示的に確保

欠点:

  • 2 倍のリソース (デプロイ中)
  • ALB Listener パターンが少し複雑 (Test listener + Production listener)
  • ECS Rolling よりセットアップが重い

Canary #

Canary
Linear (10% ずつ 5 分間隔) — 50 分で 100%
Canary (10% → 5 分待ち → 残り 90% を一気に)
AllAtOnce (即時 100% — Blue/Green の最も速い形)

CodeDeploy の deployment configuration 名:

  • CodeDeployDefault.ECSAllAtOnce
  • CodeDeployDefault.ECSLinear10PercentEvery1Minutes
  • CodeDeployDefault.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 ActionsCodePipeline
トリガーpush / PR / scheduleCodeCommit / GitHub / S3 / ECR push
ビルドrunners プール (ホスト/セルフホスト)CodeBuild
デプロイ直接呼び出しまたは actionsCodeDeploy / ECS / CFN / Lambda
価格ホスト実行 分単位 / セルフホスト無料パイプラインあたり $1/月 + CodeBuild
長所コードとワークフローが同じリポジトリ、エコシステム豊富AWS ネイティブ統合、IAM が一貫
短所OIDC セットアップ / シークレット管理が別外部サービスとの統合は弱い

GitHub にコードがあるなら GitHub Actions が自然。会社のセキュリティポリシーでコードが CodeCommit にあるなら CodePipeline。

6) 環境分離 — dev / staging / prod #

1 つのワークフローで環境別に分岐:

.github/workflows/deploy.yml (環境マトリクス)
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 (devprod) に別々の secret / required reviewers / wait timer を設定可能。本番 environment には 2 名承認 + 5 分待機タイマー を入れて誤操作を防ぐ。

7) シークレットと変数の扱い #

どこに置くか
AWS Account IDGitHub vars
クラスタ名 / サービス名GitHub vars または workflow env
DB パスワード / API キーAWS Secrets Manager (#2)
GitHub deploy role ARNGitHub 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-tasksexitCode を確認 + 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 が正常デプロイまでロールバック #

短すぎる health-check 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: truewait-for-minutes が短すぎると、正常デプロイも失敗扱い。保守的に 15~20 分。

まとめ #

今回押さえたこと:

  • OIDC — IAM OIDC Provider + Trust policy の sub パターン、id-token: write 権限
  • 権限ポリシー — ECR / ECS / iam:PassRole の 3 グループ
  • ワークフロー — test → OIDC → ECR push → migration RunTask → Service deploy → wait-stable
  • Circuit Breakerenable=true, rollback=truemaximumPercent / minimumHealthyPercent がローリングの形
  • 手動ロールバック — 前の task definition revision に update-service
  • Blue/Green & Canary — CodeDeploy の ECSLinear10PercentEvery1Minutes など
  • CodePipeline との比較 — コードの置き場所で自然な選択
  • 環境分離 — branch matrix + GitHub environments の承認 / 待機
  • シークレット — アプリは AWS Secrets Manager、CI 自体のトークンだけ GitHub secrets
  • 落とし穴 — OIDC 401、migration 検査抜け、latest タグ、IP 不足、grace period、Stale トークン、stuck stability wait

次回 — IaC #

デプロイは自動になりました。けれども インフラ自体 — VPC / SG / RDS / ALB / ECS — はまだコンソール / CLI で手元にあります。新しい環境をもう一度立てられるでしょうか? 同じ形で?

#4 IaC — Terraform 入門 ではインフラをコードに移します。provider / resource / state の形、S3+DynamoDB backend、モジュールで dev/prod を分離、そして 1 編のインフラを 1 行ずつコード化する流れまで。

X