TL;DR
Claude Code に Next.js アプリの GCP 本番環境(Terraform)を書かせた。構文は正確で、リソース定義もほぼ完璧。でもそのまま terraform apply したら本番事故が起きるレベルの問題が4つあった。
AI が書くコードの「何がよくて何がダメか」を、実際の出力を見ながら解説する。
この記事のゴール
- Claude Code が Terraform をどれくらい書けるか、実感を持つ
- 「動くけど本番では使えない」コードの見分け方を知る
- AI に何を任せて、何を自分で判断すべきかの線引きを掴む
前提: 何を作りたかったか
Web アプリの本番環境。構成はこれ:
Next.js (App Router) + Hono (API)
↓
Cloud Run (コンテナ)
↓ VPC 内通信
Cloud SQL (PostgreSQL 17)
Cloud Tasks (非同期ジョブ)
↓
Terraform (IaC)
GitHub Actions (CI/CD)
staging 環境は動いている。production 環境を新しく作りたい。
使用ツール:
- Terraform ~> 1.11
- Google Cloud Provider ~> 6.0
- Claude Code
実際のプロンプト
Claude Code にこう伝えた:
Next.js アプリを GCP にデプロイするための Terraform を書いて。
構成:
- Cloud Run(アプリ)
- Cloud SQL(PostgreSQL 17、Private IP)
- Cloud Tasks(非同期処理)
- Artifact Registry(Docker イメージ)
- VPC + VPC Access Connector
- Workload Identity Federation(GitHub Actions 用)
- IAM(サービスアカウント)
staging と production の2環境を管理したい。
Claude Code の出力
かなり長いので、ポイントを抜粋する。
ディレクトリ構成: ◎ 文句なし
infra/terraform/
├── modules/
│ ├── vpc/
│ ├── cloud-sql/
│ ├── cloud-tasks/
│ ├── iam/
│ ├── wif/
│ └── artifact-registry/
└── environments/
├── staging/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── versions.tf
│ └── backend.tf
└── production/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── backend.tf
modules と environments の分離、各環境のファイル構成、どちらも教科書通り。ここは人間が直す必要がなかった。
Cloud SQL モジュール: ◎ 構文は完璧
# Claude が書いた Cloud SQL モジュール(抜粋)
resource "google_sql_database_instance" "main" {
name = var.instance_name
database_version = "POSTGRES_17"
region = var.region
deletion_protection = var.deletion_protection
settings {
tier = var.tier
backup_configuration {
enabled = var.backup_enabled
point_in_time_recovery_enabled = var.backup_enabled
start_time = "18:00"
transaction_log_retention_days = 7
backup_retention_settings {
retained_backups = 7
}
}
ip_configuration {
ipv4_enabled = false
private_network = var.network_id
}
maintenance_window {
day = 7
hour = 4
update_track = "stable"
}
insights_config {
query_insights_enabled = true
query_string_length = 4500
record_application_tags = true
record_client_address = false
}
database_flags {
name = "log_min_duration_statement"
value = "1000"
}
}
}
バックアップ設定、PITR、メンテナンスウィンドウ、Query Insights、スロークエリログ…。record_client_address = false(PII 保護)まで設定している。
正直、ここまで書けるのかと感心した。 構文エラーもゼロ。terraform validate を通すと一発で OK。
ただし1点、後から気づいた問題がある。maintenance_window の hour = 4 は UTC。asia-northeast1 にデプロイするので JST に変換すると 13:00(日曜の昼間) にメンテナンスが走る。Claude はリージョンに応じたタイムゾーン変換をしていなかった。JST 早朝にしたいなら hour = 19(19:00 UTC = 翌4:00 JST)が正しい。
構文は完璧でも 値の妥当性 はリージョンやビジネス要件で変わる。これも「動くけど本番では使えない」パターンの一つ。
環境分離: ◎ tfvars の使い方も正しい
# staging.tfvars(Claude が生成)
database_instance_name = "myapp-db-staging"
database_tier = "db-f1-micro"
database_backup_enabled = false
database_deletion_protection = false
enable_custom_domain = false
# production.tfvars(Claude が生成)
database_instance_name = "myapp-db-production"
database_tier = "db-custom-2-8192"
database_backup_enabled = true
database_deletion_protection = true
enable_custom_domain = true
domain = "myapp.example.com"
main.tf は環境間で同一、差分は tfvars で吸収。設計原則に忠実。
条件付きリソース: ◎ count パターンも正しい
# ドメイン関連リソースは enable_custom_domain = true のときだけ作成
module "cloud_dns" {
count = var.enable_custom_domain ? 1 : 0
source = "../../modules/cloud-dns"
domain = var.domain
}
module "ssl_certificate" {
count = var.enable_custom_domain ? 1 : 0
source = "../../modules/ssl-certificate"
domain = var.domain
}
staging ではドメインなし、production ではドメインありという切り替えも正しく実装されていた。
ここまでのスコア: 85点
構文、モジュール分離、変数化、条件分岐、全部正しい。terraform plan も terraform validate もエラーなし。
AI が書いた Terraform としては、かなり高品質。
ただし、残りの15点に本番では致命的な問題が4つあった。
※ 各脆弱性の「深刻度」は CVSS 等の公式スコアではなく、本番運用への影響度を筆者が判断したもの。
脆弱性1: GitHub Actions 認証に SA キーを使っている
Claude が書いたコード
# deploy-production.yml(Claude が生成)
steps:
- uses: google-github-actions/auth@v3
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
何が問題か
SA キー(サービスアカウントキー)は JSON ファイル。漏洩すると永続的にGCP へアクセス可能。有効期限がないため、取り消すまで使い放題。
SA キーの問題:
- 漏洩 → 永続アクセス(取り消すまで有効)
- ローテーション → 手動(忘れる)
- 利用者 → 区別不可能(誰でも使える)
正しい実装: Workload Identity Federation (WIF)
# 正しい実装
permissions:
contents: read # ← リポジトリの読み取り
id-token: write # ← OIDC トークン発行に必要
steps:
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.DEPLOY_SA }}
WIF は OIDC ベースの一時トークン認証。トークンは1時間で失効する。
WIF の仕組み:
GitHub Actions → OIDC トークン発行(自動)
↓
GCP WIF Pool → トークン検証 + リポジトリ確認
↓
一時 OAuth2 トークン(1時間で失効)
↓
GCP リソースへのアクセス
興味深い点: プロンプトで「Workload Identity Federation」と明示的に書いたにもかかわらず、Claude は WIF の Terraform モジュールは正しく書いたのに、GitHub Actions ワークフローでは credentials_json を使っていた。モジュールとワークフローの整合性を取れていなかった。
深刻度: 🔴 HIGH
SA キーが GitHub Secrets から漏洩した場合、production の全リソースにアクセスされる。
脆弱性2: IAM が「1つの SA に全権限」
Claude が書いたコード
# Claude が書いた IAM(抜粋)
resource "google_service_account" "app" {
account_id = "myapp-app"
display_name = "Application Service Account"
}
resource "google_project_iam_member" "app_roles" {
for_each = toset([
"roles/cloudsql.client",
"roles/cloudtasks.enqueuer",
"roles/run.admin",
"roles/artifactregistry.writer",
"roles/iam.serviceAccountUser",
])
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.app.email}"
}
1つの SA(myapp-app)に DB 接続、タスク登録、Cloud Run デプロイ、イメージ push、SA なりすましの権限が全部入っている。
何が問題か
この SA が侵害されたら、全部終わり。
- DB のデータを抜ける
- Cloud Run に悪意のあるイメージをデプロイできる
- Artifact Registry にバックドア付きイメージを push できる
- 他の SA になりすませる
正しい設計: 5つの SA に分離
myapp-app → cloudsql.client + cloudtasks.enqueuer のみ
myapp-deploy → run.admin + artifactregistry.writer のみ
myapp-migrate → cloudsql.client のみ
myapp-tasks → run.invoker のみ
myapp-terraform → インフラ管理(最も強力だが利用シーンが限定的)
侵害されたときの被害範囲が全然違う:
| 侵害された SA | 1つの SA 設計 | 5分割設計 |
|---|---|---|
| app | 全権限 → 全滅 | DB 読み書き + タスク登録のみ |
| deploy | - | デプロイのみ(DB アクセス不可) |
| migrate | - | DB 接続のみ(デプロイ不可) |
Claude に「SA を分けて」と追加で指示したら、正しく分割してくれた。でも最初のプロンプトでは分けてくれなかった。 AI は明示的に指示しないと、最小権限の原則を適用しない傾向がある。
深刻度: 🔴 HIGH
1つの SA の侵害で本番環境の全権限が奪取される。
脆弱性3: GitHub Actions で Script Injection
Claude が書いたコード
Terraform の plan 結果を PR にコメントする部分:
# Claude が書いた PR コメント投稿
- uses: actions/github-script@v8
with:
script: |
const output = `#### Terraform Plan
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
何が問題か
${{ steps.plan.outputs.stdout }} は GitHub Actions のテンプレートエンジンが展開する。実行前に文字列置換される。
Terraform の出力にはリソース名やタグ値が含まれる。攻撃者がリソースのタグにこんな値を仕込んだら:
tags = {
name = "test`; await github.rest.repos.delete({owner:'my-org',repo:'myapp'}); `"
}
テンプレート展開後:
const output = `#### Terraform Plan
\`\`\`
~ tags.name = "old" -> "test`; await github.rest.repos.delete({owner:'my-org',repo:'myapp'}); `"
\`\`\``;
バッククォートが閉じて、任意の JavaScript が実行される。 リポジトリの削除すら可能。
攻撃シナリオ: 外部コントリビューターが PR を送信 → Terraform plan が自動実行 → plan 出力にリソース名やタグ値が含まれる → PR コメントとして展開される際に悪意ある JavaScript が実行される。OSS や社外コントリビューターを受け入れるリポジトリでは、外部からの攻撃が成立する。
正しい実装: 環境変数経由
# 安全な実装
- uses: actions/github-script@v8
env:
PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}
with:
script: |
const output = `#### Terraform Plan
\`\`\`
${process.env.PLAN_OUTPUT}
\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
env: で渡すと process.env.PLAN_OUTPUT は文字列変数として読み取られる。テンプレートリテラルへの注入にならない。
この脆弱性は AI が書く GitHub Actions のコードでは極めて頻出。 ${{ }} テンプレート展開と process.env の違いを理解していないと見逃す。
深刻度: 🔴 HIGH
GitHub Actions のトークン権限で任意コード実行。リポジトリ削除、シークレット漏洩のリスク。
脆弱性4: Terraform の plan/apply が TOCTOU 脆弱
Claude が書いたコード
# Claude が書いた Terraform ワークフロー
jobs:
plan:
steps:
- run: terraform plan
apply:
needs: plan
steps:
- run: terraform apply -auto-approve
何が問題か
TOCTOU(Time of Check, Time of Use)。plan した時点と apply する時点で State が変わっている可能性がある。
t0: plan ジョブ → 「Cloud SQL の tier を変更します」
t1: 別の誰かが terraform apply を実行 → State が変わる
t2: apply ジョブ → 再度 plan → t0 とは違う差分が適用される
terraform apply -auto-approve は内部で再度 plan を実行する。レビューした内容と違うものが適用される可能性がある。
正しい実装: plan ファイルを artifact で保存
jobs:
plan:
steps:
- run: terraform plan -out=tfplan # plan をファイルに保存
- uses: actions/upload-artifact@v4 # artifact として保存
with:
name: tfplan
path: tfplan
retention-days: 1
apply:
needs: plan
steps:
- uses: actions/download-artifact@v4 # artifact をダウンロード
with:
name: tfplan
- run: terraform apply -auto-approve tfplan # 保存された plan を適用
terraform plan -out=tfplan で plan 結果をバイナリファイルに保存。terraform apply tfplan はこのファイルの内容をそのまま適用する。再度 plan を実行しない。
深刻度: 🟡 MEDIUM
意図しないインフラ変更が適用される。直接的な攻撃よりは運用事故のリスク。
Claude Code が「よくできた」部分まとめ
批判ばかりではフェアではないので、AI が正しく書けていた部分を整理する:
| カテゴリ | 評価 | 詳細 |
|---|---|---|
| HCL 構文 | ◎ | 構文エラーゼロ。terraform validate 一発 OK |
| モジュール分離 | ◎ | modules/environments の分離が教科書通り |
| Cloud SQL 設計 | ◎ | バックアップ、PITR、メンテナンスウィンドウ、Query Insights |
| 変数化 | ◎ | 環境差分を tfvars に閉じ込める設計 |
| 条件付きリソース | ◎ |
count = var.xxx ? 1 : 0 パターン |
| VPC 設計 | ◎ | Private IP、VPC Access Connector |
| outputs 定義 | ○ | 必要な出力を網羅(一部命名の揺れあり) |
85点分は本当に優秀。 ゼロから人間が書くより圧倒的に速い。
4つの脆弱性に共通するパターン
| 脆弱性 | 共通点 |
|---|---|
| SA キー | AI は「最もシンプルな方法」を選ぶ |
| IAM 1SA | AI は明示的に指示しないと分割しない |
| Script Injection | AI はセキュリティコンテキストを考慮しない |
| TOCTOU | AI は「動く」ことを優先し、エッジケースを考慮しない |
AI は「構文的に正しいコード」を書くのが得意だが、「設計判断」はしない。
- 「WIF と SA キー、どちらが安全か」は判断できる(聞けば答える)
- でも「聞かれなければ SA キーを使う」
これは AI の限界というより、プロンプトの限界。「安全に」「本番品質で」という指示は曖昧すぎて、具体的な設計判断に繋がらない。
この記事のまとめ
Claude Code に Terraform を書かせた結果:
✅ 構文: 完璧
✅ モジュール設計: 完璧
✅ 変数化: 完璧
✅ Cloud SQL 設計: 完璧
❌ 認証方式: SA キー(WIF を使うべき)
❌ IAM 設計: 1SA に全権限(5分割すべき)
❌ CI/CD セキュリティ: Script Injection あり
❌ CI/CD 設計: TOCTOU 脆弱
次の記事では、これら4つの脆弱性を Claude Code と一緒に修正していく過程を記録する。 AI に「ここが問題だ」と指摘したとき、どんなコードが返ってくるか。そして人間が追加で何を判断する必要があるか。
シリーズ記事
- Claude Code に「GCP で本番環境作って」と言ったら、4箇所にセキュリティ脆弱性があった(この記事)
- Claude Code が書いた Terraform を本番品質にするまでの全修正記録
- Claude Code と一緒に本番デプロイして学んだ、AI に任せていいこと・ダメなこと