- 1 Terraform 実践 — モジュール化・tfstate管理・GitHub Actions + OIDC CI/CDハンズオン
- 1.1 目次
- 1.2 Section 1: この記事について
- 1.3 Section 2: Terraformモジュール化 — 概念編
- 1.4 Section 3: モジュール化ハンズオン — ローカルVPCモジュール + 公式EC2モジュール
- 1.4.1 3-1. プロジェクト初期化
- 1.4.2 3-2. AWSコンソール確認: VPC手動作成ウィザード(対比用){#3-2}
- 1.4.3 3-3. ローカルモジュール作成: modules/vpc/main.tf
- 1.4.4 3-4. modules/vpc/variables.tf
- 1.4.5 3-5. modules/vpc/outputs.tf
- 1.4.6 3-6. root module: providers.tf
- 1.4.7 3-7. root module: main.tf — local vpc + 公式 EC2モジュール呼び出し
- 1.4.8 3-8. root module: variables.tf と outputs.tf
- 1.4.9 3-9. terraform init → plan → apply
- 1.4.10 3-10. AWSコンソールでVPC・EC2作成確認
- 1.4.11 3-11. destroy で全削除
- 1.5 Section 4: tfstate管理 — S3バックエンド + DynamoDBロック
- 1.5.1 4-1. ローカルtfstateの問題点
- 1.5.2 4-2. S3バックエンドアーキテクチャ
- 1.5.3 4-3. [ハンズオン] bootstrap: S3バケット + DynamoDBテーブルをTerraformで作成
- 1.5.4 4-4. backend.tf 設定
- 1.5.5 4-5. terraform init -migrate-state でローカル→S3移行
- 1.5.6 4-6. [AWSコンソール確認] S3オブジェクト・DynamoDB LockID
- 1.5.7 4-7. apply中のロック発動確認
- 1.5.8 4-8. terraform force-unlock の使いどころと注意
- 1.5.9 4-9. stateファイルの暗号化確認(SSE-KMS)
- 1.6 Section 5: terraform workspace — 環境分離(dev/stg/prod)
- 1.7 Section 6: GitHub Actions + OIDC — secretless AWS認証セットアップ
- 1.8 Section 7: GitHub Actions workflow実装 — plan/apply自動化
- 1.8.1 7-1. workflow 構成 — 完成形の全体像
- 1.8.2 7-2. permissions: id-token: write / contents: read の意味
- 1.8.3 7-3. aws-actions/configure-aws-credentials@v4 で role-to-assume
- 1.8.4 7-4. PR時 terraform plan ジョブ — ReadOnly ロール
- 1.8.5 7-5. plan 結果の PR コメント投稿
- 1.8.6 7-6. main マージ時 terraform apply ジョブ — apply ロール + environment protection
- 1.8.7 7-7. [動作確認] PR作成 → plan自動実行 → merge → apply自動実行
- 1.8.8 7-8. ブランチ戦略ベストプラクティス — 環境別 workflow 分離 vs matrix
- 1.9 Section 8: 完全IaC統合 — モジュール・バックエンド・OIDCをTerraformで一括管理
- 1.10 Section 9: トラブルシューティング
- 1.11 Section 10: まとめ・次のステップ・参考リンク
Terraform 実践 — モジュール化・tfstate管理・GitHub Actions + OIDC CI/CDハンズオン
- この記事: Terraform実践 — モジュール化・tfstate管理・OIDC CI/CD(続編)
- Step Functions シリーズ → 第1回: AWS Step Functions 入門
公開日: 2026-04-15 / 難易度: 中級 / 所要時間: 約180分
目次
- Section 1: この記事について
- 1-1. 前作との関係
- 1-2. この記事で学ぶこと
- 1-3. 前提条件
- 1-4. 構成全体図
- 1-5. 想定所要時間とコスト見積
- Section 2: Terraformモジュール化 — 概念編
- 2-1. なぜモジュール化するのか
- 2-2. module構文の基礎
- 2-3. ディレクトリ構成パターン
- 2-4. ローカルモジュール vs リモートモジュール
- 2-5. 公式AWSモジュール紹介
- 2-6. バージョン管理戦略
- 2-7. アンチパターン
- Section 3: モジュール化ハンズオン — ローカルVPCモジュール + 公式EC2モジュール
- 3-1. プロジェクト初期化
- 3-2. AWSコンソール確認: VPC手動作成ウィザード(対比用)
- 3-3. ローカルモジュール作成: modules/vpc/main.tf
- 3-4. modules/vpc/variables.tf
- 3-5. modules/vpc/outputs.tf
- 3-6. root module: providers.tf
- 3-7. root module: main.tf — local vpc + 公式 EC2モジュール呼び出し
- 3-8. root module: variables.tf と outputs.tf
- 3-9. terraform init → plan → apply
- 3-10. AWSコンソールでVPC・EC2作成確認
- 3-11. destroy で全削除
Section 1: この記事について
1-1. 前作との関係
前作「Terraform 基礎 — IaC入門ハンズオン」(WP ID:1120)では、Terraformの根幹となる概念——tfstate(State)、provider、resource、plan/apply/destroy サイクル——を習得しました。S3バケットをコードで作成し、EC2インスタンスを変数化して管理するところまでを実践しています。
本記事はその直接の続編です。前作で「動くコードを書ける」段階に達した読者が、次の壁——チーム開発・環境分離・自動化——を突破するための実践編として設計されています。
前作との位置づけ
| スキルレベル | 状態 | 担当記事 |
|---|---|---|
| 入門 | Terraformが何かを理解し、S3・EC2を1人でコード化できる | 前作(Terraform基礎) |
| 中級 | チームでTerraformを運用し、CIで自動デプロイできる | この記事(Terraform実践) |
| 上級 | Terragrunt・Atlantis・Terraform Cloudで大規模管理できる | 次回以降(シリーズ予定) |
前作を読まずにこの記事を始める場合は、少なくとも以下の知識を事前に確認してください: terraform init / plan / apply / destroy の意味、resource ブロックの書き方、variable と output の基本、tfstateが何を管理するか。
用語統一宣言
前作と本記事で揺れが生じやすい用語を以下のように統一します。
| 表記ゆれ | 本記事での統一表記 | 意味 |
|---|---|---|
| State / state file / terraform.tfstate | tfstate | Terraformが管理する状態ファイル |
| remote backend / S3 backend | S3バックエンド | tfstateをS3に保存する設定 |
| workspace / env | workspace | Terraform公式コマンド名に統一 |
| CI/CD pipeline / workflow | workflow | GitHub Actionsの文脈ではworkflow |
| Secret / Access Key | アクセスキー | AWS認証情報(OIDCで不要になるもの) |
この宣言以降は上記の統一表記を使用します。前作を参照する際も同様の読み替えをお願いします。
1-2. この記事で学ぶこと
本記事は3本柱で構成されています。
module構文でVPC/EC2設定を再利用可能なコンポーネントにまとめる。DRY原則とチーム標準化を実現。- tfstate管理(Section 4-5): S3+DynamoDBでtfstateをチーム共有・暗号化・ロック管理する。
terraform workspaceで開発/ステージング/本番を分離。 - OIDC CI/CD(Section 6-7): GitHub ActionsからAWSへアクセスキー不要(secretless)でデプロイ。IAM OIDC ProviderとAssumeRoleWithWebIdentityを活用。
各テーマは独立して学習可能ですが、Section 8で3つを統合した完全IaC構成に仕上げます。まずはSection 1-3(モジュール化)を一通り実践し、動くコードを手元に持ってから、残りのテーマに進むことを推奨します。
各テーマが解決する「現場の痛み」
| テーマ | 解決前の状態 | 解決後の状態 |
|---|---|---|
| モジュール化 | VPCのコードをdev/stg/prodで3コピー管理。変更時に漏れが発生する | module "vpc" に変数を渡すだけ。変更は1箇所で全環境に反映 |
| tfstate管理 | terraform.tfstate がローカルにしかなく、他のメンバーが apply できない | S3にtfstateを保存。誰でも同じ状態からapply可能 |
| OIDC CI/CD | AWS_ACCESS_KEY_ID をGitHub Secretsに保存。キー漏洩・ローテーション管理が必要 | OIDCで一時クレデンシャルを自動取得。アクセスキーがリポジトリに存在しない |
この3つを組み合わせることで、「ローカルPCで1人がterraform applyする運用」から「チームでPR-based、CI/CDによる自動デプロイ」へと進化します。
1-3. 前提条件
本記事を実践するには以下が必要です。
必須
- 前作「Terraform 基礎」の内容を習得済み(tfstate・resource・plan/applyを理解している)
- AWSアカウント(IAMユーザーに
AdministratorAccessポリシーが付与済み) - AWSアクセスキーの設定完了(
~/.aws/credentialsまたは環境変数) - GitHubアカウント + 新規リポジトリの作成権限
- Terraform 1.9系インストール済み(バージョン確認:
terraform version) - Git の基礎操作(clone / add / commit / push)
任意(Section 6-7 を実践する場合)
- GitHub Actionsを使ったことがある(
.github/workflows/の構造を知っている) - IAMロール・信頼ポリシーの概念を理解している
バージョン情報(2026年4月時点)
本記事で使用する主要コンポーネントのバージョンは以下の通りです。
| コンポーネント | バージョン | 備考 |
|---|---|---|
| Terraform | 1.9.x | terraform version で確認 |
| AWS Provider (hashicorp/aws) | ~> 5.0 | terraform-aws-modules との互換性 |
| terraform-aws-modules/vpc/aws | ~> 5.0 | 公式VPCモジュール |
| terraform-aws-modules/ec2-instance/aws | ~> 5.0 | 公式EC2モジュール |
| aws-actions/configure-aws-credentials | v4 | GitHub Actions用 |
1-4. 構成全体図
本記事で構築する最終的なCI/CDパイプラインの全体像を以下の表で示します。
| フェーズ | 操作主体 | 実行内容 | AWSへの影響 |
|---|---|---|---|
| ローカル開発 | 開発者 | コード編集・terraform validate | なし |
| PR作成 | GitHub Actions | terraform plan(ReadOnlyロール使用) | 読み取りのみ |
| PRコメント | GitHub Actions | planの差分をPRにコメント投稿 | なし |
| mainへのmerge | GitHub Actions | terraform apply(applyロール使用) | リソース変更 |
| デプロイ確認 | 開発者 | AWSコンソールでリソース確認 | なし |
構成図について: Section 6-2 に OIDC認証フロー図(テキスト表形式)を掲載しています。全体のデータフローはそちらを参照してください。
1-5. 想定所要時間とコスト見積
所要時間(目安)
| セクション | 内容 | 所要時間 |
|---|---|---|
| Section 1-3 | モジュール化 概念 + ハンズオン | 約60分 |
| Section 4-5 | tfstate管理 + workspace | 約45分 |
| Section 6-7 | OIDC + GitHub Actions CI/CD | 約60分 |
| Section 8 | 完全IaC統合 | 約15分 |
| 合計 | 約180分 |
コスト見積(月額)
| リソース | コスト | 備考 |
|---|---|---|
| S3バケット(tfstate保存) | ≈ $0.02 | 状態ファイルは数KB程度 |
| DynamoDBテーブル(ロック用) | ≈ $0.00 | PAY_PER_REQUEST、リクエスト数極小 |
| EC2 t3.micro(ハンズオン用) | ≈ $0.00 | ハンズオン後に即destroy |
| 合計 | ≈ $0.5/月以内 | 放置しなければほぼ無料 |
重要: EC2インスタンスは起動したままにすると料金が発生します。各ハンズオン終了後は必ず
terraform destroyを実行してください。Section 10-3 にコスト節約チェックリストを掲載しています。
Section 2: Terraformモジュール化 — 概念編
2-1. なぜモジュール化するのか
Terraformを使い始めると、最初はすべてのリソースをルートの main.tf に書きたくなります。VPC、サブネット、EC2、セキュリティグループ——それらが一つのファイルに混在すると、開発環境と本番環境で「ほぼ同じコード」が複製されていくことに気づきます。
モジュール化は、この問題を3つの観点で解決します。
① DRY原則(Don’t Repeat Yourself)
同じVPC構成を開発・ステージング・本番それぞれに書くと、修正が発生したときに3箇所を変更しなければなりません。モジュールにまとめれば1箇所の変更で済みます。
② 環境差分の吸収
モジュールを「VPCの設計図」として定義し、cidr_block や instance_type を変数(variable)として外部から渡せるようにすると、同じモジュールで開発(t3.micro)と本番(t3.large)を使い分けられます。
③ チーム標準化
「VPCを作るときはこのモジュールを使う」というルールを設けることで、チーム全体が同じセキュリティグループルール・タグ規則・サブネット設計を自動的に踏襲できます。新メンバーがゼロからVPCを書くリスクを排除できます。
モジュール化の前後比較
モジュール化によって何が変わるかを具体的な数字で見てみましょう。
| 比較項目 | モジュール化前 | モジュール化後 |
|---|---|---|
| VPCリソース定義の箇所数 | dev/stg/prod の3箇所に各100行 = 300行 | modules/vpc/main.tf の1箇所に100行 |
| CIDRを変更したい場合の修正箇所 | 3箇所(漏れのリスクあり) | 呼び出し側で1変数を変更するだけ |
| 新メンバーがVPCを追加する難易度 | main.tf 全体を理解する必要がある | module "vpc" ブロックに変数を渡すだけ |
| セキュリティグループルールの標準化 | 個人の裁量に依存 | モジュールに組み込まれ自動適用 |
| コードの再利用性 | ゼロ(コピペのみ) | 別プロジェクトにも source で参照可能 |
この表が示すように、モジュール化は「コードを短くするテクニック」ではなく、インフラのソフトウェアエンジニアリング化です。関数やクラスでロジックをカプセル化するのと同じ考え方を、インフラ定義に適用しています。
2-2. module構文の基礎
Terraform の module ブロックには4つの必須・推奨要素があります。
# ローカルモジュールを呼び出す例
module "vpc" {
source = "./modules/vpc" # ローカルパス(versionは不要)
# 変数(モジュールのvariables.tfで定義された入力)
cidr_block = "10.0.0.0/16"
availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
environment= var.environment
}
# Terraform Registry のリモートモジュールを呼び出す例
module "ec2" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 5.0" # リモートモジュールはversionの明示が必須
name = "my-instance"
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0] # モジュール出力を参照
}
各要素の役割
| 要素 | 必須/推奨 | 説明 |
|---|---|---|
source | 必須 | モジュールの場所。ローカルパス (./modules/vpc) またはRegistryパス (org/module/provider) |
version | 必須(リモート) | Registry・Githubモジュールは必ず指定。~> 5.0 は 5.x の最新を意味する |
| 変数(入力値) | 任意 | モジュールの variables.tf で定義された変数に値を渡す |
providers | 任意 | 特定のproviderエイリアスをモジュールに渡す場合に使用(マルチリージョン構成等) |
モジュールの出力を参照する方法
上記の例で module.vpc.public_subnet_ids[0] と書いているように、モジュールの出力値は module.<MODULE_NAME>.<OUTPUT_NAME> の形式で参照できます。これにより、VPCモジュールが作成したサブネットのIDをEC2モジュールに渡すことが自然なコードで表現できます。
2-3. ディレクトリ構成パターン
Terraform プロジェクトの標準的なディレクトリ構成を示します。
terraform-advanced/ # プロジェクトルート(root module)
├── main.tf# リソース定義・moduleブロック
├── variables.tf # 入力変数定義
├── outputs.tf# 出力値定義
├── providers.tf # required_providers・provider設定
├── terraform.tfvars# 変数の実際の値(git管理可)
├── .terraform.lock.hcl# providerバージョンロック(git管理必須)
├── .gitignore# .terraform/ と *.tfstate を除外
└── modules/ # ローカルモジュール群
├── vpc/
│├── main.tf # モジュール内のリソース定義
│├── variables.tf # モジュールの入力変数
│└── outputs.tf # モジュールの出力値
└── security_group/# 例:別モジュール
├── main.tf
├── variables.tf
└── outputs.tf
重要な規則
modules/ディレクトリ内の各モジュールは必ずmain.tf・variables.tf・outputs.tfの3ファイルを持つterraform.tfstateと.terraform/ディレクトリは.gitignoreに追加する(後述のS3バックエンド設定後はローカルに残らない).terraform.lock.hclは必ずgit管理する(チーム全員が同じproviderバージョンを使うための鍵)
2-4. ローカルモジュール vs リモートモジュール
モジュールの source には3種類の指定方法があります。
# パターン1: ローカルモジュール(./modules/ 配下)
module "vpc" {
source = "./modules/vpc"
# versionは指定しない(ローカルパスに対しては無効)
}
# パターン2: Terraform Registry(公式モジュール)
module "vpc_public" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
# パターン3: GitHubリポジトリ(プライベートモジュールのチーム共有に)
module "internal_vpc" {
source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=v1.2.0"
# versionブロックは使えない。ref= でタグを固定すること
}
使い分けの指針
| 方式 | 向いているケース | 注意点 |
|---|---|---|
| ローカルモジュール | 自プロジェクト専用の設定。外部共有不要 | バージョン管理は git タグで行う |
| Terraform Registry | 汎用的なAWSリソース(VPC・EC2・RDS等)。品質保証済み | version必須。~> で安全に固定 |
| GitHub | 組織内共有モジュール。非公開にしたい場合 | git認証設定が必要。ref= でタグ固定必須 |
2-5. 公式AWSモジュール紹介
Terraform Registry の terraform-aws-modules 組織は、AWSリソースの公式モジュール群を提供しています。これらのモジュールはHashiCorpが品質を審査した「Verified Module」であり、数千のプロジェクトで実戦テスト済みです。代表的なモジュールを以下に示します。
terraform-aws-modules/vpc/aws v5.x
VPC・パブリックサブネット・プライベートサブネット・IGW・NATゲートウェイ・ルートテーブルを一括構築します。マルチAZ構成・サブネットの分割・NATゲートウェイの有無などをすべて変数で制御でき、数十行のコードで本番グレードのVPCを構築できます。
本記事のSection 3ハンズオンでは「ローカルモジュールを自作する体験」を重視するため、VPCはローカルモジュールで実装します。本番環境では terraform-aws-modules/vpc/aws の使用を強く推奨します。
| 機能 | ローカルVPCモジュール(本記事ハンズオン用) | 公式VPCモジュール(本番推奨) |
|---|---|---|
| コード量 | 約70行(学習に適したシンプルな実装) | 約2000行(フル機能) |
| NATゲートウェイ | 非対応 | 対応(台数・高可用性を設定可) |
| VPCフローログ | 非対応 | 対応 |
| IPv6 | 非対応 | 対応 |
| コミュニティサポート | なし | 活発(GitHub Issues・PR多数) |
terraform-aws-modules/ec2-instance/aws v5.x
EC2インスタンスの作成に必要なAMI検索・セキュリティグループ関連付け・EBSボリューム設定・IAMインスタンスプロファイル連携を簡潔に記述できます。ami_ssm_parameter を指定するだけで最新のAmazon Linux 2023 AMIを自動選択できるため、AMI IDのハードコーディング(リージョン依存・定期更新が必要)を回避できます。Section 3のハンズオンではこのモジュールを実際に使用します。
terraform-aws-modules/security-group/aws v5.x
よく使われるセキュリティグループルール(http-80・https-443・ssh・mysql等)をプリセットとして提供します。ingress_with_cidr_blocks と egress_rules で直感的に記述でき、ルール名のタイポによるミスを防げます。terraform-aws-modules/ec2-instance/aws との組み合わせが一般的です。
2-6. バージョン管理戦略
リモートモジュールのバージョン指定には ~> 構文(ペシミスティック制約)を使います。
# ~> 5.0 は 5.0 以上 6.0 未満 を意味する
# パッチバージョン(5.0 → 5.1 → 5.9)は自動更新されるが
# メジャーバージョン(5.x → 6.0)の破壊的変更はブロックされる
module "ec2" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 5.0"
}
# providerも同様に固定する
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.9.0"
}
.terraform.lock.hcl の役割
terraform init を実行すると、実際にダウンロードされたproviderのバージョンとハッシュが .terraform.lock.hcl に記録されます。このファイルをgitにコミットしておくことで、version = "~> 5.0" の範囲内でもチーム全員が同一バージョンを使うことが保証されます。
Dependabotとの連携(推奨)
.github/dependabot.yml に package-ecosystem: terraform を追加すると、モジュールやproviderの新バージョンが出たときにDependabotがPRを自動作成します。これにより、バージョンアップの見落としを防げます。
バージョン表記の意味まとめ
Terraform の version 制約演算子を理解しておくことで、チームのバージョン管理方針を明確に表現できます。
| 記法 | 意味 | 推奨シーン |
|---|---|---|
= 5.1.0 | ちょうど v5.1.0 のみ | 完全固定(セキュリティ審査済み環境) |
>= 5.0 | v5.0 以上(上限なし) | 非推奨(破壊的変更をブロックできない) |
~> 5.0 | v5.0 以上 v6.0 未満 | 推奨(メジャー変更をブロック、マイナーは許容) |
~> 5.1 | v5.1 以上 v5.2 未満 | より厳密に固定したい場合 |
>= 5.0, < 6.0 | v5.0 以上 v6.0 未満 | ~> 5.0 と同義(より明示的な書き方) |
一般的な推奨は ~> 5.0 です。.terraform.lock.hcl とセットで管理することで、制約の範囲内であっても実際の使用バージョンを固定できます。
2-7. アンチパターン
canonical_specが指摘する3つのアンチパターンを解説します。これらは「知らないとハマる」系のミスで、本番環境での実害(予期しないリソース削除、セキュリティホール、再現できないビルド)に直結します。
アンチパターン1: count と for_each の混在
同一モジュール内(またはその呼び出し元)で count と for_each を混在させると、tfstateのアドレッシングが破綻します。
# NG: count で作ったリソースのアドレス
module.servers[0].aws_instance.this
# OK: for_each で作ったリソースのアドレス
module.servers["web"].aws_instance.this
count を使うと要素の削除・挿入でインデックスがずれ、意図しないリソースのdestroyが発生します。ループが必要な場合は常に for_each を使用してください。
アンチパターン2: リモートモジュールのversion未指定
# NG: version を指定しない
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# versionを省略すると最新版が使われる
}
このまま半年後に terraform init を実行すると、メジャーバージョンアップされた破壊的変更が含まれる版がダウンロードされ、apply時に予期しないエラーや設定変更が発生します。リモートモジュールには必ず version を指定してください。
アンチパターン3: module間の循環依存
moduleAの出力をmoduleBが参照し、moduleBの出力をmoduleAが参照するとTerraformはグラフ解決に失敗します。循環依存が発生している場合は、data ソースを使って既存リソースから値を取得する設計に切り替えてください。
# NG: module_a の出力を module_b が参照し、module_b の出力を module_a が参照する
# → Terraform は依存グラフを解決できずエラーになる
# OK: data source で既存VPCを参照(循環を断ち切る)
data "aws_vpc" "existing" {
tags = {
Name = "production-vpc"
}
}
module "security_group" {
source = "./modules/security_group"
# module.vpc.vpc_id の代わりに data source から取得することで循環を回避
vpc_id = data.aws_vpc.existing.id
}
data ソースはTerraformが管理していない既存リソース(他チームが手動作成したVPC、別のTerraformプロジェクトが管理するリソース等)を参照する際にも有効です。循環依存の解消だけでなく、クロスプロジェクト参照の標準パターンとして覚えておいてください。
Section 3: モジュール化ハンズオン — ローカルVPCモジュール + 公式EC2モジュール
このハンズオンでは以下を構築します。
- ローカルVPCモジュール(
./modules/vpc/): パブリックサブネット2つを持つVPCを自作モジュールで定義 - 公式EC2モジュール(
terraform-aws-modules/ec2-instance/aws ~> 5.0): VPCモジュールの出力(subnet_id)を受け取りEC2を起動
二つのモジュールを組み合わせることで「モジュール間の出力渡し」を体験するのがこのハンズオンの核心です。
このハンズオンで作成されるAWSリソース一覧
| リソース | Terraform リソース名 | 作成元 | 用途 |
|---|---|---|---|
| VPC | aws_vpc.this | ローカルVPCモジュール | EC2・サブネットを収容するネットワーク |
| インターネットゲートウェイ | aws_internet_gateway.this | ローカルVPCモジュール | VPCからインターネットへの出口 |
| パブリックサブネット × 2 | aws_subnet.public["ap-northeast-1a"] 他 | ローカルVPCモジュール | EC2を配置するサブネット(各AZ1つ) |
| ルートテーブル | aws_route_table.public | ローカルVPCモジュール | IGWへのデフォルトルートを設定 |
| ルートテーブル関連付け × 2 | aws_route_table_association.public | ローカルVPCモジュール | サブネットとルートテーブルの紐付け |
| EC2インスタンス | aws_instance (内部) | 公式EC2モジュール | Amazon Linux 2023、t3.micro |
合計 8リソースが terraform apply 1回で作成されます。terraform plan 実行時に Plan: 8 to add と表示されることを確認してください。
3-1. プロジェクト初期化
# プロジェクトディレクトリの作成
mkdir terraform-advanced && cd terraform-advanced
git init
# モジュール用ディレクトリの作成
mkdir -p modules/vpc
# .gitignore の作成
cat > .gitignore << 'EOF'
.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
.terraform.lock.hcl.bak
EOF
補足:
.terraform.lock.hclは.gitignoreに含めません。このファイルはgitで管理することでチームのバージョン整合性を保ちます。
3-2. AWSコンソール確認: VPC手動作成ウィザード(対比用){#3-2}
Terraformの価値を体感するために、まずAWSコンソールでVPCを手動作成する手順を確認します(後でTerraformで自動再現します)。
コンソール操作手順
- AWSマネジメントコンソール → VPC → 左メニュー「VPC」→「VPCを作成」
- 設定内容:
- 作成するリソース: VPCなど(VPC+サブネット+IGW+ルートテーブルを一括作成)
- 名前タグの自動生成:
handson - IPv4 CIDR ブロック:
10.0.0.0/16 - アベイラビリティゾーン数: 2
- パブリックサブネット数: 2
- プライベートサブネット数: 0(今回は不要)
- NATゲートウェイ: なし(コスト節約)
- 「VPCを作成」をクリック
- 作成完了後、VPC ID と サブネットID(2つ)をメモ
コンソール作成の問題点
- 設定値がGUI上にのみ存在し、コードとして再利用できない
- 「なぜその設定にしたか」がドキュメントに残らない
- 同じ構成を別リージョンや別アカウントに再現するには、手順を繰り返す必要がある
この問題をTerraformのモジュール化が解決します。上記で手動作成したVPCは後で削除してください(Terraformで同等のものを再作成します)。
手動作成とTerraformモジュールの比較
コンソールの「VPCなど」オプションで作成したときに内部的に生成されたリソースと、本ハンズオンのローカルVPCモジュールが作成するリソースを対比します。
| AWSリソース | コンソール手動作成 | ローカルVPCモジュール | 差異 |
|---|---|---|---|
| VPC | ○ | ○ | 同等 |
| インターネットゲートウェイ | ○ | ○ | 同等 |
| パブリックサブネット(2AZ) | ○ | ○ | 同等 |
| プライベートサブネット | ○(デフォルト0に設定) | × | 今回は不要のため省略 |
| NATゲートウェイ | ○(デフォルトなしに設定) | × | 今回は不要のため省略 |
| ルートテーブル | ○ | ○ | 同等 |
| S3 VPCエンドポイント | ○(コンソール自動提案) | × | 今回は省略 |
コンソールのウィザードは「便利な追加オプション」を自動提案しますが、Terraformモジュールは必要なリソースのみを明示的に定義できます。「このコードに書いてあるリソースだけが作成される」という透明性がIaCの利点の一つです。
3-3. ローカルモジュール作成: modules/vpc/main.tf
# modules/vpc/main.tf
# ローカルVPCモジュール: パブリックサブネット2つを持つVPCを作成する
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# VPC本体
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support= true
tags = merge(var.tags, {
Name = "${var.name}-vpc"
})
}
# インターネットゲートウェイ(パブリックサブネットへの通信に必要)
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, {
Name = "${var.name}-igw"
})
}
# パブリックサブネット(AZの数だけ作成)
resource "aws_subnet" "public" {
for_each = toset(var.availability_zones)
vpc_id= aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, index(var.availability_zones, each.value))
availability_zone = each.value
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${each.value}"
Tier = "public"
})
}
# パブリック用ルートテーブル
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = merge(var.tags, {
Name = "${var.name}-public-rtb"
})
}
# ルートテーブルとサブネットの関連付け
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id= each.value.id
route_table_id = aws_route_table.public.id
}
3-4. modules/vpc/variables.tf
# modules/vpc/variables.tf
variable "name" {
description = "VPC・関連リソースの名前プレフィックス"
type = string
}
variable "cidr_block" {
description = "VPCのIPv4 CIDRブロック(例: 10.0.0.0/16)"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrnetmask(var.cidr_block))
error_message = "cidr_block は有効なCIDR形式でなければなりません(例: 10.0.0.0/16)。"
}
}
variable "availability_zones" {
description = "使用するアベイラビリティゾーンのリスト(最低2つ推奨)"
type = list(string)
default = ["ap-northeast-1a", "ap-northeast-1c"]
}
variable "tags" {
description = "すべてのリソースに付与する共通タグ"
type = map(string)
default = {}
}
3-5. modules/vpc/outputs.tf
# modules/vpc/outputs.tf
# VPCモジュールの出力値(呼び出し元からは module.vpc.<OUTPUT_NAME> で参照)
output "vpc_id" {
description = "作成したVPCのID"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "作成したパブリックサブネットのIDリスト"
value = [for s in aws_subnet.public : s.id]
}
output "public_subnet_cidr_blocks" {
description = "作成したパブリックサブネットのCIDRブロックリスト"
value = [for s in aws_subnet.public : s.cidr_block]
}
output "internet_gateway_id" {
description = "作成したインターネットゲートウェイのID"
value = aws_internet_gateway.this.id
}
3-6. root module: providers.tf
# providers.tf(プロジェクトルートに配置)
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "terraform-advanced"
ManagedBy= "terraform"
Environment = var.environment
}
}
}
default_tags ブロックを使うと、provider 配下のすべてのリソースに共通タグが自動付与されます。各リソースで tags を個別に書く手間を省きつつ、タグの一貫性を保てます(Terraform AWS Provider 3.38.0以降で利用可能)。
3-7. root module: main.tf — local vpc + 公式 EC2モジュール呼び出し
# main.tf(プロジェクトルートに配置)
# ローカルVPCモジュールを呼び出す
module "vpc" {
source = "./modules/vpc"
name= "${var.environment}-handson"
cidr_block= var.vpc_cidr
availability_zones = var.availability_zones
tags = {
Environment = var.environment
}
}
# 公式EC2モジュールを使ってEC2インスタンスを作成
# VPCモジュールの出力(public_subnet_ids)をそのまま渡している
module "ec2" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 5.0"
name = "${var.environment}-handson-ec2"
instance_type = var.instance_type
# モジュール間の出力渡し: vpc モジュールのサブネットIDを ec2 モジュールへ
subnet_id = module.vpc.public_subnet_ids[0]
# 最新のAmazon Linux 2023 AMI を自動選択
ami_ssm_parameter = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
associate_public_ip_address = true
tags = {
Environment = var.environment
Role = "handson"
}
}
subnet_id = module.vpc.public_subnet_ids[0] の一行が、VPCモジュールとEC2モジュールを接続しています。module.vpc の outputs.tf で定義した public_subnet_ids を、別の module.ec2 の入力として渡しています。このように、Terraformはモジュール間の依存関係を自動的に解決し、VPCが作成されてからEC2を作成する順序を保証します。3-8. root module: variables.tf と outputs.tf
# variables.tf(プロジェクトルートに配置)
variable "aws_region" {
description = "デプロイ先のAWSリージョン"
type = string
default = "ap-northeast-1"
}
variable "environment" {
description = "環境名(dev / stg / prod)"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "VPCのCIDRブロック"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "使用するAZ一覧"
type = list(string)
default = ["ap-northeast-1a", "ap-northeast-1c"]
}
variable "instance_type" {
description = "EC2インスタンスタイプ(ハンズオン用: t3.micro)"
type = string
default = "t3.micro"
}
# outputs.tf(プロジェクトルートに配置)
output "vpc_id" {
description = "作成したVPCのID"
value = module.vpc.vpc_id
}
output "public_subnet_ids" {
description = "パブリックサブネットのIDリスト"
value = module.vpc.public_subnet_ids
}
output "ec2_public_ip" {
description = "EC2インスタンスのパブリックIPアドレス"
value = module.ec2.public_ip
}
output "ec2_instance_id" {
description = "EC2インスタンスID"
value = module.ec2.id
}
terraform.tfvars の作成(任意)
variables.tf のデフォルト値を変更したい場合は terraform.tfvars に実際の値を記述します。
# terraform.tfvars(プロジェクトルートに配置)
# デフォルト値(variables.tf)と異なる値のみ書けばよい
aws_region= "ap-northeast-1"
environment = "dev"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
instance_type= "t3.micro"
注意:
terraform.tfvarsは通常 git で管理しても問題ありませんが、ファイル名が*.auto.tfvarsのものや、シークレット値(パスワード、APIキー等)を含む場合は.gitignoreに追加してください。ファイル構成の確認: ここまでで以下のファイルが揃っているはずです。
treeまたはls -Rで確認してください。
terraform-advanced/
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
├── .gitignore
└── modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf
3-9. terraform init → plan → apply
# Step 1: 初期化(providerとリモートモジュールをダウンロード)
terraform init
# 出力例(成功時):
# Initializing the backend...
# Initializing modules...
# Downloading registry.terraform.io/terraform-aws-modules/ec2-instance/aws 5.x.x ...
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.0"...
# - Installed hashicorp/aws v5.x.x (signed by HashiCorp)
# Terraform has been successfully initialized!
# Step 2: 変更内容の確認(リソースは作成されない)
terraform plan
# 出力例(成功時):
# Plan: 8 to add, 0 to change, 0 to destroy.
# (VPC/IGW/Subnet×2/RouteTable/RouteTableAssociation×2 + EC2 = 計8リソース)
# Step 3: 実際にリソースを作成(確認プロンプトで yes を入力)
terraform apply
# 自動承認する場合(CI環境等):
# terraform apply -auto-approve
# apply完了後に出力値を確認
terraform output
# vpc_id = "vpc-0123456789abcdef0"
# public_subnet_ids = tolist(["subnet-aaa...", "subnet-bbb..."])
# ec2_public_ip = "54.123.456.789"
# ec2_instance_id = "i-0123456789abcdef0"
apply 完了後の tfstate 確認(任意)
terraform apply が完了すると、プロジェクトルートに terraform.tfstate ファイルが生成されます(Section 4でS3バックエンドに移行するまでの一時ファイルです)。
terraform state list コマンドで、Terraformが管理しているリソースの一覧を確認できます。
module.ec2.aws_instance.this[0]— 公式EC2モジュールが管理するインスタンスmodule.vpc.aws_internet_gateway.this— ローカルVPCモジュールのIGWmodule.vpc.aws_route_table.public— ローカルVPCモジュールのルートテーブルmodule.vpc.aws_subnet.public["ap-northeast-1a"]— for_eachで作成したサブネット(AZがキー)
module.<name>. というプレフィックスで、どのモジュールが作成したリソースかがtfstate上で明示されています。これにより、複数のモジュールを使っていても、どのモジュールのリソースかを追跡できます。
running 状態になるまで待ってからコンソールを確認してください。3-10. AWSコンソールでVPC・EC2作成確認
VPCの確認
- AWSマネジメントコンソール → VPC → 左メニュー「VPC」
- 検索フィルターで
dev-handson-vpc(またはタグEnvironment: dev)を検索 - 確認事項:
- IPv4 CIDR:
10.0.0.0/16 - サブネット:
ap-northeast-1aとap-northeast-1cに各1つ(パブリック) - インターネットゲートウェイ: VPCにアタッチ済み
- ルートテーブル:
0.0.0.0/0→ IGWの経路が存在
EC2の確認
- AWSマネジメントコンソール → EC2 → 「インスタンス」
- 名前フィルターで
dev-handson-ec2を検索 - 確認事項:
- 状態:
実行中(running) - インスタンスタイプ:
t3.micro - パブリックIPアドレス:
terraform output ec2_public_ipで取得した値と一致 - サブネット: VPCモジュールが作成したサブネットID
モジュール出力の確認(terraform output コマンド)
apply完了後、モジュール間の出力が正しく連鎖していることを terraform output で確認できます。
| terraform output の値 | 参照元 | 参照先 |
|---|---|---|
vpc_id | module.vpc.vpc_id → aws_vpc.this.id | root outputs.tf |
public_subnet_ids | module.vpc.public_subnet_ids → aws_subnet.public[*].id | EC2モジュールの subnet_id に渡される |
ec2_public_ip | module.ec2.public_ip | EC2インスタンスのパブリックIP |
EC2コンソールの「ネットワーキング」タブを開き、「サブネットID」が terraform output public_subnet_ids の値と一致していることを確認してください。これによりVPCモジュールの出力がEC2モジュールに正しく渡されたことが実証されます。
コンソール確認 vs 前作の手動作成との比較
コンソールで「VPC作成ウィザード」を使って作成したVPCと、Terraformが作成したVPCを並べて比較してみてください。設定値が同じであることが確認できるはずです。違いは再現性にあります。Terraform で作成したものは main.tf に全設定が記録されており、terraform destroy → terraform apply でいつでも完全に同じ状態に戻せます。
また、AWSコンソール上ではEC2インスタンスに Project: terraform-advanced・ManagedBy: terraform・Environment: dev の3つのタグが自動付与されていることを確認できます。これは providers.tf に設定した default_tags ブロックの効果です。各リソースで個別に tags を書かなくても共通タグが適用されていることに注目してください。
タグ設計のベストプラクティス
| タグキー | 値の例 | 目的 |
|---|---|---|
Project | terraform-advanced | コスト配分タグ(AWSコスト管理で集計可能) |
ManagedBy | terraform | 手動作成リソースとの識別 |
Environment | dev / stg / prod | 環境別のリソースフィルタリング |
Owner | team-infra | 担当チームの明示 |
default_tags でプロジェクト共通タグを設定し、各モジュールの tags 変数で環境固有タグを追加するのが推奨パターンです。重複したタグキーがある場合は個別リソースの tags が優先されます。
3-11. destroy で全削除
ハンズオン終了後は必ずリソースを削除してください。
# 全リソースを削除(確認プロンプトで yes を入力)
terraform destroy
# 削除対象リソースを事前に確認したい場合:
terraform plan -destroy
# 出力例(destroyの確認メッセージ):
# Plan: 0 to add, 0 to change, 8 to destroy.
# Do you really want to destroy all resources?
#Terraform will destroy all your managed infrastructure, as shown above.
#There is no undo. Only 'yes' will be accepted to confirm.
#
# Enter a value: yes
#
# Destroy complete! Resources: 8 destroyed.
手動作成VPCの削除: 3-2で手動作成したVPCも忘れずに削除してください。VPCコンソール → 該当VPC選択 → 「アクション」→「VPCの削除」(関連リソース含め一括削除できます)。
modules/vpc/の3ファイル(main.tf / variables.tf / outputs.tf)が完成した- [ ]
terraform applyでVPC・EC2が作成された - [ ] AWSコンソールでVPC・EC2の存在を確認した
- [ ]
module.vpc.public_subnet_ids[0]がEC2のsubnet_idに渡されることを理解した - [ ]
terraform destroyで全リソースを削除した
Section 1〜3でモジュール化の基礎を習得しました。ここで習得したスキルを整理しておきましょう。
Section 1〜3 で習得したスキル
| スキル | 学んだ場所 | 本番での活用シーン |
|---|---|---|
module ブロックの書き方 | Section 2-2 | あらゆるモジュール化で使う基本構文 |
| ローカルモジュールの3ファイル構成 | Section 2-3, 3-3〜3-5 | 組織内の共通モジュール作成 |
| 公式モジュールのバージョン固定 | Section 2-6 | 再現性のあるインフラ構築 |
| モジュール間の出力渡し | Section 3-7 | VPC→EC2のような依存関係の表現 |
for_each によるマルチAZサブネット | Section 3-3 | 本番グレードのVPC構成 |
default_tags による共通タグ | Section 3-6 | コスト配分タグの一括管理 |
terraform state list | Section 3-9 | モジュール管理状態の確認 |
次のSection 4-5では、このプロジェクトのtfstateをS3に移行し、チームで安全に共有する方法を実践します。現在ローカルにある terraform.tfstate を、S3バックエンドに移行する terraform init -migrate-state コマンドも体験します。
(Section 4以降は ashigaru2〜4 が担当します)
Section 4: tfstate管理 — S3バックエンド + DynamoDBロック
tfstateファイルは Terraform がインフラの現状を把握するための「唯一の情報源」だ。
ローカルに置いたままチームで運用すると、様々な問題が発生する。
このセクションでは、tfstate を AWS S3 に移行し、DynamoDB でロックを管理するベストプラクティスを実践する。
4-1. ローカルtfstateの問題点
terraform apply を実行すると、デフォルトで作業ディレクトリ直下の terraform.tfstate にインフラ状態が記録される。
開発者1人・手元環境だけならこれでも動くが、チームで運用した瞬間に3つの根本的な問題が顕在化する。
① チーム共有不可 — 状態の不整合
terraform.tfstate はローカルファイルだ。Aさんが apply して EC2 を作成しても、その変更は Bさんのファイルには反映されない。
BさんがそのままPlanを実行すると「EC2が存在しない」前提で差分が計算され、重複リソース作成や意図しない変更が起きる。
Gitにtfstateをコミットする解決策を取ろうとするチームもいるが、同時 push 時のコンフリクト解消ミスで状態が壊れるリスクがある。
② 機密値の平文保存
tfstateにはリソースの全属性が記録される。RDSのパスワード、IAMアクセスキー、Secrets Manager の値まで平文で格納されることがある。
Gitリポジトリに誤ってコミットされた場合、機密情報が恒久的に履歴に残る。
③ 同時 apply による競合
2人が同じリソースに対して同時に apply を実行すると、tfstate への書き込みが競合しファイルが破損する可能性がある。
ロック機構なしでの並行操作は「最後に書いた人が勝つ」状態になり、一方の変更が完全に失われる事故につながる。
ポイント: S3 + DynamoDB の役割分担
| 問題 | 解決策 | 担当 AWS リソース |
|——|——–|—————–|
| チーム共有不可 | リモートバックエンドで一元管理 | S3 バケット |
| 機密値の平文保存 | バケット暗号化(SSE-KMS) | S3 + KMS |
| 同時 apply 競合 | 状態ロック | DynamoDB テーブル |
4-2. S3バックエンドアーキテクチャ
S3バックエンドを使用したときのアーキテクチャを整理しておく。
コンポーネント構成
ローカル開発環境(開発者A・B)から、共通の AWS Cloud 内リソースへアクセスする形だ。
AWS Cloud 内には次の2リソースが配置される。
- S3 バケット: tfstate を保存(SSE-KMS 暗号化・バージョニング有効)
- DynamoDB テーブル: ロック管理(
apply中のみLockIDアイテムが存在)
処理フローは次の通りだ。
- 開発者Aが
terraform applyを開始する - TerraformがDynamoDBにLockIDを書き込む(ロック取得)
- S3からtfstateを読み込み、差分を計算して適用する
- S3のtfstateを更新する
- DynamoDBのLockIDを削除する(ロック解放)
- その間に開発者Bが
applyを試みると、DynamoDBにLockIDが存在するためエラーとなり待機または停止する
4-3. [ハンズオン] bootstrap: S3バケット + DynamoDBテーブルをTerraformで作成
ここに重要な問題がある。「S3バケットとDynamoDBテーブル自体をTerraformで管理したい」 というニーズだ。
しかし、S3バックエンドを使うにはS3バケットが先に存在していなければならない。
これを bootstrap問題 と呼ぶ。
解決策は「bootstrap層」を別ディレクトリとして作成し、最初の1回だけローカルstateで適用する ことだ。
以降はS3 backend + DynamoDB でmain層を管理する。
プロジェクトは terraform-advanced/ 直下に2つのサブディレクトリを持つ。bootstrap/ は S3/DynamoDB をローカルstateで作成する初回限り用の層、main/ は本番リソースを S3 backend で管理する主要作業層だ
(各ディレクトリに main.tf / variables.tf / outputs.tf 等を配置する)。
bootstrap/variables.tf
# bootstrap/variables.tf
variable "aws_region" {
description = "AWS リージョン"
type = string
default = "ap-northeast-1"
}
variable "project_name" {
description = "プロジェクト名(リソース名のプレフィックスに使用)"
type = string
default = "terraform-advanced"
}
variable "account_id" {
description = "AWS アカウントID(バケット名の一意性確保に使用)"
type = string
}
bootstrap/main.tf
# bootstrap/main.tf
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# bootstrap層はローカルstateを使用(意図的にバックエンド指定なし)
}
provider "aws" {
region = var.aws_region
}
# ────────────────────────────────────────────
# KMS キー(tfstate 暗号化用)
# ────────────────────────────────────────────
resource "aws_kms_key" "tfstate" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 10
enable_key_rotation = true
tags = {
Name = "${var.project_name}-tfstate-kms"
Project = var.project_name
}
}
resource "aws_kms_alias" "tfstate" {
name = "alias/${var.project_name}-tfstate"
target_key_id = aws_kms_key.tfstate.key_id
}
# ────────────────────────────────────────────
# S3 バケット(tfstate 保存先)
# ────────────────────────────────────────────
resource "aws_s3_bucket" "tfstate" {
# バケット名はグローバルユニークである必要がある
bucket = "${var.project_name}-tfstate-${var.account_id}-${var.aws_region}"
# 誤削除防止: 本番バケットはコンソールやCLIから直接削除できなくなる
force_destroy = false
tags = {
Name = "${var.project_name}-tfstate"
Project = var.project_name
}
}
# バージョニング(誤上書き時のロールバック用)
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
# SSE-KMS 暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
}
# Bucket Key を有効化して KMS API コールを削減(コスト最適化)
bucket_key_enabled = true
}
}
# パブリックアクセスブロック(全方向でブロック)
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls= true
restrict_public_buckets = true
}
# ────────────────────────────────────────────
# DynamoDB テーブル(state ロック管理)
# ────────────────────────────────────────────
resource "aws_dynamodb_table" "tfstate_lock" {
name= "${var.project_name}-tfstate-lock"
billing_mode = "PAY_PER_REQUEST" # On-Demand(低頻度アクセスに最適)
# Terraform が使用するロックキー(この名前は変更不可)
hash_key = "LockID"
attribute {
name = "LockID"
type = "S" # String 型
}
tags = {
Name = "${var.project_name}-tfstate-lock"
Project = var.project_name
}
}
bootstrap/outputs.tf
# bootstrap/outputs.tf
output "tfstate_bucket_name" {
description = "tfstate 保存先 S3 バケット名"
value = aws_s3_bucket.tfstate.id
}
output "tfstate_bucket_arn" {
description = "tfstate 保存先 S3 バケット ARN"
value = aws_s3_bucket.tfstate.arn
}
output "dynamodb_table_name" {
description = "state ロック用 DynamoDB テーブル名"
value = aws_dynamodb_table.tfstate_lock.name
}
output "kms_key_arn" {
description = "tfstate 暗号化用 KMS キー ARN"
value = aws_kms_key.tfstate.arn
sensitive= true
}
bootstrap層を適用する。
# bootstrap 層を適用(ローカルstate で実行)
cd bootstrap
# アカウントIDを取得
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "Account ID: ${AWS_ACCOUNT_ID}"
# 初期化と適用
terraform init
terraform plan -var="account_id=${AWS_ACCOUNT_ID}"
terraform apply -var="account_id=${AWS_ACCOUNT_ID}"
# 出力値を確認(次のステップで使用する)
terraform output
# tfstate_bucket_name = "terraform-advanced-tfstate-123456789012-ap-northeast-1"
# dynamodb_table_name = "terraform-advanced-tfstate-lock"
# kms_key_arn = <sensitive>
注意: bootstrap層のstateについて
bootstrap層自体のtfstate(bootstrap/terraform.tfstate)はローカルに残る。
このファイルは Gitで管理するか、手動でS3にバックアップしておくことを推奨する。
bootstrap層は初期構築後ほとんど変更しないため、ローカルstate管理でも実害は少ない。
4-4. backend.tf 設定
bootstrap層で作成した S3 バケットと DynamoDB テーブルを、main 層のバックエンドとして設定する。
# main/backend.tf
terraform {
backend "s3" {
# ──────────────────────────────────────
# S3 設定
# ──────────────────────────────────────
# tfstate を保存する S3 バケット名
bucket = "terraform-advanced-tfstate-123456789012-ap-northeast-1"
# S3 内のオブジェクトキー(パス)
# workspace 使用時は自動的に env:/<workspace>/<key> になる
key = "terraform.tfstate"
# バケットのリージョン
region = "ap-northeast-1"
# ──────────────────────────────────────
# 暗号化設定
# ──────────────────────────────────────
# tfstate の暗号化を強制
encrypt = true
# SSE-KMS に使用する KMS キー ARN(省略時は aws/s3 デフォルトキーを使用)
kms_key_id = "arn:aws:kms:ap-northeast-1:123456789012:alias/terraform-advanced-tfstate"
# ──────────────────────────────────────
# DynamoDB ロック設定
# ──────────────────────────────────────
# state ロックに使用する DynamoDB テーブル名
dynamodb_table = "terraform-advanced-tfstate-lock"
}
}
各パラメータの意味を表にまとめる。
| パラメータ | 必須 | 説明 |
|---|---|---|
bucket | ✅ | tfstate を保存する S3 バケット名 |
key | ✅ | S3 内のオブジェクトキー(パス) |
region | ✅ | バケットが存在する AWS リージョン |
encrypt | 推奨 | true で tfstate の暗号化を強制 |
kms_key_id | 推奨 | SSE-KMS に使用する KMS キー ARN またはエイリアス。省略時はデフォルトキー(aws/s3) |
dynamodb_table | 推奨 | state ロック用 DynamoDB テーブル名(Terraform 1.9 以前では必須) |
Terraform 1.10 以降: S3 ネイティブロックについて
Terraform 1.10 で S3 ネイティブロック(use_lockfile = true)が実験的機能として導入された。
DynamoDB テーブルを別途作成せず、S3 の条件付き書き込み(If-None-Match ヘッダー)で .tflock ファイルを生成しロックを実現する。
Terraform 1.10+ では backend "s3" ブロックに use_lockfile = true を追加するだけでよく、dynamodb_table パラメータは省略できる。.tflock 拡張子を持つロックファイルが tfstate と同じパスに作成される。
現時点(2026年4月)では DynamoDB ロックも引き続きサポートされており、両方を同時設定して移行期間を設けることも可能だ。
既存プロジェクトでは DynamoDB ロックの実績が豊富なため、このハンズオンでは DynamoDB ロックをメインに解説する。
4-5. terraform init -migrate-state でローカル→S3移行
backend.tf を作成したら、terraform init -migrate-state でローカルのtfstateをS3に移行する。
この操作は慎重に行う必要がある。移行前のローカルstateがなくなると、Terraformがリソースを認識できなくなる。
# ──────────────────────────────────────────────────────────
# ステップ1: 移行前のバックアップを取得(state 紛失防止)
# ──────────────────────────────────────────────────────────
cd main
# ローカル tfstate のバックアップを作成
cp terraform.tfstate terraform.tfstate.backup.$(date +%Y%m%d_%H%M%S)
ls -la terraform.tfstate*
# terraform.tfstate
# terraform.tfstate.backup.20260415_103000
# ──────────────────────────────────────────────────────────
# ステップ2: backend.tf を確認してから init を実行
# ──────────────────────────────────────────────────────────
cat backend.tf # bucket名・key・regionが正しいか確認
terraform init -migrate-state
# ↓ 対話形式でマイグレーションの確認が求められる
#
# Initializing the backend...
# Terraform detected that the backend type changed from "local" to "s3".
#
# Do you want to copy existing state to the new backend?
#Pre-existing state was found while migrating the previous "local" backend to the
#newly configured "s3" backend. No existing state was found in the newly configured
#"s3" backend. Do you want to copy this state to the new backend?
#
#Enter a value: yes ← yes と入力
#
# Successfully configured the backend "s3"!
# ──────────────────────────────────────────────────────────
# ステップ3: S3 への移行を確認
# ──────────────────────────────────────────────────────────
# Terraform が S3 のstateを認識しているか確認
terraform state list
# aws_s3_bucket.example
# aws_vpc.main
# ... (既存リソースが表示されれば移行成功)
# ローカルの terraform.tfstate は空になっている(または削除されている)
cat terraform.tfstate
# {} または空
# ──────────────────────────────────────────────────────────
# ステップ4: plan を実行して差分がないことを確認
# ──────────────────────────────────────────────────────────
terraform plan
# No changes. Your infrastructure matches the configuration.
state 紛失を防ぐための注意事項
– 移行前に必ずローカルstateをバックアップする(上記ステップ1)
– terraform init -migrate-state は入力プロンプトに yes と応答するまで実際の移行は行われない
– 移行後に terraform plan を実行し、差分がないことを確認してから次のステップへ進む
– 移行が失敗した場合はバックアップから復元できる: cp terraform.tfstate.backup.YYYYMMDD_HHMMSS terraform.tfstate
4-6. [AWSコンソール確認] S3オブジェクト・DynamoDB LockID
S3バックエンドが正しく動作しているか、AWSコンソールで確認する。
S3 コンソールでの確認手順
- AWS コンソールにログインし、S3 サービスを開く
- バケット
terraform-advanced-tfstate-123456789012-ap-northeast-1をクリック - オブジェクト
terraform.tfstateが存在することを確認する - オブジェクトをクリックし、プロパティ タブで暗号化の確認:
- サーバー側の暗号化: AWS KMS を使用したサーバー側の暗号化 (SSE-KMS)
- KMS キー ARN: 設定したキーのARNが表示される
バージョニングの確認
- オブジェクト
terraform.tfstateを選択 - バージョン タブをクリック
applyを実行するたびに新しいバージョンが追加されることがわかる- 過去のバージョンを選択してダウンロードすれば、任意の時点のstateに戻せる(手動ロールバックの手段)
DynamoDB コンソールでの確認手順(ロック中の確認)
通常時はDynamoDBのテーブルは空だ。apply 実行中のみ LockID アイテムが存在する。
次の4-7 でロックが発動している最中に確認する。
- AWS コンソールで DynamoDB サービスを開く
- テーブル
terraform-advanced-tfstate-lockをクリック - アイテムを探索 をクリック
terraform apply実行中に確認すると、LockIDフィールドを持つアイテムが表示される
4-7. apply中のロック発動確認
2つのターミナルを開いて、ロック機構を実際に体験する。
# ターミナル1: 意図的に時間のかかる apply を実行
# (実際のリソースを変更せずに「変更中」状態を作るために sleep リソースを一時的に追加)
# まず長時間 apply をシミュレートするため、aws_instance に time_sleep をかける
# もしくは既存の apply を実行中にターミナル2から試みる
# --- ターミナル1 ---
terraform apply
# Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
# (apply 実行中に他のターミナルから apply を試みる)
# --- ターミナル2: ターミナル1の apply が終わる前に実行 ---
terraform apply
# ╷
# │ Error: Error acquiring the state lock
# │
# │ Error message: ConditionalCheckFailedException: The conditional request failed
# │ Lock Info:
# │ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
# │Path:terraform-advanced-tfstate-.../terraform.tfstate
# │Operation: OperationTypeApply
# │Who: user@hostname
# │Version:1.9.x
# │Created:2026-04-15 10:30:00.123456 +0000 UTC
# │Info:
# ╵
# (ロックが存在するため apply が拒否された)
このエラーが表示されれば、ロック機構が正常に動作している証拠だ。
4-8. terraform force-unlock の使いどころと注意
terraform force-unlock は 最終手段 だ。通常、apply が正常完了すればロックは自動解放される。
しかし次のような状況ではロックが残り続けることがある。
- ネットワーク障害で apply が中断された
- Terraformプロセスが強制終了(Ctrl+C など)された
- CI/CD パイプラインがタイムアウトしてロールバックされた
# ロックIDを確認(エラーメッセージに表示される)
# ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
# ロックを強制解除
terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890
# Do you really want to force-unlock?
#Terraform will remove the lock on the remote state.
#This will allow local Terraform commands to modify this state, even though it
#may be still be in use. Only 'yes' will be accepted to confirm.
#
#Enter a value: yes
#
# Terraform state has been successfully unlocked!
# 解除後、state が一貫した状態か確認
terraform plan
force-unlock を使う前に確認すること
1. 本当に誰も apply していないか確認する — チームメンバーに確認してから実行する
2. ロックIDが現在のロックのものか確認する — 古いロックIDを指定しても意味がない
3. state が一貫した状態か確認する — force-unlock 後は必ず terraform plan で差分がないことを確認する
ConditionalCheckFailedException は「別の apply が実行中」を意味する。
根本解決は「並行実行を防ぐ CI/CD 設計」であり、force-unlock は症状の抑制に過ぎない。
4-9. stateファイルの暗号化確認(SSE-KMS)
bootstrap層で設定した SSE-KMS が正しく機能しているか確認する。
# aws s3api で暗号化情報を取得
aws s3api get-object-attributes \
--bucket terraform-advanced-tfstate-123456789012-ap-northeast-1 \
--key terraform.tfstate \
--object-attributes StorageClass,ObjectSize \
--query 'StorageClass'
# "STANDARD"
# オブジェクトの暗号化設定を確認
aws s3api head-object \
--bucket terraform-advanced-tfstate-123456789012-ap-northeast-1 \
--key terraform.tfstate \
--query '{SSEAlgorithm:ServerSideEncryption, KMSKeyId:SSEKMSKeyId}'
# {
# "SSEAlgorithm": "aws:kms",
# "KMSKeyId": "arn:aws:kms:ap-northeast-1:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# }
"SSEAlgorithm": "aws:kms" が返ればSSE-KMSが有効だ。
S3コンソールでも同様の確認ができる(オブジェクトのプロパティ → サーバー側の暗号化)。
Section 4 まとめ
| 項目 | 内容 |
|——|——|
| ローカルtfstateの問題 | チーム共有不可・機密値平文・競合の3問題 |
| 解決策 | S3(保存・暗号化)+ DynamoDB(ロック)によるリモートバックエンド |
| bootstrap問題 | S3/DynamoDB自体をローカルstateで初回作成し、以降はS3 backendで管理 |
| 移行コマンド | terraform init -migrate-state(必ず事前バックアップ) |
| ロック確認 | DynamoDBに LockID アイテムが apply 中のみ存在する |
| 強制解除 | terraform force-unlock (最終手段・要確認) |
Section 5: terraform workspace — 環境分離(dev/stg/prod)
Section 4 で S3 バックエンドを構築した。次は terraform workspace を使って、同一のTerraform設定から dev / stg / prod の3環境を分離する方法を学ぶ。
5-1. workspaceの仕組み
terraform workspace は、同一のS3バケット内でtfstateをプレフィックスによって分離する 仕組みだ。
デフォルト状態(default workspace)では、tfstateは key で指定したパス(例: s3://<bucket>/terraform.tfstate)に保存される。dev workspace を作成して切り替えると、tfstateのパスが自動的に s3://<bucket>/env:/dev/terraform.tfstate に変わる。
同様に stg は env:/stg/、prod は env:/prod/ 以下に保存される。
この env: プレフィックスは Terraform がデフォルトで使用する名前だ。workspace_key_prefix パラメータで変更できる(例: envs/ や workspaces/)。
各 workspace は独立したtfstateを持つため、dev での変更が prod に影響することはない。
5-2. workspace vs ディレクトリ分離の使い分け基準
環境分離の手法には大きく2つある。
| 比較軸 | terraform workspace | ディレクトリ分離 |
|---|---|---|
| 設定の重複 | 1セットのTFファイルで複数環境 | 環境ごとにディレクトリ(または terragrunt.hcl) |
| 環境差分の表現 | terraform.workspace 変数で分岐 | 各ディレクトリの terraform.tfvars で制御 |
| 大きな構成差異 | 苦手(分岐が複雑になる) | 得意(環境ごとに独立設計) |
| state の分離 | 同一バックエンド内でプレフィックス分離 | 完全に独立したバックエンド設定も可能 |
| 適している規模 | 軽量な差分(インスタンスサイズ・レプリカ数) | 大規模差分(サービス構成・VPC設計が異なる) |
| 切り替えミスのリスク | あり(workspace select 忘れ) | なし(ディレクトリが物理的に別) |
workspace を選ぶ基準
– dev/stg/prod で インフラ構成は同じだが、インスタンスサイズや台数だけ違う → workspace
– dev は RDS なし・prod は RDS ありなど、構成そのものが異なる → ディレクトリ分離
workspace は便利だが、workspace select の切り替え忘れによる prod 誤操作が最大のリスクだ。
5-7 で説明する防護策を必ず実装すること。
5-3. [ハンズオン] terraform workspace new dev / stg / prod
S3バックエンドが設定されている main/ ディレクトリで実行する。
# ──────────────────────────────────────────────────────────
# 現在の workspace 状態を確認
# ──────────────────────────────────────────────────────────
terraform workspace list
# * default
# (デフォルトでは default のみ存在)
terraform workspace show
# default
# ──────────────────────────────────────────────────────────
# 3 環境の workspace を作成
# ──────────────────────────────────────────────────────────
terraform workspace new dev
# Created and switched to workspace "dev"!
# You're now on a new, empty workspace. Newly created workspaces are empty,
# so if you run "terraform plan" now, it will create new resources.
terraform workspace new stg
# Created and switched to workspace "stg"!
terraform workspace new prod
# Created and switched to workspace "prod"!
# ──────────────────────────────────────────────────────────
# workspace 一覧を確認
# ──────────────────────────────────────────────────────────
terraform workspace list
#default
#dev
#stg
# * prod
# (* が現在の workspace を示す)
# ──────────────────────────────────────────────────────────
# workspace を切り替える
# ──────────────────────────────────────────────────────────
terraform workspace select dev
# Switched to workspace "dev".
terraform workspace show
# dev
5-4. terraform.workspace 変数で環境差分制御
各 workspace で terraform.workspace 組み込み変数を参照できる。
この変数には現在の workspace 名("dev", "stg", "prod" など)が入る。
# main/locals.tf
locals {
env = terraform.workspace # "dev" / "stg" / "prod"
# 環境ごとのインスタンスタイプマップ
instance_type_map = {
default = "t3.micro"
dev = "t3.micro"
stg = "t3.small"
prod = "t3.medium"
}
# 環境ごとの EC2 インスタンス台数
instance_count_map = {
default = 1
dev = 1
stg = 2
prod = 3
}
# 環境ごとの RDS インスタンスクラス
db_instance_class_map = {
default = "db.t3.micro"
dev = "db.t3.micro"
stg = "db.t3.small"
prod = "db.r6g.large"
}
# 現在の環境値を取得(map に存在しない workspace 名の場合は "default" を使用)
instance_type = lookup(local.instance_type_map, local.env, local.instance_type_map["default"])
instance_count = lookup(local.instance_count_map, local.env, local.instance_count_map["default"])
db_instance_class = lookup(local.db_instance_class_map, local.env, local.db_instance_class_map["default"])
}
# main/main.tf
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
# EC2 インスタンス(環境ごとに台数・タイプが異なる)
module "ec2_app" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 5.0"
count = local.instance_count
name = "${local.env}-app-${count.index + 1}"
instance_type = local.instance_type
# ... その他の設定
tags = {
Environment = local.env
Project = "terraform-advanced"
ManagedBy= "terraform"
Workspace= terraform.workspace
}
}
dev workspace で terraform apply すると t3.micro x1 台が作成され、prod workspace では t3.medium x3 台が作成される。
5-5. locals.tf で environment-specific map パターン
5-4 で示した locals.tf パターンをより本格的に拡張したのがこちらだ。
複数のリソース設定を1つの locals ブロックに集約することで、環境差分の管理が一元化される。
# main/locals.tf(環境設定の一元管理パターン)
locals {
env = terraform.workspace
# ──────────────────────────────────────
# 環境設定マップ(全環境の設定を1箇所で管理)
# ──────────────────────────────────────
env_config = {
default = {
instance_type = "t3.micro"
instance_count = 1
db_instance_class = "db.t3.micro"
db_multi_az = false
enable_deletion_protection = false
log_retention_days = 7
}
dev = {
instance_type = "t3.micro"
instance_count = 1
db_instance_class = "db.t3.micro"
db_multi_az = false
enable_deletion_protection = false
log_retention_days = 7
}
stg = {
instance_type = "t3.small"
instance_count = 2
db_instance_class = "db.t3.small"
db_multi_az = false
enable_deletion_protection = false
log_retention_days = 14
}
prod = {
instance_type = "t3.medium"
instance_count = 3
db_instance_class = "db.r6g.large"
db_multi_az = true
enable_deletion_protection = true
log_retention_days = 90
}
}
# 現在の環境設定を取得(未知の workspace は default にフォールバック)
config = lookup(local.env_config, local.env, local.env_config["default"])
# 設定値を個別変数として展開(参照しやすくする)
instance_type = local.config.instance_type
instance_count = local.config.instance_count
db_instance_class = local.config.db_instance_class
db_multi_az = local.config.db_multi_az
enable_deletion_protection = local.config.enable_deletion_protection
log_retention_days = local.config.log_retention_days
# 共通タグ(全リソースに付与)
common_tags = {
Environment = local.env
Project = "terraform-advanced"
ManagedBy= "Terraform"
Workspace= terraform.workspace
}
}
このパターンの利点は、新しい環境差分が生まれたときに env_config マップに項目を追加するだけで対応できる点だ。main.tf 側のリソース定義は local.instance_type、local.db_multi_az 等を参照するだけでよく、変更が不要になる。
5-6. [AWSコンソール確認] S3 prefix env:/ 以下のstate分離
3環境で terraform apply を実行した後、S3コンソールで state の分離を確認する。
S3 コンソールでの確認手順
- AWS コンソールで S3 を開き、tfstate バケットをクリック
- env: というプレフィックスのフォルダが自動的に作成されている
- バケット内は、ルートに
terraform.tfstate(default workspace 用)があり、
その下にenv:/dev/terraform.tfstate、env:/stg/terraform.tfstate、env:/prod/terraform.tfstateの3オブジェクトが存在する
AWS CLI でも確認できる。
# S3 バケット内のオブジェクト一覧(プレフィックス確認)
aws s3 ls s3://terraform-advanced-tfstate-123456789012-ap-northeast-1/ --recursive
# 2026-04-15 10:30:00 2048 terraform.tfstate
# 2026-04-15 10:35:00 2048 env:/dev/terraform.tfstate
# 2026-04-15 10:40:00 3072 env:/stg/terraform.tfstate
# 2026-04-15 10:45:00 4096 env:/prod/terraform.tfstate
# 現在の workspace と state の対応を確認
terraform workspace show
# prod
terraform state list
# module.ec2_app[0].aws_instance.this[0]
# module.ec2_app[1].aws_instance.this[0]
# module.ec2_app[2].aws_instance.this[0]
# (prod は3台なので3つのリソースが表示される)
5-7. workspace切り替えミス防止
workspace の最大のリスクは 切り替え忘れによる prod 誤操作だ。terraform workspace select dev を忘れたまま apply すると、prod のリソースに dev 設定が適用されてしまう。
防護策1: CI/CD で workspace select を明示する
# GitHub Actions や CI/CD パイプラインでの安全な実行パターン
# workspace を変数で管理し、apply 前に必ず select する
# 環境変数で workspace を指定
export TF_WORKSPACE="dev" # "stg" or "prod"
# workspace を明示的に select してから plan/apply
terraform workspace select "${TF_WORKSPACE}"
echo "現在の workspace: $(terraform workspace show)"
# 現在の workspace: dev
terraform plan -var-file="envs/${TF_WORKSPACE}.tfvars"
terraform apply -var-file="envs/${TF_WORKSPACE}.tfvars" -auto-approve
防護策2: プロンプト表示 + prod apply 前の二重確認
~/.bashrc または ~/.zshrc にシェル関数を追加すると、コマンドプロンプトに現在の workspace 名が表示され(例: user@host:~/main[prod]$)、誤操作に気付きやすくなる。
さらに scripts/safe_apply.sh のようなラッパースクリプトを用意し、prod workspace での apply 時に「本当に prod に apply しますか? (yes/no)」の入力確認を強制するのが確実だ。
# prod 環境への apply 前に二重確認を強制するスクリプト(scripts/safe_apply.sh)
#!/bin/bash
set -e
# プロンプトにも workspace を表示(~/.bashrc に追加する場合の参考)
# terraform_workspace() { [ -f .terraform/environment ] && echo "[$(cat .terraform/environment)]"; }
# export PS1='\u@\h:\w$(terraform_workspace)\$ '
WORKSPACE=$(terraform workspace show)
echo "現在の workspace: ${WORKSPACE}"
if [ "${WORKSPACE}" = "prod" ]; then
echo "WARNING: 現在の workspace は 'prod' です!"
echo "本当に prod に apply しますか? (yes/no)"
read -r CONFIRM
if [ "${CONFIRM}" != "yes" ]; then
echo "apply をキャンセルしました。"
exit 1
fi
fi
terraform apply "$@"
CI/CD での workspace 切り替えミス防止のベストプラクティス
1. 環境変数 TF_WORKSPACE を使い、workspace を外部から注入する(スクリプト内でハードコードしない)
2. GitHub Actions では environment protection rules を使い、prod へのデプロイに承認者を必須にする
3. plan 結果のレビュー — apply 前に terraform plan の出力を必ず人間がレビューする(PR コメントへの投稿が有効)
4. state ドリフト検知 — 定期的に terraform plan を実行し、差分がないことを確認する
workspace 切り替えミスは「あってはならない事故」ではなく「いつか必ず起きる事故」として設計する。
多重の防護策を重ねることが重要だ。
Section 5 まとめ
| 項目 | 内容 |
|——|——|
| workspace の仕組み | S3内で env:/ プレフィックスで state を分離 |
| 環境差分の表現 | terraform.workspace + locals.tf のマップパターン |
| 向いているケース | インフラ構成は同じで、サイズ・台数だけ異なる軽量差分 |
| 向かないケース | 環境ごとに構成が大きく異なる(ディレクトリ分離を推奨) |
| 最大のリスク | workspace 切り替え忘れによる prod 誤操作 |
| 防護策 | CI/CD での workspace select 明示・environment protection rules・apply 前レビュー |
Section 6: GitHub Actions + OIDC — secretless AWS認証セットアップ
GitHub ActionsからAWSリソースを操作する際、従来はIAMアクセスキーをGitHub Secretsに保存していました。しかしこのアプローチにはセキュリティリスクが伴います。本セクションでは OIDC(OpenID Connect) を使った secretless 認証を実装し、アクセスキー不要でAWSへ安全にアクセスする仕組みを構築します。
6-1. なぜOIDCか — アクセスキー常駐の3大リスク
リスク① 漏洩リスク
IAMアクセスキーは一度発行すると 有効期限がなく、GitHub Secretsやローカルの .env ファイルに長期間保存されます。
- CI/CDログへの誤出力(
echo $AWS_ACCESS_KEY_IDのデバッグ行を残した等) - リポジトリのコミット履歴への混入(
.envファイルの誤コミット) - ビルドアーティファクトへの混入(Dockerイメージへの埋め込み等)
漏洩後は ただちに無効化と影響調査 が必要で、その間のインシデント対応コストは甚大です。
リスク② ローテーション運用コスト
アクセスキーを定期ローテーションする場合:
- 新しいキーペアを発行
- 全CI/CD環境のSecretsを更新
- 旧キーを無効化
複数のリポジトリ・複数の環境(dev/stg/prod)にキーが散在すると、ローテーション漏れが発生しやすくなります。AWS Security Hub や Config ルールで「90日以上ローテーションされていないキー」を検知できますが、対応は依然として手作業です。
リスク③ 監査困難
長期クレデンシャルは「誰がいつ使ったか」の追跡が困難です。
- 複数のCI/CDジョブが同じキーを共有していると、CloudTrailログの
userIdentityが同一になり、どのワークフローが実行したか判別できない - キーが第三者に漏洩しても、正規利用と不正利用を区別しにくい
OIDCが解決する仕組み
OIDC を使うと、GitHub Actions が JWT トークン(一時的な証明書) を AWS STS に提示し、一時クレデンシャル(有効期限1時間) を取得します。
| 比較項目 | IAMアクセスキー | OIDC一時クレデンシャル |
|---|---|---|
| 有効期限 | 無期限(手動無効化まで) | 最大1時間(自動失効) |
| 保存場所 | GitHub Secrets | 不要(都度取得) |
| ローテーション | 手動 | 不要(毎回新規発行) |
| 監査 | 共有キーで追跡困難 | ジョブ・リポジトリ・ブランチ単位で特定可 |
| 最小権限 | キー発行時に固定 | ワークフロー・ブランチ別に動的切り替え可 |
6-2. OIDC認証フロー — GitHub から AWS STS へ
GitHub Actions が AWS に認証するまでの流れを順を追って説明します。
前提: IAM OIDC Provider と IAM Role が事前に設定されている(6-3、6-4で設定)
認証シーケンス
- GitHub Actions ジョブ起動
- ワークフローファイルで
permissions: id-token: writeを宣言することで、GitHub は OIDC トークン発行を許可します - OIDC トークン発行(GitHub → GitHub OIDC Endpoint)
aws-actions/configure-aws-credentialsアクションが GitHub の OIDC エンドポイント(https://token.actions.githubusercontent.com)に JWT トークンを要求します- JWT ペイロードには以下が含まれます:
sub:repo:OWNER/REPO:ref:refs/heads/main(リポジトリとブランチの識別子)aud:sts.amazonaws.com(対象サービス)iss:https://token.actions.githubusercontent.com(発行者)
- AssumeRoleWithWebIdentity(GitHub Actions → AWS STS)
- アクションが AWS STS の
AssumeRoleWithWebIdentityAPI を呼び出し、JWT トークンと引き受ける IAM Role ARN を送信します - trust policy 検証(AWS STS)
- AWS STS は IAM Role の trust policy を参照し、以下を検証します:
iss(発行者)が登録済みの OIDC Provider かsub(主体)が trust policy の Condition に一致するかaud(対象)がsts.amazonaws.comか
- 一時クレデンシャル発行(AWS STS → GitHub Actions)
- 検証に成功すると、STS は
AccessKeyId、SecretAccessKey、SessionToken(有効期限1時間)を返します - AWS API 呼び出し(GitHub Actions → AWS)
- 一時クレデンシャルを使って Terraform や AWS CLI が実行され、IAM Role に付与された権限の範囲でリソース操作が可能になります
関係者と役割の整理
| コンポーネント | 役割 |
|---|---|
| GitHub OIDC Endpoint | JWT トークンを署名・発行(https://token.actions.githubusercontent.com) |
| IAM OIDC Provider | GitHub の OIDC Endpoint を AWS が信頼する設定(Thumbprint で検証) |
| IAM Role trust policy | どのリポジトリ・ブランチからの AssumeRole を許可するか定義 |
| AWS STS | JWT を検証し、一時クレデンシャルを発行 |
| aws-actions/configure-aws-credentials | 上記フローを自動実行する公式アクション |
6-3. [コンソール手順] IAM OIDC Provider 作成
Terraform で自動化する前に、コンソールで手動作成の手順を確認します。コンソール操作を理解することで Terraform コードの意味がより明確になります。
手順
1. IAM コンソールを開く
AWS マネジメントコンソール → IAM → 左メニューの 「ID プロバイダー」 を選択
2. プロバイダーを追加
「プロバイダーを追加」ボタンをクリック
3. プロバイダーの設定
| 項目 | 値 |
|---|---|
| プロバイダーのタイプ | OpenID Connect |
| プロバイダーの URL | https://token.actions.githubusercontent.com |
| 対象者(Audience) | sts.amazonaws.com |
| サムプリント | 「サムプリントを取得」ボタンをクリック(AWS 2023年以降は自動取得) |
4. サムプリントについて
2023年以降、AWS は GitHub の OIDC エンドポイントのサムプリントを自動管理しています。thumbprint_list を空配列([])にすることで AWS が自動取得・更新します。手動で特定のサムプリント値を指定すると、GitHub が証明書を更新した際に認証が失敗するリスクがあります。
5. 「プロバイダーを追加」で保存
作成後、ARN が発行されます。例:
arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com
6. IAM Role を作成
IAM → ロール → 「ロールを作成」
- 信頼されたエンティティのタイプ: Web ID
- ID プロバイダー: 作成した
token.actions.githubusercontent.com - 対象者:
sts.amazonaws.com - Condition の追加(後述 6-5 参照):
token.actions.githubusercontent.com:sub=repo:OWNER/REPO:ref:refs/heads/main
6-4. [Terraform実装] OIDC Provider + IAM ロール
コンソール操作を Terraform に置き換えます。以下のコードは bootstrap/oidc.tf に配置します(Section 8 で述べる2層構造の bootstrap 層)。
# bootstrap/oidc.tf
# Terraform バージョン: >= 1.5.0
# AWS Provider バージョン: ~> 5.0
# ---------------------------------------------------
# GitHub Actions OIDC Provider
# ---------------------------------------------------
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
# sts.amazonaws.com: AWS STS が audience として受け入れる値
client_id_list = ["sts.amazonaws.com"]
# thumbprint_list を空にすることで AWS が自動取得・管理
# 2023年以降は手動指定不要(手動指定すると証明書更新時に認証失敗のリスクあり)
thumbprint_list = []
tags = {
Name = "github-actions-oidc"
ManagedBy= "terraform"
Environment = "shared"
}
}
# ---------------------------------------------------
# trust policy — どのリポジトリ・ブランチを許可するか
# ---------------------------------------------------
data "aws_iam_policy_document" "github_oidc_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"]
}
# ⚠️ 最重要: sub Condition で特定リポジトリ・ブランチのみを許可
# この Condition を省略すると、任意のリポジトリから AssumeRole 可能になる
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
# PR時に plan を実行するため、全ブランチを許可(apply は main のみ)
values = ["repo:OWNER/REPO:*"]
}
}
}
data "aws_iam_policy_document" "github_oidc_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"]
}
# apply は main ブランチのマージ時のみ許可
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:OWNER/REPO:ref:refs/heads/main"]
}
}
}
# ---------------------------------------------------
# terraform-plan ロール(PR時: ReadOnly相当)
# ---------------------------------------------------
resource "aws_iam_role" "terraform_plan" {
name= "terraform-plan"
assume_role_policy = data.aws_iam_policy_document.github_oidc_plan_assume.json
max_session_duration = 3600 # 1時間(デフォルト)
tags = {
Name = "terraform-plan"
ManagedBy= "terraform"
Purpose = "github-actions-ci-plan"
}
}
# ---------------------------------------------------
# terraform-apply ロール(main merge時: 書き込み権限)
# ---------------------------------------------------
resource "aws_iam_role" "terraform_apply" {
name= "terraform-apply"
assume_role_policy = data.aws_iam_policy_document.github_oidc_apply_assume.json
max_session_duration = 3600
tags = {
Name = "terraform-apply"
ManagedBy= "terraform"
Purpose = "github-actions-cd-apply"
}
}
# ---------------------------------------------------
# plan ロールへのポリシーアタッチ(ReadOnlyAccess)
# ---------------------------------------------------
resource "aws_iam_role_policy_attachment" "terraform_plan_readonly" {
role = aws_iam_role.terraform_plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# S3 tfstate への読み取り権限(ReadOnlyAccess に含まれるが明示的に追加)
resource "aws_iam_role_policy" "terraform_plan_state" {
name = "terraform-plan-state-access"
role = aws_iam_role.terraform_plan.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:ListBucket"
]
Resource = [
"arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1",
"arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1/*"
]
},
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
Resource = "arn:aws:dynamodb:ap-northeast-1:${data.aws_caller_identity.current.account_id}:table/terraform-lock"
}
]
})
}
# ---------------------------------------------------
# apply ロールへの最小権限ポリシー(ARN限定)
# ---------------------------------------------------
resource "aws_iam_role_policy" "terraform_apply_policy" {
name = "terraform-apply-minimal"
role = aws_iam_role.terraform_apply.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
# tfstate S3 読み書き
{
Sid = "TfstateAccess"
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
]
Resource = [
"arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1",
"arn:aws:s3:::tfstate-${data.aws_caller_identity.current.account_id}-ap-northeast-1/*"
]
},
# DynamoDB ロック
{
Sid = "TfstateLock"
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
Resource = "arn:aws:dynamodb:ap-northeast-1:${data.aws_caller_identity.current.account_id}:table/terraform-lock"
},
# 管理対象リソースの操作権限(VPC/EC2 の例)
{
Sid = "VpcManagement"
Effect = "Allow"
Action = [
"ec2:*Vpc*",
"ec2:*Subnet*",
"ec2:*RouteTable*",
"ec2:*InternetGateway*",
"ec2:*SecurityGroup*",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeRegions"
]
Resource = "*"
}
# 実際のプロジェクトでは管理対象リソースに応じて Action と Resource を絞り込む
]
})
}
# 現在のアカウントID取得(ARN に埋め込む用)
data "aws_caller_identity" "current" {}
上記コードの
OWNER/REPO は実際のGitHubオーナー名とリポジトリ名に置き換えてください。例:myorg/terraform-infra。変数化する場合は variable "github_repository" {} を定義して参照します。6-5. trust policy の sub Condition 設計
trust policy から sub Condition を省略すると、GitHub 上のあらゆるリポジトリ(他のユーザーのパブリックリポジトリを含む)から AssumeRoleWithWebIdentity が可能になります。これは最頻出のセキュリティミスであり、外部からの AWS リソース操作・情報窃取・コスト攻撃につながります。
必ず token.actions.githubusercontent.com:sub Condition を trust policy に設定してください。
sub クレームの構造
GitHub の OIDC JWT に含まれる sub クレームは以下の形式です:
repo:{OWNER}/{REPO}:{filter_type}:{filter_value}
主なパターン:
| パターン | sub 値 | 用途 |
|---|---|---|
| 特定ブランチ | repo:ORG/REPO:ref:refs/heads/main | apply ロール(main のみ) |
| 全ブランチ | repo:ORG/REPO:* | plan ロール(全 PR) |
| 特定環境 | repo:ORG/REPO:environment:production | GitHub Environments 連携 |
| 特定タグ | repo:ORG/REPO:ref:refs/tags/v* | リリースタグ時のみ |
| PR からのみ | repo:ORG/REPO:pull_request | PR イベント限定 |
StringEquals vs StringLike
# ✅ 完全一致(推奨: apply ロール)
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:OWNER/REPO:ref:refs/heads/main"]
}
# ✅ ワイルドカード一致(plan ロール: 全ブランチ許可)
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:OWNER/REPO:*"]
}
# ❌ 危険: 他リポジトリも一致してしまう
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values= ["repo:*"] # 全 GitHub リポジトリが対象
}
aud Condition も合わせて設定する
sub Condition と合わせて aud Condition も設定することでセキュリティを強化できます:
# aud: sts.amazonaws.com 以外からのトークンを拒否
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values= ["sts.amazonaws.com"]
}
6-6. plan ロールと apply ロールの2ロール分離
CI/CD パイプラインでは 「確認する権限(plan)」と「実行する権限(apply)」を別ロールに分離 することがベストプラクティスです。
分離の理由
| 観点 | 1ロール共用 | 2ロール分離 |
|---|---|---|
| 権限 | PR でも write 権限あり | PR は ReadOnly、merge 後のみ write |
| 誤操作リスク | PR の plan が誤って apply できる | plan ロールでは apply 実行不可 |
| 侵害時の影響 | フォークからの PR でも write 可能 | フォーク PR は plan ロール(ReadOnly)のみ |
| 監査 | 同一 CloudTrail Principal で判別困難 | plan/apply が別 Principal で明確に区別 |
権限設計の目安
plan ロール(terraform-plan)
– トリガー: pull_request イベント(全ブランチ)
– 権限: ReadOnlyAccess + tfstate S3/DynamoDB 読み書き
– 理由: terraform plan は既存リソース情報の読み取りが中心
apply ロール(terraform-apply)
– トリガー: push イベント(main ブランチのみ)
– 権限: 管理対象リソースの操作権限(ARN 限定)+ tfstate S3/DynamoDB 読み書き
– 理由: terraform apply で実際にリソースを作成・変更・削除する
外部コントリビューターからのフォーク PR は、デフォルトで
pull_request トリガーが実行されますが、Secrets へのアクセスは制限されます。pull_request_target トリガーは Secrets にアクセスできますが、フォーク PR のコードをそのまま実行するとセキュリティリスクがあります。パブリックリポジトリでは 承認フロー(Actions の「承認が必要なフォーク」設定)の検討を推奨します。6-7. 最小権限の設計指針 — ARN 限定
apply ロールに AdministratorAccess や PowerUserAccess を付与するのは最小権限違反です。実際の権限設計では以下の指針に従います。
ARN 限定の実践例
# ✅ 推奨: Resource を特定 ARN に限定
{
Effect = "Allow"
Action = ["ec2:RunInstances", "ec2:TerminateInstances"]
Resource = [
"arn:aws:ec2:ap-northeast-1:123456789012:instance/*",
"arn:aws:ec2:ap-northeast-1::image/ami-*"
]
Condition = {
StringEquals = {
"aws:RequestedRegion" = "ap-northeast-1"
}
}
}
# ❌ 禁止: Resource "*" + 広い Action
{
Effect = "Allow"
Action = ["ec2:*"]
Resource = "*"
}
権限設計のワークフロー
- 最初は ReadOnlyAccess で plan のみ実行 → CloudTrail や IAM Access Analyzer で実際に必要な API を確認
- IAM Access Analyzer を使ったポリシー生成: CloudTrail ログから必要な Action・Resource を自動生成できます(IAM コンソール → Access Analyzer → ポリシーの生成)
- 定期的な権限見直し: IAM Access Analyzer の未使用アクセス分析で不要な権限を削除
リージョン制限の追加
Condition で操作リージョンを限定することで、意図しないリージョンへのリソース作成を防げます:
condition {
test = "StringEquals"
variable = "aws:RequestedRegion"
values= ["ap-northeast-1"]
}
6-8. セキュリティアンチパターン
アンチパターン①: sub Condition 未設定
前述(6-5参照)の最頻出ミスです。trust policy に sub Condition がないと、GitHub 上の任意リポジトリから AssumeRole が可能になります。
# ❌ 危険: Condition なし(任意リポジトリから AssumeRole 可能)
data "aws_iam_policy_document" "bad_example" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
# Condition が全くない → 全 GitHub ユーザーが AssumeRole 可能
}
}
アンチパターン②: AdministratorAccess 付与
# ❌ 危険: apply ロールに AdministratorAccess
resource "aws_iam_role_policy_attachment" "bad_apply" {
role = aws_iam_role.terraform_apply.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
# → CI/CD が侵害された場合、AWSアカウント全体が操作可能に
}
対策: 6-4 で示した ARN 限定ポリシーを使用。terraform plan 実行後に差分を確認してから apply するワークフローを徹底。
アンチパターン③: Thumbprint 手動管理
# ❌ 古い実装: 特定の thumbprint を手動指定
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
# → GitHub が証明書を更新すると認証が失敗する
}
# ✅ 推奨: 空配列で AWS 自動取得
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [] # AWS 2023年以降は自動管理
}
✅ IAM OIDC Provider を作成し GitHub を信頼済み IdP として登録
✅ trust policy に sub Condition 必須(省略 = 致命的脆弱性)
✅ plan ロール(ReadOnly)と apply ロール(最小権限)を2ロールに分離
✅ thumbprint_list は空配列で AWS 自動管理
✅ apply ロールは AdministratorAccess ではなく ARN 限定ポリシー
次の Section 7 では、この IAM 設定を使って実際の GitHub Actions ワークフローを実装します。
Section 7: GitHub Actions workflow実装 — plan/apply自動化
Section 6 で設定した OIDC 認証基盤を使って、GitHub Actions ワークフローを実装します。PR 作成時に terraform plan を自動実行し結果をコメントに投稿、main マージ時に terraform apply を自動実行する完全なパイプラインを構築します。
7-1. workflow 構成 — 完成形の全体像
まず .github/workflows/terraform.yml の完成形を掲載します。詳細は後続のサブセクションで解説します。
# .github/workflows/terraform.yml
# GitHub Actions Terraform CI/CD ワークフロー
# 動作環境: GitHub Actions (ubuntu-latest)
# Terraform: >= 1.5.0
# aws-actions/configure-aws-credentials: v4
# hashicorp/setup-terraform: v3
name: Terraform CI/CD
on:
push:
branches:
- main
paths:
- 'terraform/**'
- '.github/workflows/terraform.yml'
pull_request:
branches:
- main
paths:
- 'terraform/**'
- '.github/workflows/terraform.yml'
# OIDC トークン取得に必要な permissions
permissions:
id-token: write # OIDC JWT トークンの取得を許可
contents: read # リポジトリの読み取り
pull-requests: write # PR へのコメント投稿
env:
TF_VERSION: "1.9.5"
AWS_REGION: "ap-northeast-1"
TF_WORKING_DIR: "./terraform"
jobs:
# ----------------------------------------
# PR 時: terraform plan(ReadOnly ロール)
# ----------------------------------------
plan:
name: Terraform Plan
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
environment: plan # オプション: GitHub Environments で承認フローを追加可能
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (plan role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: GitHubActions-Plan-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
id: init
working-directory: ${{ env.TF_WORKING_DIR }}
run: terraform init -input=false
- name: Terraform Format Check
id: fmt
working-directory: ${{ env.TF_WORKING_DIR }}
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Validate
id: validate
working-directory: ${{ env.TF_WORKING_DIR }}
run: terraform validate -no-color
- name: Terraform Plan
id: plan
working-directory: ${{ env.TF_WORKING_DIR }}
run: |
terraform plan -input=false -no-color -out=tfplan 2>&1 | tee plan_output.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Post Plan 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('${{ env.TF_WORKING_DIR }}/plan_output.txt', 'utf8');
const maxLength = 60000;
const truncated = planOutput.length > maxLength
? planOutput.substring(0, maxLength) + '\n\n... (出力が長すぎるため省略)'
: planOutput;
const fmtResult = '${{ steps.fmt.outcome }}' === 'success' ? '✅' : '❌';
const validateResult = '${{ steps.validate.outcome }}' === 'success' ? '✅' : '❌';
const planResult = '${{ steps.plan.outcome }}' === 'success' ? '✅' : '❌';
const body = `## Terraform Plan 結果
| 項目 | 結果 |
|------|------|
| Format | ${fmtResult} |
| Validate | ${validateResult} |
| Plan | ${planResult} |
<details>
<summary>Plan の詳細 (クリックして展開)</summary>
\`\`\`terraform
${truncated}
\`\`\`
</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(comment =>
comment.user.type === 'Bot' && comment.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: Plan 失敗時にジョブを失敗させる
if: steps.plan.outputs.exitcode == '1'
run: exit 1
# ----------------------------------------
# main マージ時: terraform apply(apply ロール)
# ----------------------------------------
apply:
name: Terraform Apply
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment: production # GitHub Environments: 承認者を設定可能
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (apply role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_APPLY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: GitHubActions-Apply-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: ${{ env.TF_WORKING_DIR }}
run: terraform init -input=false
- name: Terraform Apply
working-directory: ${{ env.TF_WORKING_DIR }}
run: terraform apply -input=false -auto-approve -no-color
上記ワークフローで使用する Secrets をリポジトリに登録してください:
・AWS_PLAN_ROLE_ARN:
arn:aws:iam::123456789012:role/terraform-plan・AWS_APPLY_ROLE_ARN:
arn:aws:iam::123456789012:role/terraform-apply設定場所: GitHub リポジトリ → Settings → Secrets and variables → Actions → New repository secret
7-2. permissions: id-token: write / contents: read の意味
ワークフローの permissions 設定は OIDC 認証の動作に直結します。
permissions:
id-token: write # 必須: OIDC JWT トークンの取得を許可
contents: read # 必須: git checkout のためのリポジトリ読み取り
pull-requests: write # plan コメント投稿に必要
id-token: write が必須な理由
GitHub Actions は デフォルトで OIDC トークン取得が無効 です。id-token: write を明示することで、ランナーが GitHub の OIDC エンドポイント(https://token.actions.githubusercontent.com)に JWT トークンを要求できるようになります。
設定しない場合のエラー例:
Error: No OpenIDConnect token issuer found.
Please configure permissions: id-token: write in your workflow.
permissions のスコープについて
permissions はワークフロー全体(トップレベル)またはジョブ単位で設定できます。
# ✅ 推奨: ジョブ単位で最小権限を付与
jobs:
plan:
permissions:
id-token: write
contents: read
pull-requests: write # plan ジョブだけ PR 書き込み許可
apply:
permissions:
id-token: write
contents: read
# pull-requests は不要(apply は PR に書き込まない)
トップレベルで
permissions を設定すると、その設定が全ジョブのデフォルトになります。ジョブレベルで上書き可能ですが、トップレベルで省略すると全ジョブがリポジトリのデフォルト権限(通常は広め)を継承します。明示的に設定することを推奨します。7-3. aws-actions/configure-aws-credentials@v4 で role-to-assume
OIDC フローを実行する公式アクションの設定を詳しく解説します。
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
# 引き受ける IAM Role の ARN
role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
# AWS リージョン
aws-region: ap-northeast-1
# CloudTrail でジョブを識別するためのセッション名(推奨)
role-session-name: GitHubActions-Plan-${{ github.run_id }}
# セッション継続時間(秒): デフォルト 3600(1時間)
# IAM Role の max_session_duration を超えない範囲で設定
role-duration-seconds: 3600
# 出力マスク: true にすると一時クレデンシャルがログに出力されない(デフォルト true)
mask-aws-account-id: true
v3 から v4 への変更点
aws-actions/configure-aws-credentials@v4 は Node.js 20 ベースです。v3(Node.js 16)は 2024年以降に非推奨となったため、v4 へのアップグレードを推奨します。
# ❌ 非推奨(Node.js 16、廃止済み)
uses: aws-actions/configure-aws-credentials@v3
# ✅ 推奨(Node.js 20)
uses: aws-actions/configure-aws-credentials@v4
role-session-name で CloudTrail を読みやすく
role-session-name にランID(${{ github.run_id }})やジョブ名を含めると、CloudTrail の userIdentity.sessionContext.sessionIssuer.userName でどのワークフロー実行かを特定できます。
# CloudTrail ログの例
"sessionContext": {
"sessionIssuer": {
"type": "Role",
"userName": "GitHubActions-Plan-1234567890"
}
}
7-4. PR時 terraform plan ジョブ — ReadOnly ロール
plan ジョブの各ステップを詳しく解説します。
plan:
name: Terraform Plan
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
# Step 1: ソースコードのチェックアウト
- name: Checkout
uses: actions/checkout@v4
# Step 2: OIDC 認証(plan ロール)
- name: Configure AWS credentials (plan role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
aws-region: ap-northeast-1
role-session-name: GitHubActions-Plan-${{ github.run_id }}
# Step 3: Terraform CLI のセットアップ
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.5"
# terraform_wrapper: true(デフォルト)で outputs.stdout が使えるが
# 大きな出力はファイルに保存する方が安全
# Step 4: terraform init(バックエンド初期化)
- name: Terraform Init
id: init
working-directory: ./terraform
run: terraform init -input=false
# -input=false: インタラクティブ入力を無効化(CI では必須)
# Step 5: コードフォーマットチェック
- name: Terraform Format Check
id: fmt
working-directory: ./terraform
run: terraform fmt -check -recursive
continue-on-error: true
# continue-on-error: true でフォーマットエラーでも後続ステップを実行
# PR コメントでフォーマット違反を通知し、ジョブは成功扱いにする
# Step 6: 設定検証
- name: Terraform Validate
id: validate
working-directory: ./terraform
run: terraform validate -no-color
# HCL 構文・プロバイダースキーマとの整合性を確認
# Step 7: plan 実行・出力をファイルに保存
- name: Terraform Plan
id: plan
working-directory: ./terraform
run: |
terraform plan \
-input=false \
-no-color \
-out=tfplan \
2>&1 | tee plan_output.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
continue-on-error: true
# -out=tfplan: plan 結果をバイナリファイルに保存(apply 時に使用可能)
# tee で stdout とファイルの両方に出力
# PIPESTATUS[0] で terraform コマンドの終了コードを取得
plan の終了コードについて
Terraform plan の終了コードには3種類あります:
| 終了コード | 意味 |
|---|---|
0 | 差分なし(インフラが最新の状態) |
1 | エラー(設定ミス等) |
2 | 差分あり(apply すれば変更される) |
-detailed-exitcode フラグを使うと差分あり/なしを区別できます:
terraform plan -input=false -detailed-exitcode -out=tfplan
# 終了コード 2 = 差分あり(正常)として扱いたい場合:
EXITCODE=$?
if [ $EXITCODE -eq 1 ]; then exit 1; fi # エラー時のみ失敗
7-5. plan 結果の PR コメント投稿
actions/github-script@v7 を使って plan 結果を PR にコメントします。同一 PR への重複コメントを防ぐ「更新型」実装を解説します。
- name: Post Plan to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
// plan 出力ファイルを読み込む
let planOutput = '';
try {
planOutput = fs.readFileSync('./terraform/plan_output.txt', 'utf8');
} catch (e) {
planOutput = 'plan 出力ファイルが見つかりません: ' + e.message;
}
// GitHub コメントの最大文字数は 65536 文字
const maxLength = 60000;
const truncated = planOutput.length > maxLength
? planOutput.substring(0, maxLength) + '\n\n... (出力が長すぎるため省略されました)'
: planOutput;
// ステータスアイコン
const icons = {
success: '✅',
failure: '❌',
cancelled: '⚠️',
};
const fmtIcon = icons['${{ steps.fmt.outcome }}'] || '⚠️';
const validateIcon = icons['${{ steps.validate.outcome }}'] || '⚠️';
const planIcon = icons['${{ steps.plan.outcome }}'] || '⚠️';
const body = [
'## Terraform Plan 結果',
'',
'| チェック | 結果 |',
'|---------|------|',
`| \`terraform fmt\` | ${fmtIcon} \`${{ steps.fmt.outcome }}\` |`,
`| \`terraform validate\` | ${validateIcon} \`${{ steps.validate.outcome }}\` |`,
`| \`terraform plan\` | ${planIcon} \`${{ steps.plan.outcome }}\` |`,
'',
'<details>',
'<summary>📋 Plan の詳細 (クリックして展開)</summary>',
'',
'```terraform',
truncated,
'```',
'',
'</details>',
'',
`> 実行者: \`${{ github.actor }}\` | ブランチ: \`${{ github.head_ref }}\` | コミット: \`${{ github.sha }}\``,
].join('\n');
// 既存の Bot コメントを検索して更新(重複コメント防止)
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(comment =>
comment.user.type === 'Bot' &&
comment.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,
});
console.log('既存コメントを更新しました: ' + botComment.id);
} else {
// 新規コメントを作成
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
console.log('新規コメントを作成しました');
}
PR コメントの見た目(サンプル)
## Terraform Plan 結果
| チェック | 結果 |
|---------|------|
| `terraform fmt` | ✅ `success` |
| `terraform validate` | ✅ `success` |
| `terraform plan` | ✅ `success` |
<details>
<summary>📋 Plan の詳細 (クリックして展開)</summary>
# vpc.main will be created
+ resource "aws_vpc" "main" {
+ cidr_block = "10.0.0.0/16"
...
}
Plan: 5 to add, 0 to change, 0 to destroy.
</details>
> 実行者: `octocat` | ブランチ: `feature/add-vpc` | コミット: `abc1234`
7-6. main マージ時 terraform apply ジョブ — apply ロール + environment protection
apply:
name: Terraform Apply
runs-on: ubuntu-latest
# main ブランチへの push イベントのみ実行
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# GitHub Environments: 承認者・デプロイブランチ制限を設定
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
# apply ロール(書き込み権限)で認証
- name: Configure AWS credentials (apply role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_APPLY_ROLE_ARN }}
aws-region: ap-northeast-1
role-session-name: GitHubActions-Apply-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.5"
- name: Terraform Init
working-directory: ./terraform
run: terraform init -input=false
# apply 実行
- name: Terraform Apply
working-directory: ./terraform
run: terraform apply -input=false -auto-approve -no-color
# -auto-approve: 確認プロンプトをスキップ(CI では必須)
# -no-color: ログ出力をプレーンテキストに(CI ログの可読性向上)
# apply 後のリソース確認(オプション)
- name: Show Outputs
working-directory: ./terraform
run: terraform output -no-color
continue-on-error: true
GitHub Environments の設定
environment: production を指定することで、GitHub の Environments 機能 を使った承認フローを追加できます。
設定手順(GitHub コンソール):
- リポジトリ → Settings → Environments → New environment
- 環境名:
production - Required reviewers: apply を承認できるメンバーを追加(チームリード等)
- Deployment branches:
mainブランチのみ許可 - Wait timer: 承認前の待機時間を設定(例: 5分)
効果:
main マージ → apply ジョブ起動 → Environments 承認待ち → 承認者がレビュー → apply 実行
小規模プロジェクトや個人利用では Environments の承認フローが重すぎることがあります。その場合は
environment を省略し、PR レビュー + main ブランチ保護ルール(direct push 禁止・PR必須・CI 通過必須)でガードする構成でも十分です。7-7. [動作確認] PR作成 → plan自動実行 → merge → apply自動実行
ワークフローの動作を一連の流れで確認します。
事前準備チェックリスト
# 1. GitHub Secrets が設定済みか確認
# Settings → Secrets and variables → Actions で以下を確認:
# - AWS_PLAN_ROLE_ARN
# - AWS_APPLY_ROLE_ARN
# 2. IAM OIDC Provider が設定済みか確認
aws iam list-open-id-connect-providers
# 3. IAM Role の trust policy を確認
aws iam get-role --role-name terraform-plan \
--query 'Role.AssumeRolePolicyDocument' --output json
# 4. S3 バックエンドにアクセスできるか確認(plan ロール権限で)
aws s3 ls s3://tfstate-$(aws sts get-caller-identity --query Account --output text)-ap-northeast-1/
動作確認手順
Step 1: フィーチャーブランチで変更を作成
git checkout -b feature/add-security-group
# terraform ファイルに変更を加える(例: security_group を追加)
cat >> terraform/main.tf << 'EOF'
resource "aws_security_group" "web" {
name= "web-sg"
vpc_id = module.vpc.vpc_id
}
EOF
git add terraform/main.tf
git commit -m "feat: add web security group"
git push origin feature/add-security-group
Step 2: PR を作成
GitHub で PR を作成すると、数秒〜1分以内に plan ジョブが自動起動 します。
GitHub コンソールでの確認場所:
– PR ページ → Checks タブ → Terraform Plan のジョブログ
– PR ページ → コメントセクション → Terraform Plan 結果 コメント
Step 3: plan 結果を確認
PR コメントに以下が表示されることを確認:
– terraform fmt ✅
– terraform validate ✅
– terraform plan ✅(差分の内容を確認)
Step 4: PR をマージ
コードレビュー後、main ブランチへ Merge すると apply ジョブが自動起動 します。
GitHub コンソールでの確認場所:
– Actions タブ → Terraform CI/CD ワークフロー → Terraform Apply ジョブ
– Environments ページ → production → デプロイ履歴
Step 5: AWSコンソールでリソース作成を確認
# AWS CLI でも確認可能
aws ec2 describe-security-groups \
--filters "Name=group-name,Values=web-sg" \
--query 'SecurityGroups[*].[GroupId,GroupName,VpcId]' \
--output table
AWS マネジメントコンソール:
– EC2 → セキュリティグループ → web-sg が作成されていることを確認
– IAM → ロール → terraform-apply → アクセスアドバイザーで ec2:CreateSecurityGroup が記録されていることを確認
Step 6: CloudTrail でアクセスログを確認
CloudTrail → イベント履歴で以下を確認:
– userIdentity.type: AssumedRole
– userIdentity.sessionContext.sessionIssuer.userName: GitHubActions-Apply-{run_id}
– eventName: CreateSecurityGroup
7-8. ブランチ戦略ベストプラクティス — 環境別 workflow 分離 vs matrix
プロジェクトの規模によって最適なブランチ戦略は異なります。2つのパターンを比較します。
パターン A: 環境別 workflow ファイル分離
複数環境(dev/stg/prod)を それぞれ独立したワークフローファイル で管理します。
.github/workflows/
├── terraform-dev.yml# dev 環境: feature/* → dev ブランチで自動 apply
├── terraform-stg.yml# stg 環境: develop → stg ブランチで apply
└── terraform-prod.yml # prod 環境: main → prod ブランチで apply(承認必須)
# .github/workflows/terraform-prod.yml
on:
push:
branches: [main]
paths: ['terraform/environments/prod/**']
jobs:
apply:
environment: production # 承認フロー必須
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PROD_APPLY_ROLE_ARN }}
aws-region: ap-northeast-1
メリット:
– 環境ごとに IAM ロール・承認フロー・通知先を完全分離できる
– ワークフローが独立しているため、1環境の変更が他環境に影響しない
– 大規模チームで「dev は自由に試せるが prod は厳格に管理」を実現しやすい
デメリット:
– ファイル数が増える(DRY 原則に反する)
– 共通設定の変更時に全ファイルを更新する必要がある
パターン B: matrix ストラテジー
単一のワークフローファイルで複数環境を matrix で管理します。
# .github/workflows/terraform.yml
jobs:
plan:
strategy:
matrix:
environment: [dev, stg, prod]
include:
- environment: dev
tf_dir: terraform/environments/dev
role_secret: AWS_DEV_PLAN_ROLE_ARN
branch_pattern: 'refs/heads/feature/*'
- environment: stg
tf_dir: terraform/environments/stg
role_secret: AWS_STG_PLAN_ROLE_ARN
branch_pattern: 'refs/heads/develop'
- environment: prod
tf_dir: terraform/environments/prod
role_secret: AWS_PROD_PLAN_ROLE_ARN
branch_pattern: 'refs/heads/main'
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets[matrix.role_secret] }}
aws-region: ap-northeast-1
- name: Terraform Plan
working-directory: ${{ matrix.tf_dir }}
run: terraform plan -input=false -no-color
メリット:
– DRY: 共通ロジックが1ファイルにまとまる
– 環境追加時にワークフロー変更が最小限
デメリット:
– matrix は条件分岐が複雑になりやすい
– 環境ごとに大きく異なる設定(承認フロー等)の表現が難しい
– デバッグ時にどの環境の実行かが分かりにくい
推奨選択基準
| 条件 | 推奨パターン |
|---|---|
| 小〜中規模(3環境以下) | パターン A(シンプルさ優先) |
| 環境間で設定が大きく異なる | パターン A |
| 環境が4つ以上 | パターン B |
| DRY を重視・環境設定が均質 | パターン B |
| prod に厳格な承認フローが必要 | パターン A(environment 設定の明確化) |
✅ permissions: id-token: write が OIDC トークン取得の必須設定
✅ aws-actions/configure-aws-credentials@v4(Node.js 20)で role-to-assume を実行
✅ PR 時は plan ロール(ReadOnly)、main マージ時は apply ロール(最小権限)を使い分け
✅ PR コメントに plan 結果を自動投稿(更新型で重複防止)
✅ GitHub Environments の environment: production で apply に承認フローを追加
✅ ブランチ戦略は「環境別ファイル分離(小規模)」vs「matrix(大規模・均質)」で選択
次の Section 8 では、bootstrap 層と main 層に分かれた2層ディレクトリ構造でモジュール・バックエンド・OIDC をすべて Terraform で一元管理する完全 IaC 統合を解説します。
Section 8: 完全IaC統合 — モジュール・バックエンド・OIDCをTerraformで一括管理
ここまで、モジュール化(Section 2-3)・tfstate管理(Section 4-5)・GitHub Actions + OIDC CI/CD(Section 6-7)を個別に学んできました。Section 8 では、これらを bootstrap/ と main/ の 2 層構造 に統合し、「空のリポジトリから本番環境を一気通貫で構築する」最終形を示します。
8-1. 最終ディレクトリ構成(bootstrap/ と main/ の2層構造)
2層構造を採用する理由は 鶏と卵問題 の解決にあります。S3 バックエンドと DynamoDB ロックテーブルを Terraform で管理したくても、それらが存在しないうちはリモートバックエンドを使えません。bootstrap 層はこの問題を解消します。
terraform-advanced/
├── bootstrap/ ← S3 / DynamoDB / OIDC をローカル state で管理
│├── main.tf ← S3バケット・DynamoDB・IAM OIDC Providerを定義
│├── variables.tf ← aws_region, project_name, github_org, github_repo
│└── outputs.tf ← S3バケット名・DynamoDBテーブル名・OIDC ARN を出力
└── main/ ← アプリリソースを S3 backend + workspace で管理
├── backend.tf ← S3バックエンド設定(bootstrap outputs の値を使用)
├── main.tf ← VPC module・EC2 module 呼び出し
├── variables.tf ← 環境変数・インスタンスサイズなど
├── outputs.tf ← VPC ID・EC2 パブリックIPなど
├── locals.tf ← workspace 別 map(インスタンスタイプ・タグ等)
└── modules/
└── vpc/
├── main.tf ← VPC・サブネット・IGW・ルートテーブル
├── variables.tf← vpc_cidr / name / tags
└── outputs.tf ← vpc_id / public_subnet_ids / private_subnet_ids
2層の役割分担
| 層 | backend | 担当リソース | apply 頻度 |
|---|---|---|---|
| bootstrap/ | ローカル(terraform.tfstate) | S3・DynamoDB・IAM OIDC Provider | 初回のみ(通常変更なし) |
| main/ | S3(bootstrap が作成したバケット) | VPC・EC2・SG・アプリリソース | PR マージごとに CI/CD が自動実行 |
bootstrap 層のリソース(S3 バケット・DynamoDB)は main 層の state を保持する基盤です。誤って
terraform destroy すると main 層の state が失われます。本番環境では bootstrap 層への destroy を IAM ポリシーで禁止する運用を推奨します。8-2. bootstrap 層:S3 / DynamoDB / OIDC Provider をローカル state で作成
bootstrap 層は一度だけ手動で apply します。以下のコードが完成形です。
# bootstrap/main.tf
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# bootstrap はローカル state(backend "local" がデフォルト)
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project= var.project_name
ManagedBy = "Terraform"
Layer = "bootstrap"
}
}
}
# ── S3 バケット(tfstate 保存用) ──────────────────────────────
resource "aws_s3_bucket" "tfstate" {
bucket = "${var.project_name}-tfstate-${data.aws_caller_identity.current.account_id}-${var.aws_region}"
lifecycle {
prevent_destroy = true # 誤削除防止
}
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket= aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls= true
restrict_public_buckets = true
}
resource "aws_kms_key" "tfstate" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 10
enable_key_rotation = true
}
resource "aws_kms_alias" "tfstate" {
name = "alias/${var.project_name}-tfstate"
target_key_id = aws_kms_key.tfstate.key_id
}
# ── DynamoDB(state ロック用) ─────────────────────────────────
resource "aws_dynamodb_table" "terraform_lock" {
name= "${var.project_name}-terraform-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
# ── IAM OIDC Provider(GitHub Actions 用) ────────────────────
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com",
]
# 2023年以降 AWS が thumbprint を自動取得するため、プレースホルダーで可
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1",
]
}
# ── IAM Role: terraform-plan(PR 時 ReadOnly 相当) ───────────
resource "aws_iam_role" "terraform_plan" {
name = "${var.project_name}-terraform-plan"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
}
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "terraform_plan_readonly" {
role = aws_iam_role.terraform_plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# S3 state 読み取り・DynamoDB ロック取得権限を追加
resource "aws_iam_role_policy" "terraform_plan_state" {
name = "tfstate-read-lock"
role = aws_iam_role.terraform_plan.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect= "Allow"
Action= ["s3:GetObject", "s3:ListBucket"]
Resource = [aws_s3_bucket.tfstate.arn, "${aws_s3_bucket.tfstate.arn}/*"]
},
{
Effect= "Allow"
Action= ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"]
Resource = aws_dynamodb_table.terraform_lock.arn
},
{
Effect= "Allow"
Action= ["kms:Decrypt", "kms:GenerateDataKey"]
Resource = aws_kms_key.tfstate.arn
}
]
})
}
# ── IAM Role: terraform-apply(main マージ時 書き込み) ────────
resource "aws_iam_role" "terraform_apply" {
name = "${var.project_name}-terraform-apply"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
}]
})
}
resource "aws_iam_role_policy" "terraform_apply_permissions" {
name = "terraform-apply-permissions"
role = aws_iam_role.terraform_apply.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:*",
"dynamodb:*",
"ec2:*",
"iam:GetRole", "iam:ListRoles",
"kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"
]
Resource = "*"
}
]
})
}
data "aws_caller_identity" "current" {}
# bootstrap/variables.tf
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "ap-northeast-1"
}
variable "project_name" {
description = "Project name used as prefix for all resource names"
type = string
}
variable "github_org" {
description = "GitHub organization or username"
type = string
}
variable "github_repo" {
description = "GitHub repository name"
type = string
}
# bootstrap/outputs.tf
output "tfstate_bucket_name" {
description = "S3 bucket name for Terraform state"
value = aws_s3_bucket.tfstate.id
}
output "dynamodb_table_name" {
description = "DynamoDB table name for Terraform state locking"
value = aws_dynamodb_table.terraform_lock.name
}
output "kms_key_arn" {
description = "KMS key ARN for state encryption"
value = aws_kms_key.tfstate.arn
}
output "oidc_provider_arn" {
description = "IAM OIDC Provider ARN for GitHub Actions"
value = aws_iam_openid_connect_provider.github_actions.arn
}
output "terraform_plan_role_arn" {
description = "IAM Role ARN for terraform plan (PR)"
value = aws_iam_role.terraform_plan.arn
}
output "terraform_apply_role_arn" {
description = "IAM Role ARN for terraform apply (main merge)"
value = aws_iam_role.terraform_apply.arn
}
8-3. main 層:workspace + module + S3 backend で本番リソース管理
bootstrap が完了したら、main 層で S3 バックエンドを設定し、アプリリソースを管理します。
# main/backend.tf
# bootstrap/outputs.tf の値を参考に手動で設定する
terraform {
backend "s3" {
bucket= "YOUR_PROJECT-tfstate-123456789012-ap-northeast-1" # bootstrap output
key= "main/terraform.tfstate"
region= "ap-northeast-1"
dynamodb_table = "YOUR_PROJECT-terraform-lock"# bootstrap output
encrypt = true
kms_key_id = "arn:aws:kms:ap-northeast-1:123456789012:key/..."# bootstrap output
}
}
# main/main.tf
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = terraform.workspace
ManagedBy= "Terraform"
}
}
}
# ── ローカル VPC モジュール ───────────────────────────────────
module "vpc" {
source = "./modules/vpc"
name = "${var.project_name}-${terraform.workspace}"
vpc_cidr = local.env_config[terraform.workspace].vpc_cidr
tags = { Environment = terraform.workspace }
}
# ── 公式 EC2 モジュール ───────────────────────────────────────
module "ec2" {
source = "registry.terraform.io/terraform-aws-modules/ec2-instance/aws"
version = "~> 5.0"
name = "${var.project_name}-${terraform.workspace}-web"
instance_type = local.env_config[terraform.workspace].instance_type
subnet_id = module.vpc.public_subnet_ids[0]
vpc_security_group_ids = [aws_security_group.web.id]
associate_public_ip_address = true
}
resource "aws_security_group" "web" {
name= "${var.project_name}-${terraform.workspace}-web-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port= 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port= 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# main/locals.tf
locals {
env_config = {
dev = {
vpc_cidr= "10.0.0.0/16"
instance_type = "t3.micro"
}
stg = {
vpc_cidr= "10.1.0.0/16"
instance_type = "t3.small"
}
prod = {
vpc_cidr= "10.2.0.0/16"
instance_type = "t3.medium"
}
}
}
8-4. 全体の依存関係図
リソース間の依存関係をテキスト表形式で整理します。
| フェーズ | 作業 | 依存先 |
|---|---|---|
| 1 | bootstrap apply(ローカル state) | なし(初回) |
| 2 | main/backend.tf 設定 | bootstrap outputs(S3バケット名・DynamoDB名・KMS ARN) |
| 3 | main terraform init -migrate-state | backend.tf 設定済み |
| 4 | terraform workspace new dev/stg/prod | init 完了 |
| 5 | module.vpc apply | workspace 選択済み |
| 6 | module.ec2 apply | module.vpc の vpc_id・subnet_ids |
| 7 | GitHub Actions workflow 有効化 | bootstrap の OIDC ARN・Role ARN |
| 8 | PR 作成 → plan 自動実行 | workflow 有効・oidc_provider・plan_role |
| 9 | main マージ → apply 自動実行 | plan 成功・apply_role |
シークレット依存の流れ
GitHub Actions → OIDC token → AWS STS → AssumeRoleWithWebIdentity
↓↓
terraform plan/apply一時クレデンシャル(最大1時間)
↓
S3 state 読み書き + DynamoDB ロック
bootstrap 層は main 層の依存先です。bootstrap を変更・削除する場合は必ず全 main workspace の state ファイルをバックアップしてください(S3 バージョニングを有効化しているので S3 コンソールから過去バージョンを取得できます)。
8-5. [ハンズオン] 空のリポジトリから全構築の一気通貫
以下のチェックリストに従って手順を進めてください。各ステップは前のステップが完了していることを確認してから実行します。
Phase 1: リポジトリ準備
- [ ] GitHub で新規リポジトリを作成(例:
my-terraform-advanced) - [ ] ローカルにクローン:
git clone https://github.com/YOUR_ORG/my-terraform-advanced.git - [ ]
bootstrap/とmain/ディレクトリを作成 - [ ] 上記コードを各ファイルに配置
- [ ]
.gitignoreに以下を追加
# .gitignore
.terraform/
terraform.tfstate
terraform.tfstate.backup
*.tfvars
!example.tfvars
.terraform.lock.hcl # チームで共有する場合はコメントアウト
Phase 2: bootstrap 実行
- [ ]
cd bootstrap/ - [ ]
terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.94.1...
- [ ]
terraform plan -var='project_name=myapp' -var='github_org=YOUR_ORG' -var='github_repo=my-terraform-advanced' - [ ] plan 結果を確認(S3・DynamoDB・IAM OIDC Provider・IAM Role が表示されること)
- [ ]
terraform apply -var='project_name=myapp' -var='github_org=YOUR_ORG' -var='github_repo=my-terraform-advanced' - [ ] outputs を記録:
terraform outputでバケット名・テーブル名・Role ARN をコピー
Phase 3: main 層初期化
- [ ]
cd ../main/ - [ ]
backend.tfのbucket/dynamodb_table/kms_key_idを Phase 2 の outputs で更新 - [ ]
terraform init(S3 バックエンドへの接続確認)
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
- [ ] workspace 作成
terraform workspace new dev
terraform workspace new stg
terraform workspace new prod
terraform workspace select dev
- [ ]
terraform planで dev 環境のリソースを確認 - [ ]
terraform applyで dev 環境を構築
Phase 4: GitHub Actions 設定
- [ ]
.github/workflows/terraform.ymlを作成(Section 7 参照) - [ ] GitHub Secrets に以下を設定:
TF_PLAN_ROLE_ARN: bootstrap output のterraform_plan_role_arnTF_APPLY_ROLE_ARN: bootstrap output のterraform_apply_role_arn- [ ]
git push→ Actions タブで workflow が表示されることを確認 - [ ] ブランチを作成して PR → plan が自動実行されることを確認
- [ ] PR をマージ → apply が自動実行されることを確認
Phase 5: 確認・片付け
- [ ] AWSコンソール → EC2 で新規インスタンス確認
- [ ] AWSコンソール → S3 → tfstate バケット →
env:/dev/main/terraform.tfstateが存在することを確認 - [ ] 検証完了後:
terraform workspace select dev && terraform destroy(全 workspace で実行) - [ ] bootstrap:
cd bootstrap && terraform destroy(最後に実行)
8-6. ディレクトリ分離型 vs モノレポ型の比較表
大規模プロジェクトでは「環境ごとにディレクトリを分離する」か「workspace で管理する」かの選択が重要です。
| 観点 | workspace(本記事の構成) | ディレクトリ分離型 |
|---|---|---|
| 環境差分 | locals.tf の map で吸収 | ディレクトリごとに独立したコード |
| state ファイル | S3 の env:/ENV/ prefix で自動分離 | ディレクトリごとに異なる backend.tf |
| 誤適用リスク | workspace select 忘れで prod に dev 変更が混入 | ディレクトリ移動が必要なので誤適用が起きにくい |
| コード重複 | 最小(共通コードを1箇所管理) | 環境差分が大きいと3倍のコードになりうる |
| CI/CD 設定 | workspace を引数で切り替え | ディレクトリパスを引数で切り替え |
| 向いているケース | 環境差分が小さい(インスタンスサイズ・タグ程度) | 環境ごとにリソース構成が大きく異なる |
| 代表ツール | terraform workspace | Terragrunt (terragrunt.hcl) |
環境差分が「インスタンスサイズ・タグ・CIDR の 3 要素以内」であれば workspace で十分です。VPC 構成自体やセキュリティグループルールが環境ごとに大きく異なる場合は Terragrunt を使ったディレクトリ分離型を検討してください。
Section 9: トラブルシューティング
実際の運用で頻繁に遭遇するエラーと解決手順をまとめます。エラーメッセージで検索できるよう、実際のエラー文言をそのまま掲載します。
9-1. state lock 解除できない(ConditionalCheckFailedException)
症状
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Path:myapp-tfstate-123456789012-ap-northeast-1/main/terraform.tfstate
Operation: OperationTypePlan
Who: runner@ip-10-0-1-234
Version:1.9.8
Created:2026-04-15 09:30:00.123456789 +0000 UTC
Info:
原因
前回の terraform apply が GitHub Actions のタイムアウト・ネットワーク断・手動キャンセルなどで異常終了し、DynamoDB の LockID 項目が残存したままになっています。
解決手順
- AWSコンソール → DynamoDB → テーブル
myapp-terraform-lock→ 「アイテムを探索」 でロック項目を確認 - ロック所有者(
Whoフィールド)が実際に稼働中のプロセスでないことを確認 - 以下のコマンドでロックを強制解除
terraform force-unlock xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- 確認プロンプトに
yesと入力 - 再度
terraform planを実行
force-unlock は「ロックを所有しているプロセスが存在しないこと」を確認してから実行してください。別の
terraform apply が実際に実行中の状態で force-unlock すると、state ファイルが破損する可能性があります。GitHub Actions の場合は Actions タブでジョブが完了していることを確認してから実行します。9-2. workspace 切り替え忘れで prod に dev 変更を適用
症状
terraform plan の差分が予想より大きく、見覚えのないリソースの変更が含まれている。
# 現在の workspace を確認
terraform workspace show
# → prod ← dev のつもりが prod だった
発覚方法と防止策
plan 差分が「数十リソースの変更」になっている場合は必ず workspace を確認します。
# plan 実行前に workspace を確認する習慣
terraform workspace show && terraform plan
誤 apply してしまった場合のロールバック手順
- 直前の apply で変更されたリソースを
terraform showで確認 - 変更前の state バージョンを S3 コンソールで確認(バージョニング有効なら復元可能)
- 変更を元に戻す terraform コードを書いて apply(もしくは手動でリソースを修正)
CI/CD での防止策
GitHub Actions workflow で workspace を明示的に指定し、ブランチ名と workspace を紐付けます。
# .github/workflows/terraform.yml(抜粋)
- name: Select Terraform workspace
run: |
# ブランチ名から環境を決定(main → prod, stg/** → stg, それ以外 → dev)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
WORKSPACE=prod
elif [[ "${{ github.ref }}" == refs/heads/stg/* ]]; then
WORKSPACE=stg
else
WORKSPACE=dev
fi
terraform workspace select $WORKSPACE || terraform workspace new $WORKSPACE
echo "Selected workspace: $WORKSPACE"
9-3. OIDC 認証失敗(sub Condition mismatch)のログ読み方
症状
GitHub Actions で以下のエラーが発生して AWS に認証できない。
Error: Could not assume role with ARN: arn:aws:iam::123456789012:role/myapp-terraform-plan
An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation:
Not authorized to perform sts:AssumeRoleWithWebIdentity
原因の特定方法
- AWS CloudTrail → 「イベント履歴」 → フィルター: イベント名 =
AssumeRoleWithWebIdentity/ ユーザー名 =myapp-terraform-plan - エラーイベントをクリック →
errorMessageフィールドで詳細を確認
よくある原因
| 原因 | CloudTrail の errorMessage | 解決策 |
|---|---|---|
sub 値が trust policy の Condition と不一致 | Not authorized to perform sts:AssumeRoleWithWebIdentity | trust policy の sub 値を確認・修正 |
OIDC Provider の Audience 設定漏れ | 同上 | sts.amazonaws.com を Audience に追加 |
| IAM Role が別アカウントに存在 | 同上 | Role ARN のアカウント ID を確認 |
sub 値の確認方法
GitHub Actions のジョブログで OIDC token の sub 値を確認します。
# デバッグ用ステップ(本番 workflow には含めない)
- name: Debug OIDC token
run: |
ENCODED=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r '.value')
echo $ENCODED | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool | grep sub
取得した sub 値を trust policy の Condition と照合して不一致箇所を修正します。
9-4. terraform init でリモートモジュールのダウンロード失敗
症状
Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider hashicorp/aws:
could not connect to registry.terraform.io: failed to request discovery document
または
Error: Module not found
Module "vpc" (main/main.tf:5) cannot be found in module registry.terraform.io
原因と解決策(3パターン)
パターン1: ネットワーク接続の問題
プロキシ環境や VPC 内での実行時に registry.terraform.io への接続がブロックされる場合があります。
# プロキシ設定を確認
echo $HTTP_PROXY $HTTPS_PROXY
# Terraform 用のプロキシ設定
export HTTPS_PROXY=http://your-proxy.example.com:8080
# または .terraformrc で設定
cat ~/.terraformrc
パターン2: バージョン指定の誤り
# 誤り:存在しないバージョンを指定
module "ec2" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 99.0" # 存在しないバージョン
}
# 利用可能なバージョンを確認
terraform providers lock -platform=linux_amd64 \
registry.terraform.io/terraform-aws-modules/ec2-instance/aws
# または Registry ページで確認
# https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws
パターン3: Private Registry のトークン不足
プライベートの Terraform Registry(HCP Terraform)を使用する場合は認証トークンが必要です。
# .terraformrc に token を設定
cat > ~/.terraformrc << 'EOF'
credentials "app.terraform.io" {
token = "YOUR_HCP_TERRAFORM_TOKEN"
}
EOF
9-5. plan 結果と apply 結果の差分(ドリフト)対応
症状
terraform plan で「変更なし」と表示されたのに、terraform apply 後に予期しないリソースの更新が発生した。または AWS コンソールでリソースを手動変更した後、次回 plan で大量の差分が表示された。
ドリフトの確認方法
# state を AWS の実際の状態に合わせて更新(読み取り専用)
terraform refresh
# または plan で差分を確認(refresh は自動的に実行される)
terraform plan -refresh=true
対応の使い分け
| 状況 | 適切な対応 |
|---|---|
| AWS コンソールで手動変更したリソースを Terraform 管理に取り込みたい | terraform import |
| Terraform 管理外のリソースを state から削除したい | terraform state rm |
| state のリソース名を変更したい(リファクタリング) | terraform state mv |
| 実際の AWS 状態と state を同期させたい(破壊的変更なし) | terraform refresh |
terraform import の例
# 手動作成した S3 バケットを Terraform 管理に取り込む
# 1. main.tf に対応するリソース定義を追加(apply はしない)
# 2. import コマンドで state に登録
terraform import aws_s3_bucket.example my-manually-created-bucket
# Terraform 1.5 以降: import ブロックで宣言的に記述可能
# main.tf に以下を追加
import {
to = aws_s3_bucket.example
id = "my-manually-created-bucket"
}
Terraform 管理下のリソースは AWS コンソールで手動変更しない ことが大原則です。コンソール変更は次回の
terraform plan で上書きされます。緊急対応で手動変更した場合は必ず Terraform コードに反映してから PR を出してください。Section 10: まとめ・次のステップ・参考リンク
10-1. 本記事で習得したスキルの振り返り
本記事では Terraform 実践の 3 本柱を学びました。
モジュール化
ローカルモジュール(./modules/vpc/)と公式 Registry モジュール(terraform-aws-modules/ec2-instance/aws)を組み合わせることで、DRY な IaC コードを実現しました。source / version / variable / output の 4 要素を理解すれば、あらゆるモジュールを読み書きできます。
tfstate 管理
S3 バックエンド + DynamoDB ロックでチーム全員が安全に terraform apply できる基盤を構築しました。bootstrap 層でインフラ基盤を先に作り、-migrate-state で移行するパターンは実務で必須の知識です。terraform workspace による環境分離は、コードの重複なしに dev/stg/prod を管理する実践的な手法です。
OIDC CI/CD
アクセスキーを使わない secretless な AWS 認証を GitHub Actions で実現しました。trust policy の sub Condition 設定・plan/apply の 2 ロール分離・PR へのコメント投稿は、セキュアな CI/CD の基本パターンとして覚えてください。
10-2. 次のステップ
本記事の構成(workspace + S3 backend + GitHub Actions)を習得したら、以下のツールへの発展を検討してください。
Terragrunt(DRY を極める)
ディレクトリ分離型の環境管理で backend.tf の重複を排除するツールです。terragrunt.hcl で backend 設定を自動生成し、依存関係のある複数モジュールを run-all apply で一括適用できます。環境差分が大きく workspace では対応困難なケースに適しています。
参考: 公式ドキュメント https://terragrunt.gruntwork.io/
Atlantis(GitOps 式 Pull Request ベースの terraform apply)
GitHub/GitLab の webhook を受け取り、PR へのコメントで atlantis plan / atlantis apply を実行するセルフホスト型ツールです。GitHub Actions と比較して「apply 承認フローの可視化」と「plan/apply のロック管理」が優れています。
参考: 公式ドキュメント https://www.runatlantis.io/
Terraform Cloud / HCP Terraform(マネージド state・Run 管理)
HashiCorp が提供するマネージドサービスです。state 管理・実行環境・SSO 連携・コスト見積もりが統合されています。チーム規模が大きく、セルフホストのインフラ管理コストを削減したい場合に適しています。
| ツール | 向いているケース | 学習コスト |
|---|---|---|
| terraform workspace(本記事) | 小〜中規模、環境差分が小さい | 低 |
| Terragrunt | 中〜大規模、環境差分が大きい | 中 |
| Atlantis | チーム開発、apply 承認フローが必要 | 中 |
| HCP Terraform | 大規模、マネージドを優先 | 低〜中 |
10-3. コスト節約:検証後リソース削除チェックリスト
本ハンズオンで作成したリソースは検証後に必ず削除してください。
main 層の削除(全 workspace)
- [ ]
terraform workspace select dev && terraform destroy - [ ]
terraform workspace select stg && terraform destroy - [ ]
terraform workspace select prod && terraform destroy
bootstrap 層の削除
bootstrap の S3 バケットは prevent_destroy = true が設定されているため、削除前にコードを変更する必要があります。
- [ ]
bootstrap/main.tfのlifecycle { prevent_destroy = true }をコメントアウト - [ ] S3 バケットのバージョニングを無効化(コンソールまたは Terraform)
- [ ] S3 バケット内の全オブジェクト(state ファイル・旧バージョン)を削除
- [ ]
cd bootstrap && terraform destroy
手動削除が必要なリソース(terraform destroy では削除されないもの)
| リソース | 確認場所 |
|---|---|
| S3 バケット内の古いバージョンのオブジェクト | S3コンソール → バケット → 「バージョンの表示」 |
| CloudWatch ロググループ(GitHub Actions ログ) | CloudWatch → ロググループ |
| KMS キー(削除待機期間: 最低7日) | KMS → カスタマーマネージドキー |
10-4. 参考公式ドキュメント
本記事で使用した技術の公式ドキュメントです。最新仕様の確認に活用してください。
Terraform
- Terraform Module Documentation: https://developer.hashicorp.com/terraform/language/modules
- Terraform S3 Backend: https://developer.hashicorp.com/terraform/language/backend/s3
- Terraform Workspace: https://developer.hashicorp.com/terraform/language/state/workspaces
- terraform import コマンド: https://developer.hashicorp.com/terraform/cli/commands/import
AWS Provider
- hashicorp/aws Provider ドキュメント: https://registry.terraform.io/providers/hashicorp/aws/latest/docs
- terraform-aws-modules/vpc: https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest
- terraform-aws-modules/ec2-instance: https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws/latest
GitHub Actions + OIDC
- GitHub Actions: OIDC for AWS: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
- AWS ドキュメント: Creating IAM OIDC identity providers: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html
- aws-actions/configure-aws-credentials: https://github.com/aws-actions/configure-aws-credentials
10-5. シリーズ次回予告
本シリーズでは AWS の各サービスを「コンソール操作 → Terraform 実装」の流れで学ぶハンズオン記事を順次公開しています。
次回は Step Functions 実践編 — ステートマシンを使った非同期ワークフロー設計をテーマに、ECS タスク・Lambda・DynamoDB を組み合わせたサーバーレスアーキテクチャを構築します。
本記事で習得した Terraform モジュール・CI/CD パターンは次回以降の記事でも継続して活用します。ぜひシリーズを通して AWS × IaC のスキルを積み上げてください。