
Claude でhashicorp/agent-skillsを使って Terraform のモノリシック構成をモジュール化してみた
はじめに
クラウド事業本部、あきやまです。
2/2頃、hashicorpよりhashicorp agent skillsが発表されました。
今回は機能の一つであるrefactor-moduleを使用して既存のモノシリック構成をmodules構成にできるか試してみました。既存環境をmodules構成にしてみたいけど、煩雑で困っているという方の参考になればと思います。
HashiCorp Agent Skills とは
HashiCorp Agent Skills は terraform と packer の2つサービスに対してプラグインを提供しています。各プラグインには1~4つのスキルが設定されています。

| プラグイン | スキル | 概要 |
|---|---|---|
| terraform-code-generation | terraform-style-guide | HashiCorp スタイルガイド準拠の HCL 生成 |
| terraform-test | .tftest.hcl テスト作成 |
|
| azure-verified-modules | Azure Verified Modules 準拠コード生成 | |
| terraform-module-generation | refactor-module | モノリシック構成 → モジュール化リファクタリング |
| terraform-stacks | Terraform Stacks(マルチ環境オーケストレーション) | |
| terraform-provider-development | new-terraform-provider | プロバイダースキャフォールディング |
| provider-resources | リソース・データソース実装 | |
| provider-actions | ライフサイクル操作実装 | |
| run-acceptance-tests | 受け入れテスト実行・デバッグ | |
| packer-builders | aws-ami-builder | amazon-ebs ビルダーで Amazon マシンイメージ (AMI) を構築する |
| azure-image-builder | Azure マネージド イメージと Azure コンピューティング ギャラリー イメージを構築する | |
| windows-builder | WinRM と PowerShell を使用したプラットフォームに依存しない Windows イメージ パターン | |
| packer-hcp | push-to-registry | hcp_packer_registry を設定して、ビルド メタデータを HCP Packer にプッシュします。 |
環境
| 項目 | バージョン |
|---|---|
| OS | macOS Tahoe 126.3 |
| Claude Code | 2.1.50 |
| Terraform | 1.14.3 |
準備
以下がインストール済みであることを前提とします。
- Claude Code: AI アシスタント本体
- Terraform CLI: モジュール化後の
terraform plan実行用
やってみた
Step 1: プラグインのインストール
まず Claude Code のプラグインマーケットプレイスから HashiCorp Agent Skills を追加します。
claude plugin marketplace add hashicorp/agent-skills
続いて、terraform-module-generation プラグインをインストールします。
claude plugin install terraform-module-generation@hashicorp
Step 2: 検証用のモノリシック構成を用意する
リファクタリング対象として、VPC・サブネット・セキュリティグループ・EC2 の典型的なモノリシック構成を用意します。
refactor-module/
∟ main.tf # VPC + Subnet + SG + EC2 が全部入り
main.tfの内容は以下です。
main.tf
# main.tf - モノリシック構成(リファクタリング前)
terraform {
required_version = ">= 1.1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project}-vpc"
Environment = var.environment
}
}
# --- Subnet ---
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project}-public-subnet"
Environment = var.environment
}
}
# --- Security Group ---
resource "aws_security_group" "web" {
name_prefix = "${var.project}-web-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
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"]
}
tags = {
Name = "${var.project}-web-sg"
Environment = var.environment
}
}
# --- EC2 Instance ---
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "${var.project}-web"
Environment = var.environment
}
}
# variables.tf
variable "region" {
description = "AWS region"
type = string
default = "ap-northeast-1"
}
variable "project" {
description = "Project name"
type = string
default = "demo"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "Public subnet CIDR block"
type = string
default = "10.0.1.0/24"
}
variable "ami_id" {
description = "AMI ID for EC2 instance"
type = string
default = "ami-088103e734f7e0529" # /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
この構成の問題点を整理すると、次のとおりです。
- 全リソースが 1 ファイルに集約されていて見通しが悪い
- VPC 周りの構成を他プロジェクトで再利用できない
- 環境ごとの差分管理(dev/stg/prod)が変数の切り替えだけでは限界がある
- テストがない
Step 3: refactor-module でモジュール化する
Claude Code 上で、このモノリシック構成のディレクトリを開いた状態でrefactor-moduleと指示します。何度か実施したところ、どの程度、module化するのかなどの質問がClaudeから行われました。
Claude code 出力例
❯ refactor-module
⏺ Skill(terraform-module-generation:refactor-module)
⎿ Successfully loaded skill
⏺ まず、既存のTerraform構成を分析します。
⏺ Explore(Explore Terraform codebase structure) Haiku 4.5
⎿ Done (6 tool uses · 29.8k tokens · 33s)
(ctrl+o to expand)
⏺ 分析が完了しました。現在の構成を整理します。
現状分析
項目: ファイル構成
内容: main.tf 1ファイルにすべて集約(モノリシック)
────────────────────────────────────────
項目: リソース
内容: aws_vpc, aws_subnet, aws_security_group, aws_instance (4リソース)
────────────────────────────────────────
項目: 変数
内容: 7個(region, project, environment, vpc_cidr, public_subnet_cidr, ami_id, instance_type)
────────────────────────────────────────
項目: Output
内容: なし
────────────────────────────────────────
項目: State
内容: 空(管理リソースなし) → State移行不要
────────────────────────────────────────
項目: Provider
内容: AWS v5.100.0 / Terraform >= 1.1.0
依存関係
aws_vpc.main
├── aws_subnet.public
├── aws_security_group.web
│ └── aws_instance.web ← aws_subnet.public も参照
リファクタリングの方針を確認させてください。
⏺ User answered Claude's questions:
⎿ · モジュール分割の粒度をどうしますか? → 2モジュール構成 (Recommended)
· 抽象化レベルをどうしますか? → intermediate (Recommended)
⏺ 了解しました。2モジュール構成 + intermediate 抽象化レベルで進めます。
リファクタリング計画
【Before】 【After】
main.tf (全部入り) → modules/
network/ # VPC + Subnet + SG
main.tf / variables.tf / outputs.tf
compute/ # EC2
main.tf / variables.tf / outputs.tf
main.tf # Root module呼び出し
variables.tf # Root変数
outputs.tf # Root出力
versions.tf # Provider制約
並列で3エージェントに実装を委託します。
⏺ 3 Task agents finished (ctrl+o to expand)
├─ Implement network module · 4 tool uses · 25.9k tokens
│ ⎿ Done
├─ Implement compute module · 7 tool uses · 25.3k tokens
│ ⎿ Done
└─ Implement root configuration · 5 tool uses · 26.8k tokens
⎿ Done
⎿ Found 5 new diagnostic issues in 1 file (ctrl+o to expand)
⏺ 全ファイルの生成が完了しました。診断エラーはモジュール初期化前のため発生しています。生成されたコー
ドを検証します。
⏺ Explore(Validate all generated files) Haiku 4.5
⎿ Done (10 tool uses · 30.7k tokens · 20s)
(ctrl+o to expand)
⏺ コードレビューは全項目 PASS です。terraform init と terraform validate で実行検証します。
⏺ Bash(terraform init -upgrade 2>&1)
⎿ Initializing the backend...
Upgrading modules...
- compute in modules/compute
… +14 lines (ctrl+o to expand)
⏺ Bash(terraform validate 2>&1)
⎿ Success! The configuration is valid.
⏺ Bash(terraform fmt -check -recursive -diff 2>&1)
⎿ (No output)
⏺ すべて通りました。リファクタリング完了です。
結果サマリ
┌──────────────────────┬────────────────────────────┐
│ 検証項目 │ 結果 │
├──────────────────────┼────────────────────────────┤
│ terraform init │ OK │
├──────────────────────┼────────────────────────────┤
│ terraform validate │ OK │
├──────────────────────┼────────────────────────────┤
│ terraform fmt -check │ OK(フォーマット差分なし) │
└──────────────────────┴────────────────────────────┘
生成ファイル構成
refactor-module/
├── versions.tf # Provider/Terraform版制約
├── variables.tf # Root変数(7個、元のデフォルト値を保持)
├── main.tf # Provider + module "network" / "compute" 呼び出し
├── outputs.tf # 5つのoutput(vpc_id, subnet_id, sg_id, instance_id, public_ip)
└── modules/
├── network/ # VPC + Subnet + Security Group
│ ├── main.tf # 3リソース(dynamic ingress block付き)
│ ├── variables.tf # 7変数(CIDR validation付き)
│ └── outputs.tf # 4 output
└── compute/ # EC2 Instance
├── main.tf # 1リソース
├── variables.tf # 6変数
└── outputs.tf # 3 output
主な改善点
- 分離: ネットワーク層とコンピュート層を独立モジュール化
- 再利用性: 各モジュールを別プロジェクトでも使い回し可能
- 柔軟性: ingress_rules を dynamic block で可変化、tags は merge パターン
- 安全性: CIDR 変数に validation ブロックを追加
Step 4: 生成されたモジュール構成を確認する
refactor-module が生成する典型的な出力を確認していきます。
ディレクトリ構成
refactor-module/
├── versions.tf
├── variables.tf
├── main.tf
├── moved.tf
├── outputs.tf
└── modules/
├── network/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── compute/
├── main.tf
├── variables.tf
└── outputs.tf
ルートモジュール(main.tf)
main.tf
provider "aws" {
region = var.region
}
module "network" {
source = "./modules/network"
name = "${var.project}-${var.environment}"
vpc_cidr = var.vpc_cidr
subnet_cidr = var.public_subnet_cidr
availability_zone = "${var.region}a"
tags = {
Project = var.project
Environment = var.environment
}
}
module "compute" {
source = "./modules/compute"
name = "${var.project}-${var.environment}-web"
ami_id = var.ami_id
instance_type = var.instance_type
subnet_id = module.network.subnet_id
vpc_security_group_ids = [module.network.security_group_id]
tags = {
Project = var.project
Environment = var.environment
}
}
modules/network/main.tf
main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(var.tags, {
Name = "${var.name}-vpc"
})
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.this.id
cidr_block = var.subnet_cidr
availability_zone = var.availability_zone
map_public_ip_on_launch = var.map_public_ip_on_launch
tags = merge(var.tags, {
Name = "${var.name}-public-subnet"
})
}
resource "aws_security_group" "this" {
name_prefix = "${var.name}-web-"
vpc_id = aws_vpc.this.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(var.tags, {
Name = "${var.name}-web-sg"
})
}
modules/network/variables.tf
variables.tf
variable "name" {
description = "Name prefix for all resources created by this module."
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC."
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid IPv4 CIDR block (e.g. \"10.0.0.0/16\")."
}
}
variable "subnet_cidr" {
description = "CIDR block for the public subnet."
type = string
validation {
condition = can(cidrhost(var.subnet_cidr, 0))
error_message = "subnet_cidr must be a valid IPv4 CIDR block (e.g. \"10.0.1.0/24\")."
}
}
variable "availability_zone" {
description = "Availability zone in which the public subnet will be created."
type = string
}
variable "map_public_ip_on_launch" {
description = "Whether instances launched into the public subnet receive a public IP address automatically."
type = bool
default = true
}
variable "ingress_rules" {
description = "List of ingress rules to apply to the web security group."
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
]
}
variable "tags" {
description = "Map of tags to apply to all resources. Resource-specific Name tags are merged on top."
type = map(string)
default = {}
}
modules/network/outputs.tf
outputs.tf
output "vpc_id" {
description = "The ID of the VPC."
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "The primary CIDR block of the VPC."
value = aws_vpc.this.cidr_block
}
output "subnet_id" {
description = "The ID of the public subnet."
value = aws_subnet.public.id
}
output "security_group_id" {
description = "The ID of the web security group."
value = aws_security_group.this.id
}
modules/compute/main.tf
main.tf
# modules/compute/main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.vpc_security_group_ids
tags = merge(var.tags, {
Name = var.name
})
}
modules/compute/variables.tf
variables.tf
variable "name" {
description = "Name prefix for the instance"
type = string
}
variable "ami_id" {
description = "AMI ID for the EC2 instance"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "subnet_id" {
description = "Subnet ID to launch the instance in"
type = string
}
variable "vpc_security_group_ids" {
description = "List of security group IDs"
type = list(string)
}
variable "tags" {
description = "Tags to apply to the instance"
type = map(string)
default = {}
}
modules/compute/outputs.tf
outputs.tf
output "instance_id" {
description = "The ID of the EC2 instance"
value = aws_instance.this.id
}
output "instance_public_ip" {
description = "The public IP address of the EC2 instance"
value = aws_instance.this.public_ip != null ? aws_instance.this.public_ip : ""
}
output "instance_private_ip" {
description = "The private IP address of the EC2 instance"
value = aws_instance.this.private_ip
}
moved.tf(ステート移行)
モジュール化の際に見落としがちなのがステート移行です。refactor-module はここも自動で面倒を見てくれました。
moved.tf
# moved.tf - 既存ステートの移行
moved {
from = aws_vpc.main
to = module.networking.aws_vpc.main
}
moved {
from = aws_subnet.public
to = module.networking.aws_subnet.public
}
moved {
from = aws_security_group.web
to = module.networking.aws_security_group.web
}
moved {
from = aws_instance.web
to = module.compute.aws_instance.web
}
moved ブロックは Terraform 1.1 以降で利用できる機能です。
terraform state mv コマンドを使わずに、コード内でステート移行を
宣言的に記述できます。terraform plan を実行すると、リソースの
再作成なしに移行が行われることを事前確認できます。
その後、Planを実施し無事、差分がないことが確認できました。
まとめ
今回はそもそものリソース数も少なかったため、比較的スムーズに
modules化を行うことができました。何度か検証したところAIからの
質問には(もちろん)若干の差異がありました。しかしmodulesの粒度
についての質問は全件ありましたので、ご興味ある方はぜひお試しください。
注意点: Skills はあくまで知識提供であり、生成されたコードの
正しさは必ずレビューが必要です。また、検証時点では Claude Code の
プラグイン機能がプレビュー段階の場合があります。最新の対応状況は
公式ドキュメントをご確認ください。








