- 1 AWSパラメーターシートをTerraformコードから自動生成する — Plan JSON ハイブリッド実装
- 1.1 1. この記事について
- 1.2 2. 業務背景: なぜパラメーターシート自動化が必要か
- 1.3 3. 設計方針(除外ルール含む)
- 1.4 4. Terraform plan JSON を理解する
- 1.5 Section 5. Plan JSON ハイブリッドパーサの実装(Python)
- 1.6 6. マルチ環境対応(environments/ 分離と集約ループ)
- 1.7 Section 7. Excel 出力(openpyxl、統合書式、色・マージ)
- 1.8 Section 8. 対象サービス別の属性カバー
- 1.8.1 8-0. サービス別属性カバー全体マトリクス
- 1.8.2 8-1. EC2 (aws_instance)
- 1.8.3 8-2. VPC (aws_vpc)
- 1.8.4 8-3. Subnet (aws_subnet)
- 1.8.5 8-4. Security Group (aws_security_group + aws_security_group_rule)
- 1.8.6 8-5. RDS (aws_db_instance)
- 1.8.7 8-6. ALB (aws_lb + aws_lb_target_group + aws_lb_listener)
- 1.8.8 8-7. Lambda (aws_lambda_function)
- 1.8.9 8-8. API Gateway v2 (aws_apigatewayv2_api + aws_apigatewayv2_route)
- 1.8.10 8-9. DynamoDB (aws_dynamodb_table)
- 1.8.11 8-10. SNS/SQS (aws_sns_topic + aws_sqs_queue)
- 1.8.12 8-11. サービス別マッピング辞書まとめ
- 1.9 Section 9. ハンズオン実行と成果物確認
- 1.10 Section 10. まとめと第2弾への導線
AWSパラメーターシートをTerraformコードから自動生成する — Plan JSON ハイブリッド実装
AWS パラメーターシート自動化シリーズ
- 第1弾(本記事): Terraformコードから AWS パラメーターシート(Excel)を自動生成する
- 第2弾: AWS Config と Terraform パラメーターシートを突合する単体テスト自動化
関連シリーズ:
AWS×Terraform 複数人開発シリーズ(全3弾):
前提知識(必読):
- Terraform基礎 (ID:1120) — init/plan/apply・変数
- Terraform実践 (ID:1208) — module/state・複数環境
- Python 3.11+(pip install 可能な環境)
- AWS CLI v2 + Terraform 1.9系 の動作環境
関連シリーズ:
- AWS×Terraform 複数人開発シリーズ 全3弾(state管理 → GitHub Actions → CodePipeline)
1. この記事について
1-1. 本記事で達成できること
この記事を最後まで読んで手を動かすと、次のものが手元に揃う。
- Terraform plan JSON を解析する Python スクリプト(
tf_plan_parser.py) - openpyxl で生成する AWS パラメーターシート Excel(マルチ環境・2段ヘッダ付き)
- dev/stg/prod の 3 環境ぶんのパラメーターを1 枚の Excel に集約する仕組み
「Terraform コードから Excel のパラメーターシートを自動で作りたい」——その要望を、terraform plan -out tfplan && terraform show -json tfplan の 2 コマンドと Python スクリプト一式で実現する。

上の図が本記事の全体像だ。Terraform コードを起点に plan JSON を生成し、Python の「ハイブリッドパーサ」がコード記載値と計算値を区別しながら抽出、openpyxl がエンタープライズ標準の Excel 書式で出力する。
1-2. 本シリーズの全体構成と本記事の位置づけ
本シリーズは 2 弾構成で、AWS 案件で繰り返し発生する「パラメーターシート管理」の煩雑さを段階的に解消する。
| 弾 | テーマ | 主な技術スタック | 概要 |
|---|---|---|---|
| 第1弾(本記事) | Terraform → Excel 自動生成 | Terraform 1.9 / Python 3.11 / openpyxl | plan JSON を解析し Excel パラメーターシートを生成 |
| 第2弾 | AWS Config との突合テスト自動化 | AWS Config / pytest / boto3 | 実環境の Config 記録と TF コードの差分を単体テストで検出 |
第1弾は「コードから仕様書を作る」、第2弾は「仕様書と実環境のズレを検知する」。2 弾合わせて「設計 → 検証」の循環が完成する。
1-3. 関連シリーズ(AWS×Terraform 複数人開発)との役割分担
本シリーズは、同じ Terraform を扱う複数人開発シリーズ(cmd_040)と直交補完の関係にある。
| 軸 | シリーズ | キャッチフレーズ |
|---|---|---|
| 継続デプロイ軸 | AWS×Terraform 複数人開発シリーズ(全3弾) | “TF コードを継続的に作って回す” |
| 設計整合検証軸 | 本シリーズ(全2弾) | “作ったものを仕様書と突合して検証する” |
CI/CD パイプラインを育てながら(複数人開発シリーズ)、設計整合性を担保する(本シリーズ)。この 2 軸を並行して運用することで、エンタープライズ AWS 案件の品質管理が大幅に向上する。
CI 組み込みに発展させたい場合は、複数人開発シリーズ第2弾(GitHub Actions+OIDC で PR駆動CI/CD)を参照してほしい。
1-4. 対象読者とペルソナ
本記事は次のような読者を想定している。
主要ペルソナ: エンタープライズ DevOps / インフラエンジニア
- 金融・製造・公共系の AWS 案件で Terraform を使っているが、設計書管理は今も Excel 手作業
- プロジェクト完了後のパラメーターシート提出に毎回数日〜1 週間を費やしている
- 「TF コードが正義なのは分かるが、顧客への提出ドキュメントは Excel でないと困る」という板挟みを経験している
- Python は書けるが openpyxl や plan JSON の扱いは未経験
副次ペルソナ: テックリード / アーキテクト
- チーム全体の設計整合性管理を担う立場
- 手動突合レビューのボトルネック解消を探している
- 第2弾の AWS Config 突合テストまで視野に入れている
前提知識チェックリスト
以下をすべて満たせば、本記事をスムーズに進められる。
[ ] Terraform init / plan / apply の基本操作
[ ] Python 3.11+ の基礎(型ヒント・辞書操作・ファイル I/O)
[ ] AWS CLI v2 の設定(aws configure 済み)
[ ] pip install が可能なローカル環境
Terraform の基礎が不安な方は Terraform基礎(ID:1120) から始めてほしい。
1-5. AWS Config 未経験者へ
本記事(第1弾)では AWS Config を一切使わない。Terraform plan JSON と Python だけで Excel が完成する。
AWS Config が登場するのは第2弾だ。第2弾は「Config の設定記録と Terraform コードのズレを pytest で検出する」内容になる予定で、Config 未経験者でも第2弾から始められるよう丁寧に解説する。
本記事を動かすのに AWS Config の知識は不要。
「Config って聞いたことあるけど使ったことない」という方も安心して進めてほしい。
1-6. 本記事の構成
本記事は 10 セクションで構成されている。セクション間の依存関係を理解してから読み進めると効率がよい。
| セクション | タイトル | 要所 |
|---|---|---|
| 1(本節) | この記事について | 全体像・位置づけ・ペルソナ |
| 2 | 業務背景: なぜパラメーターシート自動化が必要か | Before/After・工数比較 |
| 3 | 設計方針(除外ルール含む) | Plan vs State の選択・除外設計 |
| 4 | Terraform plan JSON を理解する | 3 領域の読み分け方 |
| 5 | Plan JSON ハイブリッドパーサの実装(Python) | 本記事の核 |
| 6 | マルチ環境対応(environments/ 分離と集約ループ) | dev/stg/prod 統合 |
| 7 | Excel 出力(openpyxl・統合書式・色・マージ) | 2段ヘッダ・色分け |
| 8 | 対象サービス別の属性カバー(IaaS5 + サーバーレス5) | 10 サービス詳解 |
| 9 | ハンズオン実行と成果物確認 | E2E 実行・エラー対処 |
| 10 | まとめと第2弾への導線 | Config 突合テストへの接続 |
読み物として通読してもよいが、セクション 3〜4 を読んでから 5 に進むと実装の意図が理解しやすい。
1-7. 完成物のプレビュー
最終的に生成される Excel のイメージを先出しする。「こんなものが作れるのか」を先に把握しておくと、各セクションの実装意図が掴みやすい。
【生成される Excel: param_sheet_20260418.xlsx】
┌─ シート: EC2 ─────────────────────────────────────────────────────┐
│ 環境│ リソース名 │ インスタンスタイプ │ AMI │ VPC│
│──────────────────────────────────────────────────────────────────│
│ dev │ aws_instance.web │ t3.micro │ ami-xxxxxxxx │ vpc-xxxxxxxx │
│ stg │ aws_instance.web │ t3.small │ ami-xxxxxxxx │ vpc-yyyyyyyy │
│ prod│ aws_instance.web │ t3.medium│ ami-xxxxxxxx │ vpc-zzzzzzzz │
└──────────────────────────────────────────────────────────────────┘
┌─ シート: RDS ─────────────────────────────────────────────────────┐
│ 環境│ リソース名 │ エンジン │ インスタンスクラス │ マルチAZ │ 暗号化 │
│──────────────────────────────────────────────────────────────────│
│ dev │ aws_db_instance.main│ postgres 15 │ db.t3.micro │ false │ true│
│ stg │ aws_db_instance.main│ postgres 15 │ db.t3.small │ false │ true│
│ prod│ aws_db_instance.main│ postgres 15 │ db.r6g.large│ true │ true│
└──────────────────────────────────────────────────────────────────┘
┌─ シート: meta ────────────────────────────────────────────────────┐
│ 生成日時│ 2026-04-18T16:36:00+09:00 │
│ commit SHA │ a1b2c3d4e5f6... │
│ 環境 │ dev / stg / prod │
│ 生成者 │ generate_param_sheet.py v1.0 │
└──────────────────────────────────────────────────────────────────┘
3 環境 × 10 サービスの属性が1ファイルに集約される。セルはサービス種別ごとに色分けされ、ヘッダは「環境」「カテゴリ」の 2 段構成になっている。
1-8. 環境セットアップ(事前準備)
ハンズオンを進める前に、以下のパッケージをインストールしておく。
# Python パッケージ
pip install openpyxl==3.1.2
# Terraform バージョン確認(1.9.x 推奨)
terraform version
# Terraform v1.9.0 以上であること
# AWS CLI バージョン確認
aws --version
# aws-cli/2.x.x 以上であること
# AWS 認証確認
aws sts get-caller-identity
# 出力例:
# {
# "UserId": "AIDAXXXXXXXXXXXXXXXXX",
# "Account": "123456789012",
# "Arn": "arn:aws:iam::123456789012:user/your-user"
# }
Python バージョンは 3.11 以上が必須だ。型ヒントの str | None 記法を使用するため、3.10 以下では動作しない。
python --version
# Python 3.11.x 以上であること
1-9. サンプルコードの表記統一
本記事を通じて、以下の値を統一して使用する。環境変数や tfvars で差し替える想定だが、コード例では固定値で記述する。
| 変数 | 値 | 用途 |
|---|---|---|
| AWSアカウントID | 123456789012 | ARN・リソース参照 |
| AWSリージョン | ap-northeast-1 | Provider設定 |
| S3バケット(state) | myorg-terraform-state | backend設定 |
| DynamoDBテーブル | terraform-state-lock | ロック設定 |
これらは実行時に自分の環境の値に読み替えてほしい。ARN はすべて arn:aws:*:ap-northeast-1:123456789012:* 形式で記述する。
1-10. リポジトリ構成の先出し
本記事を通じて構築するディレクトリ構成を先出しする。実装を追いながら「今どこを作っているか」の地図として使ってほしい。
terraform-param-sheet/ # プロジェクトルート
├── environments/# 環境別 Terraform 設定
│├── dev/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││├── backend.tf
││└── plan.json # make plan-all で生成
│├── stg/
││└── ...(同構成)
│└── prod/
│ └── ...(同構成)
├── modules/ # 共通 Terraform モジュール
│├── ec2/
│├── rds/
│└── network/
├── scripts/ # Python スクリプト群
│├── tf_plan_parser.py # Section 5: ハイブリッドパーサ(核)
│├── excel_writer.py# Section 7: openpyxl 書式制御
│├── multi_env_collector.py # Section 6: 環境集約ループ
│└── generate_param_sheet.py # Section 9: エントリーポイント
├── output/# 生成物(git-ignored)
│└── param_sheet_YYYYMMDD.xlsx
├── Makefile # Section 6: make plan-all 等
└── requirements.txt# openpyxl==3.1.2 等
Section 3 以降では、このディレクトリを上から下へ順に実装していく。
2. 業務背景: なぜパラメーターシート自動化が必要か
2-1. エンタープライズ AWS 案件の現実
エンタープライズの AWS 案件には、避けて通れないドキュメント要求がある。「パラメーターシート」だ。
プロジェクト開始時に設計パラメーターを記録し、構築後に実環境と突合し、レビューを経て顧客に提出する。この一連の作業が、規模の大小にかかわらず発生する。
「Terraform でコード管理しているなら、設計書も自動で出るんじゃないですか?」——顧客から聞かれたことがある方は多いだろう。答えは「仕組みを作れば出ますが、デフォルトでは出ません」だ。この記事はその仕組みを作る。
典型的な案件では以下のようなシートが求められる。
【典型的なパラメーターシート構成例(Excel)】
シート1: EC2インスタンス一覧
- インスタンスID / AMI / インスタンスタイプ / VPC / サブネット / SG / タグ
シート2: VPC・ネットワーク一覧
- VPC ID / CIDR / サブネット名 / AZ / ルートテーブル
シート3: RDS一覧
- DB識別子 / エンジン / インスタンスクラス / ストレージ / マルチAZ / 暗号化
(以下、ALB・Lambda・API GW・DynamoDB・SNS・SQS ...)
このシートを 「Terraform コードから手動で転記」 している現場がいまだに多い。
2-2. 手動突合の工数と痛み
手動突合のフローを図で確認する。

上の図(Before)では、「設計 → Terraform コード → AWS 環境構築 → 手動でパラメーターシート記入 → レビュー → 提出」の工程がすべて人手でつながっている。各工程の間に「コードを読んで Excel に転記する」という非生産的な作業が挟まる。
典型的な工数積み上げ
中規模案件(EC2 × 10、VPC × 2、RDS × 3、ALB × 2、Lambda × 20)を想定した場合の試算だ。
| 作業 | 工数(人日) | 問題点 |
|---|---|---|
| Terraform コードから属性値を読み出す | 1.0 | コードを1リソースずつ追う |
| AWS コンソールで実環境を確認し突合 | 1.5 | コンソール画面の仕様変更リスク |
| Excel に転記・フォーマット整形 | 1.0 | タイポ・行ズレのヒューマンエラー |
| レビュー対応・修正 | 0.5 | 指摘のたびに手動修正 |
| 合計 | 4.0 人日 | 約 3〜5 営業日相当 |
これが毎フェーズ(設計 → 構築 → 検収)発生し、さらにリソース追加・変更のたびに差分更新が必要になる。
実際のヒューマンエラー事例
手動転記で発生しやすいミスをいくつか挙げる。
- SG ルールの抜け漏れ: ingress/egress のルールが多い場合、末尾のルールが転記されずにレビューを通過
- マルチ環境の取り違え: dev の値を stg シートに転記するミス
- AMI ID の古い版記載:
plan実行後に AMI を更新したが、シートは更新前の ID のまま提出 - Lambda 関数名のタイポ: 関数名の末尾ハイフンを取り違え、顧客確認で発覚し差し戻し
これらはいずれも「人が目視で転記する」以上は避けられない構造的な問題だ。
2-3. 自動化後の世界(After)
本記事で実装する仕組みを導入すると、フローはこうなる。
# 1. 全環境の plan JSON を一括生成(Makefile)
make plan-all
# → environments/dev/plan.json
# → environments/stg/plan.json
# → environments/prod/plan.json
# 2. Python でパラメーターシート生成(1コマンド)
python generate_param_sheet.py
# → output/param_sheet_20260418.xlsx
この 2 コマンドで、3 環境ぶんのパラメーターが揃った Excel が自動生成される。

