- 1 PR駆動TerraformCI/CD — GitHub Actions+OIDCで複数人レビューフローを構築
- 1.1 1. この記事について
- 1.2 2. GitHub Actions × OIDC 基礎
- 1.3 3. OIDCプロバイダーとIAMロールのセットアップ
- 1.4 4. リポジトリ・ブランチ設定
- 1.5 5. plan on PR ワークフロー構築
- 1.6 6. apply on merge ワークフロー構築
- 1.7 7. CODEOWNERS・ブランチ保護・レビュープロセス強化
- 1.8 8. 監査ログ・通知設定とまとめ
PR駆動TerraformCI/CD — GitHub Actions+OIDCで複数人レビューフローを構築
AWS×Terraform 複数人開発シリーズ
- 第1弾(公開済み): 複数人開発の基盤 — state管理・lock・drift対策
- 第2弾(本記事): PR駆動CI/CD — GitHub Actions+OIDCで複数人レビューフローを構築
- 第3弾(公開済み): AWS CodePipeline×CodeBuildで構築するTerraform CI/CD
関連シリーズ(前提知識):
Git/GitHub × Terraform 実践シリーズ(全5弾)も合わせてどうぞ:
1. この記事について
1-1. 本シリーズの位置付け
前弾(第1弾: state管理・lock・drift対策)では、複数人Terraform開発でstate競合・apply競合・driftが発生する原因と、S3+DynamoDBによるリモートstateバックエンドで問題を防ぐ基盤を構築した。しかしそれだけでは「誰でも手元から apply できる」状態は変わっていない。
本シリーズ「AWS×Terraform 複数人開発運用編」は全3弾構成で、チーム開発特有の課題を段階的に解消していく。
| 弾 | テーマ | 主な内容 | 対象 |
|---|---|---|---|
| 第1弾(公開済み) | state管理・lock・drift対策 | S3+DynamoDBバックエンド・ロック体験・drift検知 | インフラ・DevOpsエンジニア全般 |
| 第2弾(本記事) | PR駆動CI/CD | GitHub Actions+OIDC・Plan/Apply Role分離・PRコメント自動投稿 | GitHub中心のチーム |
| 第3弾(公開済み) | CodePipeline×CodeBuild | AWSネイティブなTerraform CI/CDパイプライン | AWSネイティブ派のチーム |
本記事では GitHub Actions + OIDC を採用する。その理由は3点ある。
- アクセスキー不要: AWS_ACCESS_KEY_IDをSecretsに保存する旧来の方式を廃し、一時的な認証トークンを使う
- 最小権限設計: PlanとApplyでIAM Roleを分離し、PR段階では読み取りのみ、mainマージ後にのみリソース変更を許可する
- GitHubネイティブ: PRコメント・ブランチ保護・CODEOWNERSなど、GitHubの既存ワークフローと自然に統合できる
1-2. 対象読者・前提知識
対象読者
- Terraform×GitHubを使い始めており、CI/CDを導入してチーム開発を安全にしたいエンジニア
- 個人または少人数で
terraform applyを手動実行しているが、自動化・審査フローを導入したいチーム - GitHub ActionsとAWSを連携させたいが、OIDCの仕組みがよくわからないインフラ・DevOpsエンジニア
前提知識
本記事は以下の知識を前提としている。未習得の場合は先にリンク先を参照してほしい。
| 分野 | 必要な知識 | 参照先 |
|---|---|---|
| Terraform | init / plan / apply の実行経験・リモートstateの概念 | Terraform実践 / 第1弾 |
| GitHub | PR・マージフローの理解・Actionsの基本構造(on: / jobs: / steps:) | GitHub入門 / ブランチ戦略 |
| AWS IAM | IAM Role・信頼ポリシー・最小権限の概念 | AWS公式ドキュメント |
| リモートstate | S3+DynamoDBバックエンドの設定方法 | 第1弾 |
前提環境
- 第1弾で構築したS3+DynamoDBバックエンドが存在すること(または本記事内の手順で新規作成可能)
- GitHubリポジトリへのAdmin権限があること(ブランチ保護・OIDC設定に必要)
1-3. この記事で学べること
本記事を通じて、以下のスキルと知識を習得できる。
- OIDCキーレス認証: GitHub ActionsからAWSへアクセスキーを使わずに認証する仕組みと設定方法
- Plan/Apply Role分離: PR時(読み取り専用)とmainマージ時(変更許可)でIAM Roleを使い分ける最小権限設計
- plan on PR: プルリクエスト作成・更新時に自動で
terraform planを実行し、結果をPRコメントに投稿する - apply on merge: mainブランチへのマージ時のみ
terraform applyを実行する安全なフロー - PRコメント自動投稿: plan結果をGitHub PRに自動コメントし、レビュアーが変更内容を確認できるようにする
- CODEOWNERS設定: Terraform変更に対して特定チームの承認を必須にする方法
- ブランチ保護ルール: mainへの直接pushを禁止し、必ずPR経由にする設定
- concurrency group: 同一PRへの並行ワークフロー実行を防ぎ、state競合を防止する設定
- 監査ログ: 誰がいつapplyを実行したかをGitHub Actions履歴とAWS CloudTrailで追跡する方法
1-4. 必要なもの
ハンズオンを進めるにあたり、以下の環境を用意しておくこと。
AWSアカウント
- AWSアカウント(IAM Identity Providerの作成権限を持つユーザー)
- アカウントID: 本記事では
123456789012を使用(実際のアカウントIDに読み替えること)
ローカル環境
# バージョン確認
terraform version
# → Terraform v1.5.0 以上を推奨
aws --version
# → aws-cli/2.x 以上
git --version
# → git 2.x 以上
GitHubアカウントとリポジトリ
- GitHubアカウントとリポジトリ(本記事では
myorg/terraform-repoを使用) - RepositoryのAdmin権限(ブランチ保護・Environments設定に必要)
Terraform backend(S3+DynamoDB)
第1弾で構築したバックエンドをそのまま使用できる。未構築の場合は以下のbackend.tfを参考に新規作成すること。
本記事では partial configuration を推奨する。backendの接続情報(バケット名・テーブル名・リージョン)をTerraformコードに直接書かず、CI実行時に -backend-config オプションで渡す方式で、環境ごとの切り替えが容易になる。
# backend.tf(partial configuration — 接続情報は外部から渡す)
terraform {
backend "s3" {}
}
CI実行時は以下のように渡す(詳細はSection 4で解説)。
terraform init \
-backend-config="bucket=myorg-terraform-state" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=ap-northeast-1" \
-backend-config="dynamodb_table=terraform-state-lock"
1-5. ハンズオン全体アーキテクチャ
本記事で構築するCI/CDフローの全体像を以下に示す。
┌─────────────────────────────────────────────────────────────────────┐
│GitHub Repository │
│(myorg/terraform-repo) │
││
│ feature/* ──────── Pull Request ──────── main │
││ │ │ │
││ ┌────────┴────────┐ │ │
││ │ PR イベント │ │ merge イベント │
││ │ (plan workflow) │ │ (apply workflow)│
└──────┼───────────┴────────┬────────┘────────┼───────────────────────┘
│ │ │
│ ▼ ▼
│ ┌────────────────┐ ┌────────────────┐
│ │ GitHub Actions │ │ GitHub Actions │
│ │ terraform │ │ terraform │
│ │ plan │ │ apply │
│ └───────┬────────┘ └───────┬────────┘
│ │ │
│ ┌───────────┴──────────────────┘
│ │OIDC Token Exchange
│ ▼
│ ┌──────────────────────────────────────────────┐
│ │ AWS IAM │
│ │ │
│ │ ┌───────────────────┐ ┌─────────────────┐ │
│ │ │ TerraformPlanRole│ │TerraformApplyRole│ │
│ │ │ (読み取り専用)│ │ (変更許可) │ │
│ │ │ PR時のみ発行 │ │ mainマージ時のみ │ │
│ │ └────────┬──────────┘ └────────┬────────┘ │
│ └───────────┼──────────────────────┼──────────┘
│ │ │
│ ▼ ▼
│ ┌───────────────────────────────────────────────┐
│ │ AWS │
│ │ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ S3: myorg-terraform-state │ │
│ │ │ DynamoDB: terraform-state-lock│ │
│ │ └──────────────────────────────────────┘ │
│ │ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ 管理対象リソース(EC2, RDS, etc.)│ │
│ │ └──────────────────────────────────────┘ │
│ └───────────────────────────────────────────────┘
│
▼
PRコメントにplan結果を自動投稿
→ レビュアーが変更内容を確認 → Approve → merge → apply
2つのワークフローフロー
【PRフロー(plan)】
feature/* ブランチで変更 → PR作成/更新
→ GitHub Actions 起動
→ OIDC で TerraformPlanRole を取得
→ terraform init / plan 実行
→ plan結果をPRコメントに投稿
→ レビュアーが確認・Approve
→ main マージへ
【mergeフロー(apply)】
main ブランチへマージ
→ GitHub Actions 起動
→ OIDC で TerraformApplyRole を取得
→ terraform init / apply 実行
→ 実際のAWSリソースが変更される
→ GitHub Actions 履歴 + CloudTrail に監査ログ
2. GitHub Actions × OIDC 基礎
2-1. なぜOIDCか — アクセスキー管理の限界
Terraform CI/CDを構築する際、GitHub ActionsからAWSに認証する方法として従来は以下のような方式が使われてきた。
【従来方式: アクセスキーをSecretsに保存】
AWS IAMユーザー
↓
AccessKeyId + SecretAccessKey を発行
↓
GitHub Repository Secrets に保存
↓
Workflowで環境変数として参照
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
この方式には以下のリスクがある。
| リスク | 内容 |
|---|---|
| 長期有効な認証情報 | アクセスキーは手動でローテーションしない限り永続的に有効 |
| 漏洩リスク | Secretsはリポジトリにアクセスできる人全員が間接的に利用可能 |
| 最小権限の形骸化 | 1つのキーで複数のワークフローを兼用すると権限を絞れない |
| ローテーション管理 | 定期的な更新が必要だが、忘れやすく運用負荷が高い |
| fork PRの危険性 | publicリポジトリではforkからのPRでSecretsが漏洩するリスク |
OIDCによる解決策
OIDC(OpenID Connect)フェデレーション認証を使うと、アクセスキーを一切保存せず、ワークフロー実行時に一時的なトークンをAWSから取得できる。
【OIDC方式: キーレス認証フロー】
GitHub Actions ワークフロー実行
↓
GitHub が署名付き JWT トークンを発行
(有効期限: ワークフロー実行中のみ)
↓
GitHub Actions が AWS STS に AssumeRoleWithWebIdentity リクエスト
↓
AWS が JWT トークンを検証
- 発行者: token.actions.githubusercontent.com
- クレーム: リポジトリ名・ブランチ・ジョブなどを確認
↓
検証OKなら一時的なIAM Role認証情報を返却
(AccessKeyId + SecretAccessKey + SessionToken — 有効期限1時間)
↓
GitHub Actions がその認証情報でAWSリソースにアクセス
↓
ワークフロー終了 → 認証情報は自動的に無効化
OIDCにより達成できることを整理する。
| 観点 | アクセスキー方式 | OIDC方式 |
|---|---|---|
| 認証情報の有効期限 | 永続(手動ローテーション要) | ワークフロー実行中のみ(自動失効) |
| Secrets管理 | AWS_ACCESS_KEY_IDなどを保存 | 不要 |
| 漏洩時のリスク | キーが有効な間は悪用可能 | 短命トークンのため実害が限定的 |
| 最小権限 | 1ユーザー = 1キーで兼用になりがち | ワークフローごとにRoleを使い分けられる |
| 設定の複雑さ | 低(Secretsに貼るだけ) | やや高(IAMプロバイダーとRole設定が必要) |
| AWS推奨 | 非推奨(長期認証情報) | 推奨 |
2-2. OIDC認証の仕組みをコンソールで確認
OIDCによるGitHub Actions→AWS認証を動作させるには、まずAWS IAMにGitHubのOIDCプロバイダーを登録する必要がある。AWSコンソールでの確認方法と設定内容を解説する。
IAM Identity Providerの確認
AWSコンソールで IAM → 左メニューの Identity providers を開く。GitHub OIDCプロバイダーが登録済みの場合、以下のエントリが表示される。
Provider URL: https://token.actions.githubusercontent.com
Provider type: OpenID Connect
Audience: sts.amazonaws.com
初めて設定する場合は Add provider ボタンから以下の情報を入力する。
Provider type: OpenID Connect
Provider URL: https://token.actions.githubusercontent.com
※ 「Get thumbprint」をクリックして自動取得
Audience: sts.amazonaws.com
AWSはこのプロバイダーURLに対してトークン検証リクエストを送り、GitHubが発行したJWTトークンの署名を確認する仕組みになっている。
信頼ポリシーのConditionを理解する
IAM RoleのTrust Policy(信頼ポリシー)には、どのGitHub Actionsワークフローからのリクエストを許可するかを Condition で制御する。これが最も重要な設定であり、ここを間違えると意図しないリポジトリやブランチからの認証を許してしまう。
{
"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/terraform-repo:*"
}
}
}
]
}
各クレームの意味を確認する。
| クレーム | 値 | 意味 |
|---|---|---|
aud (audience) | sts.amazonaws.com | このトークンの対象サービス。STS以外からのリクエストを弾く |
sub (subject) | repo:myorg/terraform-repo:* | リポジトリ名のフィルタ。* はすべてのブランチ・イベント |
sub (subjectをブランチ限定) | repo:myorg/terraform-repo:ref:refs/heads/main | mainブランチからのみ許可(Apply Role向け) |
Plan RoleとApply Roleでsubjectを使い分ける
TerraformPlanRole の sub:
"repo:myorg/terraform-repo:*"
→ すべてのブランチ・PRからplanを許可
TerraformApplyRole の sub:
"repo:myorg/terraform-repo:ref:refs/heads/main"
→ mainブランチへのpush(マージ完了後のワークフロー)のみapplyを許可
この sub クレームの絞り込みが 「PR段階では変更不可、mainマージ後のみ変更可」 というフローの安全弁になる。
GitHub側での permissions: id-token: write の役割
GitHub Actionsのワークフロー内で id-token: write 権限を付与しないと、JWTトークンを取得できない。デフォルトでは無効になっているため、明示的に宣言が必要だ。
permissions:
id-token: write # OIDC トークン取得に必要
contents: read # リポジトリのコードを読む
pull-requests: write # PRコメント投稿に必要
id-token: write を宣言することで、GitHub Actionsのランナーが ACTIONS_ID_TOKEN_REQUEST_URL と ACTIONS_ID_TOKEN_REQUEST_TOKEN 環境変数を通じてGitHubのOIDCエンドポイントにアクセスし、JWTトークンを取得できるようになる。
2-3. Plan Role と Apply Role — 最小権限設計
本記事では「planは誰でも(どのブランチでも)実行できるが、applyはmainマージ時のみ」という設計を採用する。これを実現するために、2つのIAM Roleを使い分ける。
TerraformPlanRole(読み取り専用)
PR段階で terraform plan を実行するためのRole。インフラに対して変更を加えられないよう、読み取り系の権限のみ付与する。
設計方針:
– S3バックエンドへの読み取りアクセス(state取得)
– DynamoDBへのロック取得・解放(plan中の競合防止)
– 管理対象リソースへのDescribe/List/Get系権限(差分計算に必要)
– Create/Update/Delete系権限は付与しない
TerraformPlanRole に付与するポリシー概要:
S3 (バックエンド):
- s3:GetObject → state ファイル取得
- s3:ListBucket→ バケット内確認
- s3:HeadObject→ state ファイル存在確認
DynamoDB (ロック):
- dynamodb:GetItem→ ロック状態確認
- dynamodb:PutItem→ ロック取得
- dynamodb:DeleteItem → ロック解放
EC2 / その他 (Describe 系のみ):
- ec2:DescribeInstances
- ec2:DescribeSecurityGroups
- ec2:DescribeVpcs
※ 管理対象リソースに応じて追加
TerraformApplyRole(変更許可)
mainブランチへのマージ後に terraform apply を実行するためのRole。実際のリソース作成・変更・削除を行うため、必要な権限を付与する。
設計方針:
– Planで必要なすべての権限を含む
– 管理対象リソースへのCreate/Update/Delete権限を追加
– 権限は管理対象のAWSサービスに絞る(AdministratorAccessは避ける)
Role分離のメリット
| 観点 | Role分離なし | Role分離あり(本記事の方式) |
|---|---|---|
| PR段階での誤apply | 発生しうる | 不可能(読み取り権限のみ) |
| 認証情報漏洩時のリスク | applyまで可能 | PR用ならplan止まり |
| 最小権限の原則 | 1 Role で全操作 = 過剰権限 | 操作ごとに適切な権限 |
| 監査 | どのワークフローがapplyしたか不明確 | Role名でplan/applyを判別可能 |
| コンプライアンス | 要件を満たしにくい | 変更操作を明確に制限・記録できる |
Conditionによる発火制限
信頼ポリシーの Condition で、どのGitHubイベントから各Roleを使えるかを制限する。
TerraformPlanRole:
Condition:
sub: "repo:myorg/terraform-repo:*"
→ pull_requestイベント・pushイベント問わず許可
→ すべてのブランチからplanを実行可能
TerraformApplyRole:
Condition:
sub: "repo:myorg/terraform-repo:ref:refs/heads/main"
→ mainブランチへのpush(マージ完了後のワークフロー)のみ許可
→ feature/* ブランチからのapplyは不可能
この設計により、たとえワークフローファイルを改ざんしてfeatureブランチからapplyを試みても、TerraformApplyRole の信頼ポリシーがそのリクエストを拒否する。コードではなくIAMレベルでの制御がセキュリティの核心となる。
2-4. Terraform backendとの接続確認
CI/CDパイプラインでTerraformを実行するとき、ローカルと同様に terraform init でbackendへの接続が必要になる。ここでの設定ミスはCI実行の最初の段階で失敗するため、事前に確認しておく。
partial configurationを推奨する理由
Terraformのbackend設定には完全なハードコードとpartial configurationの2方式がある。
# NG: 完全ハードコード(環境ごとにファイルを書き換える必要がある)
terraform {
backend "s3" {
bucket= "myorg-terraform-state"
key= "terraform.tfstate"
region= "ap-northeast-1"
dynamodb_table = "terraform-state-lock"
}
}
# OK: partial configuration(接続情報を外部から渡す)
terraform {
backend "s3" {}
}
partial configurationを採用する理由:
– 同じコードでdev / stg / prd 環境のstateを切り替えられる
– バケット名などの機密情報をコードに書かずに済む
– GitHub ActionsのSecrets・Variables・Environmentsと組み合わせやすい
GitHub Actionsからbackendへのアクセス権限確認チェックリスト
CIが初めて失敗するときの多くはbackendへのアクセス権限不足が原因だ。事前に以下を確認しておく。
□ S3バケット (myorg-terraform-state) が存在するか
→ AWSコンソール > S3 で確認
□ DynamoDBテーブル (terraform-state-lock) が存在するか
→ AWSコンソール > DynamoDB > Tables で確認
□ TerraformPlanRole に S3/DynamoDB の必要な権限があるか
→ IAM > Roles > TerraformPlanRole > Permissions タブ で確認
□ TerraformApplyRole に S3/DynamoDB の必要な権限があるか
→ IAM > Roles > TerraformApplyRole > Permissions タブ で確認
□ S3バケットのバケットポリシーで Plan/Apply Role からのアクセスが拒否されていないか
→ S3 > バケット > Permissions > Bucket policy で確認
□ S3バケットの Block Public Access が有効になっているか(セキュリティ確認)
→ S3 > バケット > Permissions > Block public access で確認
□ OIDC Provider が IAM に登録されているか
→ IAM > Identity providers で "token.actions.githubusercontent.com" が存在するか確認
□ 各 Role の信頼ポリシーの sub クレームにリポジトリ名が正しく設定されているか
→ IAM > Roles > (Role名) > Trust relationships タブ で確認
→ "repo:myorg/terraform-repo:*" の myorg と terraform-repo が実際のものと一致しているか
次のSectionでは、実際にIAMリソースをTerraformで構築し、GitHub Actionsワークフローの全体像を作り上げていく。
3. OIDCプロバイダーとIAMロールのセットアップ
GitHub ActionsからAWSに対してキーレス認証を実現するには、AWSにOIDCプロバイダーを登録し、GitHub Actionsが一時的にAssumeRoleできるIAMロールを2つ(Plan用・Apply用)作成する必要がある。本Sectionではそれぞれの設定手順をコンソール操作とTerraformコードの両面で解説する。
3-1. OIDCプロバイダーの作成
AWSコンソールでの操作手順
- AWSマネジメントコンソールにログインし、画面上部の検索バーに「IAM」と入力して IAM ダッシュボードを開く。
- 左側のナビゲーションから Identity providers をクリックする。
- 画面右上の Add provider ボタンをクリックする。
- Provider type で「OpenID Connect」を選択する。
- Provider URL フィールドに以下を入力し、Get thumbprint ボタンをクリックする:
text
https://token.actions.githubusercontent.com - Audience フィールドに以下を入力する:
text
sts.amazonaws.com - Thumbprint の値が自動入力されることを確認する(
6938fd4d98bab03faadb97b34396831e3780aea1など)。 - Add provider ボタンをクリックして登録を完了する。
登録後、Identity providers 一覧に token.actions.githubusercontent.com が表示されれば成功だ。
Terraformでのプロバイダー作成
data "tls_certificate" "github_actions" {
url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github_actions.certificates[0].sha1_fingerprint]
tags = {
Name = "github-actions-oidc"
Project = "terraform-team-cicd"
}
}
tls_certificate データソースを使うことで、サムプリントを自動取得できるため、ハードコードによるメンテナンスコストが不要になる。
3-2. Plan用IAMロールの作成
Plan用ロール(TerraformPlanRole)は、PRが作成・更新された際にterraform planを安全に実行するための読み取り専用ロールだ。Write権限を一切持たせないことがセキュリティ設計の核心である。
AWSコンソールでの操作手順
- IAMダッシュボードの左ナビから Roles をクリックし、Create role ボタンを押す。
- Trusted entity type で「Web identity」を選択する。
- Identity provider ドロップダウンから
token.actions.githubusercontent.comを選択する。 - Audience ドロップダウンから
sts.amazonaws.comを選択する。 - Add condition をクリックし、以下の条件を追加する:
| Condition key | Operator | Value |
|---|---|---|
token.actions.githubusercontent.com:sub | StringLike | repo:myorg/terraform-repo:pull_request |
- Next をクリックし、Attach permissions policies 画面で
ReadOnlyAccessにチェックを入れる。 - さらに Create policy でカスタムポリシーを作成し(次項参照)、アタッチする。
- Role name に
TerraformPlanRoleと入力し、Create role を押す。
Plan用カスタムポリシー(S3・DynamoDB書き込み許可)
terraform planのバックエンドアクセス(state読み取り・lockファイル操作)に必要な最小権限:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformStateRead",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetEncryptionConfiguration"
],
"Resource": [
"arn:aws:s3:::myorg-terraform-state",
"arn:aws:s3:::myorg-terraform-state/*"
]
},
{
"Sid": "TerraformLockRead",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/terraform-state-lock"
}
]
}
Note:
ReadOnlyAccessには一部のAWSサービスへの読み取り権限が含まれているが、state管理リソース(S3/DynamoDB)への書き込みは別途カスタムポリシーで付与する必要がある。
TerraformでのPlan Role作成
data "aws_iam_policy_document" "terraform_plan_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values= ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:myorg/terraform-repo:pull_request"]
}
}
}
resource "aws_iam_role" "terraform_plan" {
name= "TerraformPlanRole"
assume_role_policy = data.aws_iam_policy_document.terraform_plan_assume.json
tags = {
Name = "TerraformPlanRole"
Purpose = "terraform-plan-readonly"
}
}
resource "aws_iam_role_policy_attachment" "terraform_plan_readonly" {
role = aws_iam_role.terraform_plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
data "aws_iam_policy_document" "terraform_plan_state" {
statement {
sid = "TerraformStateRead"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetEncryptionConfiguration"
]
resources = [
"arn:aws:s3:::myorg-terraform-state",
"arn:aws:s3:::myorg-terraform-state/*"
]
}
statement {
sid = "TerraformLockReadWrite"
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
]
resources = [
"arn:aws:dynamodb:ap-northeast-1:123456789012:table/terraform-state-lock"
]
}
}
resource "aws_iam_policy" "terraform_plan_state" {
name= "TerraformPlanStateAccess"
policy = data.aws_iam_policy_document.terraform_plan_state.json
}
resource "aws_iam_role_policy_attachment" "terraform_plan_state" {
role = aws_iam_role.terraform_plan.name
policy_arn = aws_iam_policy.terraform_plan_state.arn
}
3-3. Apply用IAMロールの作成
Apply用ロール(TerraformApplyRole)はmainブランチへのマージ後にのみAssumeRoleできるよう、Conditionでref:refs/heads/mainを指定する。これによりPR段階では絶対にapplyが実行できない構成になる。
Conditionの設計
| クレーム | 条件 | 値 |
|---|---|---|
token.actions.githubusercontent.com:sub | StringLike | repo:myorg/terraform-repo:ref:refs/heads/main |
Point:
pull_requestとref:refs/heads/mainを別ロールに分けることで、「PRではplanのみ、マージ後のみapply」というゼロトラスト設計が実現できる。
TerraformでのApply Role作成
data "aws_iam_policy_document" "terraform_apply_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values= ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:myorg/terraform-repo:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "terraform_apply" {
name= "TerraformApplyRole"
assume_role_policy = data.aws_iam_policy_document.terraform_apply_assume.json
tags = {
Name = "TerraformApplyRole"
Purpose = "terraform-apply-write"
}
}
data "aws_iam_policy_document" "terraform_apply_permissions" {
statement {
sid = "TerraformStateWrite"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetEncryptionConfiguration"
]
resources = [
"arn:aws:s3:::myorg-terraform-state",
"arn:aws:s3:::myorg-terraform-state/*"
]
}
statement {
sid = "TerraformLockWrite"
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
]
resources = [
"arn:aws:dynamodb:ap-northeast-1:123456789012:table/terraform-state-lock"
]
}
statement {
sid = "TerraformApplyResources"
effect = "Allow"
actions = [
"ec2:*",
"s3:*",
"dynamodb:*",
"iam:GetRole",
"iam:GetPolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies"
]
resources = ["*"]
condition {
test = "StringEquals"
variable = "aws:RequestedRegion"
values= ["ap-northeast-1"]
}
}
}
resource "aws_iam_policy" "terraform_apply_permissions" {
name= "TerraformApplyPermissions"
policy = data.aws_iam_policy_document.terraform_apply_permissions.json
}
resource "aws_iam_role_policy_attachment" "terraform_apply_permissions" {
role = aws_iam_role.terraform_apply.name
policy_arn = aws_iam_policy.terraform_apply_permissions.arn
}
Note: 本ハンズオンではEC2/S3/DynamoDBへの権限を
*で付与しているが、実際の本番運用ではリソースARNを限定し最小権限原則を厳守すること。
3-4. Role設定の動作確認
AWS CLIでの確認コマンド
OIDCプロバイダーの登録確認:
# OIDCプロバイダーの一覧確認
aws iam list-open-id-connect-providers \
--query "OpenIDConnectProviderList[*].Arn" \
--output table
# プロバイダーの詳細確認
aws iam get-open-id-connect-provider \
--open-id-connect-provider-arn \
arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com
ロールの信頼ポリシー確認:
# TerraformPlanRole の信頼ポリシー確認
aws iam get-role \
--role-name TerraformPlanRole \
--query "Role.AssumeRolePolicyDocument" \
--output json
# TerraformApplyRole の信頼ポリシー確認
aws iam get-role \
--role-name TerraformApplyRole \
--query "Role.AssumeRolePolicyDocument" \
--output json
# アタッチされたポリシー一覧
aws iam list-attached-role-policies \
--role-name TerraformPlanRole \
--output table
よくあるミスとエラーメッセージ
| エラーメッセージ | 原因 | 対処 |
|---|---|---|
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity | 信頼ポリシーのArnが間違っている、またはOIDCプロバイダーが未登録 | list-open-id-connect-providersでArnを確認し、信頼ポリシーを修正 |
Error: The provided host name is not valid | Provider URLのスペルミス(末尾スラッシュ不要) | https://token.actions.githubusercontent.com(末尾スラッシュなし)に修正 |
Error: condition key mismatch | sub条件の値が実際のワークフローと一致しない | 実際のGitHub組織名/リポジトリ名と一致しているか確認 |
AccessDenied: s3:GetObject | カスタムポリシーのS3 ARNが間違っている | バケット名がリソースARNと完全一致しているか確認 |
ResourceConflict: terraform-state-lock | DynamoDBのPutItemが拒否されている | DynamoDB ARNのリージョン/アカウントIDを確認 |
4. リポジトリ・ブランチ設定
OIDCとIAMロールが準備できたら、GitHubリポジトリ側の設定を行う。Terraform用のリポジトリ構成、backend.tf、GitHub Secrets/Variables、初回initの確認手順を解説する。
4-1. GitHubリポジトリの準備
推奨ディレクトリ構成
terraform-repo/
├── .github/
│└── workflows/
│ ├── terraform-plan.yml ← PR時にplan実行
│ └── terraform-apply.yml← mainマージ後にapply実行
├── main.tf ← メインリソース定義
├── variables.tf← 変数定義
├── outputs.tf ← 出力値定義
├── backend.tf ← S3バックエンド(partial configuration)
├── versions.tf ← Terraformとプロバイダーのバージョン固定
├── terraform.tfvars.example← 変数サンプル(.gitignore対象外)
└── .gitignore ← .terraform/ / *.tfstate等を除外
.gitignore の設定
# Terraform state files
*.tfstate
*.tfstate.*
*.tfstate.backup
# Terraform working directory
.terraform/
.terraform.lock.hcl は git 管理対象(例外)
# Terraform plan files
*.tfplan
tfplan
# Variable files with secrets
*.tfvars
!terraform.tfvars.example
# Override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Crash log files
crash.log
crash.*.log
# macOS
.DS_Store
Note:
.terraform.lock.hclはプロバイダーのバージョンロック情報を含むためgit管理対象に含めること。チームメンバーが同一バージョンのプロバイダーを使うことを保証する。
versions.tf の設定
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
4-2. backend.tf の設定(partial configuration)
backend.tfに認証情報をハードコードすると、リポジトリにシークレットが混入するリスクがある。partial configuration(部分設定)を使い、接続情報はCIから動的に渡す方式が安全だ。
backend.tf(partial configuration形式)
terraform {
backend "s3" {
# 接続情報は -backend-config で外部から渡す
# ここにはバケット名等を書かない
}
}
GitHub Actionsでの-backend-config渡し方
terraform init実行時に環境変数をフラグとして渡す:
- name: Terraform Init
run: |
terraform init \
-backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=${{ vars.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ vars.TF_LOCK_TABLE }}" \
-backend-config="encrypt=true"
Note: Secretsではなく Variables(暗号化されない設定値)をバケット名・リージョン等に使う。AWSアカウントIDや認証情報は Variables で管理し、パスワード等のシークレットのみ Secrets を使う運用が分かりやすい。
4-3. GitHub Secrets / Variables の設定
OIDCを使う場合、AWSアクセスキーは不要だ。保存するのはロールARNとリージョンのみでよい。
GitHub Variables(暗号化なし・ログに表示される)
| 変数名 | 値の例 | 用途 |
|---|---|---|
AWS_ACCOUNT_ID | 123456789012 | ロールARN構築用 |
AWS_REGION | ap-northeast-1 | AWSリージョン指定 |
TF_STATE_BUCKET | myorg-terraform-state | S3バックエンド設定 |
TF_LOCK_TABLE | terraform-state-lock | DynamoDBロックテーブル |
TF_PLAN_ROLE_ARN | arn:aws:iam::123456789012:role/TerraformPlanRole | OIDC Plan Role |
TF_APPLY_ROLE_ARN | arn:aws:iam::123456789012:role/TerraformApplyRole | OIDC Apply Role |
GitHub UIでの設定手順
- GitHubリポジトリページで Settings タブをクリックする。
- 左サイドバーの Secrets and variables → Actions をクリックする。
- Variables タブをクリックする。
- New repository variable ボタンをクリックする。
- Name と Value を入力し、Add variable ボタンを押す。
- 上記6つの変数をすべて登録する。
Workflow内での参照方法
env:
AWS_REGION: ${{ vars.AWS_REGION }}
TF_PLAN_ROLE_ARN: ${{ vars.TF_PLAN_ROLE_ARN }}
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
TF_LOCK_TABLE: ${{ vars.TF_LOCK_TABLE }}
4-4. 初回 terraform init と state確認
GitHub Actionsを動かす前に、ローカルから手元でinitしてbackend接続を確認しておく。
初回 terraform init コマンド
# AWS認証の確認(ローカル実行用)
aws sts get-caller-identity
# terraform init(-backend-config で接続情報を渡す)
terraform init \
-backend-config="bucket=myorg-terraform-state" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=ap-northeast-1" \
-backend-config="dynamodb_table=terraform-state-lock" \
-backend-config="encrypt=true"
期待される出力
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v5.x.x
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to
see any changes that are required for your infrastructure. All Terraform
commands should now work.
S3とDynamoDBへの接続確認
# S3バケットへの接続テスト
aws s3 ls s3://myorg-terraform-state --region ap-northeast-1
# DynamoDBテーブルの確認
aws dynamodb describe-table \
--table-name terraform-state-lock \
--region ap-northeast-1 \
--query "Table.TableStatus"
# state一覧の確認(初回は空)
terraform state list
よくある初期化エラー
| エラー | 原因 | 対処 |
|---|---|---|
NoSuchBucket | S3バケットが存在しない | 第1弾で作成したバケット名を確認 |
AccessDenied | ローカルのAWS認証情報に権限がない | aws sts get-caller-identityで認証確認 |
ResourceNotFoundException | DynamoDBテーブルが存在しない | 第1弾のTerraformコードでテーブルを作成済みか確認 |
BucketRegionError | バケットとリージョン指定が不一致 | -backend-config="region=..."が正しいリージョンか確認 |
ここまで完了すれば、Terraform実行基盤の準備は整った。次のSectionからはいよいよGitHub Actionsのワークフローを構築していく。
5. plan on PR ワークフロー構築
PRが作成・更新されるたびにterraform planを自動実行し、結果をPRコメントに投稿するワークフローを構築する。これによりレビュアーがAWSコンソールにログインせずともインフラ変更の影響を確認できるようになる。
5-1. ワークフロー設計方針
planワークフローの全体像
PR作成/更新
↓
on: pull_request (opened/synchronize/reopened)
↓
TerraformPlanRole を OIDC で AssumeRole(読み取り専用)
↓
terraform fmt -check → エラーがあればワークフロー失敗
↓
terraform validate → 構文エラーがあればワークフロー失敗
↓
terraform plan -out=tfplan
↓
plan結果をPRコメントに投稿
↓
(plan失敗時はブランチ保護ルールによりマージブロック)
トリガー設計の選択理由
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
| type | 発火タイミング | 必要性 |
|---|---|---|
opened | PR作成時 | 最初のplan確認 |
synchronize | 追加コミット時 | コード修正後の再確認 |
reopened | クローズ→再オープン時 | 古いPRの再評価 |
ready_for_review | Draft→レビュー可能 | 省略可(必要に応じて追加) |
5-2. terraform-plan.yml の作成(完全版)
name: Terraform Plan
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
permissions:
id-token: write# OIDC トークン取得に必要
contents: read # リポジトリのチェックアウトに必要
pull-requests: write # PRコメント投稿に必要
concurrency:
group: terraform-plan-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
AWS_REGION: ${{ vars.AWS_REGION }}
TF_PLAN_ROLE_ARN: ${{ vars.TF_PLAN_ROLE_ARN }}
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
TF_LOCK_TABLE: ${{ vars.TF_LOCK_TABLE }}
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.TF_PLAN_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~1.5"
- name: Terraform Init
id: init
run: |
terraform init \
-backend-config="bucket=${{ env.TF_STATE_BUCKET }}" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ env.TF_LOCK_TABLE }}" \
-backend-config="encrypt=true" \
-input=false
- name: Terraform Format Check
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
run: |
terraform plan \
-out=tfplan \
-input=false \
-no-color \
2>&1 | tee plan_output.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Post Plan Result to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
env:
PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const planOutput = fs.readFileSync('plan_output.txt', 'utf8');
const planTruncated = planOutput.length > 60000
? planOutput.substring(0, 60000) + '\n... (出力が長すぎるため省略)'
: planOutput;
const fmtOutcome = '${{ steps.fmt.outcome }}';
const validateOutcome = '${{ steps.validate.outcome }}';
const planOutcome = '${{ steps.plan.outcome }}';
const statusEmoji = (outcome) => outcome === 'success' ? '✅' : '❌';
const body = `## Terraform Plan 結果
| ステップ | 結果 |
|---|---|
| Format (fmt) | ${statusEmoji(fmtOutcome)} \`${fmtOutcome}\` |
| Validate | ${statusEmoji(validateOutcome)} \`${validateOutcome}\` |
| Plan | ${statusEmoji(planOutcome)} \`${planOutcome}\` |
<details>
<summary>Plan 詳細(クリックで展開)</summary>
\`\`\`terraform
${planTruncated}
\`\`\`
</details>
*実行者: \`${{ github.actor }}\` | コミット: \`${{ github.sha }}\`*`;
// 既存コメントを検索して更新(重複防止)
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('## Terraform Plan 結果')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}
- name: Fail if Plan Failed
if: steps.plan.outcome == 'failure'
run: exit 1
5-3. terraform fmt と validate の設定
fmt -check の使い方
terraform fmt -checkはコードのフォーマットをチェックのみ行い、不一致があればexitcode 1で失敗する(実際のフォーマット修正はしない)。
# ローカルでの実行
terraform fmt -check -recursive# チェックのみ(CI用)
terraform fmt -recursive # 実際に整形(ローカル作業用)
# 差分確認
terraform fmt -diff -check -recursive
CI失敗時の対処:
# ローカルで整形してコミット
terraform fmt -recursive
git add -A
git commit -m "fix: terraform fmt"
git push
validate のエラーの読み方
# よくあるエラー例
│ Error: Unsupported argument
│
│on main.tf line 12, in resource "aws_instance" "web":
│12:ami_id = "ami-0abcdef1234567890"
│
│ An argument named "ami_id" is not expected here. Did you mean "ami"?
このようなエラーはリソースの引数名ミス。terraform providers schemaでスキーマを確認する。
5-4. plan結果のPRコメント自動投稿
5-2のワークフローに含まれるPost Plan Result to PRステップが担当する。重要な設計ポイントは既存コメントの更新だ。
同じPRに追加コミットするたびに新しいコメントが増殖するのを防ぐため、Botの既存コメントをupdateCommentで上書きする:
// 既存のBotコメントを検索
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('## Terraform Plan 結果')
);
// 存在すれば更新、なければ新規作成
if (botComment) {
await github.rest.issues.updateComment({ comment_id: botComment.id, body });
} else {
await github.rest.issues.createComment({ issue_number, body });
}
plan失敗時のコメント形式
plan失敗時はcontinue-on-error: trueにより後続のコメント投稿ステップが実行される。テーブルの「Plan」行が❌ failureと表示され、詳細にエラーメッセージが含まれる。
5-5. ワークフロー動作確認ハンズオン
Step 1: featureブランチの作成
git checkout -b feature/add-ec2-instance
Step 2: main.tf に小さな変更を追加
# main.tf に追記
resource "aws_instance" "web" {
ami = "ami-0d52744d6551d851e" # Amazon Linux 2023 (ap-northeast-1)
instance_type = "t3.micro"
tags = {
Name = "terraform-ci-test"
ManagedBy= "terraform"
Environment = "dev"
}
}
Step 3: コミットしてPushする
git add main.tf
git commit -m "feat: add EC2 instance for CI test"
git push origin feature/add-ec2-instance
Step 4: PRを作成する
GitHubリポジトリページで Compare & pull request ボタンをクリックし、feature/add-ec2-instance → main のPRを作成する。
Step 5: GitHub ActionsのWorkflow実行を確認する
PRページの Checks タブを開き、「Terraform Plan」ジョブが実行中であることを確認する。
期待される実行ログ(抜粋):
Run aws-actions/configure-aws-credentials@v4
Assuming role with OIDC...
TerraformPlanRole assumed successfully
Run terraform init
Initializing the backend...
Successfully configured the backend "s3"!
Terraform has been successfully initialized!
Run terraform fmt -check -recursive
No format issues found.
Run terraform validate
Success! The configuration is valid.
Run terraform plan
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0d52744d6551d851e"
+ instance_type = "t3.micro"
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Step 6: PRコメントを確認する
PR の Conversation タブに以下のようなコメントが自動投稿される:
## Terraform Plan 結果
| ステップ | 結果 |
|---|---|
| Format (fmt) | ✅ success |
| Validate | ✅ success |
| Plan | ✅ success |
(Plan 詳細は展開して確認)
5-6. よくあるエラーとトラブルシューティング
| エラー | 原因 | 対処 |
|---|---|---|
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity | OIDCプロバイダーのArnが信頼ポリシーと不一致 | IAM > Roles > TerraformPlanRole > Trust relationships で確認 |
Error: Could not load credentials from any providers | permissions.id-token: writeが未設定 | workflowのpermissionsブロックにid-token: writeを追加 |
Error: Failed to get existing workspaces: S3 bucket not accessible | S3バケット名が間違っている | vars.TF_STATE_BUCKETの値と実際のバケット名を照合 |
Error: terraform fmt check failed | コードのインデントや整形が崩れている | ローカルでterraform fmt -recursiveを実行してコミット |
Error: Resource not found | DynamoDBテーブルが存在しない | 第1弾で作成したテーブル名とvars.TF_LOCK_TABLEを確認 |
Error: GitHub token does not have permission | permissions.pull-requests: writeが未設定 | workflowのpermissionsブロックにpull-requests: writeを追加 |
Plan: 0 to add, 0 to change, 0 to destroy | stateが最新で変更なし | 意図した変更がmain.tfに正しく追記されているか確認 |
6. apply on merge ワークフロー構築
mainブランチへのPRマージ後に自動でterraform applyを実行するワークフローを構築する。TerraformApplyRole(書き込み権限)はmainブランチへのpushイベントのみでAssumeRoleできるため、この設計が最後の安全弁となる。
6-1. apply ワークフローの設計方針
applyワークフローの全体像
mainブランチへのPRマージ(= push イベント)
↓
on: push → branches: [main]
↓
TerraformApplyRole を OIDC で AssumeRole(書き込み権限)
↓
terraform init(S3バックエンド接続)
↓
terraform plan(二重確認 — stateのdrift検出)
↓
terraform apply -auto-approve
↓
apply結果をGitHub Actionsサマリーに出力
↓
Slack通知(成功/失敗)
Plan/Apply Role分離の実効性
| 操作 | 使用ロール | sub条件 | 結果 |
|---|---|---|---|
| PR作成・更新時のplan | TerraformPlanRole | pull_request | 読み取り専用で実行可能 |
| mainマージ後のapply | TerraformApplyRole | ref:refs/heads/main | 書き込み権限で実行可能 |
| PR段階でapplyを試みる | TerraformApplyRole | pull_request | AssumeRole失敗(ブロック) |
PRブランチからはTerraformApplyRoleをAssumeRoleできないため、ワークフローの設定ミスによる誤applyを完全に防止できる。
6-2. terraform-apply.yml の作成(完全版)
name: Terraform Apply
on:
push:
branches: [main]
permissions:
id-token: write# OIDC トークン取得
contents: read # リポジトリのチェックアウト
concurrency:
group: terraform-apply-main
cancel-in-progress: false # applyは絶対にキャンセルしない
env:
AWS_REGION: ${{ vars.AWS_REGION }}
TF_APPLY_ROLE_ARN: ${{ vars.TF_APPLY_ROLE_ARN }}
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
TF_LOCK_TABLE: ${{ vars.TF_LOCK_TABLE }}
jobs:
terraform-apply:
name: Terraform Apply
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.TF_APPLY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~1.5"
- name: Terraform Init
run: |
terraform init \
-backend-config="bucket=${{ env.TF_STATE_BUCKET }}" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ env.TF_LOCK_TABLE }}" \
-backend-config="encrypt=true" \
-input=false
- name: Terraform Plan (二重確認)
id: plan
run: |
terraform plan \
-out=tfplan \
-input=false \
-no-color \
2>&1 | tee plan_output.txt
- name: Terraform Apply
id: apply
run: |
terraform apply \
-auto-approve \
-input=false \
-no-color \
tfplan \
2>&1 | tee apply_output.txt
- name: Write Summary
if: always()
run: |
echo "## Terraform Apply 結果" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.apply.outcome }}" = "success" ]; then
echo "✅ **apply 成功**" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **apply 失敗**" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -50 apply_output.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Notify Slack on Success
if: steps.apply.outcome == 'success'
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"text": "✅ Terraform Apply 成功\nリポジトリ: ${{ github.repository }}\nコミット: ${{ github.sha }}\n実行者: ${{ github.actor }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Notify Slack on Failure
if: steps.apply.outcome == 'failure'
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"text": "❌ Terraform Apply 失敗\nリポジトリ: ${{ github.repository }}\nコミット: ${{ github.sha }}\n実行者: ${{ github.actor }}\n確認: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
6-3. apply の安全設計
なぜ apply でも plan を再実行するか
PRがマージされる間に、他のPRがマージされてstateが変化している可能性がある(drift)。applyワークフローで改めてplanを実行することで、最新のstateに基づいた差分を確認してからapplyできる。
【driftのシナリオ】
PR-A (EC2 1台追加) ← マージ待ち
PR-B (EC2 2台追加) ← PR-Aより先にマージ
PR-AがPR-Bマージ後にapplyされると:
- PR-A作成時のplan: EC2 1台追加
- 実際のapply: EC2 1台追加 + stateの差分処理(PR-Bで作成したEC2への影響が出る可能性)
→ apply直前の再planで差分を正確に把握できる
-auto-approve を使う条件と代替
-auto-approveを使う条件:
– ブランチ保護ルール(必須レビュー)が設定されており、レビューなしでmainへのマージが不可能な場合
– CODEOWNERS によって適切なレビュアーへの通知が設定されている場合
manual approvalが必要なケース(本番環境など):
# GitHub Environments の required reviewers 機能を活用
environment: production # Environmentに「Required reviewers」を設定
apply失敗時の対処フロー
apply失敗
↓
1. GitHub Actions の実行ログを確認
↓
2. エラーの種類を判定:
- 権限不足 → IAMポリシーを確認
- stateロック中 → DynamoDB のロックエントリを確認・削除
- リソース競合 → AWSコンソールで状態確認
↓
3. stateが壊れていないか確認:
terraform state list
↓
4. 手動でtf操作する場合はstateバックアップを取得してから:
terraform state pull > state_backup.json
↓
5. 問題解決後、ワークフローを再実行(Re-run jobs)
DynamoDBロックと apply の関係
apply実行中はDynamoDBにロックエントリが作成される。正常終了時は自動で削除されるが、apply途中で強制終了した場合はロックが残る:
# ロックエントリの確認
aws dynamodb scan \
--table-name terraform-state-lock \
--query "Items" \
--region ap-northeast-1
# 強制ロック解除(State IDを指定)
terraform force-unlock <LOCK_ID>
6-4. concurrency group の設定
planとapplyのconcurrency設定
# plan ワークフロー(同一PRの古い実行はキャンセルしてよい)
concurrency:
group: terraform-plan-${{ github.event.pull_request.number }}
cancel-in-progress: true
# apply ワークフロー(applyは絶対にキャンセルしない)
concurrency:
group: terraform-apply-main
cancel-in-progress: false
cancel-in-progress の使い分け
| ワークフロー | 設定値 | 理由 |
|---|---|---|
| terraform-plan | true | 同じPRへの連続pushで古いplanを無駄に実行しない |
| terraform-apply | false | 実行中のapplyをキャンセルするとstateが中途半端になる |
groupキーの設計
| 用途 | groupキー例 | 説明 |
|---|---|---|
| PR単位でplan | terraform-plan-${{ github.event.pull_request.number }} | PR番号ごとに独立 |
| mainブランチのapply | terraform-apply-main | 常に1つだけ実行 |
| 環境別(stg/prod) | terraform-apply-${{ github.ref_name }} | ブランチ名で分離 |
6-5. apply ハンズオン
Step 1: featureブランチでEC2インスタンスを追加
Section 5のハンズオンで作成したfeatureブランチを使う(またはSection 5のPRがまだオープンであれば継続)。
git checkout feature/add-ec2-instance
Step 2: PRを作成してplanを確認
Section 5で作成したPRのplanコメントを確認する。EC2インスタンスの作成(1 to add)がplanに含まれていればOKだ。
Step 3: コードレビューを行い承認
チームメンバーに確認を依頼し、Approveを受ける(CODEOWNERS設定後はSection 7で解説するレビューフローが動作する)。
Step 4: mainにマージ
GitHub UI で Merge pull request をクリックしてmainにマージする。
Step 5: applyワークフローの実行確認
リポジトリの Actions タブを開き、「Terraform Apply」ワークフローが実行中であることを確認する。
期待される実行ログ(抜粋):
Run aws-actions/configure-aws-credentials@v4
Assuming role with OIDC...
TerraformApplyRole assumed successfully
Run terraform plan (二重確認)
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0d52744d6551d851e"
+ instance_type = "t3.micro"
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Run terraform apply
aws_instance.web: Creating...
aws_instance.web: Creation complete after 42s [id=i-0abcdef1234567890]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Step 6: AWSコンソールでリソース確認
EC2ダッシュボードを開き、terraform-ci-testという名前のインスタンスが起動していることを確認する。
Step 7: 後片付け(terraform destroy)
# ローカルで実行
terraform destroy -auto-approve
または、featureブランチにmain.tfから該当リソースを削除するコミットを追加し、新しいPRを作成してマージする(CI/CDパイプライン経由での削除)。
6-6. apply の監査・通知設定
GitHub Actionsワークフロー履歴での監査
GitHub Actions の実行履歴は自動的に保存され、以下の情報が記録される:
- 実行日時
- トリガーとなったコミットハッシュ
- 実行者(マージした人)
- 各ステップのログ
確認場所: リポジトリ → Actions → Terraform Apply → 実行履歴
Slack通知の設定
6-2のワークフローにはSlack通知ステップが含まれている。利用するには:
- Slack で Incoming Webhooks アプリをインストールしてWebhook URLを取得
- GitHub リポジトリ Settings → Secrets and variables → Actions → Secrets タブで
SLACK_WEBHOOK_URLを登録 - ワークフロー実行時に自動通知が届くようになる
apply成功/失敗の通知フォーマット例
成功時:
✅ Terraform Apply 成功
リポジトリ: myorg/terraform-repo
コミット: abc1234def5678...
実行者: alice
失敗時:
❌ Terraform Apply 失敗
リポジトリ: myorg/terraform-repo
コミット: abc1234def5678...
実行者: bob
確認: https://github.com/myorg/terraform-repo/actions/runs/12345678
失敗通知には Actions の実行URLを含めることで、チームがすぐにログを確認できるようにしている。
7. CODEOWNERS・ブランチ保護・レビュープロセス強化
複数人でTerraformを運用するとき、「誰でもmainブランチにマージできる」「レビューなしでapplyが走る」という状態は大きなリスクです。GitHubのCODEOWNERS機能とブランチ保護ルールを組み合わせることで、コードレビューを強制し、CI/CDパイプラインの安全性をさらに高められます。
7-1. CODEOWNERS の設定
CODEOWNERS とは
CODEOWNERSは、リポジトリ内の特定ファイルやディレクトリに対して「レビュー担当者」を明示的に指定する仕組みです。Pull Requestで該当パスのファイルが変更されると、指定したチームやユーザーが自動的にレビュアーとして割り当てられます。
ファイルの作成場所
CODEOWNERSファイルは以下のいずれかに配置します。
.github/CODEOWNERS ← 推奨
CODEOWNERS
docs/CODEOWNERS
.github/CODEOWNERS が最も一般的です。
Terraform リポジトリ向けの設定例
# .github/CODEOWNERS
# Terraformコード全般 — インフラチームのレビュー必須
*.tf @myorg/infra-team
# tfvars(環境別変数ファイル)
*.tfvars @myorg/infra-team
# GitHub Actionsワークフロー — インフラ+セキュリティチームの両方が必須
.github/workflows/ @myorg/infra-team @myorg/security-team
# バックエンド設定(Stateファイル管理)
backend.tf @myorg/infra-team
# OIDCプロバイダー・IAMロール設定
iam.tf @myorg/infra-team @myorg/security-team
記述ルール
| 記述形式 | 意味 |
|---|---|
@myorg/infra-team | Organization内のチーム |
@username | 個人ユーザー |
*.tf | 全ディレクトリの .tf ファイル |
modules/ | modules/ 配下の全ファイル |
!modules/test/ | 除外パターン |
注意: パターンは上から順に評価され、後の行が優先されます。特定ディレクトリだけ別チームに割り当てたい場合は、より具体的なパターンを後に記述します。
# .github/CODEOWNERS
# 全Terraformファイルはインフラチーム
*.tf @myorg/infra-team
# ただしSandbox環境はシニアエンジニア1人でOK
environments/sandbox/*.tf @senior-engineer
CODEOWNERS とブランチ保護の連携
CODEOWNERSだけでは「レビュアーを自動アサインするだけ」で、強制力はありません。ブランチ保護ルールの “Require review from Code Owners” を有効にすることで、CODEOWNERS指定のレビュアーが承認しない限りマージをブロックできます。
7-2. ブランチ保護ルールの設定
設定場所
GitHubリポジトリの Settings → Branches → Branch protection rules から設定します。
リポジトリ: myorg/terraform-repo
Settings → Branches → Add branch protection rule
Branch name pattern: main
推奨設定項目
以下の設定を有効にすることを強く推奨します。
① Require a pull request before merging
[✓] Require a pull request before merging
Required number of approvals before merging: 1
[✓] Dismiss stale pull request approvals when new commits are pushed
[✓] Require review from Code Owners
[ ] Require approval of the most recent reviewable push
Required number of approvals: 本番環境向けなら 2 以上が望ましいDismiss stale approvals: 新しいコミットが追加されたら承認をリセット(重要)Require review from Code Owners:.github/CODEOWNERS指定のレビュアーの承認を必須化
② Require status checks to pass before merging
[✓] Require status checks to pass before merging
[✓] Require branches to be up to date before merging
Status checks that are required:
┌─────────────────────────────────────────┐
│ terraform-plan │
│ terraform-fmt-validate│
└─────────────────────────────────────────┘
Require branches to be up to date: mainブランチが進んでいたらマージ前にrebase/merge必須- Status checks名はGitHub Actionsの job name を指定(後述)
③ その他の推奨設定
[✓] Require conversation resolution before merging
→ PRのコメントが全て解決済みになるまでマージ不可
[✓] Do not allow bypassing the above settings
→ Admin権限ユーザーも例外なしでルール適用
[ ] Restrict who can push to matching branches
→ 特定チームのみpush許可(Organizationプランが必要)
設定後の動作確認
ブランチ保護を設定後、mainブランチへの直接pushを試みると以下のエラーが出ることを確認します。
git push origin main
# remote: error: GH006: Protected branch update failed for refs/heads/main.
# remote: error: At least 1 approving review is required by reviewers with write access.
7-3. Required status checks の設定
GitHub Actions の job名を status check に登録
ブランチ保護の “Required status checks” に登録する名前は、GitHub Actions ワークフローの jobs.<job-id>.name フィールドが使われます。
# .github/workflows/terraform-plan.yml(一部抜粋)
jobs:
terraform-fmt-validate: # ← この名前を status check に登録
name: terraform-fmt-validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Validate
run: |
terraform init -backend=false
terraform validate
terraform-plan: # ← この名前を status check に登録
name: terraform-plan
needs: terraform-fmt-validate
runs-on: ubuntu-latest
# ... (OIDC認証・plan実行)
name フィールドを明示的に設定することで、ワークフローファイルを変更しても status check 名を維持できます。
status check の登録手順
- ワークフローを含むブランチでPRを一度作成して実行する
- GitHub が自動的に status check を検出してリストに表示
- Settings → Branches → ルール編集 → “Search for status checks” 欄に job名を入力
- 候補が表示されたら選択して保存
注意: status check は一度でも実行されたことがあるものしか検索候補に出ません。ワークフローを新規作成した場合は、先にPRを作成して実行してから登録します。
plan 失敗時のマージブロック確認
planが失敗すると、PRマージボタンが以下のようにブロックされます。
❌ terraform-plan — Failing after 2m 30s
Some checks were not successful
[Merge pull request] ← グレーアウトされてクリック不可
fmt/validate と plan を別 job にする利点
jobs:
# 軽量チェック(30秒程度)
terraform-fmt-validate:
name: terraform-fmt-validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- run: terraform fmt -check -recursive
- run: terraform init -backend=false && terraform validate
# AWS認証が必要な重い処理(2〜3分)
terraform-plan:
name: terraform-plan
needs: terraform-fmt-validate# fmt/validate が通ってから実行
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/TerraformPlanRole
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Terraform Init
run: |
terraform init \
-backend-config="bucket=myorg-terraform-state" \
-backend-config="key=prod/terraform.tfstate" \
-backend-config="region=ap-northeast-1" \
-backend-config="dynamodb_table=terraform-state-lock"
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
- name: Post Plan to PR
uses: actions/github-script@v7
with:
script: |
const output = `### Terraform Plan 結果
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
| 観点 | 説明 |
|---|---|
| フィードバック速度 | fmt/validateは30秒で完了。AWS認証なしで即エラーを返せる |
| コスト節約 | fmt/validateが失敗したらplanを実行しない(OIDC呼び出し不要) |
| デバッグしやすさ | エラー原因がfmt/validateかplanかを明確に分離できる |
7-4. Pull Request テンプレートの整備
テンプレートファイルの作成
mkdir -p .github
touch .github/pull_request_template.md
Terraform 運用向けテンプレート例
<!-- .github/pull_request_template.md -->
## 変更概要
<!-- このPRで何を変更するか、1〜3文で記述 -->
## 変更の種類
- [ ] 新規リソースの追加
- [ ] 既存リソースの変更
- [ ] リソースの削除
- [ ] リファクタリング(機能変更なし)
- [ ] 変数・アウトプット変更
- [ ] その他:
## 影響範囲
- 影響環境: `dev` / `stg` / `prod`
- 影響リソース: (例: `aws_security_group.web`, `aws_iam_role.terraform_apply`)
- ダウンタイム: あり / なし
## 事前確認チェックリスト
### コード品質
- [ ] `terraform fmt` を実行済み(差分なし)
- [ ] `terraform validate` がパス
- [ ] リソース名・変数名が命名規則に沿っている
### Plan 確認
- [ ] `terraform plan` の出力を確認し、想定通りの変更のみ含まれている
- [ ] `destroy` や予期しない `replace` が含まれていない
- [ ] plan結果をPRコメントに添付済み(またはCIのplanコメントを確認済み)
### セキュリティ
- [ ] IAMポリシーに過剰な権限が含まれていない
- [ ] シークレット・認証情報がコードに含まれていない
- [ ] セキュリティグループのインバウンドルールが最小権限になっている
### レビュー観点(レビュアー向け)
- [ ] Planの変更内容が説明と一致している
- [ ] `force_destroy = true` など危険なフラグが含まれていない
- [ ] 本番環境に適用する場合、ロールバック手順が準備されているか
## 関連リンク
- Issue: #
- 設計書:
- 参考:
.github/pull_request_template.md を main ブランチに配置すると、GitHub が自動的に読み込み、新規PR作成時の本文テキストエリアにプリセットされます。
8. 監査ログ・通知設定とまとめ
インフラの変更が誰によって・いつ・どのように行われたかを追跡できることは、セキュリティインシデント対応と内部統制の両面で不可欠です。AWS CloudTrailとGitHubの監査ログを活用した追跡方法と、本番運用前の最終チェックリストを紹介します。
8-1. AWS CloudTrail による監査ログ
GitHub Actions からの API 呼び出しの記録
GitHub ActionsがOIDCでAWSを操作すると、CloudTrailに全API呼び出しが記録されます。AssumeRoleWithWebIdentity から始まり、Terraformが実行する全てのCreate/Update/Delete操作が追跡可能です。
長期保存にはCloudTrail Trailを作成してS3に保存することを推奨します。
# CloudTrail Trail の設定例
resource "aws_cloudtrail" "main" {
name = "terraform-audit-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail.id
include_global_service_events = true
is_multi_region_trail= true
enable_log_file_validation = true
event_selector {
read_write_type = "All"
include_management_events = true
}
tags = {
Environment = "prod"
ManagedBy= "terraform"
}
}
resource "aws_s3_bucket" "cloudtrail" {
bucket = "myorg-cloudtrail-logs"
force_destroy = false
}
data "aws_iam_policy_document" "cloudtrail_s3" {
statement {
sid = "AWSCloudTrailAclCheck"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
actions= ["s3:GetBucketAcl"]
resources = [aws_s3_bucket.cloudtrail.arn]
}
statement {
sid = "AWSCloudTrailWrite"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
actions= ["s3:PutObject"]
resources = ["${aws_s3_bucket.cloudtrail.arn}/AWSLogs/*"]
condition {
test = "StringEquals"
variable = "s3:x-amz-acl"
values= ["bucket-owner-full-control"]
}
}
}
CloudTrail でのフィルタリング方法
AWS CLI でのフィルタリング:
# TerraformPlanRole の全APIコールを検索
aws cloudtrail lookup-events \
--region ap-northeast-1 \
--lookup-attributes AttributeKey=Username,AttributeValue="assumed-role/TerraformPlanRole/GitHubActions" \
--start-time "2026-01-01T00:00:00Z" \
--end-time "2026-01-02T00:00:00Z" \
--query 'Events[*].{Time:EventTime, Event:EventName, User:Username}' \
--output table
CloudWatch Logs Insights でS3保存ログを分析する場合:
-- TerraformApplyRoleによる全変更操作を検索
fields @timestamp, eventName, userIdentity.principalId, requestParameters
| filter userIdentity.principalId like /TerraformApplyRole/
| filter eventName in ["CreateSecurityGroup", "AuthorizeSecurityGroupIngress",
"PutBucketPolicy", "CreateRole", "AttachRolePolicy"]
| sort @timestamp desc
| limit 100
重要な API イベントの確認手順
| イベント名 | 意味 | 記録されるRole |
|---|---|---|
AssumeRoleWithWebIdentity | OIDC認証でロールを引き受け | — |
GetObject / PutObject | Stateファイルの読み書き | PlanRole / ApplyRole |
CreateLockItem | DynamoDBロックの取得 | TerraformApplyRole |
DeleteLockItem | DynamoDBロックの解放 | TerraformApplyRole |
| インフラ変更API群 | 実際のリソース操作 | TerraformApplyRole のみ |
AssumeRoleWithWebIdentity のイベントには webIdFederationData フィールドが含まれ、GitHubのリポジトリ名・ブランチ名が記録されます。想定外のリポジトリからRoleが引き受けられていないか確認できます。
# AssumeRoleWithWebIdentity イベントの詳細確認
aws cloudtrail lookup-events \
--region ap-northeast-1 \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
--max-results 10 \
--query 'Events[*].CloudTrailEvent' \
--output text | python3 -m json.tool
8-2. GitHub Actions の監査ログ
Organization の Audit log
GitHub Organization の Settings → Audit log から確認できる主なイベント:
| カテゴリ | イベント例 |
|---|---|
workflows | ワークフローの実行・キャンセル・承認 |
protected_branch | ブランチ保護ルールの変更 |
org | メンバーの追加・削除・ロール変更 |
secret | Actions Secretの作成・削除 |
フィルタリング例(Audit log検索バー):
action:workflows.completed actor:github-actions[bot]
action:protected_branch.update_admin_enforced
GitHub CLI での Audit log 取得:
gh api \
-H "Accept: application/vnd.github+json" \
"/orgs/myorg/audit-log?phrase=action:workflows&per_page=30" \
--jq '.[] | {created_at, action, actor, repo}'
ワークフロー実行履歴での作業記録
リポジトリの Actions タブ からワークフロー実行履歴を確認できます。各実行には以下の情報が記録されます。
terraform-apply
├── Run #42
├── Triggered by: @engineer-name
├── Branch: main
├── Commit: abc1234 "feat: add S3 bucket"
├── Started: 2026-01-15 10:30:00
├── Duration: 2m 35s
└── Status: Success ✅
CLI で実行履歴をCSV取得:
gh api \
"/repos/myorg/terraform-repo/actions/runs?per_page=30" \
--jq '.workflow_runs[] | [.id, .name, .status, .conclusion, .created_at, .head_commit.message] | @csv'
8-3. 本番運用前のセキュリティチェックリスト
本番環境でGitHub Actions + OIDCによるTerraform CI/CDを稼働させる前に、以下の項目を全て確認してください。
IAM・OIDC 設定
- [ ] OIDCプロバイダーのThumbprintが最新
token.actions.githubusercontent.comのThumbprintはAWS公式ドキュメントの最新値と一致していることを確認。 - [ ] TerraformPlanRole の Condition が
repo:myorg/terraform-repo:*に限定されている*:*のようなワイルドカードは全リポジトリからのアクセスを許可してしまいます。 - [ ] TerraformApplyRole の Condition が
repo:myorg/terraform-repo:ref:refs/heads/mainに限定されている
applyはmainブランチのプッシュのみ実行できる構成になっていることを確認。 - [ ] 各RoleのIAMポリシーが最小権限になっている
*(全権限)を付与していないか確認。未使用の権限は削除。
ブランチ保護・レビュープロセス
- [ ] mainブランチのブランチ保護が有効
Settings → Branches でルールが存在し、有効になっていることを確認。 - [ ] Required status checks に
terraform-planが含まれている
planが失敗した状態でマージできないことを確認。 - [ ] “Do not allow bypassing the above settings” が有効
Admin権限ユーザーもブランチ保護をbypassできない設定になっていることを確認。 - [ ] CODEOWNERSが設定され、”Require review from Code Owners” が有効
インフラチームのレビューなしにTerraformコードがマージできない構成になっていることを確認。
CI/CD パイプライン
- [ ] planとapplyが別のJobであり、別のIAM Roleを使用している
- [ ] applyワークフローがPRではなく
push: branches: [main]でトリガーされている - [ ] plan結果がPRコメントに自動投稿され、レビュアーが確認できる
Stateファイル管理
- [ ] S3バケットのバージョニングが有効
誤ったapply後にStateを復元できることを確認。 - [ ] S3バケットとDynamoDBへのアクセスがTerraformロールのみに制限されている
- [ ] S3バケットのパブリックアクセスブロックが有効
StateファイルにはAWSリソース構成の詳細が含まれます。外部公開は厳禁です。
監査・モニタリング
- [ ] CloudTrailが有効で、TerraformApplyRoleの操作が記録されている
実際にplanを実行してCloudTrailにイベントが記録されることを確認。 - [ ] 不審なOIDC認証(想定外のリポジトリ・ブランチ)に対するアラートが設定されている
CloudWatch Alarmと組み合わせてSlack/メール通知を設定することを推奨。
8-4. まとめ
本記事で構築したCI/CDフローの全体サマリー
本記事では、GitHub ActionsとAWS OIDCを組み合わせたパスワードレスTerraform CI/CDを構築しました。
┌─────────────────────────────────────────────────────────────────┐
│PR駆動 Terraform CI/CD フロー │
├─────────────────────────────────────────────────────────────────┤
││
│ 開発者 GitHub AWS│
│ ──────── ──────── ────────│
│ git push ────────→ PR作成│
│ │ │
│ ┌────▼────┐OIDC Token ┌──────────┐ │
│ │ Plan│ ─────────────→ │ Plan Role│ │
│ │ Job │ ←───────────── │ (読み取り)│ │
│ └────┬────┘ └──────────┘ │
│ │ │
│ planコメント自動投稿 │
│ │ │
│ レビュアー┌────▼────┐ │
│ Approve ────────→ │ Merge │ │
│ └────┬────┘ │
│ │ push to main│
│ ┌────▼────┐OIDC Token ┌──────────┐ │
│ │ Apply │ ─────────────→ │Apply Role│ │
│ │ Job │ ←───────────── │(変更権限) │ │
│ └────┬────┘ └──────────┘ │
│ │ │
│ インフラ変更完了│
││
└─────────────────────────────────────────────────────────────────┘
構築した主なコンポーネント:
| コンポーネント | 役割 |
|---|---|
| OIDC Provider | GitHub ActionsのJWT → AWS一時認証情報の交換 |
| TerraformPlanRole | PR時のplan実行専用ロール(読み取り権限のみ) |
| TerraformApplyRole | mainブランチpush時のapply専用ロール(変更権限) |
| S3 + DynamoDB | Stateファイル管理・ロック機構 |
| GitHub Actions Workflow | plan/applyの自動化・PRコメント投稿 |
| CODEOWNERS + ブランチ保護 | 強制レビュー・ステータスチェック |
第1弾 → 第2弾で何が変わったか
| 観点 | 第1弾(手動apply) | 第2弾(自動apply) |
|---|---|---|
| apply実行者 | 人間(ローカルから実行) | GitHub Actions(自動) |
| 認証方式 | IAMユーザー(長期クレデンシャル) | OIDC(一時クレデンシャル) |
| レビュープロセス | 任意 | ブランチ保護で強制 |
| 監査証跡 | ローカル実行ログのみ | CloudTrail + GitHub Actions履歴 |
| クレデンシャル管理 | .aws/credentials(人間が管理) | 不要(OIDCが自動発行) |
最大の変化: クレデンシャルの管理負担がゼロになり、誰が・いつ・何を変更したかがコードとCIの両方で追跡できるようになりました。
GitHub Actions 方式の限界と適用場面
適している場面:
– GitHub を中心に開発フローが統一されているチーム
– 小〜中規模のインフラ(数十〜数百リソース)
– シンプルなCI/CDを短期間で構築したい場合
限界・課題:
| 課題 | 説明 |
|---|---|
| GitHub 依存 | GitHubの障害時はインフラデプロイが止まる |
| 複雑なApprovalフロー | 複数ステージのゲートや手動承認には追加実装が必要 |
| 長時間のapply | タイムアウト(6時間)制限あり |
8-5. 次のステップ(第3弾予告)
第3弾: AWS CodePipeline × CodeBuild で構築する Terraform CI/CD
第3弾ではAWSネイティブのCI/CDサービスを使った構成に移行します。
GitHub Actions 方式 vs CodePipeline 方式の使い分け基準
| 判断基準 | GitHub Actions | CodePipeline |
|---|---|---|
| GitHubを既に使っている | ✅ 最適 | 追加コスト |
| AWS環境に統一したい | 追加依存 | ✅ 最適 |
| 複雑なApprovalゲートが必要 | 実装コスト大 | ✅ 標準機能 |
| セキュリティ要件が厳しい | OIDC設定が必要 | ✅ AWS IAMで完結 |
| コスト最小化 | ✅ 無料枠あり | リソース費用あり |
| GitHubが使えない環境 | ❌ 不可 | ✅ 対応 |
一般的な推奨:
– スタートアップ・小規模チーム: GitHub Actions(セットアップが速い)
– エンタープライズ・大規模チーム: CodePipeline(AWS統合・監査・承認フロー)
第3弾で学ぶこと
- CodePipeline の構成: Source(CodeCommit/S3)→ Build(CodeBuild)→ Deploy
- CodeBuild でのTerraform実行: buildspec.yml の書き方
- Manual Approval アクション: 本番適用前の人手承認ゲート
- EventBridge との連携: apply完了通知・Slack通知
- IAM Roles for CodeBuild: EC2 Instance Profile方式でのAWS認証
- マルチ環境対応: dev/stg/prod を同一パイプラインで管理