🦔

claudeとともに歩んだawsリソースのterraform化への道

に公開

はじめに

AWSとTerraform未経験の自分が
AIと協働で既存リソースのterraform化を目指しました。

  • Terraform: 1.12.2
  • AWS Provider: 5.94.1

構成図を作る

今回Terraform化する環境は、すでにAWSのコンソール上にありました。
その環境を作ったのは自分でしたが、見よう見まねで作ったので、リソース間の依存関係であるとかがよくわかっていませんでした。
なので、構成図を作ることにしました(今更)。

ただ、自分の理解のみでは構成図を作れたとしてもその後の開発に活かせる自信がなかったので、Claude Codeと協力して作りました。
Claude Codeには制作過程も含めて構成図を読み取ってもらい、その後の開発の道標になってもらうようにしました。

作成された構成図は以下のような形になりました。

構成図のsvgを見せると、いい感じに理解してくれて、あとは作業中の補足説明だけで話が通じました。
ディレクトリ構成を作ってくれました。

❯ tree -L 1 -d
.
├── acm
├── alb
├── codedeploy
├── ecr
├── ecs
├── elasticache
├── env
├── firehose
├── gitlab
├── iam
├── modules
├── rds
├── s3
├── secrets_manager
├── security_group
└── vpc

import作業

claude codeがimportを開始するにあたって、aws cliコマンドを叩き始めました。
個人情報を叩きまくる姿勢に恐怖したので、必要な情報の列挙だけしてもらって、その情報を自らbashで取得することにしました。
そして得た情報をclaude codeに投げたところ、いい感じにimport.tfを作ってくれました。

# <service>/import.tf を作成
import {
  to = module.<module_name>.aws_<resource_type>.<resource_name>["<key>"]
  id = "<aws_resource_id>"
}

その後、terraformコマンドを叩いてgenerated.tfを作ります。

> terraform plan -generate-config-out=generated.tf

変数化とモジュール化

importされたtfファイルはハードコードです。なので、そのハードコードを変数に置き換える必要があります。
また、開発環境と本番環境とがあるので、その分terraform化する必要があります。
それを踏まえて、モジュール化して本番と開発でいい感じにコードを共有できるようにする必要もあります。

ここでの開発作業自体も基本人力でやっています。Claude Codeにはリソースがなぜこのような値になっているか、といった基本的なことばかり質問しています。
import作業のときと同様に、巨大な変更差分を提示されると私が混乱するのでそういう場合は自分でやるしかないです。

ちなみに最終的なディレクトリ構成は以下の形になります(例: ECS)

├── ecs
│   └── dev
│       ├── common.tf -> ../../env/common.tf
│       ├── ecs.tf
│       ├── import.tf
│       ├── import.tfvars
│       ├── outputs.tf
│       └── variables.tf
├── modules
│   ├── ecs
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf

plan apply

ある程度tfファイルが完成してきたら、planしてimportが正常に行えるかどうか、applyしてimport内容をstateファイルに反映します。
planやapply時にdestroyの差分が発生する場合は都度Claude Codeに質問して原因とその対策を教えてもらっていました。

リファクタリング

ある程度の変数化やモジュール化ができたら、さらに共通化するべく、リファクタリングをする必要があります。
terraform_remote_stateを使ったり、resourceの配置に不整合が生じることがあります。
そのときは、Claude Codeからterraform MCP等を使って仕様を教えてもらって実装することになります。

例えばterraform_remote_stateを使うためにoutputブロックを作らないといけないであるとか、リソース名の変更を同じtfファイル内で行うためにはmovedブロックを使う必要がある、といったことをClaude Codeに教えてもらいました。

結局人力でやっている

質問したいことは質問して、Claude Codeの持ち味であるコード生成やbashの自動実行といった機能は、怖くてあまり使えていないというのが現状です。
業務で使っているのでそこに恐怖するのは個人的には正しい行動だと思うものの、とはいえ結局人力でやっているのでは、ちょっと前のchatGPTとやっていることは変わらないです。
なので、CLAUDE.mdを自身で育てる必要があります。

ということで、一通りterraform化は終わらせていましたが
「このAWSサービスについて、arnとか教えてるのでimportしてくれない?」
みたいな感じの命令をしたらimportしてくれるようなCLAUDE.mdを作ってもらいました。(未検証)

できたCLAUDE.md

この手順をClaude Codeに学習してもらい、import作業を手伝ってもらいます。
今更感がすごいが、次の開発に活かしたい

完成したCLAUDE.md
### 確立された標準手順

既存のAWSインフラをTerraformで管理下に置く際は、以下のプロセスに従ってください:

#### Phase 1: import.tf作成とリソースインポート

**1-1. import.tfの作成**
```hcl
# <service>/dev/import.tf を作成
import {
  to = module.<module_name>.aws_<resource_type>.<resource_name>["<key>"]
  id = "<aws_resource_id>"
}
```

