0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Terraform×Claude Code ④(全6回) |CLAUDE.mdカスタムルール|チーム独自のルールをAIに教えてみた

0
Last updated at Posted at 2026-02-24

📁GitHubにコード公開tf_claudecode02

シリーズ記事一覧

📑 目次

  1. この記事について
  2. この記事のゴール
  3. 前提知識:CLAUDE.md とは
  4. 前提条件
  5. Step 1:チームルールを設計する
  6. Step 2:コード生成(03と同じプロンプト)
  7. Step 3:ルールの反映を確認する
  8. 03記事との比較
  9. 体験して分かったこと
  10. まとめ

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. 1つ目のプロンプト(S3バケット)→ variables.tf / locals.tf / main.tf / outputs.tf / provider.tf を新規作成
  2. 2つ目のプロンプト(EC2インスタンス)→ 既存ファイルに 追記。EC2 + セキュリティグループを main.tf に追加
  3. 3つ目のプロンプト(VPCモジュール)→ modules/vpc/ を新規作成し、EC2 の subnet_idmodule.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つあります。

  1. 自己修正サイクル: fmt -check でエラーを検出 → fmt で自動修正 → 再チェック OK
  2. CLAUDE.md に書いていない terraform init も自主実行。
    validate を実行するには init が必要だと Claude が判断し、自ら init -backend=false を実行した

[!NOTE]
CLAUDE.md には「fmtvalidate を実行して」と書いただけです。
「エラーがあったら修正して」「init を先に実行して」とは書いていません。
Claude がルールの意図を理解し、
目的達成に必要なステップを自律的に判断 しました。


7. Step 3:ルールの反映を確認する

7-1. ルールA〜E の反映状況

ルール 判定 根拠
A. 必須タグ ✅ 完璧 locals.tfcommon_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.logsvar.allowed_role_arn を Principal に指定
D. SG description 日本語 ✅ 完璧 SG本体:"Webサーバー用セキュリティグループ"、SSH:"管理者からのSSHアクセス"、Egress:"全てのアウトバウンド通信を許可"
E. コード品質チェック ✅ 完璧+α fmt -check → エラー検出 → 自動修正 → 再チェック → initvalidate 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. 良かった点

  1. ルールの反映率が非常に高い:
    5つのルール全てが反映された。
    特に、例に挙げていないリソースにも命名規則が自動拡張されたのは驚き
  2. ルールEの自律性:
    「実行して報告」と書いただけで、エラー検出→自動修正→再チェックのサイクルを回した。
    init も自主実行した
  3. Skills との補完関係:
    Skills のベストプラクティス(ファイル分割・description・新型 SG リソース)は維持されたまま、CLAUDE.md のチームルールが追加された
  4. 統合プロジェクトの自動構成:
    3つのプロンプトを順番に投げたら、Claude が文脈を保持して1つのプロジェクトにまとめた。
    VPC モジュール化や EC2 参照の自動書き換えも行われた

9-2. 気になった点

  1. CLAUDE.md の書き方が品質を左右する:
    具体例とテーブルで指示を明確にしたから高い反映率になった。
    曖昧に書いたら結果が変わる可能性がある
  2. ルールが増えすぎると管理が大変:
    5つのルールでも CLAUDE.md は約40行。
    チームのルールが増えたら、ファイル分割(CLAUDE.md からインクルード)を検討する必要がある
  3. 「本当にルール通りか」を人間が全ファイル確認するのは大変:
    今回は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に自動で行わせます。


参考リンク

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?