Before / After 比較
| 観点 | Before(手動) | After(本記事の自動化) |
|---|---|---|
| 所要時間 | 3〜5 営業日(4.0 人日) | 約 15 分(初回セットアップ後) |
| 転記ミス | 発生しやすい(人的ミス) | ゼロ(コードから直接生成) |
| 環境追加 | 新環境ぶんの転記が増える | 自動対応(environments/ に追加するだけ) |
| 更新対応 | TF 変更のたびに手動差分確認 | make plan-all && python generate_param_sheet.py を再実行 |
| 監査対応 | 「誰がいつ書いたか」が不明 | 生成日時・コミット SHA を meta シートに自動記録 |
数日かかっていた作業が 15 分に短縮される。さらに「転記ミス」が構造的にゼロになることで、レビュー工数も大幅に削減できる。
2-4. なぜ「terraform state」ではなく「terraform plan」を使うのか
「terraform state pull で現在の state を読めばいいのでは」という疑問は自然だ。しかし state には致命的な問題がある。
State の問題点
// terraform.tfstate から EC2 の instance_type を読む場合
{
"resources": [
{
"type": "aws_instance",
"instances": [
{
"attributes": {
"instance_type": "t3.medium"
}
}
]
}
]
}
State が持つ instance_type の値は 「AWS 環境に現在適用されている値」 だ。これはコードに記載された意図ではなく、apply 後の実態だ。
問題は「コンソールから手動変更された値」「別ツールが書き換えた値」も state に混入する点だ。パラメーターシートに求められるのは「Terraform コードに記載された設計意図の値」であり、実環境の現状値ではない。
Plan JSON の優位性
terraform plan -out tfplan && terraform show -json tfplan で生成される plan JSON には、コード記載値と計算値(aws_caller_identity 等の data source)の両方が含まれる。本記事では「ハイブリッドパーサ」と呼ぶアプローチで、コード記載値を優先しながら計算値を補完する手法を実装する(詳細はセクション 3〜5)。
2-5. AWS Config との関係(第2弾への接続)
本記事では AWS Config を使わないが、「なぜ第2弾で Config が登場するのか」を 1 段落で予告しておく。
AWS Config は AWS リソースの設定変更履歴を継続記録するサービスだ。「いつ、誰が、どのリソースの、何を変えたか」が記録に残る。第2弾では、Config の記録と Terraform コードが生成したパラメーターシートを pytest で突合し、「コードが想定している設定と実環境の設定がズレていないか」を単体テストとして自動検出する。
本記事でやること: Terraform コード → plan JSON → Excel パラメーターシート生成
第2弾でやること: パラメーターシート + AWS Config 記録 → 差分検出テスト
第1弾は Config がなくても完結する。Config を有効化していない AWS アカウントでも、本記事のハンズオンは動作する。
2-6. ハンズオンで使うサンプル構成
本記事のハンズオンでは、以下のシンプルな AWS 構成を例として使う。本番規模でなくても、仕組みを理解するには十分だ。
# サンプル構成の概要(terraform/main.tf)
# - VPC × 1
# - パブリックサブネット × 2(マルチAZ)
# - EC2 インスタンス × 1(t3.micro)
# - セキュリティグループ × 1(SSH・HTTP 許可)
# - RDS(db.t3.micro・PostgreSQL 15)× 1
# - S3 バケット(state 格納用)× 1
これだけのリソースでも、手動転記すると 30〜40 行の Excel 入力が必要になる。自動生成であれば 0 秒だ。
2-7. 手動突合フローの詳細解剖
Before の工程をさらに細かく分解する。自動化の価値を正確に把握するためだ。
典型的な手動フロー(フェーズごと)
【フェーズ1: 設計時】
┌─────────────────────────────────────────────────┐
│ 1. 設計者が Terraform コードを書く │
│ 2. 設計者が Excel にパラメーター値を手入力│
│ (コードと Excel が別々に管理開始) │
│ 3. レビュアーが両方を見比べて確認 │
└─────────────────────────────────────────────────┘
↓ 構築フェーズへ進む
【フェーズ2: 構築時】
┌─────────────────────────────────────────────────┐
│ 4. terraform apply 実施 │
│ 5. apply 後の差分(計算値・リソースIDなど)を│
│ コンソールで確認し Excel に追記 │
│ 6. 環境ごとに同作業を繰り返す(dev→stg→prod) │
└─────────────────────────────────────────────────┘
↓ 検収フェーズへ進む
【フェーズ3: 検収時】
┌─────────────────────────────────────────────────┐
│ 7. 顧客レビューで「この値が違う」と指摘 │
│ 8. コード・コンソール・Excel を再度突合 │
│ 9. 修正→再提出(フェーズ2〜3 が繰り返される) │
└─────────────────────────────────────────────────┘
フェーズ2で「環境ごとに繰り返す」という点が特に痛い。dev・stg・prod の 3 環境があれば、工数は単純に 3 倍になる。
2-8. コードと Excel の二重管理問題
手動フローで最も深刻なのは「コードと Excel が二重管理になる」点だ。
Terraform コードを変更したとき、Excel も更新しなければならない。しかし、コードの変更通知が Excel 担当者に届くとは限らない。特にチーム開発では、誰かが variables.tf の値を変えても、Excel が古いまま放置されることが頻繁に起きる。
【よくある二重管理の破綻シナリオ】
Day 1: Aさんが EC2 インスタンスタイプを t3.small に変更
→ コードを PR でマージ
→ Bさんの Excel 更新漏れ
Day 3: Bさんが Excel を顧客に提出
→ 実環境は t3.small、Excel は t3.micro
Day 5: 顧客からの指摘で発覚
→ 差し戻し・再確認・再提出
本記事の自動化では、Excel の生成元が常に Terraform コードのみになる。コードを変更して make plan-all && python generate_param_sheet.py を再実行すれば、Excel が自動更新される。二重管理という概念がなくなる。
2-9. 自動化のコスト(初期投資)
「自動化の構築にもコストがかかるのでは」という疑問は正当だ。正直に答える。
初期構築コスト
| 作業 | 想定時間 |
|---|---|
| 本記事の通読 | 2〜3 時間 |
| ハンズオン実行(Section 9) | 1〜2 時間 |
| 自分のプロジェクトへの適用 | 4〜8 時間 |
| 合計 | 7〜13 時間 |
初回は 1〜2 日の投資が必要だ。しかし 2 回目以降は「make plan-all && python generate_param_sheet.py を実行する 15 分」だけになる。
損益分岐点の試算
初期投資 10 時間(= 1.25 人日)を、手動突合の節約(4 人日/フェーズ)で回収する場合:
損益分岐 = 10時間 ÷ 4人日 = 0.3フェーズ
→ 最初のフェーズで回収完了
→ 以降は毎フェーズ 4人日 × 人日単価が削減効果
実際の案件では複数フェーズにまたがることが多く、長期プロジェクトほど投資対効果が高くなる。
2-10. 本シリーズが解決する 3 つの課題
まとめると、本シリーズは以下の 3 課題を解決する。
| 課題 | 第1弾での解決 | 第2弾での解決 |
|---|---|---|
| Q1: パラメーターシート作成の工数 | plan JSON → Excel 自動生成で数日→15分 | — |
| Q2: 転記ミス・ヒューマンエラー | コードから直接生成するため構造的にゼロ | — |
| Q3: 実環境との整合性確認 | plan JSON でコード意図値を取得 | Config 突合テストで継続監視 |
本記事(第1弾)は Q1・Q2 の解決に集中する。Q3 の「実環境との継続的な整合性監視」は第2弾で AWS Config を使って実装する。
AWS Config 未経験の方へ: Q3 の対応は第2弾の担当です。本記事では Config の設定も Config の知識も一切不要です。
では、次のセクション(Section 3)から設計方針の詳細に入っていく。「なぜこのアーキテクチャになったのか」の背景を理解してからコードを読むと、実装の意図がよりクリアになる。
3. 設計方針(除外ルール含む)
本章では「なぜ Plan JSON ハイブリッド方式を選ぶのか」を3方式の比較から解説し、
除外ルール設計の多層防御とアーキテクチャ全体像を整理する。
3-1. Terraform パース3方式の比較
Terraform コードからパラメーターシート用の「コード記載値」を取り出す手段は、大きく3方式(+1)がある。
それぞれの特性を比較し、本記事が採用する方式を決定する。
| 方式 | 取得内容 | ARN / ID 混入 | AWSデフォルト混入 | 変数解決 | 実装難易度 |
|---|---|---|---|---|---|
A. terraform show -json(state) | 実態値(apply 後) | 混入する | 混入する | 済 | 低 |
B. terraform plan -out + show -json(plan 全体) | 計画値+構成情報 | 一部(known after apply はマスク) | プロバイダ補完値が混入 | 済 | 中 |
| C. HCL 直接解析(python-hcl2 等) | コード記載値のみ | 無 | 無 | 未解決(変数参照のまま) | 中〜高 |
| D. Plan JSON ハイブリッド(本記事採用) | code 記載キーだけ plan 値で抽出 | 無 | 無 | 済 | 中 |
「なぜ state ではダメか」— 具体例
terraform show -json が返す state JSON には、AWS が払い出した 実リソースの ARN・ID・IP アドレス がすべて含まれる。
{
"values": {
"root_module": {
"resources": [
{
"address": "aws_instance.app",
"values": {
"id": "i-0a1b2c3d4e5f67890",
"arn": "arn:aws:ec2:ap-northeast-1:123456789012:instance/i-0a1b2c3d4e5f67890",
"public_ip": "54.199.xxx.xxx",
"private_ip": "10.0.1.50",
"instance_type": "t3.micro",
"ami": "ami-0123456789abcdef0",
"subnet_id": "subnet-0123456789abcdef0"
}
}
]
}
}
}
パラメーターシートに必要なのは「instance_type: t3.micro」「ami: ami-0123456789abcdef0」といった 設計者がコードに書いた値 だけだ。
しかし state JSON には id、arn、public_ip、subnet_id も混入する。
これらは AWS が動的に払い出す値であり、設計書に記載すべき「仕様」ではない。
state をそのままパラメーターシートに流し込むと 「AWS 払出し値 vs 設計値」の突合が崩壊 する。
「なぜ HCL 直解析ではダメか」
python-hcl2 を使った HCL 直接解析は ARN 混入を避けられるが、変数参照が未解決のまま残るという致命的な問題がある。
resource "aws_instance" "app" {
instance_type = var.instance_type
ami = local.base_ami
subnet_id = module.vpc.private_subnet_id
}
このコードを HCL パーサで読むと instance_type の値は var.instance_type という文字列のままになる。
実際の値(t3.micro)はどこにも現れない。
パラメーターシートに var.instance_type と書かれても、突合に使える情報にはならない。
Plan JSON ハイブリッドの選定根拠
Plan JSON ハイブリッドは上記2つの欠点を同時に解消する。
configuration.root_module.resources[].expressions(コード記載キー集合の抽出元):
コードに書いたキーだけが現れる。ARN や AWS デフォルト値はexpressionsに 登場しない。planned_values.root_module.resources[].values(解決済み値の抽出元):
var.instance_typeやlocal.base_amiが plan 時に解決された実値に変換されている。
「expressions に存在するキー ∩ planned_values の解決済み値」だけを Excel に出力することで、
コードに書いた値だけを、変数解決された状態で 取得できる。
# code_keys = configuration の expressions に現れるキー集合(設計者が書いたもの)
# values = planned_values の values(変数解決済み)
code_keys = set(expressions.keys())
exported = {k: v for k, v in values.items()
if k in code_keys and v is not None}
3-2. 除外ルール設計(多層防御)
Plan JSON ハイブリッドで expressions フィルタを通しても、
一部の ARN 派生フィールドや AWS 固有 ID が planned_values に残ることがある。
そのため Blacklist + code_keys フィルタの2層防御 を設計している。
実装コード(設計書 §5-3 準拠)
# 終端マッチ: 末尾がこれらで終わるキーは除外
EXCLUDE_SUFFIXES = ('_id', '_arn', 'tags_all')
# 完全一致: これらのキー名は無条件除外
EXCLUDE_EXACT = {'arn', 'id', 'owner_id', 'self_link'}
# サービス別除外: リソースタイプに応じて追加除外
EXCLUDE_BY_SERVICE = {
'aws_instance': {'primary_network_interface_id', 'public_ip'},
'aws_db_instance': {'address', 'endpoint', 'resource_id'},
}
def should_exclude(service: str, key: str) -> bool:
"""パラメーターシートへの出力対象から除外すべきキーか判定する。"""
if key in EXCLUDE_EXACT:
return True
if any(key.endswith(s) for s in EXCLUDE_SUFFIXES):
return True
if key in EXCLUDE_BY_SERVICE.get(service, set()):
return True
return False
なぜ Whitelist(許可リスト)ではなく Blacklist(除外リスト)か
Whitelist 方式は「出力を許可するキーを明示的に列挙する」設計だ。
安全性は高いが、新しい AWS サービスやリソースタイプを追加するたびにリストを更新しなければならない。
10 サービスから 20 サービスに増やすだけで保守工数が倍増する。
一方 Blacklist 方式は「除外したいパターンだけを管理する」。
AWS 払出し値の多くは末尾が _id や _arn で終わるという一貫したパターンを持つため、EXCLUDE_SUFFIXES で大部分をまとめて除外できる。
残った個別ケースはサービス別の EXCLUDE_BY_SERVICE で補う。
この「広域 Blacklist + code_keys フィルタ + サービス別 Blacklist」の多層防御 が、
シンプルさと正確性を両立する理由だ。
多層防御の全体図
plan.json
│
├─ configuration.expressions ─→ code_keys(Layer 1: コード記載キーのみ)
│
└─ planned_values.values─→ 全キー(AWSデフォルト含む)
│
∩ code_keys フィルタ(Layer 1 通過)
│
EXCLUDE_SUFFIXES チェック(Layer 2-a)
│
EXCLUDE_EXACT チェック(Layer 2-b)
│
EXCLUDE_BY_SERVICE チェック(Layer 2-c)
│
パラメーターシート出力値(最終結果)
3-3. 全体アーキテクチャの整理
3論点回答マトリクス
本記事の設計判断は「論点B(TF パース方式)・論点C(AWS Config 設定範囲)・論点E(既存記事との接続)」の3論点から成る。
第1弾(本記事)が対象とする論点 B・E をマトリクスで整理する。
┌─────────────────────────────────────────────────────────────────────┐
│ 3論点回答マトリクス(第1弾スコープ)│
├──────────┬────────────────────────┬──────────────────────────────────┤
│ 論点 │ 選択肢 │ 本記事の回答 │
├──────────┼────────────────────────┼──────────────────────────────────┤
│ 論点B│ A. state│ │
│ (TFパース)│ B. plan 全体 │ D. Plan JSON ハイブリッド ✓ │
│ │ C. HCL 直解析 │ (code_keys × planned_values) │
│ │ D. Plan JSON ハイブリッド││
├──────────┼────────────────────────┼──────────────────────────────────┤
│ 論点C│ Config なし│ 第2弾スコープ │
│ (Config) │ Config 最小構成 │ (本記事は TF → Excel のみ)│
│ │ Config + Aggregator │ │
├──────────┼────────────────────────┼──────────────────────────────────┤
│ 論点E│ 前提知識なし │ │
│ (既存記事)│ TF 基礎のみ参照 │ TF 基礎(ID:1120) +│
│ │ TF 基礎 + 実践参照 │ TF 実践(ID:1208) 参照 ✓ │
└──────────┴────────────────────────┴──────────────────────────────────┘
本記事の技術スタック
| 領域 | 採用技術 | 採用理由 |
|---|---|---|
| TF パース | Plan JSON ハイブリッド(configuration.expressions ∩ planned_values) | コード記載値のみ要件を厳密充足、変数解決済み |
| HCL 補助 | python-hcl2(オプション) | Python 主言語のエコシステム統一、将来の HCL フォールバック用 |
| Excel 生成 | openpyxl | スタイル / 色 / 列幅 / 数式の書き戻しに対応 |
| Excel 比較演算 | 自作 Python(pandas なし) | 依存軽量化、突合対象行数は最大数千行で pandas 不要 |
| マルチ環境 | environments/{dev,stg,prod}/ ディレクトリ分離 | cmd_040 第3弾(CodePipeline)と同手法で学習連続性確保 |
| ビルド自動化 | GNU Make / シェルスクリプト | 全環境の plan.json 一括生成に make plan-all を利用 |
| Terraform バージョン | 1.9 系 | plan JSON スキーマ(schema_version 1.2)が安定 |
| AWS プロバイダ | hashicorp/aws ~> 5.0 | 各サービスの最新属性に対応 |
| 対象リージョン | ap-northeast-1(東京) | 本記事サンプルの統一値 |
4. Terraform plan JSON を理解する
Plan JSON ハイブリッドの実装には plan.json の構造を理解する必要がある。
本章では plan の生成コマンドから、3層構造の読み分け方、実際の JSON 抜粋による解説まで順に進める。
4-1. plan コマンドの実行
# ステップ1: バイナリプラン(tfplan)を生成
terraform plan -out tfplan -input=false
# ステップ2: バイナリプランを JSON 形式に変換
terraform show -json tfplan > plan.json
ポイント:
-out tfplanはバイナリ形式でプラン結果を保存する。このファイルを直接読むことはできない。terraform show -json tfplanによって人間・プログラムが読める JSON に変換する。-input=falseは CI / スクリプト実行時に変数入力プロンプトをスキップするために指定する。plan.jsonのスキーマは Terraform 1.0 以降schema_version: 1で安定している。
本記事は Terraform 1.9 系の schema_version 1.2 を前提とする。
注意:
terraform show -json(引数なし)は state を JSON 化するコマンドだ。plan.jsonを生成するには必ず-out tfplan→terraform show -json tfplanの2ステップが必要。
4-2. plan.json の3層構造
plan.json の最上位レベルには多くのキーがあるが、本記事で利用するのは以下の 3領域 だ。
plan.json
├── configuration← 【Layer 1】コード記載情報(変数参照・式)
│└── root_module
│ └── resources[]
│ └── expressions ← ★ code_keys の抽出元
│
├── planned_values ← 【Layer 2】変数解決後の計画値
│└── root_module
│ └── resources[]
│ └── values ← ★ 実値の抽出元
│
└── resource_changes[] ← 【Layer 3】差分情報(add / change / delete)
└── change.actions[] (本記事では参照しない)
Layer 1: configuration
コードに書いた式・参照がそのまま記録される領域。
- キー
expressionsに、リソースブロック内の各属性の記述方法(定数値 or 変数参照)が格納される。 - ここに 現れるキーだけが「設計者がコードに書いた属性」。AWS が自動補完したデフォルト値はここに出てこない。
Layer 2: planned_values
plan 時点で変数・locals・モジュール引数が解決された後の値が格納される領域。
var.instance_type→"t3.micro"のように、すべての変数参照が実値に展開されている。known after apply(apply 後に初めて確定する値)はnullになる。ARN・ID の多くはこれに該当し、自動的に除外できる。
Layer 3: resource_changes(本記事では参照しない)
actions フィールド(["create"] / ["update"] / ["delete"] / ["no-op"])で差分の種類がわかる。
Drift 検知など差分管理目的には有用だが、パラメーターシート生成では利用しない。
4-3. configuration.expressions の読み方
expressions の各エントリは「定数値(constant_value)」または「変数参照(references)」のいずれかの形式を取る。
最小 JSON 例(aws_instance)
{
"configuration": {
"root_module": {
"resources": [
{
"address": "aws_instance.app",
"type": "aws_instance",
"name": "app",
"provider_config_key": "aws",
"expressions": {
"ami": {
"references": ["local.base_ami"]
},
"instance_type": {
"constant_value": "t3.micro"
},
"key_name": {
"references": ["var.key_name"]
},
"subnet_id": {
"references": ["module.vpc.private_subnet_id"]
},
"tags": {
"constant_value": {
"Name": "app-server",
"Env": "dev"
}
}
}
}
]
}
}
}
このように expressions には:
constant_value: HCL に直接書いたリテラル値(文字列・数値・マップ)references: 変数(var.*)/ ローカル値(local.*)/ モジュール出力(module.*.*)への参照
のいずれかが格納される。
「constant_value または references があれば code_keys に含める」のロジックは以下のとおりだ:
def extract_code_keys(expressions: dict) -> set[str]:
"""expressions に登場するキーを code_keys として返す。"""
code_keys = set()
for key, expr in expressions.items():
# constant_value または references が存在すれば設計者が書いたキー
if "constant_value" in expr or "references" in expr:
code_keys.add(key)
return code_keys
この code_keys と planned_values.values を組み合わせることで、
変数参照が実値に解決された状態で、コード記載キーだけを取り出せる。
planned_values と組み合わせた実例
対応する planned_values の values は次のようになる:
{
"planned_values": {
"root_module": {
"resources": [
{
"address": "aws_instance.app",
"type": "aws_instance",
"name": "app",
"values": {
"ami": "ami-0123456789abcdef0",
"instance_type": "t3.micro",
"key_name": "my-keypair",
"subnet_id": "subnet-0123456789abcdef0",
"tags": {
"Name": "app-server",
"Env": "dev"
},
"id": null,
"arn": null,
"public_ip": null,
"private_ip": null,
"primary_network_interface_id": null,
"tags_all": {
"Name": "app-server",
"Env": "dev"
}
}
}
]
}
}
}
planned_values.values には id、arn、public_ip 等の AWS 払出し値も含まれるが:
- code_keys フィルタ:
expressionsにid・arn・public_ipは現れない → 一次除外 - EXCLUDE_SUFFIXES:
tags_all(_all終端 → 除外対象)を補足 - EXCLUDE_EXACT: もし
id・arnが code_keys を漏れても完全一致で除外
この3層によって最終的に出力されるのは:
{
"ami": "ami-0123456789abcdef0",
"instance_type": "t3.micro",
"key_name": "my-keypair",
"tags": {"Name": "app-server", "Env": "dev"},
}
設計者がコードに書いた値だけが残る。
変数・locals・モジュールがどこで解決されるか
| 記述パターン | expressions | planned_values |
|---|---|---|
"t3.micro"(リテラル) | constant_value: "t3.micro" | "t3.micro" |
var.instance_type | references: ["var.instance_type"] | "t3.micro"(変数解決済み) |
local.base_ami | references: ["local.base_ami"] | "ami-0123456789abcdef0"(locals 展開済み) |
module.vpc.private_subnet_id | references: ["module.vpc.private_subnet_id"] | "subnet-0123456789abcdef0"(モジュール出力展開済み) |
AWS 払出し(id, arn 等) | 現れない | null(known after apply) |
変数 / locals / モジュール引数の解決は planned_values で完了している。expressions は「何を書いたか」を判断するためだけに使い、実際の値は常に planned_values から取得する。
4-4. ハンズオン: 最小 HCL で plan.json を生成する
最小構成の aws_instance リソースと S3 バックエンド設定で、実際に plan.json を出力するまでの手順を示す。
ディレクトリ構成
handson/
├── environments/
│└── dev/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── (以降の章でモジュールを追加)
backend.tf
terraform {
backend "s3" {
bucket= "myorg-terraform-state"
key= "dev/terraform.tfstate"
region= "ap-northeast-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
読み替え:
myorg-terraform-stateは自分の S3 バケット名に、terraform-state-lockは DynamoDB テーブル名に置き換えること。
main.tf(最小 aws_instance 定義)
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = "handson-app"
Env = "dev"
}
}
variables.tf
variable "ami_id" {
type = string
description = "EC2 AMI ID(Amazon Linux 2023 等)"
}
variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2 インスタンスタイプ"
}
terraform.tfvars
ami_id = "ami-0123456789abcdef0"
instance_type = "t3.micro"
注意: 実際の AMI ID は
aws ec2 describe-images等で最新の Amazon Linux 2023 AMI を確認して指定すること。
plan.json の生成手順
# environments/dev ディレクトリへ移動
cd environments/dev
# プロバイダのダウンロードと初期化
terraform init
# バイナリプランを出力(AWSへの変更は行われない)
terraform plan -out tfplan -input=false
# バイナリプランを JSON に変換
terraform show -json tfplan > plan.json
# 生成確認
wc -l plan.json # ファイルサイズ確認(数百〜数千行)
cat plan.json | python3 -m json.tool | head -30# 整形して先頭確認
生成された plan.json の構造確認
# configuration.expressions の確認
python3 -c "
import json, sys
plan = json.load(open('plan.json'))
for r in plan['configuration']['root_module']['resources']:
print(r['address'], ':', list(r['expressions'].keys()))
"
期待される出力:
aws_instance.app : ['ami', 'instance_type', 'tags']
subnet_id を HCL に書いていないため、expressions には現れない。
このように 設計者が書いたキーだけが expressions に登録される ことが確認できる。
# planned_values の確認(変数解決後の値)
python3 -c "
import json
plan = json.load(open('plan.json'))
for r in plan['planned_values']['root_module']['resources']:
print(r['address'])
for k, v in r['values'].items():
print(f' {k}: {v}')
"
期待される出力(抜粋):
aws_instance.app
ami: ami-0123456789abcdef0
instance_type: t3.micro
tags: {'Name': 'handson-app', 'Env': 'dev'}
id: None
arn: None
public_ip: None
...(AWS 払出し値は null)
var.ami_id が ami-0123456789abcdef0 に、var.instance_type が t3.micro に解決されていることがわかる。id・arn・public_ip は null(known after apply)のため、v is not None フィルタで自動除外される。
ローカル実行(AWS 接続不要)の代替
backend として S3 を使わずローカルで試したい場合は、backend.tf を削除して local バックエンドを使う:
# backend.tf(ローカルテスト用)
terraform {
backend "local" {
path = "terraform.tfstate"
}
}
ただし terraform plan 時に AWS への認証(EC2 AMI の存在確認等)は発生するため、AWS_PROFILE または AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY の設定は必要だ。
# AWS CLI プロファイルを指定して実行
AWS_PROFILE=handson-dev terraform plan -out tfplan -input=false
terraform show -json tfplan > plan.json
これで plan.json が生成される。次章(Section 5)では、この plan.json を受け取って
パラメーターシート用のデータ構造に変換する Plan JSON ハイブリッドパーサ を実装する。
Section 5. Plan JSON ハイブリッドパーサの実装(Python)
Section 4 で plan.json の構造を理解したところで、いよいよ本記事の核となる Plan JSON ハイブリッドパーサ を実装します。このパーサが「Terraform コードに明示的に記載した値だけを、変数解決済みの状態で抽出する」ことで、後段の Excel 出力・AWS Config 突合が成り立ちます。
5-1. 実装方針の再確認
Section 3 で設計した「2段フィルタ方式」をコード化します。
2段フィルタの原理
plan.json
│
├─ configuration.root_module.resources[].expressions
│↓
│【Layer 1: code_keys フィルタ】
│Terraform コードに書かれた属性名のみを抽出 → code_keys 集合
│
├─ planned_values.root_module.resources[].values
│↓
│【Layer 1 適用】 code_keys に含まれる key のみ通過
│↓
│【Layer 2: suffix マッチ除外】
│key.endswith(('_id', '_arn', 'tags_all')) → 除外
│↓
│【Layer 3: exact / service 別除外】
│key ∈ exclude_exact or exclude_by_service → 除外
│↓
│出力: {service_type: {resource_address: {attr: value}}}
なぜ 2 段構えが必要か?
planned_values には Terraform が AWS から補完したデフォルト値も含まれます。たとえば aws_instance の tenancy(シングルテナンシー)や ebs_optimized は、HCL に書かなくても plan.json には常に現れます。Layer 1 の code_keys フィルタだけでこれらを除外できますが、Layer 2/3 でさらに _id, _arn 系のシステム払出し値を多重に弾くことで、false negative(意図せず値が漏れる)を防ぎます。
処理フローチャート(概念図)
┌──────────────────────────────────────────────────┐
│ parse_plan(plan_json_path, config) │
│ │
│ 1. JSON 読み込み・format_version 検証 │
│ 2. extract_code_keys(configuration) │
│ └── resource_address → code_keys の辞書を構築│
│ 3. planned_values を走査│
│ ├── モジュール再帰展開(module.*)│
│ └── count / for_each インスタンス展開│
│ 4. 各リソースに _extract_values() を適用 │
│ ├── Layer 1: code_keys 交差│
│ ├── Layer 2: suffix 除外│
│ └── Layer 3: exact / service 別除外 │
│ 5. 戻り値: {service: {address: {attr: value}}} │
└──────────────────────────────────────────────────┘
注意: draw.io フローチャート PNG(
articles/diagrams/param-sheet-s5-flow.png)は別途掲載。本節ではテキスト版で構成を把握してください。
5-2. モジュール構成
aws-handson/
└── scripts/
└── tf_plan_parser/
├── __init__.py # 公開 API をエクスポート
├── parser.py# コア実装(parse_plan 等)
├── cli.py# `python -m tf_plan_parser` エントリポイント
└── tests/
├── __init__.py
├── fixtures/ # テスト用 plan.json スニペット
│├── minimal_vpc.json
│├── variable_resolved.json
│└── sensitive_env.json
├── test_normal.py# T01-T05 正常系
├── test_exclude.py # T10-T13 除外系
└── test_edge.py # T20-T24 エッジケース
各ファイルの役割を以下に示します。
| ファイル | 役割 |
|---|---|
parser.py | parse_plan, extract_code_keys, should_exclude, ParserConfig を実装 |
cli.py | argparse + parse_plan を組み合わせた CLI ラッパ |
__init__.py | from tf_plan_parser import parse_plan, ParserConfig の公開 |
tests/ | pytest テストスイート(T01-T24) |
5-3. parser.py 完全実装
5-3-1. ParserConfig — パーサ設定の型定義
# scripts/tf_plan_parser/parser.py
from __future__ import annotations
import json
import logging
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
SUPPORTED_FORMAT_VERSIONS = {"1.0", "1.1", "1.2"}
# ---------------------------------------------------------------
# 設定クラス
# ---------------------------------------------------------------
@dataclass(frozen=True)
class ParserConfig:
"""ハイブリッドパーサの除外ルール設定。
イミュータブル(frozen=True)なので、複数スレッドから共有しても安全。
"""
exclude_suffixes: tuple[str, ...] = ("_id", "_arn", "tags_all")
"""Layer 2: キー末尾マッチで除外するサフィックス群"""
exclude_exact: frozenset[str] = frozenset({"arn", "id", "owner_id", "self_link"})
"""Layer 3: 完全一致で除外するキー名"""
exclude_by_service: dict[str, frozenset[str]] = field(
default_factory=lambda: {
"aws_instance": frozenset({"primary_network_interface_id", "public_ip", "private_ip"}),
"aws_db_instance": frozenset({"address", "endpoint", "resource_id", "hosted_zone_id"}),
"aws_lb": frozenset({"dns_name", "zone_id"}),
}
)
"""Layer 3: サービス別の追加除外キー"""
include_sensitive: bool = False
"""True にすると sensitive=true の属性値を保持する(デフォルト: REDACTED 置換)"""
ParserConfig を dataclass(frozen=True) にすることで、設定を誤って書き換えるバグを防ぎます。呼び出し側は通常デフォルトをそのまま使い、exclude_by_service を追加したい場合のみ上書きします。
5-3-2. extract_code_keys — Layer 1 の辞書構築
def extract_code_keys(configuration: dict) -> dict[str, set[str]]:
"""configuration.root_module から resource_address → code_keys の辞書を構築する。
Args:
configuration: plan.json の configuration キー配下の dict
Returns:
{resource_address: {attr_key, ...}}
例: {"aws_instance.web": {"instance_type", "ami", "tags"}}
"""
result: dict[str, set[str]] = {}
root = configuration.get("root_module", {})
_collect_resources_keys(root, prefix="", result=result)
return result
def _collect_resources_keys(
module_block: dict,
prefix: str,
result: dict[str, set[str]],
) -> None:
"""モジュール再帰で configuration.resources の expressions を収集する。"""
# 直接配置のリソース
for res in module_block.get("resources", []):
address = res.get("address", "")
if prefix:
address = f"{prefix}.{address}"
expressions: dict = res.get("expressions", {})
code_keys = _flatten_expression_keys(expressions)
if code_keys:
result[address] = code_keys
# module_calls を再帰的に辿る
for mod_name, mod_block in module_block.get("module_calls", {}).items():
child_module = mod_block.get("module", {})
child_prefix = f"module.{mod_name}" if not prefix else f"{prefix}.module.{mod_name}"
_collect_resources_keys(child_module, prefix=child_prefix, result=result)
def _flatten_expression_keys(expressions: dict) -> set[str]:
"""expressions 辞書のトップレベルキーを返す。
ネストされたブロック(例: network_interface {})はキー名だけ収録し、
その内部属性は現状スコープ外とする。
"""
return {k for k in expressions if not k.startswith("//") and k != "depends_on"}
_flatten_expression_keys は expressions のトップレベルキーだけを拾います。ingress / egress のようにブロック内がリストの場合、キー名 ingress は収録され、ブロック内部の from_port 等は Section 8 の SG 専用処理で扱います。
5-3-3. should_exclude — Layer 2/3 の除外判定
def should_exclude(service: str, key: str, config: ParserConfig) -> bool:
"""3 層除外ルールを統合判定する。
Args:
service: リソースタイプ(例: "aws_instance")
key: 属性キー名(例: "instance_id", "ami")
config: ParserConfig
Returns:
True = 除外すべき(パラメーターシートに出力しない)
"""
# Layer 2: suffix マッチ
if key.endswith(config.exclude_suffixes):
return True
# Layer 3a: exact マッチ
if key in config.exclude_exact:
return True
# Layer 3b: サービス別除外
service_excludes = config.exclude_by_service.get(service, frozenset())
if key in service_excludes:
return True
return False
5-3-4. _extract_values — 値の抽出と null 処理
def _extract_values(
planned: dict,
code_keys: set[str],
service: str,
config: ParserConfig,
) -> dict[str, Any]:
"""planned_values の values dict から、code_keys ∩ !excluded の属性を抽出する。
Args:
planned: `planned_values.root_module.resources[i].values` に相当する dict
code_keys: extract_code_keys で得た、このリソースの code 記載キー集合
service: リソースタイプ(除外判定に使用)
config: ParserConfig
Returns:
{attr: value, ...} — 除外済み・null 処理済みの dict
"""
extracted: dict[str, Any] = {}
for key, raw_value in planned.items():
# Layer 1: code_keys フィルタ
if key not in code_keys:
continue
# Layer 2/3: 除外ルール
if should_exclude(service, key, config):
continue
# sensitive 値の処理
if isinstance(raw_value, dict) and raw_value.get("sensitive") is True:
if not config.include_sensitive:
extracted[key] = "***REDACTED***"
else:
extracted[key] = raw_value.get("value")
continue
# known_after_apply(None) はそのまま保持
# → パラメーターシート側で「(apply後確定)」と表示
extracted[key] = raw_value
return extracted
known_after_apply の値は null(Python では None)として plan.json に現れます。None をそのまま辞書に入れておき、Excel 出力時に「(apply後確定)」と表示する設計です。
5-3-5. parse_plan — メインエントリポイント
def parse_plan(
plan_json_path: Path,
config: ParserConfig | None = None,
) -> dict[str, dict[str, dict[str, Any]]]:
"""plan.json を読み込み、code 記載値のみを抽出して返す。
Args:
plan_json_path: `terraform show -json tfplan` の出力ファイルパス
config: パーサ設定(None なら ParserConfig() デフォルト値)
Returns:
{service_type: {resource_address: {attr: value, ...}}}
例:
{
"aws_instance": {
"aws_instance.web": {"instance_type": "t3.medium", "ami": "ami-0abc1234"}
},
"aws_vpc": {
"aws_vpc.main": {"cidr_block": "10.0.0.0/16", "enable_dns_hostnames": True}
}
}
Raises:
FileNotFoundError: plan_json_path が存在しない
ValueError: plan.json のパースに失敗、または format_version 非対応
"""
if config is None:
config = ParserConfig()
if not plan_json_path.exists():
raise FileNotFoundError(f"plan.json が見つかりません: {plan_json_path}")
with plan_json_path.open(encoding="utf-8") as fp:
try:
plan = json.load(fp)
except json.JSONDecodeError as exc:
raise ValueError(f"plan.json の JSON パースに失敗: {exc}") from exc
# format_version チェック
fmt_ver = plan.get("format_version")
if fmt_ver is None:
raise ValueError("plan.json に format_version がありません。壊れたファイルの可能性があります。")
if fmt_ver not in SUPPORTED_FORMAT_VERSIONS:
warnings.warn(
f"未検証の format_version: {fmt_ver}。可能な範囲でパースを続行します。",
UserWarning,
stacklevel=2,
)
# Step 1: code_keys 辞書の構築
configuration = plan.get("configuration", {})
code_keys_map = extract_code_keys(configuration)
if not code_keys_map:
logger.warning("configuration.root_module にリソースが見つかりませんでした。空 dict を返します。")
return {}
# Step 2: planned_values を走査して値を抽出
planned_values = plan.get("planned_values", {})
if not planned_values:
return {}
result: dict[str, dict[str, dict[str, Any]]] = {}
root_module = planned_values.get("root_module", {})
_collect_planned_resources(root_module, prefix="", code_keys_map=code_keys_map, config=config, result=result)
return result
def _collect_planned_resources(
module_block: dict,
prefix: str,
code_keys_map: dict[str, set[str]],
config: ParserConfig,
result: dict[str, dict[str, dict[str, Any]]],
) -> None:
"""planned_values モジュールを再帰的に走査してリソース値を収集する。"""
for resource in module_block.get("resources", []):
address: str = resource.get("address", "")
if prefix:
address = f"{prefix}.{address}"
resource_type: str = resource.get("type", "")
values: dict = resource.get("values", {})
# code_keys が未登録のリソース(動的に生成されたもの等)はスキップ
code_keys = code_keys_map.get(address, set())
if not code_keys:
# モジュール展開(count/for_each)では address に "[0]" / `["key"]` がつく
# ベースアドレスで fallback 検索
base = _strip_index_suffix(address)
code_keys = code_keys_map.get(base, set())
extracted = _extract_values(values, code_keys, resource_type, config)
if extracted:
result.setdefault(resource_type, {})[address] = extracted
# 子モジュールの再帰処理
for child_module in module_block.get("child_modules", []):
child_address = child_module.get("address", "")
_collect_planned_resources(
child_module,
prefix=child_address,
code_keys_map=code_keys_map,
config=config,
result=result,
)
def _strip_index_suffix(address: str) -> str:
"""count/for_each の index suffix を取り除く。
例:
"aws_instance.web[0]" → "aws_instance.web"
"aws_instance.web[\"a\"]" → "aws_instance.web"
"""
import re
return re.sub(r'\[.*?\]$', '', address)
5-3-6. __init__.py — 公開 API
# scripts/tf_plan_parser/__init__.py
from .parser import ParserConfig, extract_code_keys, parse_plan, should_exclude
__all__ = ["parse_plan", "extract_code_keys", "should_exclude", "ParserConfig"]
5-4. cli.py — コマンドラインエントリポイント
# scripts/tf_plan_parser/cli.py
"""
使用例:
python -m tf_plan_parser environments/dev/plan.json
python -m tf_plan_parser environments/dev/plan.json --format table --service aws_instance
python -m tf_plan_parser environments/dev/plan.json --include-sensitive
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .parser import ParserConfig, parse_plan
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="tf_plan_parser",
description="terraform show -json の plan.json からコード記載値のみを抽出します。",
)
p.add_argument("plan_json", type=Path, help="plan.json のパス")
p.add_argument(
"--format",
choices=["json", "table"],
default="json",
help="出力フォーマット(デフォルト: json)",
)
p.add_argument(
"--service",
default=None,
help="絞り込むサービス種別(例: aws_instance)。省略時は全サービス",
)
p.add_argument(
"--include-sensitive",
action="store_true",
default=False,
help="sensitive=true の属性値を REDACTED せずに出力する",
)
return p
def _print_table(data: dict) -> None:
"""簡易テーブル形式で出力する。"""
for service, resources in data.items():
print(f"\n=== {service} ===")
for address, attrs in resources.items():
print(f" [{address}]")
for key, value in attrs.items():
print(f" {key}: {value!r}")
def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
config = ParserConfig(include_sensitive=args.include_sensitive)
try:
result = parse_plan(args.plan_json, config=config)
except (FileNotFoundError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
# サービス絞り込み
if args.service:
result = {k: v for k, v in result.items() if k == args.service}
if args.format == "json":
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
else:
_print_table(result)
return 0
if __name__ == "__main__":
sys.exit(main())
__main__.py を追加することで python -m tf_plan_parser が動作します。
# scripts/tf_plan_parser/__main__.py
from .cli import main
import sys
sys.exit(main())
5-5. 動作確認
最小 HCL でのエンドツーエンド確認
以下の HCL で plan.json を生成して動作を確認します。
# environments/dev/main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "ap-northeast-1" }
variable "instance_type" { default = "t3.micro" }
variable "ami_id" { default = "ami-0abc123456789012" }
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = { Name = "dev-web", Env = "dev" }
}
# plan.json の生成
cd environments/dev
terraform init
terraform plan -out tfplan
terraform show -json tfplan > plan.json
# パーサの実行
cd /path/to/aws-handson
python -m tf_plan_parser environments/dev/plan.json | python3 -m json.tool
期待出力:
{
"aws_instance": {
"aws_instance.web": {
"ami": "ami-0abc123456789012",
"instance_type": "t3.micro",
"tags": {
"Env": "dev",
"Name": "dev-web"
}
}
}
}
instance_id, public_ip, private_ip など HCL に書いていない属性は一切出力されていません。
テーブル形式での確認
python -m tf_plan_parser environments/dev/plan.json --format table --service aws_instance
=== aws_instance ===
[aws_instance.web]
ami: 'ami-0abc123456789012'
instance_type: 't3.micro'
tags: {'Env': 'dev', 'Name': 'dev-web'}
複数環境の並列確認
for env in dev stg prod; do
echo "=== $env ==="
python -m tf_plan_parser environments/$env/plan.json --format table
done
5-6. テストコード(tests/test_normal.py · tests/test_exclude.py)
スキル設計書 T01–T24 のうち 5 ケースを pytest でコード化します。テスト用フィクスチャは辞書で定義し、ファイル I/O の代わりに tmp_path を使います。
# scripts/tf_plan_parser/tests/test_normal.py
"""正常系テスト: T01, T02, T03"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tf_plan_parser import ParserConfig, parse_plan
# ---------------------------------------------------------------
# フィクスチャ生成ヘルパ
# ---------------------------------------------------------------
def _write_plan(tmp_path: Path, plan_dict: dict) -> Path:
p = tmp_path / "plan.json"
p.write_text(json.dumps(plan_dict), encoding="utf-8")
return p
def _minimal_plan(resources_conf: list, resources_planned: list) -> dict:
"""最小構成の plan.json テンプレートを生成する。"""
return {
"format_version": "1.0",
"configuration": {
"root_module": {
"resources": resources_conf
}
},
"planned_values": {
"root_module": {
"resources": resources_planned
}
},
}
# ---------------------------------------------------------------
# T01: 最小 HCL(aws_vpc 1 個)の plan.json
# ---------------------------------------------------------------
def test_T01_minimal_vpc(tmp_path: Path) -> None:
"""cidr_block のみ抽出され、id / arn は出力されないこと。"""
plan = _minimal_plan(
resources_conf=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"expressions": {
"cidr_block": {"constant_value": "10.0.0.0/16"}
},
}],
resources_planned=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"values": {
"cidr_block": "10.0.0.0/16",
"id": "vpc-0abc1234",# AWS払出し → 除外期待
"arn": "arn:aws:ec2:ap-northeast-1:123456789012:vpc/vpc-0abc1234", # 除外期待
"enable_dns_hostnames": True, # code 未記載 → 除外期待
},
}],
)
path = _write_plan(tmp_path, plan)
result = parse_plan(path)
assert "aws_vpc" in result
attrs = result["aws_vpc"]["aws_vpc.main"]
assert attrs == {"cidr_block": "10.0.0.0/16"}
assert "id" not in attrs
assert "arn" not in attrs
assert "enable_dns_hostnames" not in attrs
# ---------------------------------------------------------------
# T02: 変数参照(var.instance_type)の plan.json
# ---------------------------------------------------------------
def test_T02_variable_resolved(tmp_path: Path) -> None:
"""planned_values に変数解決済み値が入り、パーサがそれを抽出できること。"""
plan = _minimal_plan(
resources_conf=[{
"address": "aws_instance.web",
"type": "aws_instance",
"expressions": {
"ami": {"references": ["var.ami_id"]},
"instance_type": {"references": ["var.instance_type"]},
},
}],
resources_planned=[{
"address": "aws_instance.web",
"type": "aws_instance",
"values": {
"ami": "ami-0abc123456789012",
"instance_type": "t3.medium",
"id":None,# known_after_apply
"public_ip": None,# aws_instance service-別除外
},
}],
)
path = _write_plan(tmp_path, plan)
result = parse_plan(path)
attrs = result["aws_instance"]["aws_instance.web"]
assert attrs["ami"] == "ami-0abc123456789012"
assert attrs["instance_type"] == "t3.medium"
assert "id" not in attrs
assert "public_ip" not in attrs
# ---------------------------------------------------------------
# T03: 複数リソース・複数サービス混在
# ---------------------------------------------------------------
def test_T03_multiple_services(tmp_path: Path) -> None:
"""サービス種別ごとに辞書が分かれて格納されること。"""
plan = _minimal_plan(
resources_conf=[
{
"address": "aws_vpc.main",
"type": "aws_vpc",
"expressions": {"cidr_block": {"constant_value": "10.0.0.0/16"}},
},
{
"address": "aws_instance.web",
"type": "aws_instance",
"expressions": {"instance_type": {"constant_value": "t3.micro"}, "ami": {"constant_value": "ami-x"}},
},
],
resources_planned=[
{
"address": "aws_vpc.main",
"type": "aws_vpc",
"values": {"cidr_block": "10.0.0.0/16", "id": "vpc-0000"},
},
{
"address": "aws_instance.web",
"type": "aws_instance",
"values": {"instance_type": "t3.micro", "ami": "ami-x", "id": None},
},
],
)
path = _write_plan(tmp_path, plan)
result = parse_plan(path)
assert set(result.keys()) == {"aws_vpc", "aws_instance"}
assert "aws_vpc.main" in result["aws_vpc"]
assert "aws_instance.web" in result["aws_instance"]
# scripts/tf_plan_parser/tests/test_exclude.py
"""除外系テスト: T10, T21"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tf_plan_parser import ParserConfig, parse_plan
from .test_normal import _minimal_plan, _write_plan
# ---------------------------------------------------------------
# T10: planned_values に arn / id が含まれる → 出力されないこと
# ---------------------------------------------------------------
def test_T10_arn_id_excluded(tmp_path: Path) -> None:
plan = _minimal_plan(
resources_conf=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"expressions": {
"cidr_block": {"constant_value": "172.16.0.0/12"},
"arn": {"constant_value": "dummy"},# code に書いてある場合でも除外
"id":{"constant_value": "dummy"},
},
}],
resources_planned=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"values": {
"cidr_block": "172.16.0.0/12",
"arn": "arn:aws:ec2:ap-northeast-1:123456789012:vpc/vpc-1111",
"id": "vpc-1111",
},
}],
)
path = _write_plan(tmp_path, plan)
result = parse_plan(path)
attrs = result["aws_vpc"]["aws_vpc.main"]
assert "arn" not in attrs
assert "id" not in attrs
assert attrs["cidr_block"] == "172.16.0.0/12"
# ---------------------------------------------------------------
# T21: sensitive=true の属性 → REDACTED / include_sensitive で保持
# ---------------------------------------------------------------
def test_T21_sensitive_redacted_by_default(tmp_path: Path) -> None:
"""include_sensitive=False(デフォルト)なら ***REDACTED*** に置換されること。"""
plan = _minimal_plan(
resources_conf=[{
"address": "aws_lambda_function.handler",
"type": "aws_lambda_function",
"expressions": {
"function_name": {"constant_value": "my-func"},
"environment":{"references": ["var.env_vars"]},
},
}],
resources_planned=[{
"address": "aws_lambda_function.handler",
"type": "aws_lambda_function",
"values": {
"function_name": "my-func",
"environment": {"sensitive": True, "value": {"DB_PASSWORD": "s3cr3t"}},
},
}],
)
path = _write_plan(tmp_path, plan)
# デフォルト(include_sensitive=False)
result = parse_plan(path)
attrs = result["aws_lambda_function"]["aws_lambda_function.handler"]
assert attrs["environment"] == "***REDACTED***"
# include_sensitive=True なら生値が入る
config = ParserConfig(include_sensitive=True)
result2 = parse_plan(path, config=config)
attrs2 = result2["aws_lambda_function"]["aws_lambda_function.handler"]
assert attrs2["environment"] == {"DB_PASSWORD": "s3cr3t"}
テストの実行
cd aws-handson
pip install pytest
python -m pytest scripts/tf_plan_parser/tests/ -v
tests/test_normal.py::test_T01_minimal_vpcPASSED
tests/test_normal.py::test_T02_variable_resolvedPASSED
tests/test_normal.py::test_T03_multiple_servicesPASSED
tests/test_exclude.py::test_T10_arn_id_excluded PASSED
tests/test_exclude.py::test_T21_sensitive_redacted_by_default PASSED
5 passed in 0.12s
5 ケース全件 PASS で、パーサの基本動作・除外ルール・sensitive 処理を網羅しています。
Section 5 まとめ
本セクションで実装したコアパーサの特徴を整理します。
| 機能 | 実装ポイント |
|---|---|
| 2段フィルタ | extract_code_keys で code_keys を先行構築 → _extract_values で交差 |
| モジュール再帰 | _collect_resources_keys / _collect_planned_resources で再帰展開 |
| count/for_each | _strip_index_suffix でベースアドレスへ fallback |
| sensitive 処理 | ParserConfig.include_sensitive フラグで切替 |
| CLI 二重利用 | cli.py + __main__.py で python -m tf_plan_parser として直接呼出し可 |
| テスト容易性 | _minimal_plan ヘルパで fixtures を dict として宣言 → 外部ファイル不要 |
Section 6 では、このパーサを3環境(dev/stg/prod)に対して並列実行し、結果を集約するマルチ環境ループを実装します。
6. マルチ環境対応(environments/ 分離と集約ループ)
単一の main.tf で dev/stg/prod を切り替える構成もありますが、エンタープライズ案件では
環境ごとに独立したディレクトリを持つ方式が主流です。
理由は明快で、各環境のバックエンド設定・変数値・バージョンピン留めを完全に独立管理できるためです。
本シリーズでも、AWS×Terraform 複数人開発シリーズ 第3弾
で採用した environments/ ディレクトリ分離パターンを踏襲します。
本セクションでは、その構成を前提にしながら、全環境の plan.json を一括生成し、
3つの結果を1つの Python 集約ループで統合するところまでを実装します。
6-1. ディレクトリ構成
プロジェクト全体のディレクトリ構成を確認しましょう。environments/ 配下に dev/stg/prod が並び、それぞれが独立した Terraform ルートモジュールになっています。
param-sheet-project/
├── environments/
│├── dev/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││└── backend.tf
│├── stg/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││└── backend.tf
│└── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── modules/
│├── ec2/
││├── main.tf
││├── variables.tf
││└── outputs.tf
│├── vpc/
│├── rds/
│└── lambda/
├── scripts/
│├── tf_plan_parser/
││├── __init__.py
││└── parser.py
│├── tf_to_excel.py
│└── aggregate_envs.py
├── output/
└── Makefile
ポイント:
environments/各ディレクトリが独立したbackend.tfを持つため、state ファイルが環境ごとに隔離されます。modules/には環境横断で再利用するリソース定義を置き、各environmentsplan.json-rw-r--r-- 1 user staff84K Apr 18 16:45 environments/dev/plan.json -rw-r--r-- 1 user staff87K Apr 18 16:45 environments/stg/plan.json -rw-r--r-- 1 user staff91K Apr 18 16:46 environments/prod/plan.jsonステップ 4: 集約ループで動作確認する
python scripts/aggregate_envs.py .=== dev: 12 resources === aws_instance.web aws_lb.main aws_security_group.web ... (9 more) === stg: 12 resources === aws_instance.web aws_lb.main aws_security_group.web ... (9 more) === prod: 14 resources === aws_instance.web aws_lb.main aws_rds_cluster.main ... (11 more)prodのみ RDS クラスターが追加されているような環境差も、集約ループが正しく拾えていることがわかります。よくあるエラーと対処
エラー 原因 対処 Error: No valid credential sources foundAWS 認証情報が未設定 aws configureまたは環境変数AWS_PROFILEを設定Error: Failed to get existing workspacesS3 バケット or DynamoDB テーブルが存在しない バケット/テーブルを先に作成する(bootstrap 手順は 第3弾 参照) FileNotFoundError: plan.json not foundmake plan-allが未実行make plan-allを先に実行するError: Unsupported Terraform Core versionTerraform バージョンが required_versionと不一致tfenvなどでバージョンを合わせる(本記事は 1.9.x 想定)本セクションのまとめ
本セクションでは以下を実装しました:
environments/{dev,stg,prod}/ディレクトリ分離と、S3 バックエンドを使った state 管理設計Makefileのplan-allターゲットによる全環境一括plan.json生成aggregate_envs.pyによる3環境の解析結果統合({env: {resource: {attr: value}}}形式)make plan-allからの実際の動作確認手順
次の Section 7 では、
aggregate()が返す辞書を受け取り、openpyxl で2段ヘッダ・セル色分け・列幅自動調整を施したパラメーターシート Excel を生成する実装を見ていきます。Section 7. Excel 出力(openpyxl、統合書式、色・マージ)
本セクションでは、Section 5 の
parse_plan()が返す{address: {key: value}}形式のデータを
受け取り、複数環境を統合した Excel パラメーターシートを生成する実装を解説する。7-1. 出力 Excel の構成
生成する xlsx ファイルは 3 シート構成とする。
シート名 内容 目的 統合ビュー 2段ヘッダ × 全環境の期待値 設計値の一覧と環境横断の比較 差分一覧 NG 行のみ抽出(第2弾で活用) 差分レビューの効率化 meta 実行時刻・リージョン・AWSアカウント・tf version 成果物のトレーサビリティ確保 # シート名定数 SHEET_INTEGRATED = "統合ビュー" SHEET_DIFF = "差分一覧" SHEET_META = "meta"Sheet1「統合ビュー」の列構成:
[A] サービス [B] リソース名 [C] 属性 [D] 期待値(dev) [E] 期待値(stg) [F] 期待値(prod) ...(第2弾では現状値・判定列が右側に追加される)第1弾では「期待値」のみ出力し、第2弾(AWS Config 突合)で現状値・判定列を右側に追加する設計にしておく。
これにより、第2弾移行時にENV_SUBCOLSリストを変更するだけで列グループが拡張できる。Note: Section 6 で生成した
{env: {address: {key: val}}}の入力形式を前提とする。
Section 5 のパーサ出力スキーマ(parse_plan()の戻り値)と合わせておくことで、
Section 6 の集約ループがそのまま本モジュールへ接続できる。7-2. 2段ヘッダの実装(設計書 §5-1)
設計書の指定通り、Row1 に環境グループを、Row2 に「期待値」列名を配置する。
openpyxlのmerge_cellsで Row1 の環境グループヘッダを横方向にマージする。7-2-1. 列インデックスの設計
from __future__ import annotations from openpyxl import Workbook from openpyxl.utils import get_column_letter from openpyxl.styles import ( PatternFill, Font, Alignment, Border, Side, ) from openpyxl.worksheet.worksheet import Worksheet from typing import Any # 固定列: サービス・リソース名・属性 FIXED_COLS = ["サービス", "リソース名", "属性"] N_FIXED = len(FIXED_COLS) # = 3 # 環境ごとの列グループ(第1弾: 期待値のみ) # 第2弾では ("期待値", "現状値", "判定") に拡張 ENV_SUBCOLS= ["期待値"] N_ENV_SUBCOL = len(ENV_SUBCOLS) # = 1(第1弾)7-2-2.
build_header()関数def build_header(ws: Worksheet, envs: list[str]) -> int: """ Row1: サービス | リソース名 | 属性 | === dev === | === stg === | ... Row2:| | | 期待値 | 期待値 | ... Returns: int: データ書き込み開始行番号(= 3) """ # ---- Row1: 固定列ヘッダ(Row2 と縦マージ)---- for col_idx, label in enumerate(FIXED_COLS, start=1): cell = ws.cell(row=1, column=col_idx, value=label) _style_header_fixed(cell) ws.merge_cells( start_row=1, start_column=col_idx, end_row=2,end_column=col_idx, ) # ---- Row1: 環境グループヘッダ ---- for env_idx, env in enumerate(envs): group_start_col = N_FIXED + env_idx * N_ENV_SUBCOL + 1 group_end_col= group_start_col + N_ENV_SUBCOL - 1 cell = ws.cell(row=1, column=group_start_col, value=f"=== {env} ===") _style_header_env(cell, env_idx) # N_ENV_SUBCOL > 1 のとき(第2弾以降)は横マージ if N_ENV_SUBCOL > 1: ws.merge_cells( start_row=1, start_column=group_start_col, end_row=1,end_column=group_end_col, ) # ---- Row2: 環境サブ列ヘッダ ---- for sub_idx, sub_label in enumerate(ENV_SUBCOLS): sub_col = group_start_col + sub_idx cell = ws.cell(row=2, column=sub_col, value=sub_label) _style_header_sub(cell, env_idx) return 3 # データ開始行7-2-3. ヘッダスタイル補助関数
HEADER_FONT_FIXED = Font(name="Meiryo UI", bold=True, size=10) HEADER_FONT_ENV= Font(name="Meiryo UI", bold=True, size=10, color="FFFFFF") # 環境ごとの背景色(最大5環境) ENV_HEADER_COLORS = ["2F75B6", "375623", "843C0C", "5C3317", "1F3864"] THIN_BORDER = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) def _style_header_fixed(cell) -> None: cell.font= HEADER_FONT_FIXED cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) cell.fill= PatternFill("solid", fgColor="D9D9D9") cell.border = THIN_BORDER def _style_header_env(cell, env_idx: int) -> None: color = ENV_HEADER_COLORS[env_idx % len(ENV_HEADER_COLORS)] cell.font= HEADER_FONT_ENV cell.fill= PatternFill("solid", fgColor=color) cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = THIN_BORDER def _style_header_sub(cell, env_idx: int) -> None: color = ENV_HEADER_COLORS[env_idx % len(ENV_HEADER_COLORS)] cell.fill= PatternFill("solid", fgColor=color) cell.font= Font(name="Meiryo UI", bold=False, size=9, color="FFFFFF") cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = THIN_BORDER7-3. セル色分け定数と Verdict
Section 6-2(設計書)の差分判定アルゴリズムと連携する色コードを定数として一元管理する。
第1弾では Verdict を定義しておき、第2弾の突合実装でそのまま使用できるようにする。from enum import Enum class Verdict(str, Enum): OK= "OK" NG_VALUE_DIFFERS = "NG_VALUE_DIFFERS" NG_ONLY_IN_EXPECTED = "NG_ONLY_IN_EXPECTED" NG_ONLY_IN_ACTUAL= "NG_ONLY_IN_ACTUAL" SKIP = "SKIP" # セル背景色(None = 無色) VERDICT_COLORS: dict[Verdict, str | None] = { Verdict.OK:"C6EFCE", # 緑 Verdict.NG_VALUE_DIFFERS: "FFC7CE", # 赤 Verdict.NG_ONLY_IN_EXPECTED: "FFEB9C", # 橙 Verdict.NG_ONLY_IN_ACTUAL:"CC99FF", # 紫 Verdict.SKIP: None, # 無色 } # Excel 判定列表示テキスト VERDICT_LABELS: dict[Verdict, str] = { Verdict.OK:"✓", Verdict.NG_VALUE_DIFFERS: "× 値差異", Verdict.NG_ONLY_IN_EXPECTED: "! 実環境未検出", Verdict.NG_ONLY_IN_ACTUAL:"! 設計外の存在", Verdict.SKIP: "-", } def make_fill(verdict: Verdict) -> PatternFill | None: """Verdict から PatternFill を返す。SKIP は None(無色)。""" color = VERDICT_COLORS[verdict] if color is None: return None return PatternFill("solid", fgColor=color)色の選定根拠:
Verdict カラーコード 意味 選定理由 OK C6EFCE全一致 Excel「条件付き書式>緑」デフォルトと同一。視認しやすい NG_VALUE_DIFFERS FFC7CE値が異なる Excel「条件付き書式>赤」デフォルト。最重要フラグ NG_ONLY_IN_EXPECTED FFEB9C実環境で確認できず 橙 = 警告。値はTFにあるが実環境取得不可 NG_ONLY_IN_ACTUAL CC99FF設計にない値が実環境に存在 紫 = 想定外。ドリフト検出の主なシグナル SKIP なし 両方未設定 ノイズ排除。空白行は目立たせない 7-4. Excel 書込み関数
7-4-1. 関数シグネチャとデータ構造
from pathlib import Path def write_excel( data: dict[str, dict[str, dict[str, Any]]], output_path: Path, metadata: dict, ) -> None: """ Args: data: {env: {address: {key: val}}} output_path: 書き出し先 .xlsx パス metadata: {"run_at": str, "region": str, "account_id": str, "tf_version": str} """ wb = Workbook() wb.remove(wb.active) # デフォルトの空シートを削除 ws_main = wb.create_sheet(SHEET_INTEGRATED) ws_diff = wb.create_sheet(SHEET_DIFF) ws_meta = wb.create_sheet(SHEET_META) envs = list(data.keys()) # ① ヘッダ生成 data_start_row = build_header(ws_main, envs) build_header(ws_diff, envs) # ② データ行書込み(差分行をリストに蓄積) diff_rows: list[tuple] = [] _write_data_rows(ws_main, data, envs, data_start_row, diff_rows) # ③ 差分行を Sheet2 へ転記 _write_diff_sheet(ws_diff, diff_rows, data_start_row) # ④ Sheet3 meta 書込み _write_meta_sheet(ws_meta, metadata) # ⑤ 書式: オートフィルタ・ウィンドウ枠固定・列幅調整 _apply_sheet_format(ws_main, envs, data_start_row) _apply_sheet_format(ws_diff, envs, data_start_row) wb.save(output_path) print(f"[write_excel] Saved → {output_path}")7-4-2. データ行書込み
def _write_data_rows( ws: Worksheet, data: dict[str, dict[str, dict[str, Any]]], envs: list[str], start_row: int, diff_rows: list[tuple], ) -> None: """全 address × key を行方向に展開して書込む。""" all_rows: list[tuple[str, str, str]] = _collect_rows(data, envs) for row_idx, (service, address, attr_key) in enumerate(all_rows, start=start_row): # 固定列(サービス・リソース名・属性) ws.cell(row=row_idx, column=1, value=service).border = THIN_BORDER ws.cell(row=row_idx, column=2, value=address).border = THIN_BORDER ws.cell(row=row_idx, column=3, value=attr_key).border = THIN_BORDER # 環境列 for env_idx, env in enumerate(envs): col= N_FIXED + env_idx * N_ENV_SUBCOL + 1 value = data.get(env, {}).get(address, {}).get(attr_key) cell = ws.cell(row=row_idx, column=col, value=_format_cell_value(value)) cell.border = THIN_BORDER cell.font= Font(name="Meiryo UI", size=9) # 第1弾: 値ありセルを薄い水色で識別(第2弾で Verdict 色付けに置換) if value is not None: cell.fill = PatternFill("solid", fgColor="DEEAF1")7-4-3. アドレス収集(全環境のキーを統合)
def _collect_rows( data: dict[str, dict[str, dict[str, Any]]], envs: list[str], ) -> list[tuple[str, str, str]]: """ 全環境のアドレス・属性キーを収集し、 (service, address, attr_key) のリストとして返す。 サービス・アドレス・属性キーの順でソートして返す。 """ seen: dict[tuple[str, str], set[str]] = {} for env in envs: for address, attrs in data.get(env, {}).items(): service = _address_to_service(address) key = (service, address) if key not in seen: seen[key] = set() seen[key].update(attrs.keys()) rows: list[tuple[str, str, str]] = [] for (service, address), attr_keys in sorted(seen.items()): for attr_key in sorted(attr_keys): rows.append((service, address, attr_key)) return rows def _address_to_service(address: str) -> str: """ 'aws_instance.web' → 'aws_instance' 'module.ec2.aws_instance.web' → 'aws_instance' """ for part in address.split("."): if part.startswith("aws_"): return part return address.split(".")[0] # フォールバック def _format_cell_value(value: Any) -> str: """リスト・dict は JSON 文字列に変換して表示する。""" if value is None: return "" if isinstance(value, (list, dict)): import json return json.dumps(value, ensure_ascii=False) return str(value)7-4-4. 差分シート書込み
def _write_diff_sheet( ws: Worksheet, diff_rows: list[tuple], start_row: int, ) -> None: """ Sheet2「差分一覧」には第2弾(AWS Config 突合)で NG 判定した行を転記する。 第1弾ではシートを空のままにし、ヘッダのみ出力する。 """ # 第1弾: プレースホルダ行を挿入して、第2弾時の書式確認に使う placeholder = ws.cell(row=start_row, column=1, value="(第2弾: AWS Config 突合後に差分行が自動転記されます)") placeholder.font = Font(name="Meiryo UI", size=9, italic=True, color="808080")7-4-5. Sheet3 meta 書込み
def _write_meta_sheet(ws: Worksheet, metadata: dict) -> None: """実行情報をシート形式で記録する。""" ws.column_dimensions["A"].width = 25 ws.column_dimensions["B"].width = 50 rows = [ ("実行日時(JST)", metadata.get("run_at", "N/A")), ("AWSリージョン",metadata.get("region", "ap-northeast-1")), ("AWSアカウントID", metadata.get("account_id", "123456789012")), ("Terraform バージョン", metadata.get("tf_version", "N/A")), ("生成スクリプト", "scripts/tf_to_excel.py"), ("備考",metadata.get("note", "")), ] for row_idx, (label, value) in enumerate(rows, start=1): label_cell = ws.cell(row=row_idx, column=1, value=label) value_cell = ws.cell(row=row_idx, column=2, value=value) label_cell.font= Font(bold=True, name="Meiryo UI", size=10) value_cell.font= Font(name="Meiryo UI", size=10) label_cell.fill= PatternFill("solid", fgColor="D9D9D9") label_cell.border = THIN_BORDER value_cell.border = THIN_BORDER7-4-6. オートフィルタ・ウィンドウ枠固定・列幅自動調整
def _apply_sheet_format( ws: Worksheet, envs: list[str], data_start_row: int, ) -> None: """オートフィルタ・ウィンドウ枠固定・列幅自動調整を一括適用する。""" total_cols = N_FIXED + len(envs) * N_ENV_SUBCOL last_col_letter = get_column_letter(total_cols) # オートフィルタ(Row2 ヘッダ行に付与) ws.auto_filter.ref = f"A2:{last_col_letter}2" # ウィンドウ枠固定(固定列3列 + ヘッダ2行を常時表示) ws.freeze_panes = ws.cell(row=data_start_row, column=N_FIXED + 1) # 列幅自動調整 _auto_fit_columns(ws) # 行の高さを統一 for row in ws.iter_rows(): ws.row_dimensions[row[0].row].height = 18 def _auto_fit_columns(ws: Worksheet, padding: int = 4) -> None: """全列の幅をセル内容の最大文字数に合わせて調整する(上限60文字)。""" col_widths: dict[int, int] = {} for row in ws.iter_rows(): for cell in row: if cell.value is None: continue col= cell.column width = len(str(cell.value)) + padding if col not in col_widths or col_widths[col] < width: col_widths[col] = width for col_idx, width in col_widths.items(): ws.column_dimensions[get_column_letter(col_idx)].width = min(width, 60)7-5. ハンズオン: Excel 生成と確認
7-5-1. 必要パッケージのインストール
pip install openpyxl # requirements.txt に追記する場合 echo "openpyxl>=3.1.2" >> requirements.txt7-5-2. エントリポイント(scripts/tf_to_excel.py)
#!/usr/bin/env python3 """tf_to_excel.py — Terraform plan.json から Excel パラメーターシートを生成する""" from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime, timezone, timedelta from pathlib import Path from tf_plan_parser import parse_plan# Section 5 実装 from excel_writerimport write_excel # 本 Section の実装 JST = timezone(timedelta(hours=9)) def _get_tf_version() -> str: try: result = subprocess.run( ["terraform", "version", "-json"], capture_output=True, text=True, timeout=10, ) return json.loads(result.stdout).get("terraform_version", "unknown") except Exception: return "unknown" def main() -> None: parser = argparse.ArgumentParser(description="Terraform plan.json → Excel") parser.add_argument( "--envs", nargs="+", required=True, metavar="ENV=plan.json", help="例: dev=environments/dev/plan.json stg=environments/stg/plan.json", ) parser.add_argument("--out", required=True, help="出力先 xlsx パス") parser.add_argument("--account-id", default="123456789012") parser.add_argument("--region", default="ap-northeast-1") args = parser.parse_args() # "env=path" 形式をパース env_plans: dict[str, Path] = {} for env_arg in args.envs: if "=" not in env_arg: print(f"[ERROR] --envs は 'env=path' 形式で指定: {env_arg}") sys.exit(2) env_name, plan_path_str = env_arg.split("=", 1) env_plans[env_name] = Path(plan_path_str) # 各環境の plan.json をパース data: dict[str, dict[str, dict]] = {} for env_name, plan_path in env_plans.items(): if not plan_path.exists(): print(f"[ERROR] plan.json が見つかりません: {plan_path}") sys.exit(2) print(f"[parse] {env_name}: {plan_path}") data[env_name] = parse_plan(plan_path) metadata = { "run_at": datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S JST"), "region": args.region, "account_id": args.account_id, "tf_version": _get_tf_version(), } output_path = Path(args.out) output_path.parent.mkdir(parents=True, exist_ok=True) write_excel(data, output_path, metadata) if __name__ == "__main__": main()7-5-3. 実行コマンド
# 単一環境(dev のみ) python3 scripts/tf_to_excel.py \ --envs dev=environments/dev/plan.json \ --out output/param-sheet-$(date +%Y%m%d-%H%M).xlsx \ --region ap-northeast-1 # 複数環境(dev / stg / prod) python3 scripts/tf_to_excel.py \ --envs dev=environments/dev/plan.json \ stg=environments/stg/plan.json \ prod=environments/prod/plan.json \ --out output/param-sheet-$(date +%Y%m%d-%H%M).xlsx \ --region ap-northeast-1make excelで上記を実行できるよう、Makefile(Section 6 実装)に以下を追記する:excel: python3 scripts/tf_to_excel.py \ --envs dev=environments/dev/plan.json \ stg=environments/stg/plan.json \ prod=environments/prod/plan.json \ --out output/param-sheet-$(shell date +%Y%m%d-%H%M).xlsx \ --region ap-northeast-17-5-4. 生成後の確認手順
実行後、
output/param-sheet-20260418-1600.xlsxが生成される。ls -lh output/param-sheet-*.xlsx # -rw-r--r-- 1 user staff 48K Apr 18 16:00 output/param-sheet-20260418-1600.xlsxExcel または LibreOffice で開き、以下を確認する:
- 「統合ビュー」シート
- Row1 にカラー背景の環境グループヘッダ(
=== dev ===など)が表示されること - Row2 に「期待値」列名が表示されること
- Row1 の固定列(サービス/リソース名/属性)が Row1〜Row2 縦マージされていること
- データ行に値が正しく展開されていること
- 「差分一覧」シート: プレースホルダメッセージが表示されていること
- 「meta」シート: 実行時刻・リージョン・アカウントIDが正しく記録されていること
- ウィンドウ枠固定: 右スクロール時に A〜C 列が固定されていること
- オートフィルタ: Row2 のヘッダセルにドロップダウン矢印が表示されていること
7-5-5. よくあるエラーと対処
エラー 原因 対処 ModuleNotFoundError: openpyxlインストール未了 pip install openpyxlFileNotFoundError: plan.jsonplan.json 未生成 make plan-allを先に実行KeyError: 'root_module'plan.json フォーマット不一致(TF バージョン差異) terraform versionで 1.9.x を確認列幅が極端に広がる 長いリスト値がセルに入っている _auto_fit_columnsの上限値(60)を調整日本語が文字化けする WSL 環境 + LibreOffice のフォント非対応 LibreOffice で「游ゴシック」等に変更して確認 IllegalCharacterError制御文字がセル値に混入 _format_cell_valueでre.sub(r'[\x00-\x1f]', '', ...)を適用7-6. Excel 完成イメージ(図)
以下に「統合ビュー」シートのレイアウト図を示す。
╔══════════════════════════════════════════════════════════════════════════════════════╗ ║ 統合ビュー シート — 2段ヘッダ構成(第1弾: 期待値のみ)║ ╠══════════╤════════════════════════════╤════════════════╦═══════════╦═══════════╦═══╣ ║ Row1 │ A: サービス │ B:リソース名 │ C: 属性 ║ === dev ===║ === stg ===║...║ ║ │(グレー・縦マージ) │(グレー・縦マージ)║(青背景) ║(緑背景) ║║ ╠══════════╪═════════════╪═════════════╪════════════════╬═══════════╬═══════════╬═══╣ ║ Row2 │ │ │ ║ 期待値║ 期待値║...║ ║(サブ列)│(縦マージ) │(縦マージ) │(縦マージ) ║(青背景) ║(緑背景) ║║ ╠══════════╪═════════════╪═════════════╪════════════════╬═══════════╬═══════════╬═══╣ ║ Row3 │aws_instance │mod.ec2.inst │ instance_type ║ t3.micro ║ t3.small ║...║ ║ │aws_instance │mod.ec2.inst │ ami║ami-0bba69 ║ami-0bba69 ║...║ ║ │aws_vpc│mod.vpc.main │ cidr_block ║10.0.0.0/16║10.1.0.0/16║...║ ║ │aws_vpc│mod.vpc.main │ enable_dns_.. ║true ║true ║...║ ║ │aws_subnet│mod.subnet.az│ availability_..║ap-ne-1a║ap-ne-1a║...║ ╚══════════╧═════════════╧═════════════╧════════════════╩═══════════╩═══════════╩═══╝ 凡例(第1弾の色付け): ████ 薄い水色(DEEAF1): 値が存在する期待値セル 無色 : 値未設定(None)のセル ▼ ウィンドウ枠固定ライン: 列 D・行 3(A〜C列とRow1〜Row2が常時表示) ▼ オートフィルタ: Row2 のすべての列ヘッダにドロップダウン矢印 第2弾(AWS Config 突合)で追加される列: [現状値(dev)][現状値(stg)][現状値(prod)] [判定(dev) ][判定(stg) ][判定(prod) ] ✓ = 緑(C6EFCE) × 値差異 = 赤(FFC7CE) ! 実環境未検出 = 橙(FFEB9C) ! 設計外の存在 = 紫(CC99FF)ポイント解説:
要素 設定内容 効果 ウィンドウ枠固定 D3を起点に設定A〜C 列と Row1〜Row2 が常時表示 オートフィルタ Row2 全列に付与 サービス/属性での絞込みが即座に可能 列幅自動調整 最大文字数 + padding(上限60) 内容に合わせた幅で読みやすいレイアウト 環境別ヘッダ色 環境ごとに異なる色(青/緑/橙…) 視覚的に環境を識別しやすい サービス別ソート _collect_rowsでアドレス順ソート同一サービスが連続して表示され比較しやすい 7-まとめ
本セクションで実装した Excel 出力モジュールのポイントを整理する。
項目 実装内容 2段ヘッダ merge_cellsによる環境グループヘッダ(Row1)+サブ列名(Row2)色分け定数 VerdictEnum +VERDICT_COLORSで判定結果ごとのセル色を一元管理meta シート 実行時刻・リージョン・アカウントID・TFバージョンを記録しトレーサビリティを確保 オートフィルタ Row2 に設定し、サービス/属性での絞込みを即可能に ウィンドウ枠固定 固定列(サービス/リソース名/属性)+ヘッダ行を常時表示 列幅自動調整 セル内容の最大文字数に合わせて自動設定(上限60文字) 第2弾への拡張路 ENV_SUBCOLSを["期待値", "現状値", "判定"]に変更するだけで列グループが拡張できる設計第2弾(AWS Config 突合編)では、本モジュールの
write_excel()にcompare()関数の
Verdict 結果を渡すことで、判定列の色付けと差分シート(Sheet2)の自動生成が完成する。第1弾: parse_plan() ─────────────────────────→ write_excel() → Excel(期待値のみ) ↑ 第2弾: parse_plan() + config_fetch() → compare() ──┘ → Excel(期待値+現状値+判定)次のセクション(Section 8)では、EC2/VPC/RDS/Lambda など 10 サービスの Terraform 定義と
属性マッピングを詳細に解説する。Section 8. 対象サービス別の属性カバー
本セクションでは、パラメーターシート自動生成の対象となる 10 サービスそれぞれについて、
Terraform リソース定義・plan.json からの期待抽出値・Python 属性マッピング辞書を網羅的に解説する。8-0. サービス別属性カバー全体マトリクス
設計書 §5-2 の属性カバー表を冒頭に再掲する。「◎」= Excel 出力対象、「✕」= 除外(AWS 払出し値 or ID 系)。
サービス Terraform リソース 主な対象属性 除外属性 EC2 aws_instanceinstance_type, ami, key_name, iam_instance_profile subnet_id, primary_network_interface_id, public_ip VPC aws_vpccidr_block, enable_dns_hostnames, enable_dns_support id, owner_id, arn Subnet aws_subnetcidr_block, availability_zone, map_public_ip_on_launch subnet_id, vpc_id SG aws_security_groupname, description, ingress(正規化済), egress(正規化済) id, owner_id, arn RDS aws_db_instanceengine, engine_version, instance_class, allocated_storage, multi_az address, endpoint, resource_id ALB aws_lbload_balancer_type, internal, security_groups arn, dns_name Lambda aws_lambda_functionruntime, handler, memory_size, timeout, environment.variables arn, function_name(重複時) API GW v2 aws_apigatewayv2_apiprotocol_type, route_key, integration_type id, api_endpoint DynamoDB aws_dynamodb_tablebilling_mode, hash_key, range_key, attribute arn, id, stream_arn SNS/SQS aws_sns_topic/aws_sqs_queuedisplay_name / visibility_timeout_seconds, message_retention_seconds arn, id 除外ルール共通実装(設計書 §5-3 準拠):
EXCLUDE_SUFFIXES = ('_id', '_arn', 'tags_all') EXCLUDE_EXACT = {'arn', 'id', 'owner_id', 'self_link'} EXCLUDE_BY_SERVICE: dict[str, set[str]] = { 'aws_instance': {'primary_network_interface_id', 'public_ip'}, 'aws_db_instance': {'address', 'endpoint', 'resource_id'}, } def should_exclude(service: str, key: str) -> bool: if key in EXCLUDE_EXACT: return True if any(key.endswith(s) for s in EXCLUDE_SUFFIXES): return True if key in EXCLUDE_BY_SERVICE.get(service, set()): return True return False8-1. EC2 (
aws_instance)対象属性
属性 区分 備考 instance_type◎ 出力 t3.micro 等 ami◎ 出力 コード記載値のみ key_name◎ 出力 SSH キーペア名 iam_instance_profile◎ 出力 省略時は null subnet_id✕ 除外 _idsuffix → EXCLUDE_SUFFIXES でカットprimary_network_interface_id✕ 除外 EXCLUDE_BY_SERVICE[“aws_instance”] public_ip✕ 除外 EXCLUDE_BY_SERVICE[“aws_instance”] 最小 HCL 例
resource "aws_instance" "web" { ami= "ami-xxxxxxxxxxxxxxxxx" instance_type = "t3.micro" key_name = "my-keypair" iam_instance_profile = aws_iam_instance_profile.web.name tags = { Name = "web-server" Env = var.env } }plan.json からの抽出期待値
{ "configuration": { "root_module": { "resources": [ { "address": "aws_instance.web", "type": "aws_instance", "expressions": { "ami": { "constant_value": "ami-xxxxxxxxxxxxxxxxx" }, "instance_type": { "constant_value": "t3.micro" }, "key_name": { "constant_value": "my-keypair" }, "iam_instance_profile": { "references": ["aws_iam_instance_profile.web"] } } } ] } }, "planned_values": { "root_module": { "resources": [ { "address": "aws_instance.web", "type": "aws_instance", "values": { "ami": "ami-xxxxxxxxxxxxxxxxx", "instance_type": "t3.micro", "key_name": "my-keypair", "iam_instance_profile": "web-instance-profile", "subnet_id": "subnet-0123456789abcdef0", "public_ip": null, "primary_network_interface_id": null } } ] } } }expressionsに現れるキー:ami,instance_type,key_name,iam_instance_profile
→subnet_idはexpressionsに存在しないため code_keys フィルタで自動除外。Python 属性マッピング辞書
EC2_ATTR_MAP: dict[str, dict] = { "aws_instance": { "include": ["instance_type", "ami", "key_name", "iam_instance_profile"], "exclude_service": ["primary_network_interface_id", "public_ip"], } }特記事項
amiはダミー値ami-xxxxxxxxxxxxxxxxxを HCL に使用する(実AMI ID の漏洩防止)。iam_instance_profileはモジュール参照の場合、plan.json のplanned_valuesで解決済みの文字列名が得られる。
8-2. VPC (
aws_vpc)対象属性
属性 区分 備考 cidr_block◎ 出力 10.0.0.0/16 等 enable_dns_hostnames◎ 出力 true/false enable_dns_support◎ 出力 true/false id✕ 除外 EXCLUDE_EXACT owner_id✕ 除外 EXCLUDE_EXACT 最小 HCL 例
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support= true tags = { Name = "main-vpc" } }plan.json からの抽出期待値
{ "address": "aws_vpc.main", "type": "aws_vpc", "expressions": { "cidr_block": { "constant_value": "10.0.0.0/16" }, "enable_dns_hostnames": { "constant_value": true }, "enable_dns_support": { "constant_value": true } }, "values": { "cidr_block": "10.0.0.0/16", "enable_dns_hostnames": true, "enable_dns_support": true, "id": "vpc-0123456789abcdef0", "owner_id": "123456789012" } }Python 属性マッピング辞書
VPC_ATTR_MAP: dict[str, dict] = { "aws_vpc": { "include": ["cidr_block", "enable_dns_hostnames", "enable_dns_support"], } }8-3. Subnet (
aws_subnet)対象属性
属性 区分 備考 cidr_block◎ 出力 availability_zone◎ 出力 ap-northeast-1a 等 map_public_ip_on_launch◎ 出力 パブリックサブネット判定 subnet_id✕ 除外 _idsuffixvpc_id✕ 除外 _idsuffix最小 HCL 例
resource "aws_subnet" "public_1a" { vpc_id= aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = "ap-northeast-1a" map_public_ip_on_launch = true tags = { Name = "public-1a" } }plan.json からの抽出期待値
{ "address": "aws_subnet.public_1a", "expressions": { "cidr_block": { "constant_value": "10.0.1.0/24" }, "availability_zone": { "constant_value": "ap-northeast-1a" }, "map_public_ip_on_launch": { "constant_value": true } }, "values": { "cidr_block": "10.0.1.0/24", "availability_zone": "ap-northeast-1a", "map_public_ip_on_launch": true, "vpc_id": "vpc-0123456789abcdef0" } }vpc_idはexpressionsにreferencesとして存在するが_idsuffix により除外される。Python 属性マッピング辞書
SUBNET_ATTR_MAP: dict[str, dict] = { "aws_subnet": { "include": ["cidr_block", "availability_zone", "map_public_ip_on_launch"], } }8-4. Security Group (
aws_security_group+aws_security_group_rule)対象属性
属性 区分 備考 name◎ 出力 description◎ 出力 ingress◎ 出力(正規化後) ソート必須 egress◎ 出力(正規化後) ソート必須 id✕ 除外 EXCLUDE_EXACT owner_id✕ 除外 EXCLUDE_EXACT 最小 HCL 例
resource "aws_security_group" "web" { name = "web-sg" description = "Security group for web servers" vpc_id= aws_vpc.main.id ingress { from_port= 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } 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"] } }plan.json からの抽出期待値
{ "address": "aws_security_group.web", "expressions": { "name": { "constant_value": "web-sg" }, "description": { "constant_value": "Security group for web servers" }, "ingress": [ { "from_port": { "constant_value": 443 }, "to_port": { "constant_value": 443 }, "protocol": { "constant_value": "tcp" }, "cidr_blocks": { "constant_value": ["0.0.0.0/0"] } }, { "from_port": { "constant_value": 80 }, "to_port": { "constant_value": 80 }, "protocol": { "constant_value": "tcp" }, "cidr_blocks": { "constant_value": ["0.0.0.0/0"] } } ] } }ingress/egress 正規化(特殊処理)
SG ルールはリスト要素でかつ順序に意味がない。plan.json 上の並び順は Terraform 内部実装依存のため、
比較前に正規化キーでソートする必要がある。def normalize_sg_rules(rules: list[dict]) -> list[dict]: """ingress/egress ルールを正規化キーでソートして順序依存を排除する。""" def sort_key(r: dict) -> tuple: return ( r.get("protocol", ""), r.get("from_port", 0), r.get("to_port", 0), ",".join(sorted(r.get("cidr_blocks") or [])), ",".join(sorted(r.get("ipv6_cidr_blocks") or [])), ) return sorted(rules, key=sort_key)使用例:
from typing import Any def extract_sg_attributes(values: dict[str, Any]) -> dict[str, Any]: result = { "name": values.get("name"), "description": values.get("description"), "ingress": normalize_sg_rules(values.get("ingress") or []), "egress": normalize_sg_rules(values.get("egress") or []), } return resultPython 属性マッピング辞書
SG_ATTR_MAP: dict[str, dict] = { "aws_security_group": { "include": ["name", "description", "ingress", "egress"], "special_handler": "normalize_sg_rules", } }8-5. RDS (
aws_db_instance)対象属性
属性 区分 備考 engine◎ 出力 mysql, postgres 等 engine_version◎ 出力 instance_class◎ 出力 db.t3.micro 等 allocated_storage◎ 出力 GB単位の整数 multi_az◎ 出力 本番環境の確認項目 address✕ 除外 EXCLUDE_BY_SERVICE[“aws_db_instance”] endpoint✕ 除外 EXCLUDE_BY_SERVICE[“aws_db_instance”] resource_id✕ 除外 EXCLUDE_BY_SERVICE[“aws_db_instance”] db_subnet_group_name◎ 出力 サブネットグループ名(文字列)は出力対象 最小 HCL 例
resource "aws_db_instance" "main" { engine= "mysql" engine_version = "8.0" instance_class = "db.t3.micro" allocated_storage = 20 multi_az = false db_name = "appdb" username = "admin" password = var.db_password # シークレットは変数経由 db_subnet_group_name = aws_db_subnet_group.main.name skip_final_snapshot = true tags = { Name = "main-rds" } }plan.json からの抽出期待値
{ "address": "aws_db_instance.main", "expressions": { "engine": { "constant_value": "mysql" }, "engine_version": { "constant_value": "8.0" }, "instance_class": { "constant_value": "db.t3.micro" }, "allocated_storage": { "constant_value": 20 }, "multi_az": { "constant_value": false }, "db_subnet_group_name": { "references": ["aws_db_subnet_group.main"] } }, "values": { "engine": "mysql", "engine_version": "8.0", "instance_class": "db.t3.micro", "allocated_storage": 20, "multi_az": false, "db_subnet_group_name": "main-subnet-group", "address": "main-rds.xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com", "endpoint": "main-rds.xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306", "resource_id": "db-XXXXXXXXXXXXXXXXXXXXXXX" } }Python 属性マッピング辞書
RDS_ATTR_MAP: dict[str, dict] = { "aws_db_instance": { "include": [ "engine", "engine_version", "instance_class", "allocated_storage", "multi_az", "db_subnet_group_name", ], "exclude_service": ["address", "endpoint", "resource_id"], } }8-6. ALB (
aws_lb+aws_lb_target_group+aws_lb_listener)ALB は複数の Terraform リソースで構成される複合サービスである。
パラメーターシートでは ALB 本体・ターゲットグループ・リスナーを 別行 として記録し、
リソース名(resource_name列)で紐づける。対象属性
リソース 属性 区分 aws_lbload_balancer_type,internal,security_groups◎ 出力 aws_lbarn,dns_name✕ 除外 aws_lb_target_groupport,protocol,target_type◎ 出力 aws_lb_target_grouparn✕ 除外 aws_lb_listenerport,protocol,default_action◎ 出力 aws_lb_listenerarn,load_balancer_arn✕ 除外 最小 HCL 例
resource "aws_lb" "main" { name= "main-alb" load_balancer_type = "application" internal = false security_groups = [aws_security_group.alb.id] subnets= [aws_subnet.public_1a.id, aws_subnet.public_1c.id] } resource "aws_lb_target_group" "web" { name = "web-tg" port = 80 protocol = "HTTP" target_type = "ip" vpc_id= aws_vpc.main.id } resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.main.arn port = "80" protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.web.arn } }plan.json からの抽出期待値(aws_lb 抜粋)
{ "address": "aws_lb.main", "expressions": { "name": { "constant_value": "main-alb" }, "load_balancer_type": { "constant_value": "application" }, "internal": { "constant_value": false }, "security_groups": { "references": ["aws_security_group.alb"] } }, "values": { "name": "main-alb", "load_balancer_type": "application", "internal": false, "security_groups": ["sg-0123456789abcdef0"], "arn": "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:loadbalancer/app/main-alb/0123456789abcdef", "dns_name": "main-alb-123456789.ap-northeast-1.elb.amazonaws.com" } }Python 属性マッピング辞書
ALB_ATTR_MAP: dict[str, dict] = { "aws_lb": { "include": ["name", "load_balancer_type", "internal", "security_groups"], }, "aws_lb_target_group": { "include": ["name", "port", "protocol", "target_type"], }, "aws_lb_listener": { "include": ["port", "protocol", "default_action"], }, }8-7. Lambda (
aws_lambda_function)対象属性
属性 区分 備考 runtime◎ 出力 python3.12 等 handler◎ 出力 index.handler 等 memory_size◎ 出力 MB単位 timeout◎ 出力 秒単位 environment.variables◎ 出力 KMS マスク注記あり function_name◎ 出力 コード記載の命名 arn✕ 除外 _arnsuffixinvoke_arn✕ 除外 _arnsuffix最小 HCL 例
resource "aws_lambda_function" "processor" { function_name = "data-processor" runtime = "python3.12" handler = "index.handler" memory_size= 256 timeout = 30 role = aws_iam_role.lambda.arn filename= "lambda.zip" environment { variables = { ENV = var.env TABLE_NAME = aws_dynamodb_table.main.name # シークレット値は直接記載禁止 — SSM Parameter Store / Secrets Manager 参照を使用 } } }plan.json からの抽出期待値
{ "address": "aws_lambda_function.processor", "expressions": { "function_name": { "constant_value": "data-processor" }, "runtime": { "constant_value": "python3.12" }, "handler": { "constant_value": "index.handler" }, "memory_size": { "constant_value": 256 }, "timeout": { "constant_value": 30 }, "environment": [ { "variables": { "ENV": { "references": ["var.env"] }, "TABLE_NAME": { "references": ["aws_dynamodb_table.main"] } } } ] }, "values": { "function_name": "data-processor", "runtime": "python3.12", "handler": "index.handler", "memory_size": 256, "timeout": 30, "environment": [{ "variables": { "ENV": "dev", "TABLE_NAME": "main-table" } }], "arn": "arn:aws:lambda:ap-northeast-1:123456789012:function:data-processor" } }⚠️ セキュリティ注記: environment.variables の KMS マスク
environment.variablesに機密値(API キー・パスワード等)が直接入っている場合、
plan.json にも平文で出力される。パラメーターシートへのエクスポート前に KMS 暗号化設定を確認すること。KMS_SENTINEL = "***MASKED***" def mask_sensitive_env_vars( variables: dict[str, str], kms_key_arn: str | None, ) -> dict[str, str]: """KMS キーが設定されている場合は環境変数値を Excel 上でマスクする。""" if kms_key_arn: return {k: KMS_SENTINEL for k in variables} return variablesPython 属性マッピング辞書
LAMBDA_ATTR_MAP: dict[str, dict] = { "aws_lambda_function": { "include": [ "function_name", "runtime", "handler", "memory_size", "timeout", "environment", ], "special_handler": "mask_sensitive_env_vars", } }8-8. API Gateway v2 (
aws_apigatewayv2_api+aws_apigatewayv2_route)対象属性
リソース 属性 区分 aws_apigatewayv2_apiname,protocol_type◎ 出力 aws_apigatewayv2_apiid,api_endpoint✕ 除外 aws_apigatewayv2_routeroute_key◎ 出力 aws_apigatewayv2_integrationintegration_type,integration_uri◎ 出力 最小 HCL 例
resource "aws_apigatewayv2_api" "main" { name = "main-http-api" protocol_type = "HTTP" } resource "aws_apigatewayv2_route" "processor" { api_id = aws_apigatewayv2_api.main.id route_key = "POST /process" target = "integrations/${aws_apigatewayv2_integration.processor.id}" } resource "aws_apigatewayv2_integration" "processor" { api_id = aws_apigatewayv2_api.main.id integration_type= "AWS_PROXY" integration_uri = aws_lambda_function.processor.invoke_arn }plan.json からの抽出期待値
{ "address": "aws_apigatewayv2_api.main", "expressions": { "name": { "constant_value": "main-http-api" }, "protocol_type": { "constant_value": "HTTP" } }, "values": { "name": "main-http-api", "protocol_type": "HTTP", "id": "abc1234567", "api_endpoint": "https://abc1234567.execute-api.ap-northeast-1.amazonaws.com" } }Python 属性マッピング辞書
APIGW_ATTR_MAP: dict[str, dict] = { "aws_apigatewayv2_api": { "include": ["name", "protocol_type"], }, "aws_apigatewayv2_route": { "include": ["route_key"], }, "aws_apigatewayv2_integration": { "include": ["integration_type", "integration_uri"], }, }8-9. DynamoDB (
aws_dynamodb_table)対象属性
属性 区分 備考 name◎ 出力 テーブル名 billing_mode◎ 出力 PAY_PER_REQUEST / PROVISIONED hash_key◎ 出力 パーティションキー名 range_key◎ 出力 ソートキー名(省略可) attribute◎ 出力 属性スキーマ一覧 read_capacity◎ 出力 PROVISIONED 時のみ write_capacity◎ 出力 PROVISIONED 時のみ arn✕ 除外 _arnsuffixid✕ 除外 EXCLUDE_EXACT stream_arn✕ 除外 _arnsuffix最小 HCL 例
resource "aws_dynamodb_table" "main" { name= "main-table" billing_mode = "PAY_PER_REQUEST" hash_key = "pk" range_key = "sk" attribute { name = "pk" type = "S" } attribute { name = "sk" type = "S" } attribute { name = "gsi_pk" type = "S" } global_secondary_index { name= "gsi-index" hash_key = "gsi_pk" projection_type = "ALL" } tags = { Name = "main-table" } }plan.json からの抽出期待値
{ "address": "aws_dynamodb_table.main", "expressions": { "name": { "constant_value": "main-table" }, "billing_mode": { "constant_value": "PAY_PER_REQUEST" }, "hash_key": { "constant_value": "pk" }, "range_key": { "constant_value": "sk" }, "attribute": [ { "name": { "constant_value": "pk" }, "type": { "constant_value": "S" } }, { "name": { "constant_value": "sk" }, "type": { "constant_value": "S" } }, { "name": { "constant_value": "gsi_pk" }, "type": { "constant_value": "S" } } ] }, "values": { "name": "main-table", "billing_mode": "PAY_PER_REQUEST", "hash_key": "pk", "range_key": "sk", "attribute": [ { "name": "pk", "type": "S" }, { "name": "sk", "type": "S" }, { "name": "gsi_pk", "type": "S" } ], "arn": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/main-table", "id": "main-table", "stream_arn": null } }Python 属性マッピング辞書
DYNAMODB_ATTR_MAP: dict[str, dict] = { "aws_dynamodb_table": { "include": [ "name", "billing_mode", "hash_key", "range_key", "attribute", "read_capacity", "write_capacity", ], } }8-10. SNS/SQS (
aws_sns_topic+aws_sqs_queue)SNS と SQS は独立したリソースだが、パラメーターシート上ではメッセージング系サービスとして
同一セクションにまとめて記録する。対象属性
リソース 属性 区分 備考 aws_sns_topicname◎ 出力 aws_sns_topicdisplay_name◎ 出力 省略可 aws_sns_topickms_master_key_id◎ 出力 暗号化設定の確認項目 aws_sns_topicarn✕ 除外 _arnsuffixaws_sns_topicid✕ 除外 EXCLUDE_EXACT aws_sqs_queuename◎ 出力 aws_sqs_queuevisibility_timeout_seconds◎ 出力 デフォルト 30 秒 aws_sqs_queuemessage_retention_seconds◎ 出力 デフォルト 4 日 = 345600 aws_sqs_queuedelay_seconds◎ 出力 デフォルト 0 aws_sqs_queuereceive_wait_time_seconds◎ 出力 Long Polling 設定 aws_sqs_queuearn✕ 除外 _arnsuffixaws_sqs_queueid✕ 除外 EXCLUDE_EXACT aws_sqs_queueurl✕ 除外 EXCLUDE_EXACT 対象外だが AWS 払出し値のため code_keys で除外 最小 HCL 例
resource "aws_sns_topic" "alerts" { name = "system-alerts" display_name= "System Alerts" kms_master_key_id = aws_kms_key.sns.id } resource "aws_sqs_queue" "processor" { name = "processor-queue" visibility_timeout_seconds = 60 message_retention_seconds = 86400 delay_seconds = 0 receive_wait_time_seconds = 20 } resource "aws_sqs_queue" "processor_dlq" { name = "processor-dlq" message_retention_seconds = 1209600 # 14 日 }plan.json からの抽出期待値(SNS 抜粋)
{ "address": "aws_sns_topic.alerts", "expressions": { "name": { "constant_value": "system-alerts" }, "display_name": { "constant_value": "System Alerts" }, "kms_master_key_id": { "references": ["aws_kms_key.sns"] } }, "values": { "name": "system-alerts", "display_name": "System Alerts", "kms_master_key_id": "arn:aws:kms:ap-northeast-1:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "arn": "arn:aws:sns:ap-northeast-1:123456789012:system-alerts", "id": "arn:aws:sns:ap-northeast-1:123456789012:system-alerts" } }plan.json からの抽出期待値(SQS 抜粋)
{ "address": "aws_sqs_queue.processor", "expressions": { "name": { "constant_value": "processor-queue" }, "visibility_timeout_seconds": { "constant_value": 60 }, "message_retention_seconds": { "constant_value": 86400 }, "delay_seconds": { "constant_value": 0 }, "receive_wait_time_seconds": { "constant_value": 20 } }, "values": { "name": "processor-queue", "visibility_timeout_seconds": 60, "message_retention_seconds": 86400, "delay_seconds": 0, "receive_wait_time_seconds": 20, "arn": "arn:aws:sqs:ap-northeast-1:123456789012:processor-queue", "url": "https://sqs.ap-northeast-1.amazonaws.com/123456789012/processor-queue", "id": "https://sqs.ap-northeast-1.amazonaws.com/123456789012/processor-queue" } }Python 属性マッピング辞書
SNS_SQS_ATTR_MAP: dict[str, dict] = { "aws_sns_topic": { "include": ["name", "display_name", "kms_master_key_id"], }, "aws_sqs_queue": { "include": [ "name", "visibility_timeout_seconds", "message_retention_seconds", "delay_seconds", "receive_wait_time_seconds", ], }, }8-11. サービス別マッピング辞書まとめ
各サービスの属性マッピング辞書を1つの
SERVICE_ATTR_MAPに集約する。
パーサーはこの辞書を参照して、各リソースタイプの出力対象属性を決定する。from typing import TypedDict class ServiceAttrConfig(TypedDict, total=False): include: list[str] exclude_service: list[str] special_handler: str SERVICE_ATTR_MAP: dict[str, ServiceAttrConfig] = { # --- IaaS 系 --- "aws_instance": { "include": ["instance_type", "ami", "key_name", "iam_instance_profile"], "exclude_service": ["primary_network_interface_id", "public_ip"], }, "aws_vpc": { "include": ["cidr_block", "enable_dns_hostnames", "enable_dns_support"], }, "aws_subnet": { "include": ["cidr_block", "availability_zone", "map_public_ip_on_launch"], }, "aws_security_group": { "include": ["name", "description", "ingress", "egress"], "special_handler": "normalize_sg_rules", }, "aws_db_instance": { "include": [ "engine", "engine_version", "instance_class", "allocated_storage", "multi_az", "db_subnet_group_name", ], "exclude_service": ["address", "endpoint", "resource_id"], }, # --- ロードバランサー --- "aws_lb": { "include": ["name", "load_balancer_type", "internal", "security_groups"], }, "aws_lb_target_group": { "include": ["name", "port", "protocol", "target_type"], }, "aws_lb_listener": { "include": ["port", "protocol", "default_action"], }, # --- サーバーレス系 --- "aws_lambda_function": { "include": [ "function_name", "runtime", "handler", "memory_size", "timeout", "environment", ], "special_handler": "mask_sensitive_env_vars", }, "aws_apigatewayv2_api": { "include": ["name", "protocol_type"], }, "aws_apigatewayv2_route": { "include": ["route_key"], }, "aws_apigatewayv2_integration": { "include": ["integration_type", "integration_uri"], }, "aws_dynamodb_table": { "include": [ "name", "billing_mode", "hash_key", "range_key", "attribute", "read_capacity", "write_capacity", ], }, # --- メッセージング系 --- "aws_sns_topic": { "include": ["name", "display_name", "kms_master_key_id"], }, "aws_sqs_queue": { "include": [ "name", "visibility_timeout_seconds", "message_retention_seconds", "delay_seconds", "receive_wait_time_seconds", ], }, }この辞書を
should_exclude()関数と組み合わせることで、多層防御が実現される:def extract_attributes( resource_type: str, code_keys: set[str], values: dict[str, object], ) -> dict[str, object]: """ plan.json の code_keys と SERVICE_ATTR_MAP の include リストの積集合から 属性を抽出し、除外ルールを適用して返す。 """ config = SERVICE_ATTR_MAP.get(resource_type, {}) include = set(config.get("include", code_keys)) # 辞書未定義なら code_keys をそのまま使用 result: dict[str, object] = {} for key in include & code_keys: if should_exclude(resource_type, key): continue val = values.get(key) if val is None: continue result[key] = val return resultSection 8 執筆完了。10 サービス(EC2/VPC/Subnet/SG/RDS/ALB+TG+Listener/Lambda/API GW v2/DynamoDB/SNS/SQS)の属性カバー網羅。
Section 9. ハンズオン実行と成果物確認
ここまでのセクションで、Plan JSON ハイブリッドパーサ・マルチ環境集約・Excel 出力・サービス別属性マッピングの実装が揃いました。本セクションでは、これらのコンポーネントを組み合わせ、実際に手を動かして パラメーターシート Excel を生成するエンドツーエンドのハンズオン を実施します。
空の AWS アカウントでも動作確認できるよう、最小構成の Terraform サンプル(EC2 + VPC)を用意します。作業完了後には Sheet1(統合ビュー)・Sheet3(meta)が埋まった Excel ファイルが手元に残ります。
9-1. 前提確認
ツールバージョン確認
ハンズオンを開始する前に、以下のコマンドで必要なツールが揃っているかを確認してください。
# Python バージョン(3.11 以上必須) python3 --version # 期待出力例: Python 3.11.9 # Terraform バージョン(1.9 系必須) terraform version # 期待出力例: Terraform v1.9.8 # AWS CLI バージョン(v2 必須) aws --version # 期待出力例: aws-cli/2.15.30 Python/3.11.8 ... # AWS 認証確認 aws sts get-caller-identity # 期待出力例(アカウント ID はダミー): # { # "UserId": "AIDAEXAMPLEID", # "Account": "123456789012", # "Arn": "arn:aws:iam::123456789012:user/your-iam-user" # }Terraform が
1.9.x未満の場合、plan JSON のスキーマバージョンが異なりパーサが動作しない可能性があります。tfenvや公式バイナリで 1.9 系にアップデートしてください。Python 依存ライブラリのインストール
pip install openpyxl python-hcl2 # 確認 python3 -c "import openpyxl; print('openpyxl', openpyxl.__version__)" python3 -c "import hcl2; print('python-hcl2 OK')"openpyxlは Excel ファイルの生成に、python-hcl2は HCL フォールバック解析(オプション)に使用します。ディレクトリ構成の確認
ハンズオン用のプロジェクトディレクトリが以下の構成になっていることを確認します。
param-sheet-project/ ├── environments/ │├── dev/ ││├── main.tf ││├── variables.tf ││├── terraform.tfvars ││└── backend.tf │├── stg/ ││├── main.tf ││├── variables.tf ││├── terraform.tfvars ││└── backend.tf │└── prod/ │ ├── main.tf │ ├── variables.tf │ ├── terraform.tfvars │ └── backend.tf ├── modules/ │├── ec2/ │└── vpc/ ├── scripts/ │└── tf_to_excel.py ├── output/ # 生成 xlsx の出力先(git-ignore 推奨) └── Makefile9-2. エンドツーエンド実行手順
Step 1: サンプル Terraform コードの準備
Section 4 で解説した最小構成(EC2 + VPC)を
environments/dev/main.tfに用意します。以下は動作確認用の最小 HCL です。実際の本番コードと置き換えて使用してください。# environments/dev/main.tf terraform { required_version = "~> 1.9" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = var.aws_region } variable "aws_region" { default = "ap-northeast-1" } variable "env" { default = "dev" } variable "instance_type" { default = "t3.micro" } # VPC resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support= true tags = { Name = "param-sheet-vpc-${var.env}", Env = var.env } } # Subnet resource "aws_subnet" "public" { vpc_id= aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = "${var.aws_region}a" map_public_ip_on_launch = true tags = { Name = "public-subnet-${var.env}", Env = var.env } } # Security Group resource "aws_security_group" "web" { name = "web-sg-${var.env}" description = "Web tier security group" vpc_id= aws_vpc.main.id ingress { from_port= 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTPS from internet" } egress { from_port= 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] description = "All outbound" } tags = { Name = "web-sg-${var.env}", Env = var.env } } # EC2 Instance resource "aws_instance" "web" { ami= "ami-0d52744d6551d851e" # ap-northeast-1 Amazon Linux 2023 instance_type = var.instance_type subnet_id= aws_subnet.public.id vpc_security_group_ids = [aws_security_group.web.id] tags = { Name = "web-server-${var.env}", Env = var.env } }stg/とprod/も同様の構成で作成し、terraform.tfvarsでenvとinstance_typeの値を環境ごとに変えます。# environments/stg/terraform.tfvars env = "stg" instance_type = "t3.small"# environments/prod/terraform.tfvars env = "prod" instance_type = "t3.medium"Step 2:
make plan-allで全環境の plan.json を生成# プロジェクトルートで実行 make plan-allMakefile の
plan-allターゲットは Section 6 で作成済みです。各環境ディレクトリでterraform init→terraform plan -out tfplan→terraform show -json tfplan > plan.jsonを順次実行します。初回実行時は
terraform initに時間がかかります。プロバイダプラグインのダウンロードが完了するまで待機してください(目安: 60〜120 秒)。# 期待出力イメージ === dev === Initializing the backend... ... Plan: 4 to add, 0 to change, 0 to destroy. === stg === ... Plan: 4 to add, 0 to change, 0 to destroy. === prod === ... Plan: 4 to add, 0 to change, 0 to destroy.成功すると各環境ディレクトリに
plan.jsonが生成されます。# ファイル生成確認 ls -lh environments/*/plan.json # 期待出力例 # -rw-r--r-- 1 user group 48K environments/dev/plan.json # -rw-r--r-- 1 user group 49K environments/stg/plan.json # -rw-r--r-- 1 user group 49K environments/prod/plan.jsonplan.json のファイルサイズ目安: EC2 + VPC 最小構成で 40〜60 KB 程度です。モジュールや追加リソースが増えると比例して増加します。
Step 3:
tf_to_excel.pyでパラメーターシートを生成python3 scripts/tf_to_excel.py \ --envs environments/dev/plan.json \ environments/stg/plan.json \ environments/prod/plan.json \ --out output/param-sheet-$(date +%Y%m%d-%H%M).xlsxスクリプトの実行時間目安: EC2 + VPC 構成(3環境 × 4リソース)で 3〜8 秒 程度。大規模構成(100リソース超)でも 30〜60 秒以内が目安です。
# 期待出力例 [INFO] Parsing environments/dev/plan.json ... [INFO]resources: 4 (aws_vpc:1, aws_subnet:1, aws_security_group:1, aws_instance:1) [INFO] Parsing environments/stg/plan.json ... [INFO]resources: 4 [INFO] Parsing environments/prod/plan.json ... [INFO]resources: 4 [INFO] Writing Excel: output/param-sheet-20260418-1000.xlsx [INFO] Sheet1 (統合ビュー): 22 rows written [INFO] Sheet3 (meta): written [OK] output/param-sheet-20260418-1000.xlsx (38 KB)9-3. 成果物の確認方法
生成された
output/param-sheet-20260418-1000.xlsxを Excel または LibreOffice Calc で開きます。Sheet1「統合ビュー」の構造
Row 1 (ヘッダ第1段): サービス | リソース名 | 属性 | ===== 期待値 ===== | ===== 現状値 ===== | ===== 判定 ===== ↑ 3列マージセル ↑ 3列マージセル ↑ 3列マージセル Row 2 (ヘッダ第2段): - | -| - | dev | stg | prod | dev | stg | prod | dev | stg | prod Row 3 以降 (データ行): EC2| web-server-dev | instance_type | t3.micro | t3.small | t3.medium | - | - | - | - | - | - EC2| web-server-dev | ami | ami-0d52... | ami-0d52... | ami-0d52... | ... VPC| param-sheet-vpc-dev | cidr_block | 10.0.0.0/16 | ... ...行数目安: EC2 + VPC + Subnet + SG の4リソース、3環境で 20〜25 行 程度(除外フィルタ適用後)。10サービス・複数モジュールを追加すると 200〜300 行になります。
列幅: スクリプトが
column_dimensionsで自動調整済みのため、開いた時点で読みやすい状態です。必要に応じて手動で追加調整してください。セル色の見方:
色 意味 緑( #C6EFCE)OK(期待値と現状値が一致) 赤( #FFC7CE)NG(値の不一致) 橙( #FFEB9C)実環境で未検出 紫( #CC99FF)設計外の存在 白(無色) SKIP(両方未設定) 第1弾では AWS Config による実環境取得がまだないため、現状値列はすべて空白(
-)になります。この段階での用途は「コードから期待値を自動転記した Excel を関係者に配布する」ことです。第2弾(Config 突合)で現状値・判定列が埋まります。Sheet3「meta」の確認
Sheet3 の内容例: 実行日時: 2026-04-18 10:00:00 リージョン: ap-northeast-1 AWSアカウント: 123456789012 Terraform バージョン: 1.9.8 スクリプトバージョン: 1.0.0 環境: dev, stg, prod リソース総数: 12meta シートは突合の再現性(いつ・誰が・どのバージョンで生成したか)を記録するための監査証跡です。社内レビュー時にこのシートを見せることで、パラメーターシートの鮮度を説明できます。
9-4. よくあるエラーと対処
エラー1: Terraform バージョン不一致(plan.json スキーマ差異)
# エラー例 KeyError: 'configuration' File "scripts/tf_to_excel.py", line 42, in parse_plan code_keys = extract_code_keys(plan["configuration"]["root_module"]["resources"])原因: Terraform 1.8 以前の plan.json は
configurationキーの構造が異なります。対処:
# Terraform バージョン確認 terraform version # tfenv でバージョン切り替え(tfenv 使用時) tfenv install 1.9.8 tfenv use 1.9.8 # plan.json を再生成 make plan-all本スクリプトは Terraform 1.9.x の plan JSON スキーマ(
schema_version: 1.2)を前提としています。エラー2: AWS 認証エラー(plan 失敗)
# エラー例 ╷ │ Error: No valid credential sources found │ │with provider["registry.terraform.io/hashicorp/aws"], │ ... ╵対処:
# AWS 認証情報の確認 aws sts get-caller-identity # 認証が切れている場合(SSO 使用時) aws sso login --profile your-profile # 環境変数で指定する場合 export AWS_PROFILE=your-profile # または export AWS_ACCESS_KEY_ID=... export AWS_SECRET_ACCESS_KEY=... export AWS_REGION=ap-northeast-1注意:
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYを直接コードや tfvars に書かないでください。環境変数または AWS プロファイルを使用します。エラー3: openpyxl ImportError
# エラー例 ModuleNotFoundError: No module named 'openpyxl'対処:
pip install openpyxl # 仮想環境を使用している場合 source .venv/bin/activate pip install openpyxlPython の実行環境(仮想環境・グローバル環境)がスクリプト実行時と pip install 時で一致しているかを確認してください。
エラー4: plan.json が空またはリソースゼロ
# スクリプト出力例 [WARN] environments/dev/plan.json: 0 resources found [WARN] Sheet1 has no data rows. Check plan.json content.原因 A:
main.tfに resource ブロックがない、または全リソースがcount = 0になっている。原因 B:
terraform planが失敗してplan.jsonが空ファイルになっている。対処:
# plan.json の内容確認 python3 -c " import json, sys with open('environments/dev/plan.json') as f: d = json.load(f) print('format_version:', d.get('format_version')) res = d.get('planned_values', {}).get('root_module', {}).get('resources', []) print('resources count:', len(res)) " # plan を再実行 cd environments/dev terraform plan -out tfplan -input=false terraform show -json tfplan > plan.json9-5. AWS Config の概念:第2弾への準備
この記事(第1弾)は AWS Config なしで動作します。
本サブセクションは、次回の第2弾「AWS Config と Terraform パラメーターシートを突合する」に向けた概念整理です。Config 未経験の方は読み進めることで第2弾の準備ができます。Config 経験者はスキップしても構いません。AWS Config とは
AWS Config は、AWS リソースの構成変更を継続的に記録・追跡するマネージドサービスです。「今、EC2 インスタンスの
instance_typeは何か?」「1週間前と比べて Security Group のルールは変わったか?」といった問いに、API 1本で回答できます。本シリーズとの関係はシンプルです。
第1弾(本記事): Terraform コード → plan.json → Excel(期待値列を埋める) 第2弾: AWS Config → Advanced Query → Excel(現状値列を埋める) ↓ 期待値 vs 現状値 を突合 → 差分検知第2弾で Config を使う理由は、
aws ec2 describe-instancesのような個別 API を何十本も叩くのではなく、SQL ライクなクエリで複数リソースタイプをまとめて取得できるからです。-- Config Advanced Query の例(第2弾で詳解) SELECT resourceId, resourceName, configuration.instanceType, configuration.subnetId, tags WHERE resourceType = 'AWS::EC2::Instance' AND tags.Env = 'prod'第2弾に向けた前提確認コマンド
今すぐ Config を有効化する必要はありませんが、以下のコマンドで 現在の有効化状態 を確認しておくと第2弾の作業がスムーズになります。
# Configuration Recorder の有効化状態を確認 aws configservice describe-configuration-recorder-status \ --region ap-northeast-1 \ --query 'ConfigurationRecordersStatus[0].recording' # 出力例: true(有効)/ false(無効) # Config が有効でない場合はこの段階では OK(第2弾で設定します) # 出力がエラーになる場合も問題なしコスト感(正直に)
AWS Config の料金は主に2つです。
料金項目 単価 目安 構成変更の記録 $0.003 / 項目 EC2 1台を1日運用: 数円以下 ルール評価 $0.001 / 評価 ハンズオン用途: 月 $1〜5 ハンズオン終了後は Recorder を停止(
aws configservice stop-configuration-recorder)することでコストを抑制できます。第2弾の記事内で詳しく解説します。「Config 未経験でも第2弾から始められます。」第2弾では Recorder の有効化から丁寧にステップを踏むので、今回のハンズオンが完了した時点で第2弾の準備は十分です。
Section 10. まとめと第2弾への導線
10-1. 本記事で得たもの
本記事(第1弾)では、エンタープライズ AWS プロジェクトに欠かせない パラメーターシート の自動生成 を実現しました。得られた成果を振り返ります。
Plan JSON ハイブリッド方式の価値:
–terraform stateの問題(ARN・ID 混入)とHCL 直解析の問題(変数未解決)を同時に回避
–configuration.expressions×planned_values.valuesの組合せで、コード記載値のみを変数解決済みで抽出 するという設計上の核心を実装した
– Terraform のメジャーバージョン間でも安定して動作する plan JSON スキーマ(format_version: 1.2)に依存するため、将来の互換性リスクを最小化Excel 統合シートの実用性:
– dev / stg / prod の3環境を1シートに横断統合し、環境差を一目で比較できるフォーマット
– 2段ヘッダ・セル色分け(OK/NG/未検出/設計外)・オートフィルタを組み合わせ、社内レビューにそのまま提出できるレベルのドキュメントを自動生成
– 生成時間は 100 リソース規模でも 30 秒以内。会議前の最終確認にも使える第1弾単独の実用価値:
– AWS Config は第2弾のテーマです。本記事の成果物(Excel)は Config なしで単独利用できます。
– Terraform コードから期待値を自動転記した Excel を関係者に配布し、「この構成で合っていますか?」という設計レビューを実施するユースケースは、今日からすぐに実践できます
– 手作業で Excel を埋める工数(典型案件で数日〜数週間)が、スクリプト実行1コマンドに短縮されます10-2. cmd_040 シリーズ読者への横展開
本シリーズ(cmd_042)と
cmd_040(AWS×Terraform 複数人開発)は 直交補完 の関係にあります。cmd_040 シリーズ ─── 継続的デプロイ軸("作って回す") 第1弾: state 管理・lock・drift 対策 第2弾: GitHub Actions + OIDC で PR駆動 CI/CD 第3弾: CodePipeline × CodeBuild でエンタープライズ CI/CD cmd_042 シリーズ ─── 設計整合検証軸("作ったものを検証する") 第1弾(本記事): Terraform → Excel パラメーターシート自動生成 第2弾: AWS Config と突合する単体テスト自動化cmd_040 でデプロイパイプラインを構築済みの読者は、本シリーズを重ねることで「CI/CD で自動デプロイ → Config で設計整合を自動検証」という完全な DevOps ループが完成します。
具体的な連携イメージ:
– cmd_040 第2弾(GitHub Actions)のパイプライン末尾にmake excelを追加 → デプロイのたびにパラメーターシートを自動更新
– 第2弾(Config 突合)のexit code 1を CI の fail 条件に組み込む → 設計外リソースが存在したらパイプラインを止めるCI 組込みに発展させたい方は cmd_040 第2弾(GitHub Actions + OIDC で PR駆動 CI/CD) を参照してください。本記事のスクリプトはそのまま利用できます。
10-3. 第2弾への導線
第2弾では、本記事で生成した Excel の 現状値列と判定列を AWS Config で自動入力 します。
第2弾で追加実装する主な内容:
– AWS Config Recorder の最小構成セットアップ(IAM ロール・Delivery Channel 含む)
– Config Advanced Query(SQL ライクな API)でリソース属性を一括取得
– 取得値を正規化・比較し、OK / NG / SKIP の判定を Excel に書き戻す
– CLI サマリと終了コード(0=全一致/1=差分あり)の実装
– 「意図的に差分を起こして検出する」ハンズオン第2弾は Config 未経験の方を想定した丁寧なセットアップ手順 から始まります。月額コストは $1〜5 程度(ハンズオン後に Recorder を停止すればほぼゼロ)。Config のコストを懸念して踏み出せずにいた方も、ぜひ挑戦してみてください。
AWS パラメーターシート自動化シリーズ
- 第1弾(本記事): Terraformコードから AWS パラメーターシート(Excel)を自動生成する
- 第2弾: AWS Config と Terraform パラメーターシートを突合する単体テスト自動化
10-4. Config 未経験者へのメッセージ
本記事を読んで「AWS Config という言葉は知っているが、まだ使ったことがない」という方に向けて、第2弾に向けた安心感をお伝えします。
Config の有効化は難しくありません。 第2弾では Terraform で Configuration Recorder を数十行の HCL で立ち上げるところから始めます。既存の Terraform 知識がそのまま活かせます。
コストは月額数ドルです。 EC2・RDS・Lambda などのリソースが数十台程度の環境であれば、月 $1〜5 程度が目安です。ハンズオン完了後に Recorder を止めればほぼゼロになります。
第2弾から始めても大丈夫です。 第1弾(本記事)の Excel 生成部分は、第2弾でそのまま使い回します。第1弾をハンズオンまで完了させておくと、第2弾の作業がスムーズです。ただし、第2弾の冒頭で第1弾の成果物の使い方も改めて説明するので、第1弾を読んだだけでも第2弾を追うことはできます。
エンタープライズ AWS プロジェクトでは、「設計書(パラメーターシート)と実環境がいつの間にかズレている」 という問題は珍しくありません。本シリーズを活用して、そのズレを自動検知できる仕組みを自チームに持ち込んでください。