GitHub Actions×OIDCでTerraform CI/CD構築 — PR駆動で複数人開発を安全に自動化

目次

PR駆動TerraformCI/CD — GitHub Actions+OIDCで複数人レビューフローを構築

AWS×Terraform 複数人開発シリーズ

関連シリーズ(前提知識):

Git/GitHub × Terraform 実践シリーズ(全5弾)も合わせてどうぞ:

Git入門 /
GitHub入門 /
ブランチ戦略 /
セキュリティ /
Terraform実践

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/CDGitHub Actions+OIDC・Plan/Apply Role分離・PRコメント自動投稿GitHub中心のチーム
第3弾(公開済み)CodePipeline×CodeBuildAWSネイティブなTerraform CI/CDパイプラインAWSネイティブ派のチーム

本記事では GitHub Actions + OIDC を採用する。その理由は3点ある。

  1. アクセスキー不要: AWS_ACCESS_KEY_IDをSecretsに保存する旧来の方式を廃し、一時的な認証トークンを使う
  2. 最小権限設計: PlanとApplyでIAM Roleを分離し、PR段階では読み取りのみ、mainマージ後にのみリソース変更を許可する
  3. GitHubネイティブ: PRコメント・ブランチ保護・CODEOWNERSなど、GitHubの既存ワークフローと自然に統合できる

1-2. 対象読者・前提知識

対象読者

  • Terraform×GitHubを使い始めており、CI/CDを導入してチーム開発を安全にしたいエンジニア
  • 個人または少人数で terraform apply を手動実行しているが、自動化・審査フローを導入したいチーム
  • GitHub ActionsとAWSを連携させたいが、OIDCの仕組みがよくわからないインフラ・DevOpsエンジニア

前提知識

本記事は以下の知識を前提としている。未習得の場合は先にリンク先を参照してほしい。

