📁GitHubにコード公開: tf_claudecode02
シリーズ記事一覧
- 第1回:Skills比較
- 第2回:サブエージェント活用
- 第3回:Skillsインストール&実践
- 第4回:CLAUDE.mdカスタムルール
- 第5回:カスタムサブエージェント自作
- 第6回:カスタムエージェント&Agent Teams
📑 目次
- この記事について
- この記事のゴール
- 前提知識:CLAUDE.md とは
- 前提条件
- Step 1:チームルールを設計する
- Step 2:コード生成(03と同じプロンプト)
- Step 3:ルールの反映を確認する
- 03記事との比較
- 体験して分かったこと
- まとめ
1. この記事について
1-1. シリーズの位置づけ
本記事は「Terraform × Claude Code」シリーズの第4回です。
| 記事 | サブタイトル | 状態 |
|---|---|---|
| 01 | Skills比較|4つの公開スキルを調べて分かった最適な組み合わせ | ✅実施済み |
| 02 | サブエージェント活用|並列調査で01記事の数値を裏付け検証してみた | ✅実施済み |
| 03 | Skillsインストール&実践|コード生成の品質がどう変わるか検証してみた | ✅実施済み |
| 04 | CLAUDE.mdカスタムルール|チーム独自のルールをAIに教えてみた(本記事) | 📝本記事 |
| 05 | カスタムサブエージェント自作|AIにコードレビューさせたら意外な発見 | 📄未 |
| 06 | カスタムエージェント&Agent Teams|AI開発チームを編成してみた | 📄未 |
1-2. なぜこの記事を書いたか?
03記事で Skills(antonbabenko/terraform-skill)を使い、
コード品質が大きく向上することを確認しました。
しかし、
03記事の最後にこう書きました。
Skills を使えばコード品質は上がった。
しかし、既存の Skills がカバーしない領域 ではどうするか?
たとえば以下のような「チーム独自のルール」は、
公開 Skills には含まれていません。
- 自社独自の命名規則
- 必須タグのルール
- セキュリティグループの日本語 description
そこで本記事では、
CLAUDE.md にチームルールを書いて、コード生成に反映されるか を検証します。
1-3. 04記事の方針(本記事)
| 項目 | 方針 |
|---|---|
| Skills | 有効のまま(03記事と同じ状態) |
| CLAUDE.md | チームルールを記述して追加 |
| サブエージェント | 使わない(次回05記事で使う) |
| 記事の主役 | CLAUDE.md でチーム独自ルールを教える体験 |
03記事は「Skills の効果」が主役でした。
本記事は「CLAUDE.md の効果」が主役です。
Skills + CLAUDE.md の組み合わせでどうなるかを確かめます。
1-4. 対象読者
- Skills は導入済みだが、CLAUDE.md はまだ活用していない方
- チーム独自のルールを Claude Code に教えたい方
- 03記事を読んだ方
2. この記事のゴール
| # | ゴール |
|---|---|
| ① | CLAUDE.md の役割と書き方が分かる |
| ② | チームルールを設計して CLAUDE.md に書ける |
| ③ | Skills + CLAUDE.md の組み合わせ効果を体験できる |
3. 前提知識:CLAUDE.md とは
3-1. Skills と CLAUDE.md の違い
03記事で使った Skills と、本記事で使う CLAUDE.md は、どちらも Claude Code の「指示書」ですが、役割が異なります。
| Skills(SKILL.md) | CLAUDE.md | |
|---|---|---|
| スコープ | 汎用(Terraform 全般のベストプラクティス) | プロジェクト固有(チームのルール) |
| 管理方法 |
claude plugin install でインストール |
プロジェクトのルートに配置 |
| 配布 | GitHub リポジトリ経由で共有 | プロジェクトのリポジトリに含める |
| 更新 | プラグイン作者が更新 | チームが自分で更新 |
| 例 | 「for_each を使え」「description を付けろ」 |
「タグに CostCenter を必須にする」 |
3-2. 関係を図にすると
Skills が「業界標準」、CLAUDE.md が「うちのルール」です。
両方がシステムプロンプトに注入されて、コード生成に影響します。
3-3. CLAUDE.md の配置ルール
CLAUDE.md はプロジェクトのルートディレクトリに配置します。
Claude Code のセッション起動時に自動的に読み込まれます。
my-project/
├── CLAUDE.md ← ここに配置
├── main.tf
├── variables.tf
└── ...
[!NOTE]
CLAUDE.md は 対話型セッション(claudeコマンド)または VSCode拡張 で読み込まれます。
claude -p(ワンショットモード)では読み込まれません。
これは03記事で学んだ Skills と同じ制約です。
4. 前提条件
4-1. 環境
| 項目 | 内容 |
|---|---|
| OS | Windows 11 + WSL2(Ubuntu) |
| ツール | Claude Code(最新版) |
| プラン | Claude Max |
| Skills | antonbabenko/terraform-skill(有効) |
| 作業ディレクトリ | 新規作成(CLAUDE.md を配置する) |
4-2. 03記事からの引き継ぎ
03記事で以下が確認済みです。
- Skills(antonbabenko/terraform-skill)がインストール・有効化されている
- 対話型セッションで Skills が反映される
- Skills により、ファイル分割・description 付与・セキュリティグループの新型リソースなどが自動適用される
本記事では Skills は有効のまま、CLAUDE.md を追加します。
5. Step 1:チームルールを設計する
5-1. 目的
「チーム独自のルール」を5つ設計し、CLAUDE.md に記述します。
これらは 既存の Skills には含まれていない ルールです。
5-2. 5つのチームルール
| # | ルール | 内容 | なぜ Skills にないのか |
|---|---|---|---|
| A | 必須タグ | 全リソースに Project / Owner / CostCenter タグを付与 | コストセンターは各社固有 |
| B | リソース命名規則 | Name タグを {project}-{env}-{種別} 形式にする |
命名規則はチームごとに異なる |
| C | S3 バケットポリシー | 指定 IAM ロールのみアクセス許可 | アクセス制御方針はプロジェクト固有 |
| D | SG description 日本語 | セキュリティグループの description を日本語で記述 | 言語は Skills の範囲外 |
| E | コード品質チェック |
terraform fmt / terraform validate を自動実行 |
ワークフローはチーム固有 |
5-3. CLAUDE.md を作成
# Terraform チームルール
このプロジェクトの Terraform コードは以下のルールに従うこと。
## A. 必須タグ
全てのリソースに以下の3つのタグを必ず付与する。
変数 `var.project`, `var.owner`, `var.cost_center` を使い、`locals` でマージする。
| タグキー | 変数 | 例 |
|---|---|---|
| Project | var.project | "my-web-app" |
| Owner | var.owner | "infra-team" |
| CostCenter | var.cost_center | "CC-1234" |
## B. リソース命名規則
リソースの Name タグは `{project}-{env}-{リソース種別}` の形式にする。
例:
- S3: `my-web-app-prod-logs`
- EC2: `my-web-app-prod-web`
- VPC: `my-web-app-prod-vpc`
- SG: `my-web-app-prod-web-sg`
## C. S3 バケットポリシー
S3 バケットには必ずバケットポリシーを付与し、
指定された IAM ロール(`var.allowed_role_arn`)からのみアクセスを許可する。
> **補足(⑤記事での学び):** この文言は Allow ステートメントのみ生成され、明示的な Deny は生成されなかった。
> 「指定外を拒否」したい場合は、ルール文言に「指定外の Principal からのアクセスを Deny する」と明記する必要がある。
> 詳細は⑤記事セクション 7-1 を参照。
## D. セキュリティグループの description
セキュリティグループおよび各ルールの description は日本語で記述する。
例:
- SG 本体: "Webサーバー用セキュリティグループ"
- ルール: "管理者からのSSHアクセス"
## E. コード品質チェック
Terraform コードを生成・変更した後は、以下のコマンドを実行して結果を報告する。
- `terraform fmt -check -recursive`
- `terraform validate`
5-4. CLAUDE.md の書き方のポイント
CLAUDE.md を書く際に意識したポイントです。
| ポイント | 理由 |
|---|---|
| 具体例を入れる | 「命名規則に従え」だけでは曖昧。my-web-app-prod-logs のように例を示す |
| 実装方法を指定する | ルールAで「locals でマージする」と書いたのは、実装方法をClaude に委ねないため |
| テーブルで整理する | ルールAのタグキー・変数名・例のマッピングを表で示すと解釈ブレが減る |
| 除外条件を書かない | 「〇〇の場合は除外」のような例外は、最初は書かずにシンプルに保つ |
6. Step 2:コード生成(03と同じプロンプト)
6-1. 目的
03記事と同じ3つのプロンプトを投げて、CLAUDE.md のルールが反映されるか確認します。
Skills は有効のまま、CLAUDE.md を追加した状態です。
6-2. セッションを起動
cd ~/aws_pj07/claudecode@02/iac_04 # CLAUDE.md があるディレクトリ
claude # 対話型セッションを起動
6-3. 予想外の展開:統合プロジェクトが生成された
03記事では3つのプロンプトをそれぞれ別ディレクトリ(round1_s3/ round2_ec2/ round3_vpc/)で実行しました。
今回は 同一セッション内で順番に 投げました。
すると、Claude は3つのリソースを 1つの統合プロジェクト として生成しました。
| 03記事(Skills のみ) | 04記事(Skills + CLAUDE.md) | |
|---|---|---|
| 構造 | 3つの独立ディレクトリ | 1つの統合プロジェクト |
| S3 | round1_s3/main.tf |
main.tf に統合 |
| EC2 | round2_ec2/main.tf |
main.tf に統合 |
| VPC | round3_vpc/main.tf |
modules/vpc/ にモジュール化 |
| 共通変数 | なし(各自で定義) |
variables.tf / locals.tf で一元管理 |
| ファイル数 | 3 | 8(ルート5 + モジュール3) |
何が起きたか:
-
1つ目のプロンプト(S3バケット)→
variables.tf/locals.tf/main.tf/outputs.tf/provider.tfを新規作成 -
2つ目のプロンプト(EC2インスタンス)→ 既存ファイルに 追記。EC2 + セキュリティグループを
main.tfに追加 -
3つ目のプロンプト(VPCモジュール)→
modules/vpc/を新規作成し、EC2 のsubnet_idをmodule.vpc.private_subnet_ids[0]に 自動書き換え
Claude がセッション内の文脈を保持し、既存コードをリファクタリングしながら統合した のです。
[!NOTE]
この振る舞いは CLAUDE.md のルールではなく、Claude の文脈保持能力によるものです。
03記事で3つが別々になったのは、各ラウンドを別セッション(別ディレクトリ)で実行したためです。
6-4. 生成されたファイル構成
iac_04/
├── CLAUDE.md ← チームルール
├── provider.tf ← Terraform/プロバイダ設定
├── variables.tf ← 全変数(description付き)
├── locals.tf ← 共通タグ定義(ルールA)
├── main.tf ← S3 + EC2 + VPC呼び出し
├── outputs.tf ← 全出力(description付き)
└── modules/vpc/ ← VPCモジュール
├── main.tf
├── variables.tf
└── outputs.tf
6-5. 生成されたコード
locals.tf — 共通タグ定義(ルールA)
locals {
common_tags = {
Project = var.project
Owner = var.owner
CostCenter = var.cost_center
}
}
CLAUDE.md のルールA通り、locals で3つの必須タグをマージ用に定義しています。
variables.tf — 全変数
クリックで展開(59行)
variable "project" {
description = "プロジェクト名"
type = string
}
variable "env" {
description = "環境名 (dev, stg, prod)"
type = string
}
variable "owner" {
description = "リソースの所有者・チーム名"
type = string
}
variable "cost_center" {
description = "コストセンターコード"
type = string
}
variable "allowed_role_arn" {
description = "S3バケットへのアクセスを許可するIAMロールのARN"
type = string
}
variable "azs" {
description = "使用するアベイラビリティゾーンのリスト"
type = list(string)
}
variable "vpc_cidr" {
description = "VPCのCIDRブロック"
type = string
default = "10.0.0.0/16"
}
variable "enable_nat_gateway" {
description = "NATゲートウェイを作成するかどうか"
type = bool
default = true
}
variable "single_nat_gateway" {
description = "単一のNATゲートウェイを使用するかどうか(コスト削減用)"
type = bool
default = false
}
variable "key_name" {
description = "EC2に設定するSSHキーペア名"
type = string
default = null
}
variable "allowed_ssh_cidr_blocks" {
description = "SSHアクセスを許可するCIDRブロックのリスト"
type = list(string)
default = []
}
ルールA の var.project / var.owner / var.cost_center と、ルールC の var.allowed_role_arn が含まれています。
main.tf — S3 + EC2 + VPC統合
クリックで展開(178行)
#------------------------------------------------------------------------------
# VPC Module
#------------------------------------------------------------------------------
module "vpc" {
source = "./modules/vpc"
project = var.project
env = var.env
vpc_cidr = var.vpc_cidr
azs = var.azs
common_tags = local.common_tags
enable_nat_gateway = var.enable_nat_gateway
single_nat_gateway = var.single_nat_gateway
}
#------------------------------------------------------------------------------
# S3 Bucket - アプリケーションログ保存用
#------------------------------------------------------------------------------
resource "aws_s3_bucket" "logs" {
bucket = "${var.project}-${var.env}-logs" # ルールB: {project}-{env}-{種別}
tags = merge(local.common_tags, { # ルールA: 共通タグをマージ
Name = "${var.project}-${var.env}-logs"
})
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
id = "log-retention"
status = "Enabled"
transition {
days = 30
storage_class = "STANDARD_IA"
}
transition {
days = 90
storage_class = "GLACIER"
}
expiration {
days = 365
}
}
}
#------------------------------------------------------------------------------
# S3 Bucket Policy - 指定IAMロールのみアクセス許可(ルールC)
#------------------------------------------------------------------------------
resource "aws_s3_bucket_policy" "logs" {
bucket = aws_s3_bucket.logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowAccessFromSpecifiedRole"
Effect = "Allow"
Principal = {
AWS = var.allowed_role_arn
}
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
]
Resource = [
aws_s3_bucket.logs.arn,
"${aws_s3_bucket.logs.arn}/*"
]
}
]
})
}
#------------------------------------------------------------------------------
# Security Group - EC2用(ルールD: description を日本語で)
#------------------------------------------------------------------------------
resource "aws_security_group" "web" {
name = "${var.project}-${var.env}-web-sg"
description = "Webサーバー用セキュリティグループ" # ルールD: 日本語
vpc_id = module.vpc.vpc_id
tags = merge(local.common_tags, {
Name = "${var.project}-${var.env}-web-sg"
})
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
count = length(var.allowed_ssh_cidr_blocks) > 0 ? 1 : 0
security_group_id = aws_security_group.web.id
description = "管理者からのSSHアクセス" # ルールD: 日本語
ip_protocol = "tcp"
from_port = 22
to_port = 22
cidr_ipv4 = var.allowed_ssh_cidr_blocks[0]
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.web.id
description = "全てのアウトバウンド通信を許可" # ルールD: 日本語
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
}
#------------------------------------------------------------------------------
# EC2 Instance - Amazon Linux 2023
#------------------------------------------------------------------------------
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = "t3.micro"
subnet_id = module.vpc.private_subnet_ids[0]
vpc_security_group_ids = [aws_security_group.web.id]
key_name = var.key_name
root_block_device {
volume_type = "gp3"
volume_size = 8
encrypted = true
delete_on_termination = true
}
metadata_options {
http_tokens = "required"
http_endpoint = "enabled"
}
tags = merge(local.common_tags, {
Name = "${var.project}-${var.env}-web"
})
}
outputs.tf — 全出力
クリックで展開(44行)
output "bucket_id" {
description = "S3バケットのID"
value = aws_s3_bucket.logs.id
}
output "bucket_arn" {
description = "S3バケットのARN"
value = aws_s3_bucket.logs.arn
}
output "ec2_instance_id" {
description = "EC2インスタンスのID"
value = aws_instance.web.id
}
output "ec2_private_ip" {
description = "EC2インスタンスのプライベートIPアドレス"
value = aws_instance.web.private_ip
}
output "ec2_public_ip" {
description = "EC2インスタンスのパブリックIPアドレス(割り当てられている場合)"
value = aws_instance.web.public_ip
}
output "security_group_id" {
description = "セキュリティグループのID"
value = aws_security_group.web.id
}
output "vpc_id" {
description = "VPCのID"
value = module.vpc.vpc_id
}
output "public_subnet_ids" {
description = "パブリックサブネットのIDリスト"
value = module.vpc.public_subnet_ids
}
output "private_subnet_ids" {
description = "プライベートサブネットのIDリスト"
value = module.vpc.private_subnet_ids
}
modules/vpc/ — VPCモジュール
VPC はモジュールとして分離されました。
03記事ではルートに直接書かれていたのが、自動的にモジュール化されています。
modules/vpc/variables.tf(クリックで展開)
variable "project" {
description = "プロジェクト名"
type = string
}
variable "env" {
description = "環境名 (dev, stg, prod)"
type = string
}
variable "vpc_cidr" {
description = "VPCのCIDRブロック"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "使用するアベイラビリティゾーンのリスト"
type = list(string)
}
variable "public_subnet_cidrs" {
description = "パブリックサブネットのCIDRブロックのリスト"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "プライベートサブネットのCIDRブロックのリスト"
type = list(string)
default = ["10.0.11.0/24", "10.0.12.0/24"]
}
variable "enable_nat_gateway" {
description = "NATゲートウェイを作成するかどうか"
type = bool
default = true
}
variable "single_nat_gateway" {
description = "単一のNATゲートウェイを使用するかどうか(コスト削減用)"
type = bool
default = false
}
variable "common_tags" {
description = "全リソースに付与する共通タグ"
type = map(string)
}
modules/vpc/main.tf(クリックで展開)
#------------------------------------------------------------------------------
# VPC
#------------------------------------------------------------------------------
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-vpc"
})
}
#------------------------------------------------------------------------------
# Internet Gateway
#------------------------------------------------------------------------------
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-igw"
})
}
#------------------------------------------------------------------------------
# Public Subnets
#------------------------------------------------------------------------------
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-public-${count.index + 1}"
Tier = "Public"
})
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-public-rt"
})
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
#------------------------------------------------------------------------------
# Private Subnets
#------------------------------------------------------------------------------
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-private-${count.index + 1}"
Tier = "Private"
})
}
#------------------------------------------------------------------------------
# NAT Gateway (Optional)
#------------------------------------------------------------------------------
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.public_subnet_cidrs)) : 0
domain = "vpc"
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-nat-eip-${count.index + 1}"
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.public_subnet_cidrs)) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(var.common_tags, {
Name = "${var.project}-${var.env}-nat-${count.index + 1}"
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_route_table" "private" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.private_subnet_cidrs)) : 1
vpc_id = aws_vpc.this.id
tags = merge(var.common_tags, {
Name = var.single_nat_gateway || !var.enable_nat_gateway ? "${var.project}-${var.env}-private-rt" : "${var.project}-${var.env}-private-rt-${count.index + 1}"
})
}
resource "aws_route" "private_nat" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.private_subnet_cidrs)) : 0
route_table_id = aws_route_table.private[count.index].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this[0].id : aws_nat_gateway.this[count.index].id
}
resource "aws_route_table_association" "private" {
count = length(var.private_subnet_cidrs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = var.single_nat_gateway || !var.enable_nat_gateway ? aws_route_table.private[0].id : aws_route_table.private[count.index].id
}
modules/vpc/outputs.tf(クリックで展開)
output "vpc_id" {
description = "VPCのID"
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "VPCのCIDRブロック"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "パブリックサブネットのIDリスト"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "プライベートサブネットのIDリスト"
value = aws_subnet.private[*].id
}
output "public_subnet_cidrs" {
description = "パブリックサブネットのCIDRブロックリスト"
value = aws_subnet.public[*].cidr_block
}
output "private_subnet_cidrs" {
description = "プライベートサブネットのCIDRブロックリスト"
value = aws_subnet.private[*].cidr_block
}
output "internet_gateway_id" {
description = "インターネットゲートウェイのID"
value = aws_internet_gateway.this.id
}
output "nat_gateway_ids" {
description = "NATゲートウェイのIDリスト"
value = aws_nat_gateway.this[*].id
}
6-6. ルールEの自律的な動き
CLAUDE.md のルールE に「terraform fmt / terraform validate を実行して結果を報告する」と書いただけで、Claude は 毎回のコード生成後に自動実行 しました。
実際のセッションログ:
● Bash(terraform fmt -check -recursive)
⎿ Error: Exit code 3
main.tf
● フォーマットエラーがあるので修正します。
● Bash(terraform fmt -recursive)
⎿ main.tf
● Bash(terraform fmt -check -recursive)
⎿ (No content)
● Bash(terraform init -backend=false)
⎿ Initializing provider plugins...
...
● Bash(terraform validate)
⎿ Success! The configuration is valid.
注目すべき点が2つあります。
-
自己修正サイクル:
fmt -checkでエラーを検出 →fmtで自動修正 → 再チェック OK -
CLAUDE.md に書いていない
terraform initも自主実行。
validateを実行するにはinitが必要だと Claude が判断し、自らinit -backend=falseを実行した
[!NOTE]
CLAUDE.md には「fmtとvalidateを実行して」と書いただけです。
「エラーがあったら修正して」「initを先に実行して」とは書いていません。
Claude がルールの意図を理解し、
目的達成に必要なステップを自律的に判断 しました。
7. Step 3:ルールの反映を確認する
7-1. ルールA〜E の反映状況
| ルール | 判定 | 根拠 |
|---|---|---|
| A. 必須タグ | ✅ 完璧 |
locals.tf で common_tags を定義。全リソースで merge(local.common_tags, {...}) を使用。VPCモジュールには common_tags 変数で受け渡し |
| B. 命名規則 | ✅ 完璧 | 全リソースの Name タグが {project}-{env}-{種別} 形式。例: ${var.project}-${var.env}-logs, -web, -vpc, -web-sg
|
| C. S3バケットポリシー | ✅ 反映 |
aws_s3_bucket_policy.logs で var.allowed_role_arn を Principal に指定 |
| D. SG description 日本語 | ✅ 完璧 | SG本体:"Webサーバー用セキュリティグループ"、SSH:"管理者からのSSHアクセス"、Egress:"全てのアウトバウンド通信を許可"
|
| E. コード品質チェック | ✅ 完璧+α |
fmt -check → エラー検出 → 自動修正 → 再チェック → init → validate Success。毎ラウンド自動実行 |
5つのルール全てが反映されました。
7-2. ルールAの反映を詳しく見る
ルールAは、
最も複雑なルール(3つのタグ × 全リソース × モジュール間受け渡し)でしたが、
完璧に反映されています。
| ステップ | ファイル | 処理 | 対象リソース |
|---|---|---|---|
| 1. 変数定義 | variables.tf |
var.project / var.owner / var.cost_center
|
— |
| 2. タグ集約 | locals.tf |
common_tags に3変数を集約 |
— |
| 3. ルートに付与 | main.tf |
merge(local.common_tags, {…}) |
S3, SG, EC2 |
| 4. モジュールに渡す | モジュール呼び出し | common_tags = local.common_tags |
— |
| 5. VPCに付与 | modules/vpc/main.tf |
merge(var.common_tags, {…}) |
VPC, Subnet |
7-3. ルールBの反映を詳しく見る
全リソースの Name タグを一覧にしました。
| リソース | Name タグ | ルール準拠 |
|---|---|---|
| S3 バケット | ${var.project}-${var.env}-logs |
✅ |
| セキュリティグループ | ${var.project}-${var.env}-web-sg |
✅ |
| EC2 インスタンス | ${var.project}-${var.env}-web |
✅ |
| VPC | ${var.project}-${var.env}-vpc |
✅ |
| Internet Gateway | ${var.project}-${var.env}-igw |
✅ |
| パブリックサブネット | ${var.project}-${var.env}-public-${count.index + 1} |
✅ |
| プライベートサブネット | ${var.project}-${var.env}-private-${count.index + 1} |
✅ |
| NAT Gateway | ${var.project}-${var.env}-nat-${count.index + 1} |
✅ |
| ルートテーブル(パブリック) | ${var.project}-${var.env}-public-rt |
✅ |
| ルートテーブル(プライベート) | ${var.project}-${var.env}-private-rt |
✅ |
CLAUDE.md の例に挙げていない、
IGW / NAT / サブネット / ルートテーブルにも、
同じ命名規則が 自動的に拡張適用 されています。
8. 03記事との比較
8-1. 03記事(Skills のみ)vs 04記事(Skills + CLAUDE.md)
| 比較項目 | 03記事(Skills のみ) | 04記事(Skills + CLAUDE.md) |
|---|---|---|
| 必須タグ(Project / Owner / CostCenter) | なし | ✅ 全リソースに付与 |
命名規則({project}-{env}-{種別}) |
なし(固定文字列) | ✅ 変数で動的生成 |
| S3 バケットポリシー | なし | ✅ IAM ロール制限付き |
| SG description 日本語 | 英語 | ✅ 日本語 |
| コード品質チェック | 手動 | ✅ 自動実行 + 自己修正 |
| ファイル構成 | 4ファイル分割 | 5ファイル + VPCモジュール |
| プロジェクト構造 | 個別リソース × 3 | 統合プロジェクト |
8-2. CLAUDE.md で変わったこと・変わらなかったこと
| 変わったこと(CLAUDE.md の効果) | 変わらなかったこと(Skills の効果が継続) |
|---|---|
| 必須タグ・命名規則・バケットポリシー・日本語 description | ファイル分割(variables.tf / main.tf / outputs.tf) |
| コード品質チェックの自動実行サイクル | 全変数・全出力に description
|
| VPC モジュール化(統合プロジェクトへの自動リファクタリング) | 新型 SG リソース(aws_vpc_security_group_ingress_rule) |
SSH デフォルト閉(allowed_ssh_cidr_blocks = 空リスト) |
|
| EC2 の IMDSv2 強制 / EBS 暗号化 |
注記:
ここでの「Skills の効果が継続」は、
CLAUDE.md ルール A〜E と共存できた Skills 由来のパターン(ファイル分割・description・新型 SG リソース等)を指します。
Skills にはこれ以外にも、
for_each推奨・versions.tf作成・~>バージョン制約などのベストプラクティスがありますが、
それらの準拠状況は次回⑤記事で別途検証します。
Skills と CLAUDE.md は競合せず、補完関係にあります。
9. 体験して分かったこと
9-1. 良かった点
-
ルールの反映率が非常に高い:
5つのルール全てが反映された。
特に、例に挙げていないリソースにも命名規則が自動拡張されたのは驚き -
ルールEの自律性:
「実行して報告」と書いただけで、エラー検出→自動修正→再チェックのサイクルを回した。
initも自主実行した -
Skills との補完関係:
Skills のベストプラクティス(ファイル分割・description・新型 SG リソース)は維持されたまま、CLAUDE.md のチームルールが追加された -
統合プロジェクトの自動構成:
3つのプロンプトを順番に投げたら、Claude が文脈を保持して1つのプロジェクトにまとめた。
VPC モジュール化や EC2 参照の自動書き換えも行われた
9-2. 気になった点
-
CLAUDE.md の書き方が品質を左右する:
具体例とテーブルで指示を明確にしたから高い反映率になった。
曖昧に書いたら結果が変わる可能性がある -
ルールが増えすぎると管理が大変:
5つのルールでも CLAUDE.md は約40行。
チームのルールが増えたら、ファイル分割(CLAUDE.md からインクルード)を検討する必要がある -
「本当にルール通りか」を人間が全ファイル確認するのは大変:
今回は3リソース × 5ルール = 15箇所以上を目視で確認した。
プロジェクトが大きくなれば、この確認作業自体を自動化したくなる
9-3. 次回への伏線
9-2-3 で「確認作業を自動化したい」と書きました。
実は、この確認を サブエージェントに任せる ことができます。
しかも、ルール準拠チェックだけでなく、
Skills のベストプラクティスも同時に監査 するとどうなるでしょうか?
CLAUDE.md のルールは守られていても、
Skills のパターンはどうなっているのか——。
次回の05記事で、この疑問を解消していきます。
10. まとめ
10-1. この記事で分かったこと
| ゴール | 結果 |
|---|---|
| ①CLAUDE.md の役割と書き方 | Skills が「業界標準」、CLAUDE.md が「チーム固有ルール」。 具体例とテーブルで明確に書くのがコツ。 |
| ②チームルールを CLAUDE.md に書ける | 5つのルール(タグ・命名・バケットポリシー・日本語description・品質チェック)を記述し、全て反映を確認。 |
| ③Skills + CLAUDE.md の組み合わせ | 競合せず補完関係。 Skills のベストプラクティスを維持しつつ、チーム固有ルールが追加された。 |
10-2. Skills / CLAUDE.md の使い分け
| 用途 | 使うべき仕組み |
|---|---|
| Terraform 全般のベストプラクティス | Skills(antonbabenko/terraform-skill) |
| チーム独自の命名規則・タグルール | CLAUDE.md |
| プロジェクト固有のセキュリティ方針 | CLAUDE.md |
| コード品質チェックの自動化 | CLAUDE.md |
| 両方必要な場合 | Skills + CLAUDE.md を併用 |
10-3. 次のステップ
CLAUDE.md でチームルールを教えたら、ルールは守られました。
しかし、「守られた」ことの確認を人間が毎回やるのは大変 です。
次回の05記事では、カスタムサブエージェントを自作 して、
ルール準拠チェックとベストプラクティス監査を AIに自動で行わせます。