0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWSの最新を追求かつベストプラクティスを意識したTerraformコードを生成するスキルをIBM Bobで作成してみた

0
Posted at

はじめに

私は普段AWSの環境をプロビジョニングする際には、何度でも再構築可能なIaC(インフラストラクチャー・アズ・コード)ツールを使用しています。ツールはAWSのサービスであるCloudFormationか、オープンソースのTerraformを使用していますが、Terraformは他のクラウドベンダーの環境(AzureやGoogle Cloud等)をプロビジョニングする際にも、同じHCLフォーマットで記述できるので、拡張性があることから最近ではTerraformを使うことが多いです。

近年は、IBM Z(メインフレーム)向けにも「Terraform for Z」というものが提供されるようになっています。

ただ、最近Terraformでコードを書いている中で、自分が無意識に以下のような調査をしていることに気づきました。おそらくAWSのTerraform使いの方々も1度は同じような事を考えた方も多いのではないでしょうか。

  • AWSが提供しているTerraformモジュールの最新バージョンは何か?

    • AWSの提供しているサービスの進化・スピードは速いです。その為、新サービスの提供あるいは機能が拡張された後には、AWS提供のTerraformモジュールに機能が追加されて新バージョンのモジュールが発行されます。しばらく、Terraformコード作成から離れていて、いざ新しいサービスのプロビジョニングを試してみようと思ったときに、手元のTerraformのモジュールのバージョンが古くて、そんなサービスは存在しないとか言われてエラーになることがあります。(Terraformに限らず、AWS CLIも同様です。)
  • AWSのベストプラクティスを意識したTerraformコードは書けないか?

    • 初めてAWSのTerraformコード作成に挑戦した5年前は、最初にAWSコンソールで一連のオペレーションを実施してやるべきことを整理した後に、TerraformのAWSマニュアルのサンプルコードを見ながら作成していました。とにかくサービスが問題なく稼働できるコードさえ書ければよいという思想の元でコードを作成していたのですが、現在はもう一歩踏み込んで、AWSのベストプラクティスを意識したTerraformコードが書けるようになりたいなと常々思っていました。

そこで、今回AWSの最新を追求しつつ、かつ、ベストプラクティスを意識したTerraformコード生成を行うスキルをIBM Bobで作成してみましたので、以下にスキルの作成からスキルの稼働検証までを行った際の記録を備忘録として纏めてみました。

MCPサーバーの登録

AIのなかった時代は、AWSのドキュメントとTerraformのドキュメントを人間の目で一から参照してコーディングをする必要がありましたが、AIの登場により今ではAWSのMCPサーバーとTerraformのMCPサーバーが提供されています。今回のスキル作成においては、この両者のMCPサーバーを活用することにしました。

AWS MCPサーバー

AWSのドキュメントにアクセスして、各々のAWSサービスに関する情報を取得したり、AWSのベストプラクティスやサービスガイドを提供してくれるMCPサーバーです。

AWSのMCPサーバーが利用できる前提として、AWSアカウントを保有している事とAWS CLIでAWS側との認証を済ませておく必要があります。その後は、上記リンク先の手順を参考に設定します。

Terraform MCPサーバー

現行のTerraformプロバイダーが提供しているTerraformレジストリー(今回はAWS用)から、現行のドキュメント・モジュール・ポリシーをリアルタイムでアクセスし、最新情報をAIに提供してくれるMCPサーバーです。

TerraformのMCPサーバーはリモートのサーバーに接続する方法と、ローカルPCにTerraform MCPサーバーの実行ファイルをダウンロードして設定する方法の2通りの方法があります。今回、私はWindows版のIBM Bobを使用してますので、Windows版のTerraform MCPサーバーの実行ファイルをダウンロードして、それをIBM Bobから呼び出す方法を選択しました。

両者のMCPサーバーを、IBM Bobのmcp.jsonファイルに下記のように登録します。

mcp.json
{
  "mcpServers": {
    "aws-mcp": {
      "command": "uvx",
      "timeout": 100000,
      "transport": "stdio",
      "args": [
        "mcp-proxy-for-aws@latest",
        "https://aws-mcp.us-east-1.api.aws/mcp",
        "--metadata", "AWS_REGION=ap-northeast-1"
      ]
    },
    "terraform": {
      "command": "C:/Users/userid/terraform-mcp-server.exe",
      "transport": "stdio"
    }
  }
}

jsonファイルでWindowsのフォルダ・パスを指定する場合、バックスラッシュを使う場合には注意が必要です。json内では、バックスラッシュは文字の打ち消しを意味するので、例えば下記のような指定をすると、そのようなファイルは存在しないということでMCPサーバーの接続がエラーになります。

"command": "C:\Users\userid\terraform-mcp-server.exe"

バックスラッシュを使って指定する場合には、下記のようにバックスラッシュを続けて入力するようにしてください。

"command: "C:\\Users\\userid\\terraform-mcp-server.exe"

上記のような注意点もあるので、バックスラッシュではなく、設定例のようにスラッシュを使って指定いただくことを推奨します。

その後、IBM BobのMCP設定パネルで、AWSのMCPサーバー(aws-mcp)とTerraformのMCPサーバー(terraform)のステータスが「接続済み」になっていることを確認してください。

IBM Bobを活用したスキル作成

IBM Bobのスキル概要

スキルとは、毎度毎度プロンプトに同じ作業手順を入力してAIに処理を依頼するといったルーチンワークを、1つの纏まった手順書という形で整備しておくようなイメージです。

※IaC(インフラストラクチャー・アズ・コード)が誰が実行しても再現性のあるインフラ基盤環境をプロビジョニングできるのと同じように、ここでの「スキル」は同じ作業プロセスをAIが再現性をもって実行できるようにするというAI技術になります。