**IAMリソースのimport ID形式:**
- `aws_iam_role_policy_attachment`: `<role_name>/<policy_arn>` (スラッシュ区切り)
  - 例: `my-app-pipeline-role/arn:aws:iam::aws:policy/SecretsManagerReadWrite`
- `aws_iam_role_policy` (inline): `<role_name>:<policy_name>` (コロン区切り)
  - 例: `my-app-pipeline-role:MyInlinePolicy`

**1-2. import実行**
```bash
cd <service>/dev
terraform plan -var-file=../../env/dev.tfvars
# "Plan: N to import, 0 to add, 0 to change, 0 to destroy" を確認
terraform apply -var-file=../../env/dev.tfvars
```

**1-3. import.tfの削除**
```bash
rm import.tf  # import完了後は不要
```

#### Phase 2: モジュール構造の作成

**2-1. ディレクトリ構造**
```bash
mkdir -p modules/<service>
mkdir -p <service>/dev
```

**2-2. modules/<service>/main.tf作成**
```hcl
# for_eachパターンの採用(ECR・GitLabモジュールを参考)
resource "aws_iam_role_policy" "inline_policies" {
  for_each = var.inline_policies

  name   = each.value.policy_name
  role   = var.role_name
  policy = each.value.policy_document
}

resource "aws_iam_role_policy_attachment" "managed_policies" {
  for_each = var.managed_policies

  role       = var.role_name
  policy_arn = each.value.policy_arn
}
```

**2-3. modules/<service>/variables.tf作成**
```hcl
variable "role_name" {
  description = "IAM role name"
  type        = string
}

variable "inline_policies" {
  description = "Inline policies"
  type = map(object({
    policy_name     = string
    policy_document = string
  }))
  default = {}
}

variable "managed_policies" {
  description = "Managed policies"
  type = map(object({
    policy_arn = string
  }))
  default = {}
}
```

**2-4. <service>/dev/<service>.tf作成**
```hcl
module "<service>" {
  source = "../../modules/<service>"

  role_name         = "<role_name>"
  inline_policies   = local.inline_policies
  managed_policies  = local.managed_policies
}

locals {
  managed_policies = {
    policy_name = {
      policy_arn = "arn:aws:iam::aws:policy/PolicyName"
    }
  }

  inline_policies = {
    policy_name = {
      policy_name = "PolicyName"
      policy_document = jsonencode({
        Statement = [
          {
            Action   = ["service:Action"]
            Effect   = "Allow"
            Resource = "arn:aws:service:region:account:resource"
          }
        ]
        Version = "2012-10-17"
      })
    }
  }
}
```

**2-5. common.tfのシンボリックリンク作成**
```bash
cd <service>/dev
ln -sf ../../env/common.tf common.tf
```

#### Phase 3: 変数化と動的参照

**3-1. ハードコードの段階的解消**
```hcl
# 初期: ハードコード(import直後は必須)
Resource = "arn:aws:ecr:<region>:<account_id>:repository/<repo_name>"

# 最終: 動的参照(import完了後に変数化)
Resource = data.terraform_remote_state.ecr.outputs.repository_arns
```

**3-2. 参照方法の優先順位**
1. `data source` - 同一AWSアカウント内での動的参照
2. `terraform_remote_state` - モジュール間の出力値参照
3. 変数渡し - 上位モジュールからの値注入

**3-3. remote_state参照の実装**
```hcl
data "terraform_remote_state" "iam" {
  backend = "s3"
  config = {
    bucket = "${var.env}-${var.aws_account_id}-<project>-tfstate"
    key    = "iam/terraform.tfstate"
    region = var.region
  }
}

locals {
  role_name = data.terraform_remote_state.iam.outputs.role_name
}
```

#### Phase 4: 既存管理場所からのステート削除

**4-1. terraform state rmコマンドで削除**
```bash
cd <old_service>/dev
terraform state rm 'module.<module>.aws_iam_role_policy_attachment.policies["key"]'
terraform state rm 'module.<module>.aws_iam_role_policy.policies["key"]'
```

**注意事項:**
- `removed`ブロックは`for_each`のキー指定ができないため使用不可
- 必ず`terraform state rm`コマンドを使用すること
- 削除後は`terraform plan`で該当リソースが消えていることを確認

#### Phase 5: 検証と最適化

**5-1. No changesの確認**
```bash
# 新しいモジュールで確認
cd <service>/dev
terraform plan -var-file=../../env/dev.tfvars
# "No changes" を確認

# 古いモジュールでも確認
cd <old_service>/dev
terraform plan -var-file=../../env/dev.tfvars
# state rmしたリソースが消えていることを確認
```

**5-2. 正常性確認後の設定ファイル削除**
```bash
# 古いモジュールから該当設定を削除
# 例: iam/<env>/iam.tf から移行したポリシーの locals を削除
```

