目次
24 章

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 一度で終わる流れ
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 がそのトークンを信頼するようにします。

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

一度だけ — 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/*タグプッシュのみ
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安定状態まで待機 — 失敗時に step fail

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

第22章 インフラの骨格 で少し扱った機能です。新しいデプロイが立ち上がらないと、自動で以前の 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 rolling は、新しい task が healthy になると即座にトラフィックを受けます。より保守的な形が必要なら CodeDeploy が役割を果たします。

Blue/Green #

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 #

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 #

一つのワークフローで環境ごとに分岐します。

.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 またはワークフロー env
DB パスワード / API キーAWS Secrets Manager(第23章)
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 が正常なデプロイまでロールバック #

短すぎるヘルスチェックの 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分に置きます。

練習問題 #

  1. OIDC が IAM ユーザーのアクセスキー方式より安全な理由を、§「GitHub OIDC」を根拠に二つ書いてみてください。Trust Policy の sub パターンを repo:myorg/blog-api:* に置くとなぜ危険かも一行で説明してみてください。
  2. マイグレーション RunTask の段階で終了コードを検査しないとどんな事故が起きるかを §「落とし穴 2」を根拠に説明し、ワークフロー YAML のどの部分がこの検査を担うかを指してみてください。
  3. 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章のインフラを一行ずつコード化する流れまで扱います。

X