IBM Bobのスキルは、適用されるスコープとして「グローバル」と「プロジェクト」レベルの2種類の作成方法があります。

  • グローバル設定
    • ルート直下の「.bob/skills」フォルダ内にスキル名のフォルダを作成し、その配下に「SKILL.md」ファイルを作成する。
  • プロジェクト設定
    • 特定のフォルダ内に「.bob/skills」フォルダを作成し、その下にスキル名のフォルダを作成し、その配下に「SKILL.md」ファイルを作成する。

今回はグローバル設定を選択し、スキル名は「terraform-aws」にしました。ファイル構造は下記の通りです。

C:.bob
└─skills
    ├─terraform-aws
    │      SKILL.md

スキル作成

直接1からSKILL.mdファイルを手書きしてもよいのですが、ある程度AIに実施させたい作業方針を自分なりに整理した上で、今回はIBM Bobに下記のようなプロンプトを入力して、スキルを作成してもらいました。

TerraformでAWSの環境プロビジョニングを行う際、AWSのベストプラクティスに則ったコーディングができるように、以下のスキルを作成してください。
・スキル名:terraform-aws
・既存の「terraform」MCPサーバーにアクセスし、TerraformのAWSプロバイダーの最新バージョンのモジュールを確認して、コーディングを行う。
・既存の「aws-mcp」MCPサーバーにアクセスし、AWSのベストプラクティスに則ったコーディングを意識する。
・パーツ化を意識し、コードの構成はmodule構造とする。
・可読性を意識し、適宜コメント文を記述する。
・CloudFormationとTerraformでサポートしている機能に違いがある場合には、その点を考慮したコーディングを行う。

後半3つの要件をプロンプトに追記した理由は下記の通りです。

  • module構造とすることで再利用・エラー時の修正が容易になる。
    • 1つのmain.tfファイルに全てのロジックを記述すると、ステップ数が多くなり、モノリシックなプログラムコードを書いているのと同じになってしまう。
  • 適宜コメント文を記述することにより、可読性をもたせる。
    • 初めて使うサービスに対するコード作成の場合も、何をやろうとしているのかが視覚的にわかる。
  • CloudFormationとTerraformのサポート機能の差異
    • ドリフト検出や依存関係等、CloudFormationではネイティブサポートされている機能が、Terraformの場合には明示指定しないと上手く動作しないなどの差異があるので、そういった処理への対応を追加。

最終的にBob君は下記のようなSKILL.mdファイルを作成してくれました。(あくまでも1例です。)

SKILL.md
---
name: terraform-aws
description: >
  Use when the user wants to provision AWS infrastructure with Terraform — covers
  module-based code structure, AWS best practices via aws-mcp, latest provider
  versions via terraform MCP, readability through comments, and CloudFormation vs
  Terraform feature parity awareness.
---

# Terraform × AWS プロビジョニング スキル

このスキルは、Terraform を使って AWS 環境をプロビジョニングする際に、
AWSベストプラクティス・モジュール構造・可読性・CloudFormation との機能差異
を考慮したコーディングをガイドするものです。

---

## Step 1 — 要件の明確化

`ask_followup_question` ツールを使って、以下を確認する(未確定の項目のみ質問する)。

1. **対象 AWS サービス** — EC2 / VPC / RDS / Lambda / S3 など何を構築するか。
2. **環境数** — dev / stg / prod など複数環境を想定するか。
3. **リージョン** — デプロイ先リージョン(例: ap-northeast-1)。
4. **既存リソース**`data` ソースで参照すべき既存インフラがあるか。
5. **状態管理** — tfstate を S3+DynamoDB などのリモートバックエンドで管理するか。

---

## Step 2 — 最新プロバイダー・モジュールバージョンの確認

### 2-1. AWS プロバイダー最新バージョンを取得

`mcp__terraform__get_latest_provider_version` ツールを呼び出す。


namespace: "hashicorp"
name:      "aws"


取得したバージョンを `required_providers` ブロックに記載する。

### 2-2. 使用モジュールの最新バージョンを取得

構築対象サービスごとに `mcp__terraform__search_modules``mcp__terraform__get_module_details`
の順で公式モジュールを調査し、最新の `version` を確認する。

代表的な公式モジュールの例:
| AWS サービス | モジュール検索キーワード |
|---|---|
| VPC | `vpc aws terraform-aws-modules` |
| EKS | `eks aws terraform-aws-modules` |
| RDS | `rds aws terraform-aws-modules` |
| ALB | `alb aws terraform-aws-modules` |
| S3  | `s3 aws terraform-aws-modules` |

---

## Step 3 — AWS ベストプラクティスの確認

`mcp__aws-mcp__aws_search_documentation` ツールを使い、構築対象サービスのベストプラクティスを調査する。

topics: ["general"]
search_phrase: "<サービス名> best practices Terraform"

調査結果をもとに、以下の観点をコードに反映する。

- **最小権限の原則** — IAM ロール・ポリシーは必要最小限の権限のみ付与する。
- **暗号化** — 保存データ・転送データの暗号化を有効化する(KMS, TLS など)。
- **マルチ AZ** — RDS・ELB・EKS などは Multi-AZ / 複数サブネットを使用する。
- **タグ付け**`environment`, `project`, `owner`, `managed-by = terraform` などのタグを統一する。
- **ログ・監査** — CloudTrail, VPC Flow Logs, ALB Access Logs など監査ログを有効化する。
- **パブリックアクセス制限** — S3 Block Public Access・RDS の `publicly_accessible = false` など。

---

## Step 4 — CloudFormation との機能差異の考慮

以下の差異が関係する場合は、コード内コメントで注意書きを追記し、代替手段を実装する。