### リソース調査の手順

#### AWSコンソール/CLIで確認すべき情報
```bash
# IAMロールにアタッチされているmanaged policy確認
aws iam list-attached-role-policies --role-name <role_name>

# IAMロールのinline policy一覧確認
aws iam list-role-policies --role-name <role_name>

# inline policyの内容取得
aws iam get-role-policy --role-name <role_name> --policy-name <policy_name>
```

**確認項目:**
1. リソースのARN/ID
2. アタッチされているポリシー一覧(managed/inline)
3. inline policyの内容(JSON)
4. 依存関係(どのサービスから参照されているか)

#### Terraformステートで確認すべき情報
```bash
# 現在どこで管理されているか確認
cd <service>/<env>
terraform state list | grep <resource_name>

# リソースの詳細情報取得
terraform state show 'module.<module>.aws_iam_role_policy["key"]'
```

### 実行コマンド例(IAMポリシー移行)

```bash
# Phase 1: Import実行
cd <new_service>/<env>
terraform init
terraform plan -var-file=../../env/<env>.tfvars
terraform apply -var-file=../../env/<env>.tfvars
rm import.tf

# Phase 2-3: 検証
terraform plan -var-file=../../env/<env>.tfvars  # No changes確認

# Phase 4: 旧ステート削除
cd ../../<old_service>/<env>
terraform state list | grep <resource_name>  # 削除対象確認
terraform state rm 'module.<module>.aws_iam_role_policy_attachment.policies["key"]'
terraform state rm 'module.<module>.aws_iam_role_policy.inline_policies["key"]'

# Phase 5: 最終確認
cd ../../<new_service>/<env>
terraform plan -var-file=../../env/<env>.tfvars  # No changes
cd ../../<old_service>/<env>
terraform plan -var-file=../../env/<env>.tfvars  # 削除確認
```

### Phase 2詳細: モジュール構造作成

#### 既存モジュールを参考にする場合
```bash
# 類似モジュール(ECR/GitLabなど)の構造を確認
ls -la modules/ecr/
ls -la modules/gitlab/

# 新規モジュールディレクトリ作成
mkdir -p modules/<new_service>
mkdir -p <new_service>/<env>

# 必要なファイル作成
touch modules/<new_service>/main.tf
touch modules/<new_service>/variables.tf
# outputs.tf は他モジュールからの参照が必要な場合のみ作成
```

#### ゼロから作成する場合のテンプレート
参考: `modules/ecr/main.tf`, `modules/gitlab/main.tf` の for_each パターンを踏襲

### よくあるエラーと対処法

#### import ID形式エラー
**症状:**
```
Error: invalid role name
ValidationError: The specified value for roleName is invalid
```

**原因:** import IDの区切り文字が間違っている

**対処:**
- managed policy attachment: `<role_name>/<policy_arn>` (スラッシュ)
- inline policy: `<role_name>:<policy_name>` (コロン)
- 区切り文字を間違えないこと

#### No changes にならない
**症状:** import後に `terraform plan` で差分が出る

**原因:** ポリシードキュメントのJSON形式が微妙に異なる(フィールドの順序、不要なフィールドなど)

**対処:**
1. `terraform state show` で既存の設定を確認
2. `Statement` の順序を既存設定に合わせる
3. 不要なフィールド(`Sid` など)を削除または追加
4. `jsonencode()` の出力と AWS 上の実際の値を比較

#### 循環参照エラー
**症状:** `Error: Cycle` または `depends_on` 関連エラー

**原因:** moduleがlocalsを参照し、localsがmoduleを参照している

**対処:**
- import完了まではハードコード維持
- 変数化・動的参照はPhase 3(import完了後)に実施

#### removed blockエラー
**症状:**
```
Error: Resource instance keys not allowed
```

**原因:** `removed` ブロックは `for_each` のキー指定ができない

**対処:** `terraform state rm` コマンドを使用

### 実装のベストプラクティス

1. **段階的アプローチ**: 一度に複数のサービスを変更せず、1つずつ完成させる
2. **import完了まではハードコード**: "No changes"になるまで既存の設定値をハードコードで維持
3. **import完了後に変数化**: remote_stateや変数参照は必ずimport成功後に実施
4. **検証の徹底**: 各段階でterraform planを実行し、意図しない変更がないことを確認
5. **参考モジュールの活用**: 新規モジュール作成時は既存の類似モジュール構造を参考にする

AI開発を終えて

Claude Codeはものすごく良いパートナーになりそうです。
なりそうですが、権限をどこまで許してあげるのかが考え所だとも感じました。
どのAIがいいかどうかの議論もできずにClaude Code一点張りみたいな感じで開発したのですが
ほかにも良いAIがあれば教えて欲しいです(メリットデメリットも添えて)。

Discussion