MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第11回】GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ

    ここまで手作業だった「テスト → イメージの build と push → マイグレーション → ECS の更新」を、GitHub Actions(=GitHub に組み込まれた自動実行の仕組み)で自動化します。AWS への接続には、長期間有効なアクセスキーを使わず、OIDC(=GitHub が発行する「このリポジトリのこのジョブです」という署名付きの証明)で一時的な権限を受け取ります。

    「本番へのデプロイ手順を知っているのが1人だけで、その人が休むと更新できない。しかも AWS のキーを GitHub に登録したままで大丈夫なのか心配…」

    結論から言うと、CI/CD は「プルリクエストで必ずテストを通す」「main に入ったものだけを、決まった順番(マイグレーション → web → worker → scheduler)でデプロイする」の2本に分け、AWS の権限は OIDC で「このリポジトリの、この環境のジョブ」だけに渡します。GitHub に保存するのはロールの ARN などの秘密ではない値だけで、アクセスキーの漏えいや退職者の管理の心配がなくなります。

    前回(第10回:キューワーカーとスケジューラを ECS で動かす)で、web・worker・scheduler のサービスと、マイグレーション用のタスク定義がそろいました。今回はそれを自動で更新する仕組みを作ります。

    執筆環境には AWS アカウントがないため、これまでと同じく terraform apply は行っておらず、GitHub Actions から AWS への実際のデプロイも行っていません。ワークフローの構文チェックと、CI と同じ手順(MySQL でのテスト・フロントのビルド・Terraform の検証)のローカルでの実行、AWS に接続しない plan までを確認しています。

    目次

    CI/CD の全体像

    ワークフローきっかけ内容
    .github/workflows/ci.ymlプルリクエスト、deploy からの呼び出しPHP のテスト(MySQL 8.4)とコード整形のチェック、フロントのビルド、Terraform の fmt・validate
    .github/workflows/deploy.ymlmain へのマージ、手動実行ci → イメージの build と push → マイグレーション → web → worker → scheduler

    AWS 側には、デプロイ専用の IAM ロールを infra/terraform/github_oidc.tf で作ります。

    使う GitHub Actions のアクションと、2025年9月時点のメジャーバージョンは次のとおりです。

    アクション用途
    actions/checkout@v4リポジトリの取得
    shivammathur/setup-php@v2、actions/setup-node@v4、hashicorp/setup-terraform@v3PHP 8.4・Node.js 22・Terraform 1.11 の準備
    aws-actions/configure-aws-credentials@v4OIDC でロールを引き受ける
    aws-actions/amazon-ecr-login@v2ECR へのログイン
    aws-actions/amazon-ecs-render-task-definition@v1タスク定義のイメージだけを差し替える
    aws-actions/amazon-ecs-deploy-task-definition@v2タスク定義の登録、サービスの更新、マイグレーションのタスクの実行

    OIDC でアクセスキーを持たずに AWS に入る

    信頼ポリシーでリポジトリと環境を限定する

    infra/terraform/github_oidc.tf(OIDC プロバイダーと信頼ポリシーの抜粋)

    # OIDC プロバイダーは AWS アカウントに 1 つだけ作れる。既に作ってある場合は create_github_oidc_provider = false にして ARN を渡す
    resource "aws_iam_openid_connect_provider" "github" {
      count = var.create_github_oidc_provider ? 1 : 0
    
      url            = "https://token.actions.githubusercontent.com"
      client_id_list = ["sts.amazonaws.com"]
      # thumbprint_list は省略(GitHub の OIDC は AWS 側が信頼する認証局で検証される)
    }
    
    # 信頼ポリシー:このリポジトリの、この環境(GitHub の environment 名 = var.environment)のジョブだけが引き受けられる
    data "aws_iam_policy_document" "github_deploy_assume" {
      statement {
        actions = ["sts:AssumeRoleWithWebIdentity"]
    
        principals {
          type        = "Federated"
          identifiers = [local.github_oidc_provider_arn]
        }
    
        condition {
          test     = "StringEquals"
          variable = "token.actions.githubusercontent.com:aud"
          values   = ["sts.amazonaws.com"]
        }
    
        # sub の例:repo:your-org/admin-console:environment:stg
        # environment を条件にすると、GitHub 側の environment の保護ルール(main ブランチのみ・承認者)と組み合わせられる
        condition {
          test     = "StringEquals"
          variable = "token.actions.githubusercontent.com:sub"
          values   = ["repo:${var.github_repository}:environment:${var.environment}"]
        }
      }
    }

    GitHub Actions のジョブは、実行のたびに OIDC トークンを発行できます。トークンの sub(=誰のためのトークンか)には、リポジトリ名と、ジョブが使う environment(=GitHub 上の「デプロイ先」の設定)の名前が入ります。AWS はトークンの署名を検証し、信頼ポリシーの条件に合えば、1時間だけ有効な認証情報を渡します。

    一番大事なのは sub の条件です。ここを repo:your-org/* のように広く書くと、同じ組織の別のリポジトリやフォークからもロールを引き受けられてしまいます。この構成では、リポジトリ名と environment 名を完全一致で指定しました。

    📰 出典:アマゾン ウェブ サービスでの OpenID Connect の構成(GitHub Docs)

    デプロイ用ロールの権限

    infra/terraform/github_oidc.tf(権限の抜粋)

      # この環境の app / web リポジトリへの push
      statement {
        sid = "EcrPush"
        actions = [
          "ecr:BatchCheckLayerAvailability",
          "ecr:BatchGetImage",
          "ecr:CompleteLayerUpload",
          "ecr:DescribeImages", # 同じタグのイメージが既にあるかの確認(再実行時)
          "ecr:InitiateLayerUpload",
          "ecr:PutImage",
          "ecr:UploadLayerPart",
        ]
        resources = [for r in aws_ecr_repository.this : r.arn]
      }
    
      # web / worker / scheduler サービスの更新
      statement {
        sid       = "UpdateServices"
        actions   = ["ecs:UpdateService", "ecs:DescribeServices"]
        resources = [aws_ecs_service.web.id, aws_ecs_service.worker.id, aws_ecs_service.scheduler.id]
      }
    
      # マイグレーションのタスクの起動(migrate のタスク定義だけ、このクラスターだけ)
      statement {
        sid       = "RunMigration"
        actions   = ["ecs:RunTask"]
        resources = ["${aws_ecs_task_definition.migrate.arn_without_revision}:*"]
    
        condition {
          test     = "ArnEquals"
          variable = "ecs:cluster"
          values   = [aws_ecs_cluster.main.arn]
        }
      }
    
      # タスク定義に書いたロールを ECS に渡す(ECS のタスク以外には渡せない)
      statement {
        sid       = "PassTaskRoles"
        actions   = ["iam:PassRole"]
        resources = [aws_iam_role.ecs_task_execution.arn, aws_iam_role.ecs_task.arn]
    
        condition {
          test     = "StringEquals"
          variable = "iam:PassedToService"
          values   = ["ecs-tasks.amazonaws.com"]
        }
      }
    
      # (ECR へのログイン、タスク定義の取得・登録、起動したタスクの状態の確認は省略)

    デプロイに必要な操作だけを、この環境のリソースに限って許可しています。ECR へのログインやタスク定義の登録のように、AWS の仕様で対象を絞れない操作だけは * にしました。iam:PassRole は「ECS にロールを渡す」権限で、これを広く許可すると、強い権限のロールをタスクに付けて悪用される経路になります。渡せるロールと渡し先を条件で限定しています。

    CI:プルリクエストでテストを必ず通す

    .github/workflows/ci.yml(PHP のジョブの抜粋)

    jobs:
      php:
        runs-on: ubuntu-24.04
        services:
          # 本番と同じ MySQL 8.4 でテストする(パスワードは CI 専用のダミー値)
          mysql:
            image: mysql:8.4
            env:
              MYSQL_DATABASE: testing
              MYSQL_ROOT_PASSWORD: ci-only-password
            ports:
              - 3306:3306
            options: >-
              --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci-only-password"
              --health-interval=5s --health-timeout=5s --health-retries=20
        env:
          # phpunit.xml の sqlite より、ここで設定した環境変数が優先される
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: ci-only-password
        steps:
          - uses: actions/checkout@v4
          - uses: shivammathur/setup-php@v2
            with:
              php-version: "8.4"
              extensions: pdo_mysql, intl, bcmath, zip, pcntl
              coverage: none
              tools: composer:v2
          - name: Install PHP dependencies
            run: composer install --no-interaction --no-progress --prefer-dist
          - name: Prepare .env
            run: |
              cp .env.example .env
              php artisan key:generate
          - name: Code style (Pint)
            run: vendor/bin/pint --test
          - name: Tests
            run: php artisan test

    ローカルでは速さを優先して SQLite のメモリ上の DB でテストしていますが、CI では本番と同じ MySQL 8.4 を「サービスコンテナ」として起動し、その上でテストします。文字列の比較や日付の扱いなど、DB の違いで結果が変わる部分を本番前に見つけるためです。phpunit.xml の <env> は、すでに設定されている環境変数を上書きしないので、ジョブの env で MySQL に切り替えられます。

    このほか、frontend ジョブで npm ci と npm run build、terraform ジョブで terraform fmt -check -recursive・init -backend=false・validate を実行します。GitHub のブランチ保護ルールで「CI が成功しないとマージできない」ようにしておくと、テストが落ちたコードが main に入らなくなります。

    deploy:マイグレーションから順番に更新する

    イメージの build と push

    .github/workflows/deploy.yml(前半の抜粋)

    on:
      push:
        branches: [main]
      workflow_dispatch:
    
    permissions:
      contents: read
      id-token: write # OIDC トークンの発行に必要
    
    # 同じ環境へのデプロイは 1 つずつ(途中で打ち切らない)
    concurrency:
      group: deploy-stg
      cancel-in-progress: false
    
    jobs:
      ci:
        uses: ./.github/workflows/ci.yml
    
      deploy:
        needs: ci
        runs-on: ubuntu-24.04
        # environment の保護ルール(デプロイできるブランチ・承認者)がここで効く。ロールの信頼ポリシーもこの名前で絞っている
        environment: stg
        env:
          IMAGE_TAG: ${{ github.sha }}
        steps:
          - uses: actions/checkout@v4
    
          - name: Configure AWS credentials (OIDC)
            uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
              aws-region: ${{ vars.AWS_REGION }}
    
          - name: Login to Amazon ECR
            id: ecr
            uses: aws-actions/amazon-ecr-login@v2
    
          - name: Build and push images
            id: images
            env:
              REGISTRY: ${{ steps.ecr.outputs.registry }}
              NAME_PREFIX: ${{ vars.NAME_PREFIX }}
            run: |
              APP_IMAGE="$REGISTRY/$NAME_PREFIX-app:$IMAGE_TAG"
              WEB_IMAGE="$REGISTRY/$NAME_PREFIX-web:$IMAGE_TAG"
              # ECR はタグの上書きを禁止している(第9回)。同じコミットの再実行では build と push を飛ばす
              if aws ecr describe-images --repository-name "$NAME_PREFIX-app" --image-ids imageTag="$IMAGE_TAG" > /dev/null 2>&1 \
                && aws ecr describe-images --repository-name "$NAME_PREFIX-web" --image-ids imageTag="$IMAGE_TAG" > /dev/null 2>&1; then
                echo "Images for $IMAGE_TAG already exist. Skip build and push."
              else
                docker build --target app -t "$APP_IMAGE" .
                docker build --target web -t "$WEB_IMAGE" .
                docker push "$APP_IMAGE"
                docker push "$WEB_IMAGE"
              fi
              echo "app=$APP_IMAGE" >> "$GITHUB_OUTPUT"
              echo "web=$WEB_IMAGE" >> "$GITHUB_OUTPUT"

    permissions の id-token: write が、OIDC トークンを発行するための設定です。secrets は1つも使っていません。vars.AWS_DEPLOY_ROLE_ARN などは、GitHub の environment「stg」に登録した Variables(=秘密ではない設定値)で、登録する値は Terraform の出力 github_actions_variables にまとめてあります。

    イメージのタグにはコミット ID(github.sha)を使います。第9回で ECR のタグの上書きを禁止したので、どのコミットのコードが動いているかを、ECS のタスク定義から必ずたどれます。その代わり、マイグレーションの一時的な失敗などで同じコミットのワークフローを再実行すると push が拒否されるため、同じタグのイメージが既にあれば build と push を飛ばすようにしています。

    マイグレーション → web → worker → scheduler

    .github/workflows/deploy.yml(後半の抜粋)

          # Terraform が作った最新のタスク定義を取得し、イメージだけを差し替える(環境変数・secrets などは Terraform の定義のまま)
          - name: Download current task definitions
            env:
              NAME_PREFIX: ${{ vars.NAME_PREFIX }}
            run: |
              for name in web worker scheduler migrate; do
                aws ecs describe-task-definition --task-definition "$NAME_PREFIX-$name" \
                  --query taskDefinition > "task-definition-$name.json"
              done
    
          - name: Render migrate task definition
            id: migrate-td
            uses: aws-actions/amazon-ecs-render-task-definition@v1
            with:
              task-definition: task-definition-migrate.json
              container-name: migrate
              image: ${{ steps.images.outputs.app }}
    
          # 失敗したら(終了コードが 0 以外なら)ここで止まり、サービスは古いイメージのまま
          - name: Run database migration
            uses: aws-actions/amazon-ecs-deploy-task-definition@v2
            with:
              task-definition: ${{ steps.migrate-td.outputs.task-definition }}
              cluster: ${{ vars.ECS_CLUSTER }}
              run-task: true
              run-task-launch-type: FARGATE
              run-task-subnets: ${{ vars.PRIVATE_SUBNET_IDS }}
              run-task-security-groups: ${{ vars.APP_SECURITY_GROUP_ID }}
              run-task-assign-public-IP: DISABLED
              wait-for-task-stopped: true
    
          # (web の php・nginx の2つのコンテナのイメージを差し替える render は省略)
    
          - name: Deploy web
            uses: aws-actions/amazon-ecs-deploy-task-definition@v2
            with:
              task-definition: ${{ steps.web-td.outputs.task-definition }}
              cluster: ${{ vars.ECS_CLUSTER }}
              service: web
              wait-for-service-stability: true
    
          # (worker・scheduler も同じく render → deploy)

    流れは次のとおりです。

    1. 各タスク定義の最新版を取得し、コンテナのイメージだけを今回のタグに差し替える
    2. migrate のタスクを1回起動し、終了を待つ。終了コードが 0 以外なら、ここでワークフローが失敗し、サービスは更新されない
    3. web を更新し、新しいタスクが ALB のヘルスチェックに通って安定するまで待つ
    4. worker、scheduler の順に更新する

    マイグレーションを先に流すため、新しいテーブル構造でも古いコードが動くようにマイグレーションを書く必要があります。たとえば列の名前を変えるときは「新しい列を追加 → コードを切り替え → 次のデプロイで古い列を削除」のように、2回のデプロイに分けます。

    📰 出典:Amazon ECS “Deploy Task Definition” Action for GitHub Actions(GitHub)

    Terraform と CI の役割分担

    CI がタスク定義の新しいリビジョン(=版)を登録してサービスを切り替えるので、Terraform が次の apply で古いリビジョンに戻さないようにします。

    infra/terraform/ecs_web.tf(サービスに追加。worker・scheduler も同じ)

      # 第11回から、イメージの更新(新しいタスク定義のリビジョンへの切り替え)は GitHub Actions が行う。
      # Terraform が古いリビジョンに戻さないよう、サービスのタスク定義の変更は無視する
      lifecycle {
        ignore_changes = [task_definition]
      }

    役割は「環境変数・secrets・CPU・メモリなどのタスク定義の中身は Terraform」「イメージの差し替えとサービスの切り替えは CI」です。Terraform で環境変数を変えると新しいリビジョンが登録されますが、サービスに反映されるのは次の CI のデプロイのとき(CI が最新のリビジョンを取得してイメージを差し替えるため)です。すぐ反映したい場合は、deploy ワークフローを手動実行(workflow_dispatch)します。

    GitHub 側の設定:environment の保護ルール

    コードの外で、GitHub のリポジトリ設定に次の設定を行います。

    設定内容目的
    environment「stg」の Deployment branchesmain だけに限定作業中のブランチから stg にデプロイできないようにする
    environment「stg」の Variablesterraform output github_actions_variables の値を登録ロールの ARN・クラスター名・サブネットなど(秘密ではない値)
    本番用の environment「prod」Required reviewers(承認者)を設定承認者が OK を出すまでデプロイのジョブが始まらない

    ロールの信頼ポリシーで environment 名を条件にしているので、ワークフローのファイルを書き換えて environment を外したジョブは、そもそもロールを引き受けられません。「誰が本番に出せるか」を GitHub の承認ルールと AWS の信頼ポリシーの両方で守る形です。

    📰 出典:デプロイ環境の管理(GitHub Docs)

    動作確認の方法

    筆者の環境で確認したことは次のとおりです。

    • ワークフローの構文を actionlint(GitHub Actions のワークフローの静的チェックツール)で確認し、エラーなし
    • CI と同じ手順をローカルの Docker で実行:MySQL 8.4 に接続して php artisan test が 68件成功、vendor/bin/pint --test が成功、npm ci && npm run build が成功、Terraform の fmt -check・init -backend=false・validate が成功
    • AWS に接続しない plan で Plan: 70 to add(第10回の67件に、OIDC プロバイダー・デプロイ用ロール・その権限の3件)。既存の OIDC プロバイダーを使う設定(create_github_oidc_provider = false)では 69件。信頼ポリシーの sub が repo:your-org/admin-console:environment:stg になっている
    • 使っているアクションのメジャーバージョンが2025年9月時点で公開済みであることを、各リポジトリのリリース履歴で確認

    GitHub Actions から AWS への実際のデプロイ(OIDC でロールを引き受けられること、ECR に :<コミット ID> のイメージが入ること、サービスが新しいリビジョンで安定すること)と、別のリポジトリや environment なしのジョブからはロールを引き受けられないことは、AWS アカウントがないため確認できていません。導入時は、わざと条件に合わないジョブを実行して拒否されることを必ず確かめてください。

    つまずきやすい点・セキュリティ上の注意

    • sub の条件を広げない:StringLike とワイルドカードで広げると、フォークやほかのリポジトリからの引き受けを許してしまうことがあります。
    • プルリクエストのジョブにロールを渡さない:CI(ci.yml)には AWS の権限を与えていません。外部からのプルリクエストでも安全に実行できるようにするためです。
    • イメージの CPU アーキテクチャ:GitHub のホストランナー(ubuntu-24.04)は x86_64 なので、第9回のタスク定義(X86_64)とそろいます。
    • マイグレーションの互換性:前述のとおり、古いコードと新しいテーブル構造が同時に動く時間があります。列の削除や名前の変更は2回に分けます。
    • デプロイの時間帯:第10回で触れたとおり、scheduler の入れ替え中は定期実行が止まります。締め処理の時刻を避けるルールは、ワークフローではなく運用で決めます。

    発注者向けメモ:自動化の次に決めるのは「誰がいつ本番に出せるか」

    デプロイの自動化で、手順の属人化と作業ミスは大きく減ります。一方で、ボタン1つで本番が変わるようになるので、承認のルールを決めておくことが大切です。

    決めること例
    本番に出す前の承認者開発会社の責任者+発注側の担当者の2名
    デプロイしてよい時間帯平日の業務時間内。締め処理の前後は避ける
    検証環境での確認stg で動作確認してから prod へ(同じイメージを使う)
    失敗したときの連絡ワークフローが失敗したら誰に通知するか

    開発会社への質問例です。

    • 「AWS のアクセスキーを GitHub や CI に保存していますか?保存しているなら、誰が管理し、いつ更新していますか?」
    • 「本番へのデプロイは誰が承認しますか?承認の記録は残りますか?」
    • 「デプロイが途中で失敗したら、システムはどういう状態になりますか?」
    • 「データベースの変更を含むリリースは、どのように進めますか?」

    まとめと次回予告

    第11回では、テストからデプロイまでを GitHub Actions で自動化しました。

    • AWS のアクセスキーは使わず、OIDC で「このリポジトリの、この environment のジョブ」だけにロールを渡す
    • デプロイ用ロールの権限は、この環境の ECR・サービス・migrate のタスク定義に限定する
    • CI は本番と同じ MySQL 8.4 でテストし、Terraform の構文もチェックする
    • デプロイはマイグレーション → web → worker → scheduler の順で、失敗したらそこで止める
    • タスク定義の中身は Terraform、イメージの切り替えは CI という役割分担にする

    次回は連載の最終回「運用編:ログ・監視・スケーリング・切り戻し」です。CloudWatch のアラーム、オートスケーリング、ECS Exec での調査、失敗したデプロイの自動切り戻しを追加し、12回の内容と本番運用までの残作業をまとめます。

    この連載の記事一覧

    この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次