| 観点 | CloudFormation | Terraform |
|---|---|---|
| ドリフト検出 | ネイティブサポート | `terraform plan` / `terraform refresh` で代替。`lifecycle { ignore_changes }` を意図的に使う場合は明示コメントを入れる |
| カスタムリソース | Lambda-backed Custom Resource | `null_resource` + `local-exec` / AWS provider resource で代替。Lambda不要なら `aws_lambda_invocation` |
| StackSets (マルチアカウント展開) | ネイティブサポート | `providers` マップ + `for_each` でマルチアカウント対応する |
| Change Sets のプレビュー | Change Set | `terraform plan` が相当。CI/CDには `terraform plan -out=tfplan` を推奨 |
| Rollback トリガー | 組み込み | `-target` + state操作で部分ロールバック。`prevent_destroy = true` で誤削除を防ぐ |
| 依存関係の自動解決 | 暗黙的 | `depends_on` を明示的に記述する。循環参照が起きやすいリソースは構造で分離する |
| SSM パラメータストア参照 | `{{resolve:ssm:...}}` | `data "aws_ssm_parameter"` で参照する |
| Secrets Manager 参照 | `{{resolve:secretsmanager:...}}` | `data "aws_secretsmanager_secret_version"` で参照し、sensitive = true を付与する |

---

## Step 5 — module 構造の設計

以下のディレクトリ構成を標準テンプレートとして採用する。

<project-root>/
├── main.tf               # ルートモジュール:module呼び出しのエントリポイント
├── variables.tf          # ルートの入力変数定義
├── outputs.tf            # ルートの出力値定義
├── versions.tf           # terraform / required_providers バージョン固定
├── terraform.tfvars      # 変数の実値(gitignore 推奨)
│
├── modules/
│   ├── networking/       # VPC, Subnet, IGW, NAT, Route Table
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── compute/          # EC2, Auto Scaling, Launch Template
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── database/         # RDS, ElastiCache など
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── security/         # Security Group, IAM, KMS
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
│
└── environments/
    ├── dev/
    │   ├── main.tf       # 環境固有のモジュール呼び出し
    │   └── terraform.tfvars
    ├── stg/
    └── prod/

> **複数環境の注意点**
> Terraform Workspaces よりも `environments/` ディレクトリ分割が推奨される。
> Workspace は state の分離のみでコードの差異を管理しにくいため。

---

## Step 6 — コーディング規約とコメント記述方針

### 6-1. ファイルヘッダーコメント`main.tf` の先頭に記述する:

##############################################################################
# モジュール名: <module-name>
# 概要        : <何を作るか一行で>
# 依存モジュール: <呼び出し元や依存するモジュール>
# 最終更新    : <YYYY-MM-DD>
##############################################################################

### 6-2. リソースコメント

各リソースブロックの直前に記述する:

# [目的] プライベートサブネット向け NAT Gateway(AZ-a)
# [注意] EIP は別リソースで管理。EIP の destroy 前に NAT GW を先に削除すること。
resource "aws_nat_gateway" "this" {
  ...
}

### 6-3. 変数・出力コメント

`description` フィールドを必ず記述し、`sensitive = true` を機密値に付与する:

variable "db_password" {
  description = "RDS マスターユーザーのパスワード(Secrets Manager から渡す)"
  type        = string
  sensitive   = true
}

---

## Step 7 — コード生成

以下の順序でファイルを生成する。`write_file` または `apply_diff` ツールを使用する。

1. `versions.tf` — terraform / AWS プロバイダーのバージョン固定(Step 2 の値を使用)
2. `variables.tf` — 環境・リージョン・共通タグ変数
3. `modules/<service>/main.tf` — 各サービスのリソース定義
4. `modules/<service>/variables.tf` / `outputs.tf`
5. ルート `main.tf` — モジュール呼び出し
6. `outputs.tf` — 上位層への出力

### versions.tf のテンプレート

##############################################################################
# バージョン制約
# AWS プロバイダーは terraform MCP で確認した最新バージョンに固定する。
##############################################################################
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> <LATEST_VERSION>"  # Step 2 で取得した最新バージョン
    }
  }

  # リモートバックエンド(本番環境では必須)
  # backend "s3" {
  #   bucket         = "<tfstate-bucket-name>"
  #   key            = "<project>/<env>/terraform.tfstate"
  #   region         = "ap-northeast-1"
  #   encrypt        = true
  #   dynamodb_table = "<lock-table-name>"
  # }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = var.common_tags
  }
}

---

## Step 8 — 最終レビューチェックリスト

コード生成後、以下を確認してからユーザーに提示する。

- [ ] `required_providers` に最新バージョンが固定されている
- [ ] すべてのリソースに `tags` が付与されている(`default_tags` 経由でも可)
- [ ] IAM ポリシーに `*` アクションや `*` リソースが残っていない
- [ ] 暗号化設定(`encrypted = true`, `kms_key_id` など)が有効化されている
- [ ] パブリックアクセスが不要なリソースで `publicly_accessible = false` / Block Public Access が設定されている
- [ ] `sensitive = true` を付与すべき変数に漏れがない
- [ ] CloudFormation との差異が関係する箇所にコメントが入っている
- [ ] `modules/``outputs.tf` が必要な値を適切にエクスポートしている
- [ ] `depends_on``lifecycle` ブロックを使った箇所に理由コメントがある

今回作成したスキルはあくまでコード生成までを目的としたものであり、コードの実行まで自動的に行わせることは想定していません。実際に作成されたコードの中身を確認し、Terraform planを実行して、今からプロビジョニングしようとしている環境が想定されたものになっているかをチェックするのは人間の責務だからです。

Terraformのコードを実行する際には、事前にTerraformの実行ファイルをPCにインストールしておいてください。

