ここまで手作業だった「テスト → イメージの 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.yml | main へのマージ、手動実行 | 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@v3 | PHP 8.4・Node.js 22・Terraform 1.11 の準備 |
aws-actions/configure-aws-credentials@v4 | OIDC でロールを引き受ける |
aws-actions/amazon-ecr-login@v2 | ECR へのログイン |
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)
流れは次のとおりです。
- 各タスク定義の最新版を取得し、コンテナのイメージだけを今回のタグに差し替える
- migrate のタスクを1回起動し、終了を待つ。終了コードが 0 以外なら、ここでワークフローが失敗し、サービスは更新されない
- web を更新し、新しいタスクが ALB のヘルスチェックに通って安定するまで待つ
- 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 branches | main だけに限定 | 作業中のブランチから stg にデプロイできないようにする |
| environment「stg」の Variables | terraform output github_actions_variables の値を登録 | ロールの ARN・クラスター名・サブネットなど(秘密ではない値) |
| 本番用の environment「prod」 | Required reviewers(承認者)を設定 | 承認者が OK を出すまでデプロイのジョブが始まらない |
ロールの信頼ポリシーで environment 名を条件にしているので、ワークフローのファイルを書き換えて environment を外したジョブは、そもそもロールを引き受けられません。「誰が本番に出せるか」を GitHub の承認ルールと AWS の信頼ポリシーの両方で守る形です。
動作確認の方法
筆者の環境で確認したことは次のとおりです。
- ワークフローの構文を 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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第1回】全体像と開発環境:Laravel 12+Vue 3 スターターキットを Docker で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第2回】顧客マスタの一覧と登録:FormRequest でバリデーションする
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第3回】ロールと Policy:誰が何をできるかをコードで決める
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第4回】案件管理と一覧画面の作り込み:検索・並べ替え・ページング
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第5回】請求と非同期処理:キューとスケジューラをローカルで動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第6回】本番用コンテナイメージを作る:マルチステージ Dockerfile
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第7回】Terraform の土台とネットワーク:state 管理と VPC
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第8回】RDS for MySQL と秘密情報:パスワードをコードに書かない
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第9回】ECR・ECS on Fargate・ALB:管理画面をインターネットに公開する
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第10回】キューワーカーとスケジューラを ECS で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第11回】GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ(この記事)
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第12回】運用編:ログ・監視・スケーリング・切り戻し










コメント
コメント一覧 (1件)
[…] 前回(第11回:GitHub Actions で CI/CD)では、OIDC を使ったテストとデプロイの自動化を作りました。 […]