分野必要な知識参照先
Terraforminit / plan / apply の実行経験・リモートstateの概念Terraform実践 / 第1弾
GitHubPR・マージフローの理解・Actionsの基本構造(on: / jobs: / steps:GitHub入門 / ブランチ戦略
AWS IAMIAM Role・信頼ポリシー・最小権限の概念AWS公式ドキュメント
リモートstateS3+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/mainmainブランチからのみ許可(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_URLACTIONS_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コンソールでの操作手順

  1. AWSマネジメントコンソールにログインし、画面上部の検索バーに「IAM」と入力して IAM ダッシュボードを開く。
  2. 左側のナビゲーションから Identity providers をクリックする。
  3. 画面右上の Add provider ボタンをクリックする。
  4. Provider type で「OpenID Connect」を選択する。
  5. Provider URL フィールドに以下を入力し、Get thumbprint ボタンをクリックする:
    text
    https://token.actions.githubusercontent.com
  6. Audience フィールドに以下を入力する:
    text
    sts.amazonaws.com
  7. Thumbprint の値が自動入力されることを確認する(6938fd4d98bab03faadb97b34396831e3780aea1 など)。
  8. 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コンソールでの操作手順

  1. IAMダッシュボードの左ナビから Roles をクリックし、Create role ボタンを押す。
  2. Trusted entity type で「Web identity」を選択する。
  3. Identity provider ドロップダウンから token.actions.githubusercontent.com を選択する。
  4. Audience ドロップダウンから sts.amazonaws.com を選択する。
  5. Add condition をクリックし、以下の条件を追加する:
Condition keyOperatorValue
token.actions.githubusercontent.com:subStringLikerepo:myorg/terraform-repo:pull_request
  1. Next をクリックし、Attach permissions policies 画面で ReadOnlyAccess にチェックを入れる。
  2. さらに Create policy でカスタムポリシーを作成し(次項参照)、アタッチする。
  3. Role nameTerraformPlanRole と入力し、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:subStringLikerepo:myorg/terraform-repo:ref:refs/heads/main

Point: pull_requestref: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 validProvider URLのスペルミス(末尾スラッシュ不要)https://token.actions.githubusercontent.com(末尾スラッシュなし)に修正
Error: condition key mismatchsub条件の値が実際のワークフローと一致しない実際のGitHub組織名/リポジトリ名と一致しているか確認
AccessDenied: s3:GetObjectカスタムポリシーのS3 ARNが間違っているバケット名がリソースARNと完全一致しているか確認
ResourceConflict: terraform-state-lockDynamoDBの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_ID123456789012ロールARN構築用
AWS_REGIONap-northeast-1AWSリージョン指定
TF_STATE_BUCKETmyorg-terraform-stateS3バックエンド設定
TF_LOCK_TABLEterraform-state-lockDynamoDBロックテーブル
TF_PLAN_ROLE_ARNarn:aws:iam::123456789012:role/TerraformPlanRoleOIDC Plan Role
TF_APPLY_ROLE_ARNarn:aws:iam::123456789012:role/TerraformApplyRoleOIDC Apply Role

GitHub UIでの設定手順

  1. GitHubリポジトリページで Settings タブをクリックする。
  2. 左サイドバーの Secrets and variablesActions をクリックする。
  3. Variables タブをクリックする。
  4. New repository variable ボタンをクリックする。
  5. NameValue を入力し、Add variable ボタンを押す。
  6. 上記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

よくある初期化エラー

エラー原因対処
NoSuchBucketS3バケットが存在しない第1弾で作成したバケット名を確認
AccessDeniedローカルのAWS認証情報に権限がないaws sts get-caller-identityで認証確認
ResourceNotFoundExceptionDynamoDBテーブルが存在しない第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発火タイミング必要性
openedPR作成時最初のplan確認
synchronize追加コミット時コード修正後の再確認
reopenedクローズ→再オープン時古いPRの再評価
ready_for_reviewDraft→レビュー可能省略可(必要に応じて追加)

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-instancemain の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:AssumeRoleWithWebIdentityOIDCプロバイダーのArnが信頼ポリシーと不一致IAM > Roles > TerraformPlanRole > Trust relationships で確認
Error: Could not load credentials from any providerspermissions.id-token: writeが未設定workflowのpermissionsブロックにid-token: writeを追加
Error: Failed to get existing workspaces: S3 bucket not accessibleS3バケット名が間違っているvars.TF_STATE_BUCKETの値と実際のバケット名を照合
Error: terraform fmt check failedコードのインデントや整形が崩れているローカルでterraform fmt -recursiveを実行してコミット
Error: Resource not foundDynamoDBテーブルが存在しない第1弾で作成したテーブル名とvars.TF_LOCK_TABLEを確認
Error: GitHub token does not have permissionpermissions.pull-requests: writeが未設定workflowのpermissionsブロックにpull-requests: writeを追加
Plan: 0 to add, 0 to change, 0 to destroystateが最新で変更なし意図した変更が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作成・更新時のplanTerraformPlanRolepull_request読み取り専用で実行可能
mainマージ後のapplyTerraformApplyRoleref:refs/heads/main書き込み権限で実行可能
PR段階でapplyを試みるTerraformApplyRolepull_requestAssumeRole失敗(ブロック)

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-plantrue同じPRへの連続pushで古いplanを無駄に実行しない
terraform-applyfalse実行中のapplyをキャンセルするとstateが中途半端になる

groupキーの設計

用途groupキー例説明
PR単位でplanterraform-plan-${{ github.event.pull_request.number }}PR番号ごとに独立
mainブランチのapplyterraform-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通知ステップが含まれている。利用するには:

  1. Slack で Incoming Webhooks アプリをインストールしてWebhook URLを取得
  2. GitHub リポジトリ Settings → Secrets and variables → Actions → Secrets タブで SLACK_WEBHOOK_URL を登録
  3. ワークフロー実行時に自動通知が届くようになる

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-teamOrganization内のチーム
@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 の登録手順

  1. ワークフローを含むブランチでPRを一度作成して実行する
  2. GitHub が自動的に status check を検出してリストに表示
  3. Settings → Branches → ルール編集 → “Search for status checks” 欄に job名を入力
  4. 候補が表示されたら選択して保存

注意: 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.mdmain ブランチに配置すると、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
AssumeRoleWithWebIdentityOIDC認証でロールを引き受け
GetObject / PutObjectStateファイルの読み書きPlanRole / ApplyRole
CreateLockItemDynamoDBロックの取得TerraformApplyRole
DeleteLockItemDynamoDBロックの解放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メンバーの追加・削除・ロール変更
secretActions 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 ProviderGitHub ActionsのJWT → AWS一時認証情報の交換
TerraformPlanRolePR時のplan実行専用ロール(読み取り権限のみ)
TerraformApplyRolemainブランチpush時のapply専用ロール(変更権限)
S3 + DynamoDBStateファイル管理・ロック機構
GitHub Actions Workflowplan/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 ActionsCodePipeline
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 を同一パイプラインで管理

第1弾を復習する