スキルの稼働検証

IBM Bobのチャットウィンドウ上で「/」を入力すると、下記画面のようにスキルの一覧が表示されるので、そこで今回の「terraform-aws」スキル(赤枠部分)を選択します。
sample1.jpg

最初からIBM Bobに内蔵されているスキルと、個人で作成したスキルが表示されています。先頭から5行目までは私個人で作成したスキルでして、また今後追々記事で紹介したいと思います。

該当のスキルを選択後、今回は下記のようなAWS環境プロビジョニングを行うTerraformコードをBob君に作成してもらうことにしました。
sample2.drawio.png

  • 東京リージョンにVPCを1個作成、AZは1個とする。(本来は複数AZが望ましい)
  • VPC内にプライベートサブネットを用意し、そこにEC2インスタンスを1個稼働する。
  • 外部のS3バケツに対して、ゲートウェイ型のVPCエンドポイント経由でプライベート接続を行う。

Bob君のチャットウィンドウで下記のようなプロンプトを入力し実行します。

 以下の構成をプロビジョニングするTerraformコードを作成してください。
・東京リージョンにVPCを1個用意。AZは1個。
・VPC内にプライベートサブネットを作成し、その中にEC2インスタンスを1個用意。
・EC2インスタンスから外部のS3バケツにVPCゲートウェイエンドポイント経由でプライベート接続できるようにする。

Bob君は「terraform-aws」スキルを読み込み、今回の目玉でもあるTerraformの最新バージョンのモジュール確認と、AWSのベストプラクティス調査を開始します。

ちなみに本記事執筆時点でのAWS提供のTerraformモジュールの最新バージョンは「6.66.0」となっております。
sample4.jpg

その後、Bob君は無事にTerraformモジュールの最新バージョン「6.66.0」の確認に成功しました。併せて、AWSのベストプラクティスの抽出もできたようなので、Terraformコード作成に入ります。
sample3.jpg

ファイルの作成が終わった後、Bob君はスキル内に設定されているチェックリストを確認し、問題が無いかを検証していきます。
sample5.jpg

その後、Bob君は生成ファイル一覧とアーキテクチャーのポイント、コードの使い方、主要なベストプラクティスの適用箇所をサマリーして、処理を終了しました。

sample6.jpg
sample7.jpg
sample8.jpg

ベストプラクティスの中でも、VPCエンドポイントポリシーやIAM最小権限の指定、SSH接続ではなくSSMセッションマネージャーを使用してEC2インスタンスへの接続を行うようにしてくれていることに感動しました。特にIAM最小権限の指定については、頭では必要だとわかっていても、いざテストしようとすると検証が楽なようにフルアクセスをしてしまいがちなので助かります。

生成されたTerraformコード

AIによる成果物なのであくまでもサンプルになりますが、ここでは特に今回のスキルならではのポイントが反映されているコードの中身を1例として紹介します。

  • ルートのversions.tfファイル
versions.tf
##############################################################################
# モジュール名: root
# 概要        : Terraform / AWS プロバイダーバージョン制約
# 依存モジュール: なし
# 最終更新    : 2025-07-10
##############################################################################

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.60.0" # terraform MCP で確認した最新バージョン(2025-07-10時点)
    }
  }

  # [CFn差異] CloudFormation は StackSets でマルチアカウント展開をネイティブサポートするが、
  # Terraform では providers マップ + for_each で対応する。
  # 本構成は単一環境のためリモートバックエンドはコメントアウト。
  # 本番環境では以下のバックエンド設定を有効化すること。
  # backend "s3" {
  #   bucket         = "<tfstate-bucket-name>"
  #   key            = "ec2-s3-endpoint/dev/terraform.tfstate"
  #   region         = "ap-northeast-1"
  #   encrypt        = true
  #   dynamodb_table = "<lock-table-name>"
  # }
}

provider "aws" {
  region = var.aws_region

  # default_tags を使うことで全リソースに共通タグを自動付与する。
  # CloudFormation の Stack Tags に相当。
  default_tags {
    tags = var.common_tags
  }
}

冒頭の「required_version」に指定されているバージョンはもちろんの事、「required_providers」のソースのバージョンが、「6.60.0」以上と指定されていることがわかります。
また、CloudFormationとの差異に関する部分についてもコメント文が追記されているのがわかります。

  • ルートのmain.tfファイル
main.tf
##############################################################################
# モジュール名: root
# 概要        : VPC + プライベートサブネット + EC2 + S3 VPC ゲートウェイエンドポイント
#               EC2 から S3 へのプライベート接続(インターネット経由なし)を実現する。
# 依存モジュール: modules/networking, modules/security, modules/compute
# 最終更新    : 2025-07-10
##############################################################################

# -----------------------------------------------------------------------------
# networking モジュール
# VPC / サブネット / ルートテーブル / S3 VPC ゲートウェイエンドポイント / VPC Flow Logs
# -----------------------------------------------------------------------------
module "networking" {
  source = "./modules/networking"

  project             = var.common_tags["project"]
  aws_region          = var.aws_region
  vpc_cidr            = var.vpc_cidr
  private_subnet_cidr = var.private_subnet_cidr
  availability_zone   = var.availability_zone
  s3_bucket_name      = var.s3_bucket_name
}

# -----------------------------------------------------------------------------
# security モジュール
# EC2 用セキュリティグループ / IAM ロール(SSM + S3 アクセス)
# -----------------------------------------------------------------------------
module "security" {
  source = "./modules/security"

  project        = var.common_tags["project"]
  vpc_id         = module.networking.vpc_id
  s3_bucket_name = var.s3_bucket_name

  # [CFn差異] Terraform では module 間の依存関係を明示する。
  # security モジュールは networking の vpc_id に依存しているため自動解決されるが、
  # 意図を明確にするため depends_on を記述する。
  depends_on = [module.networking]
}

# -----------------------------------------------------------------------------
# compute モジュール
# EC2 インスタンス(プライベートサブネット / IMDSv2 / EBS 暗号化)
# -----------------------------------------------------------------------------
module "compute" {
  source = "./modules/compute"

  project               = var.common_tags["project"]
  subnet_id             = module.networking.private_subnet_id
  security_group_id     = module.security.ec2_security_group_id
  instance_profile_name = module.security.ec2_instance_profile_name
  instance_type         = var.ec2_instance_type
  ec2_ami_id            = var.ec2_ami_id

  # [CFn差異] security / networking モジュールの完了後に compute を作成する。
  depends_on = [module.networking, module.security]
}

ついつい油断してると忘れがちなCloudFormationとの差異であるモジュール間の依存関係「depends_on」の設定を入れてくれています。

以前、AWS TransformのTransform CLIを使用して、CloudFormationのコードからTerraformのコードへのマイグレーション検証をした際には、この「depends_on」の設定がされませんでした。その時は追加でプロンプトで指示して追記してもらいましたが、こういう所は人間の目で事前のチェックが必要なところですね。

  • networkingモジュールのmain.tfファイル
main.tf
##############################################################################
# モジュール名: networking
# 概要        : VPC / プライベートサブネット / ルートテーブル / S3 VPC ゲートウェイエンドポイント
# 依存モジュール: なし(ルートモジュールから呼び出し)
# 最終更新    : 2025-07-10
##############################################################################

# [目的] VPC 本体。DNS ホスト名・DNS 解決を有効化しておくと、
#        エンドポイント DNS が正しく動作する。
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.project}-vpc"
  }
}

# [目的] プライベートサブネット(インターネット GW・NAT GW なし)。
#        EC2 は S3 へのアクセスをゲートウェイエンドポイント経由のみで行う。
resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = var.availability_zone
  map_public_ip_on_launch = false # パブリック IP 自動割り当て無効(ベストプラクティス)

  tags = {
    Name = "${var.project}-private-subnet-${var.availability_zone}"
    Tier = "private"
  }
}

# [目的] プライベートサブネット用ルートテーブル。
#        ゲートウェイエンドポイントのルートはエンドポイントリソースが自動追加する。
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${var.project}-private-rtb"
  }
}

# [目的] プライベートサブネットとルートテーブルを関連付け。
resource "aws_route_table_association" "private" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}

# [目的] S3 向け VPC ゲートウェイエンドポイント。
#        - ゲートウェイ型は NAT GW 不要・追加コストなし(インターフェース型と異なる)。
#        - route_table_ids に指定したルートテーブルへ S3 の pl-xxxxxxxx プレフィックスリストへの
#          ルートを自動追加する。
#        - CloudFormation との差異: CFn では AWS::EC2::VPCEndpoint で同様に定義する。
#          Terraform では depends_on 不要(aws_route_table との参照で自動解決)。
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.this.id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"

  route_table_ids = [aws_route_table.private.id]

  # [ベストプラクティス] エンドポイントポリシーで S3 へのアクセスを最小化する。
  # 以下では指定バケットへの GetObject / PutObject / ListBucket のみ許可。
  # "Principal": "*" は VPC 内のすべての IAM プリンシパルを意味する(VPC 外からは到達不可)。
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowSpecificBucketAccess"
        Effect    = "Allow"
        Principal = "*"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket",
          "s3:DeleteObject",
          "s3:GetBucketLocation"
        ]
        Resource = [
          "arn:aws:s3:::${var.s3_bucket_name}",
          "arn:aws:s3:::${var.s3_bucket_name}/*"
        ]
      }
    ]
  })

  tags = {
    Name = "${var.project}-vpce-s3"
  }
}

# [目的] VPC Flow Logs 用 CloudWatch ロググループ。
#        ネットワーク監査ログとして有効化する(ベストプラクティス)。
resource "aws_cloudwatch_log_group" "vpc_flow_log" {
  name              = "/aws/vpc/flowlog/${var.project}"
  retention_in_days = 90 # 90日保持(要件に応じて変更)

  tags = {
    Name = "${var.project}-vpc-flow-log-group"
  }
}

# [目的] Flow Logs が CloudWatch Logs に書き込むための IAM ロール。
resource "aws_iam_role" "vpc_flow_log" {
  name = "${var.project}-vpc-flow-log-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "vpc-flow-logs.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = {
    Name = "${var.project}-vpc-flow-log-role"
  }
}

# [目的] Flow Logs ロールに CloudWatch Logs への書き込み権限を付与(最小権限)。
resource "aws_iam_role_policy" "vpc_flow_log" {
  name = "${var.project}-vpc-flow-log-policy"
  role = aws_iam_role.vpc_flow_log.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams"
      ]
      Resource = "${aws_cloudwatch_log_group.vpc_flow_log.arn}:*"
    }]
  })
}

# [目的] VPC Flow Logs を有効化(ALL トラフィックを記録)。
# [注意] CloudFormation では AWS::EC2::FlowLog リソースに相当。
resource "aws_flow_log" "this" {
  vpc_id          = aws_vpc.this.id
  traffic_type    = "ALL"
  iam_role_arn    = aws_iam_role.vpc_flow_log.arn
  log_destination = aws_cloudwatch_log_group.vpc_flow_log.arn

  # [CFn差異] depends_on を明示的に記述する。
  # IAM ロールポリシーが適用される前に Flow Log が作成されるとエラーになるため。
  depends_on = [aws_iam_role_policy.vpc_flow_log]

  tags = {
    Name = "${var.project}-vpc-flow-log"
  }
}

ゲートウェイ型のVPCエンドポイントポリシーに対する最小アクセス権の絞り込みなど、どの部分がベストプラクティスなのかを[ベストプラクティス]という表記を前に付けてコメントを付けてくれています。

  • securityモジュールのmain.tfファイル
main.tf
##############################################################################
# モジュール名: security
# 概要        : EC2 用セキュリティグループ / IAM ロール(S3 アクセス権限付き)
# 依存モジュール: networking(vpc_id を受け取る)
# 最終更新    : 2025-07-10
##############################################################################

# [目的] EC2 インスタンス用セキュリティグループ。
#        プライベートサブネットのため外部からのインバウンドは全て拒否。
#        アウトバウンドは HTTPS のみ許可(S3 ゲートウェイエンドポイント経由の通信は HTTPS)。
# [注意] VPC ゲートウェイエンドポイントへの通信はルートテーブル経由で行われるため、
#        セキュリティグループで特別なルールは不要。アウトバウンドの HTTPS(443)を許可すれば十分。
resource "aws_security_group" "ec2" {
  name        = "${var.project}-ec2-sg"
  description = "EC2 インスタンス用セキュリティグループ(プライベートサブネット)"
  vpc_id      = var.vpc_id

  # インバウンドは全て拒否(プライベートサブネットのため外部からのアクセスは不要)
  # VPC 内からの管理アクセスが必要な場合は Systems Manager Session Manager を利用する。

  # [目的] S3 への HTTPS アウトバウンドを許可。
  # ゲートウェイエンドポイント経由のため、宛先は S3 の IP(prefix list)となるが、
  # セキュリティグループでは 0.0.0.0/0 HTTPS を許可する形式が一般的。
  egress {
    description = "S3 へのアウトバウンド HTTPS(ゲートウェイエンドポイント経由)"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project}-ec2-sg"
  }
}

# [目的] EC2 インスタンスプロファイル用 IAM ロール。
#        Systems Manager Session Manager 経由での管理アクセスを可能にする
#        (プライベートサブネットのため SSH キーペア不要)。
resource "aws_iam_role" "ec2" {
  name = "${var.project}-ec2-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = {
    Name = "${var.project}-ec2-role"
  }
}

# [目的] SSM Session Manager に必要なマネージドポリシーをアタッチ。
#        SSH 不要・キーペア不要でプライベートインスタンスに接続可能。
resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# [目的] S3 バケットへの最小権限アクセスポリシー。
#        GetObject / PutObject / ListBucket / DeleteObject のみ許可。
#        [最小権限の原則] S3:* や Resource: * は使用しない。
resource "aws_iam_role_policy" "ec2_s3_access" {
  name = "${var.project}-ec2-s3-policy"
  role = aws_iam_role.ec2.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowS3BucketAccess"
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject",
          "s3:GetObjectVersion"
        ]
        # [最小権限] バケット名を明示指定してリソースを限定する
        Resource = "arn:aws:s3:::${var.s3_bucket_name}/*"
      },
      {
        Sid    = "AllowS3ListBucket"
        Effect = "Allow"
        Action = [
          "s3:ListBucket",
          "s3:GetBucketLocation"
        ]
        Resource = "arn:aws:s3:::${var.s3_bucket_name}"
      }
    ]
  })
}

# [目的] EC2 インスタンスプロファイル(IAM ロールを EC2 にアタッチするためのラッパー)。
resource "aws_iam_instance_profile" "ec2" {
  name = "${var.project}-ec2-instance-profile"
  role = aws_iam_role.ec2.name

  tags = {
    Name = "${var.project}-ec2-instance-profile"
  }
}

EC2接続の際にSSHキーペアではなく、SSMセッションマネージャーからのアクセスができるように必要なIAM権限なども付与してくれています。

生成されたTerraformコードの稼働確認

terraform.tfvarsファイルでパラメータ値の設定をした後、最初の「terraform init」コマンドから確認していきます。

terraform init

C:\Users\userid\Documents\terraform-ec2-s3-endpoint>terraform init
Initializing the backend...

Initializing modules...
- security in modules\security
- networking in modules\networking
- compute in modules\compute

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.60.0"...
- Installing hashicorp/aws v6.60.0...
- Installed hashicorp/aws v6.60.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

AWS提供の最新バージョン「6.60.0」のモジュールを取り込むことに成功しました。

terraform validate

ここでは、生成されたコードが文法的に不備が無いかを確認するために「terraform validate」コマンドを実行します。すると、いきなり下記のエラーメッセージに装具遇しました。

C:\Users\userid\Documents\terraform-ec2-s3-endpoint>terraform validate
╷
│ Error: "egress.0.description" doesn't comply with restrictions ("^[0-9A-Za-z_ .:/()#,@\\[\\]+=&;{}!$*-]*$"): "S3 へのアウトバウンド HTTPS(ゲートウェイエンドポイント経由)"
│
│   with module.security.aws_security_group.ec2,
│   on modules\security\main.tf line 13, in resource "aws_security_group" "ec2":
│   13: resource "aws_security_group" "ec2" {
│
╵

過去に自分が作成していたコードと比較して、aws_security_groupモジュールに指定しているdescriptionの中身に不備があるのではと思いましたが、原因を特定すべくBob君に解析を依頼しました。原因は、下記の通りdescriptionの記載に日本語文字が含まれていたことによります。
sample8.jpg
これは目検ではわからなかった事なので、「terraform plan」コマンドに入る前に「terraform validate」コマンドで文法チェックを入れて良かったです。Bob君にファイルを修正してもらった後は、無事「terraform validate」コマンドはパスしました。

C:\Users\userid\Documents\terraform-ec2-s3-endpoint>terraform validate
Success! The configuration is valid.

terraform plan

実際にプロビジョニングされる予定のリソースが想定通りのもの(過不足が無いか、不備が無いか)を「terraform plan」コマンドで確認します。

C:\Users\userid\Documents\Bob study\z_with_awsスキル作成\terraform-ec2-s3-endpoint>terraform plan

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # module.compute.data.aws_ami.amazon_linux_2023 will be read during apply
  # (depends on a resource or a module with changes pending)
 <= data "aws_ami" "amazon_linux_2023" {
      + architecture          = (known after apply)
:
:
Plan: 15 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ec2_ami_id_used    = (known after apply)
  + ec2_iam_role_arn   = (known after apply)
  + ec2_instance_id    = (known after apply)
  + ec2_private_ip     = (known after apply)
  + private_subnet_id  = (known after apply)
  + vpc_endpoint_s3_id = (known after apply)
  + vpc_id             = (known after apply)

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if
you run "terraform apply" now

コマンド自体は正常終了しました。ただ、事前にコードレビューをしていて何となく違和感を感じていて、「terraform plan」を実行してみて明らかになった点があります。それは、今回Bob君はSSHキーではなく、SSMセッションマネージャー経由でプライベートEC2インスタンスに接続するようにしてくれているのですが、SSMセッションエンドポイントのリソースが含まれていないことです。これだど、プロビジョニングはできてもEC2インスタンスへの接続はできないので、再度Bob君にプロンプトで指示してコードを修正してもらいました。
sample9.jpg
sample10.jpg
sample11.jpg
この後、再度「terraform init」→「terraform plan」と実行し、SSMエンドポイントのリソースと関連するセキュリティーグループが追加されたことを確認できました。追加されたコードのみ下記に添付します。

  • networkモジュールのmain.tf
main.tf
# =============================================================================
# SSM Session Manager 用インターフェース型 VPC エンドポイント
# プライベートサブネットから NAT GW・インターネット不要で SSM に接続するために必要。
# 必要なエンドポイントは以下の 3 つ:
#   1. ssm         — SSM サービス本体(エージェント登録・コマンド実行)
#   2. ssmmessages — Session Manager のセッション通信
#   3. ec2messages — SSM エージェントのメッセージ中継
# [注意] CloudFormation では AWS::EC2::VPCEndpoint(Type: Interface)に相当。
#        インターフェース型は ENI をサブネットに作成するため、ゲートウェイ型と異なり
#        セキュリティグループの指定が必要。また、時間単位の料金が発生する。
# =============================================================================

# [目的] SSM エンドポイント(ssm)。
resource "aws_vpc_endpoint" "ssm" {
  vpc_id              = aws_vpc.this.id
  service_name        = "com.amazonaws.${var.aws_region}.ssm"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [var.ssm_endpoint_security_group_id]
  private_dns_enabled = true # プライベート DNS を有効化すると標準エンドポイント URL がそのまま使える

  tags = {
    Name = "${var.project}-vpce-ssm"
  }
}

# [目的] SSM エンドポイント(ssmmessages)。Session Manager のセッション通信に必要。
resource "aws_vpc_endpoint" "ssmmessages" {
  vpc_id              = aws_vpc.this.id
  service_name        = "com.amazonaws.${var.aws_region}.ssmmessages"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [var.ssm_endpoint_security_group_id]
  private_dns_enabled = true

  tags = {
    Name = "${var.project}-vpce-ssmmessages"
  }
}

# [目的] SSM エンドポイント(ec2messages)。SSM エージェントのメッセージ中継に必要。
resource "aws_vpc_endpoint" "ec2messages" {
  vpc_id              = aws_vpc.this.id
  service_name        = "com.amazonaws.${var.aws_region}.ec2messages"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [var.ssm_endpoint_security_group_id]
  private_dns_enabled = true

  tags = {
    Name = "${var.project}-vpce-ec2messages"
  }
}
  • securityモジュールのmain.tf
main.tf
# [目的] SSM インターフェース型エンドポイント用セキュリティグループ。
#        EC2 SG からのインバウンド HTTPS のみ許可する。
# [注意] このリソースは networking モジュールの vpc_endpoint_ssm_* リソースに渡す。
#        先に定義して ID を outputs.tf 経由で networking モジュールへ受け渡す。
resource "aws_security_group" "ssm_endpoint" {
  name        = "${var.project}-ssm-endpoint-sg"
  description = "SSM interface endpoint SG - inbound HTTPS from EC2 SG only"
  vpc_id      = var.vpc_id

  # [目的] EC2 SG からの HTTPS インバウンドのみ許可。
  # インターフェース型エンドポイントはセキュリティグループで通信を制御できる。
  ingress {
    description     = "HTTPS from EC2 SG to SSM endpoint"
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.ec2.id]
  }

  tags = {
    Name = "${var.project}-ssm-endpoint-sg"
  }
}

# [目的] EC2 インスタンス用セキュリティグループ。
#        プライベートサブネットのため外部からのインバウンドは全て拒否。
# [注意] egress ルールを SSM と S3 で分けて記述する理由:
#        - SSM インターフェース型エンドポイントは VPC 内に ENI を作成するため、
#          宛先 IP は VPC CIDR 内(10.0.0.0/16)→ vpc_cidr で到達可能。
resource "aws_security_group" "ec2" {
  name        = "${var.project}-ec2-sg"
  description = "EC2 SG for private subnet - outbound HTTPS to SSM endpoints and S3"
  vpc_id      = var.vpc_id

  # インバウンドは全て拒否(プライベートサブネットのため外部からのアクセスは不要)
  # 管理アクセスは SSM Session Manager 経由で行う。

  # [目的] SSM インターフェース型エンドポイント(VPC 内 ENI)への HTTPS アウトバウンドを許可。
  egress {
    description = "Outbound HTTPS to SSM interface endpoints within VPC"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }

terraform apply

それでは実際にプロビジョニングに入ります。順調にプロビジョニングが進みましたが、1点だけ下記のエラーに遭遇しました。

module.compute.data.aws_ami.amazon_linux_2023: Reading...
module.compute.data.aws_ami.amazon_linux_2023: Read complete after 0s [id=ami-0235d4e680cef92d8]
module.compute.aws_instance.this: Creating...
╷
│ Error: creating EC2 Instance: operation error EC2: RunInstances, https response error StatusCode: 400, RequestID: 7820f297-c30c-4faf-9007-f536be890bdb, api error InvalidBlockDeviceMapping: Volume of size 20GB is smaller than snapshot 'snap-0a7482fb57b5ce61d', expect size >= 30GB
│
│   with module.compute.aws_instance.this,
│   on modules\compute\main.tf line 37, in resource "aws_instance" "this":
│   37: resource "aws_instance" "this" {

私は普段EC2インスタンスはUbuntu Linux 24.04 LTSを使用していて、今回初めてAmazon Linux 2023を使ってみてわかったのですが、Amazon Linux 2023のAMIはスナップショットが30GB以上を前提としているので、20GBだと受け付けられないようです。
sample12.jpg
Bob君にEBSのルートボリュームのボリュームサイズを30GBに直してもらって、再度「terraform apply」を実行してようやく最後まで辿り着くことができました。

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

ec2_ami_id_used = "ami-0235d4e680cef92d8"
ec2_iam_role_arn = "arn:aws:iam::xxxxxxxxxx:role/ec2-s3-endpoint-ec2-role"
ec2_instance_id = "i-0a214796212d0412b"
ec2_private_ip = "10.0.1.193"
private_subnet_id = "subnet-096c9918084383984"
vpc_endpoint_s3_id = "vpce-03228dbd9f7857222"
vpc_id = "vpc-0addb02fbeb660038"

EC2インスタンスからゲートウェイ型のVPCエンドポイントを介したS3バケツへのアクセス確認

最初にSSMセッションマネージャー・プラグインを使って、DOSプロンプトからEC2インスタンスにログオンします。

C:\Users\userid>aws ssm start-session --target i-0a214796212d0412b --region ap-northeast-1

Starting session with SessionId: ProdUser-85rq9u7k4kiupjpqbt6u5kpkbq
sh-5.2$ bash
[ssm-user@ip-10-0-1-29 bin]$

問題なくSSMセッションマネージャー経由でEC2インスタンスに接続できました。この後、続けてS3バケツへのアクセス確認を実施します。

[ssm-user@ip-10-0-1-29 ~]$ aws s3 ls s3://duelist2020jp-temp-bucket --region ap-northeast-1
                           PRE bob/
                           PRE kiro/

無事にゲートウェイ型のVPCエンドポイント経由で、プライベートEC2インスタンスから外部のS3バケツへのアクセスができることを確認できました!!

今回のスキルを作成してみて感じたこと

常にAWSの最新バージョンのTerraformモジュールを使えるようになった。
冒頭にも書きましたが、AWSのサービスの進化は速いです。AWSの技術者としては、常に最新のAWS情報をキャッチアップし、自身のAWSスキルも日々アップデートしていくことが求められます。手元のAWS提供Terraformモジュールが古くて、いざというときに新サービスの検証ができないという事は避けねばなりません。ですので、常に最新バージョンのモジュールを使えるようになったのはありがたいです。
多くのAWSベストプラクティスを適用したコードが作成できるようになった。
最小のIAMアクセス権限付与や、VPCエンドポリシーでの最小権限の設定等、普段の検証であれば楽ができるようにフルアクセスにしてしまいがちな部分を強化できるようになったと思います。
人間のコードレビュー及び自身のAWSの更なるスキル強化継続の重要性を強く感じた。
今の時代、AIがコードを書くスピードには人間は追いつくことができません。ですが、AIの作成してくれたコードが必ずしも100%正しいとも限りません。コードの実行過程でエラーメッセージが出力される分には、そこの修正をして再実行するだけで済みますが、元々のコードにプロビジョニングが必要なリソースの構文が書かれていないケースだとエラーも出ないので、なかなかコードレビューの時点で気づくのは難しいかと思います。今回は普段からよく利用しているSSMセッションマネージャーのSSMエンドポイントに関する設定部分の抜け漏れだったので事前に気づくことができましたが、私自身もあまり詳しくないAWSサービスを使った構成を作る場合には、事前に設定漏れに気づける自信がありません。その為、自分自身の更なるAWSスキル強化継続の重要性を強く感じました。

おわりに

今回は、AWSのMCPサーバーとTerraformのMCPサーバーを活用して、AWSの最新を追求しつつ、ベストプラクティスも意識したTerraformコードを生成するスキルをIBM Bobで作成してみた際の手順と使用感について紹介しました。

スキルは育てるもの

作成したスキルについては1度作成したら、もう終わりというものではありません。今回、いくつかエラーメッセージが出力されたり、SSMセッションマネージャーを使ってEC2インスタンスにアクセスするのに必要なSSMエンドポイント等の設定が抜け落ちていた点については、次回以降同じ過ちを繰り返さないように、忘れないようにBob君にスキルへ追記してもらいました。今後も、スキルの機能拡張だけではなく、経験したトラブルシューティング情報もスキルに蓄積することを継続し、より価値のあるスキルに育てていこうと思います。

ご興味をお持ちになられた方は、下記のGitHubリポジトリから「terraform-aws」スキルの「SKILL.md」をクローンしてみてください。

Blog banner with free trial guidance - Blue.png

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?