ENECHANGE Developer Blog

ENECHANGE開発者ブログ

マネージドに依存しないTerraform CIでPR可視化と段階的整備 - Claude Codeと進めるGitHub Actionsへのtfcmt導入

Terraformの課題に取り組む

ENECHANGEのSREチーム id:sjisjis です。

SREチームでは、複数の環境(dev、stg、prod)にわたって多数のTerraformスタックを管理しています。従来のTerraform運用では以下の課題がありました:

  1. terraform planの結果がローカルでしか確認できず、レビュワーがPRで実際の変更内容を把握しにくい
  2. 複数のディレクトリで実行されるplanの結果を個別に確認する手間
  3. どのリソースが作成・更新・削除されるのかがすぐに分からない

これらの課題について、前職ではHCP Terraform(旧Terraform Cloud)を利用していたため特に意識していませんでした。しかし、現職ではマネージドサービスの利用がないため、GitHub Actionsを使って同様の機能を実現する方法を検討することにしました。

その第一歩として、tfcmtというツールの導入を検討、Claude Codeに簡単に実装してみてとお願いしたところ、すぐにある程度精度の高い実装を出してくれたので、壁打ちしながら実際に導入してみることにしました。

Claude Codeとの「壁打ち」で設計を固める

新しいツールを導入する前に、一人で考えていると視野が狭くなってしまうことがあります。そこで、Claude Codeを「壁打ち相手」として活用しました。 事前にtfcmtの公式ドキュメントを読ませておいてから相談を始めました。

最初の壁打ち:課題整理フェーズ

私: 「tfcmt導入のため、現在の課題を整理していきます」

Claude Code: 「Terraformの運用で具体的にどんな困りごとがありますか?現在のワークフローも教えてください」

この「質問返し」に対して漠然とした不満を具体的な課題として返答していきます:

  • PRレビューでplanの結果を確認するために、PR作成時にローカルで実行したplan結果を手動でコメントに貼り付け、またはレビュワーが各自ローカルでplan実行して確認している
  • 複数ディレクトリの変更があると、それぞれCIログを確認する必要がある
  • エラーが起きた時の原因特定に時間がかかる

2回目の壁打ち:要件定義フェーズ

私: 「課題は整理できたので理想的な状態を考えてみて」

Claude Code: 「PRのコメントでplan結果が見えれば解決しそうですね。具体的には以下のような機能があると良いのではないでしょうか:

  • PRコメントにplan結果を自動投稿
  • 変更の種類(作成・更新・削除)を分かりやすく表示
  • 複数ディレクトリの結果を個別に表示
  • エラー時の詳細情報も含める

この提案で要件が明確になりました。

3回目の壁打ち:技術選定フェーズ

私: 「tfcmt以外にも選択肢はありますか?」

Claude Code: 「terraform-github-actionsやplan-checkerもありますが、tfcmtの方がテンプレートの柔軟性が高いです。現在のGitHub Actionsの構成はどうなっていますか?」

既存のワークフローを見せながら相談することで、tfcmtが最適解であることを確認できました。

Step 1: GitHub Actionsワークフローの実装

壁打ちで方針が固まったところで、具体的な実装に入りました。まずはtfcmtを使ったCIパイプラインの構築から。

最大の課題:複数ディレクトリの動的検出

要件定義フェーズで「複数ディレクトリの結果を個別に表示」などという要件が明確になっていました。

私: 「この要件を実現するワークフローを書いて」

Claude Codeが提案してくれた実装:

# .github/workflows/terraform-plan.yml
name: Terraform Plan

on:
  pull_request:
    types: [opened, synchronize, reopened]
    paths:
      - '**.tf'
      - '.github/workflows/terraform-*.yml'
    branches:
      - main

env:
  TFCMT_CONFIG: ${{ github.workspace }}/.github/tfcmt.yml

permissions:
  id-token: write
  contents: read
  pull-requests: write
  issues: write

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      directories: ${{ steps.detect.outputs.directories }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changed directories
        id: detect
        run: |
          # PRで変更されたファイルを取得
          CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(tf|hcl)$' || true)

          # 変更されたディレクトリを抽出(ユニーク)
          DIRS=$(echo "$CHANGED_FILES" | xargs -I {} dirname {} | sort -u | grep -v '^\.$' || true)
          
          # JSON配列形式に変換
          if [ -z "$DIRS" ]; then
            echo "directories=[]" >> $GITHUB_OUTPUT
          else
            JSON_DIRS=$(echo "$DIRS" | jq -R -s -c 'split("\n")[:-1]')
            echo "directories=$JSON_DIRS" >> $GITHUB_OUTPUT
          fi

  plan:
    needs: detect-changes
    if: needs.detect-changes.outputs.directories != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        directory: ${{ fromJson(needs.detect-changes.outputs.directories) }}
      fail-fast: false

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::XXXXXXXXXXXX:role/github-actions-terraform-plan
          aws-region: ap-northeast-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ">= 1.13.0"

      - name: Setup tfcmt
        uses: shmokmt/actions-setup-tfcmt@v2
        with:
          version: v4.14.10

      - name: Configure Git to use token
        run: |
          git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
          git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf "ssh://git@github.com/"
        env:
          GITHUB_TOKEN: ${{ secrets.TERRAFORM_MODULES_TOKEN }}

      - name: Terraform Init
        working-directory: ${{ matrix.directory }}
        run: terraform init
        env:
          GITHUB_TOKEN: ${{ secrets.TERRAFORM_MODULES_TOKEN }}

      - name: Terraform Plan
        working-directory: ${{ matrix.directory }}
        env:
          GITHUB_TOKEN: ${{ secrets.TERRAFORM_MODULES_TOKEN }}
        run: |
          echo "Running tfcmt for directory: ${{ matrix.directory }}"
          tfcmt -var "target:${{ matrix.directory }}" plan --patch -- terraform plan -no-color -detailed-exitcode -input=false
        continue-on-error: true

ワークフローの工夫ポイント

  1. 動的ディレクトリ検出: Git diffで変更のあったTerraformファイルから、対象ディレクトリを自動抽出
  2. マトリックス戦略: 複数ディレクトリを並列実行して時間短縮
  3. エラー耐性: continue-on-error: trueで一部のplanが失敗してもコメントを投稿
  4. 変数渡し: tfcmt -varでディレクトリ情報をテンプレートに渡す
  5. プライベートモジュール対応: Git URL置換によるプライベートリポジトリアクセス

Claude Code:fail-fast: falseを入れておくと、一つのディレクトリでplanが失敗しても他のディレクトリの実行が止まらないので便利です」

この提案で、複数環境でエラーが起きても全体の実行が止まらない仕組みになりました。

プライベートTerraformモジュールへの対応

私たちの環境では、共通のTerraformモジュールを別のプライベートリポジトリで管理していました。しかし、CI環境でのアクセス権限の課題がありました:

私: 「Terraformのmodule sourceがSSH形式で書かれてるんだけど、CI環境でSSHキーを設定するのは面倒だから、HTTPS経由でアクセスできるようにしたい。」

Claude Code: 「Gitの insteadOf 設定を使う方法があります。これを利用すれば、SSH形式のURLを自動的に認証情報付きのHTTPS URLに置き換えることができます。CI環境ではアクセストークン(例:GITHUB_TOKEN)などを組み合わせて安全に設定するとよいです。」

git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf "ssh://git@github.com/"

この設定により: - https://github.com/org/repohttps://token:x-oauth-basic@github.com/org/repo - ssh://git@github.com/org/repohttps://token:x-oauth-basic@github.com/org/repo

として自動変換され、Terraform moduleのsource URLを変更することなく、CI環境でプライベートリポジトリにアクセスできるようになりました。

CI実行のためのAWS権限設計

GitHub ActionsでTerraformを実行するため、適切なAWS権限の設定:

私: 「GitHub ActionsからAWSにアクセスする権限設定を実装して」

実際のIAMロール設定:

# terraform plan用のロール(読み取り専用)
resource "aws_iam_role" "github_actions_terraform_plan" {
  name = "github-actions-terraform-plan"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = data.aws_iam_openid_connect_provider.github_actions.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = [
              "repo:org/repo:pull_request",
              "repo:org/repo:ref:refs/heads/*"
            ]
          }
        }
      }
    ]
  })
}

# 最小限の権限付与
resource "aws_iam_role_policy_attachment" "github_actions_terraform_plan_readonly" {
  role       = aws_iam_role.github_actions_terraform_plan.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

権限設計のポイント:

  1. OIDC認証: GitHub ActionsとAWS間でセキュアな認証を実現
  2. リポジトリ制限: 特定のリポジトリからのみ実行可能
  3. ブランチ制限: PRとspecific branchesからのみアクセス可能
  4. 最小権限: plan実行には読み取り専用権限のみ付与
  5. Terraform State用権限: S3バケットへの読み書き権限を別途付与

この設計により、セキュリティを保ちながらCI/CDでTerraformを実行できる環境を構築できました。

Step 2: 設定ファイルの作成 - テンプレートをカスタマイズ

次に、tfcmtの設定ファイルを作成します。

理想のコメント形式を描く

私: 「こんな感じのコメントが自動生成されると嬉しい」と具体例を示しました。

Claude Code: 「分かりました。その形式になるようにテンプレートを書いてみますね」

Claude Codeが提案してくれたのは、ほぼ公式ドキュメントの設定を流用したものでしたが、絵文字やラベル設定などを微修正してくれていたので、そのまま採用しました。

※注意: 以下の設定には誤りがあります。修正版は追記セクションをご覧ください。

# .github/tfcmt.yml
ci:
  pr:
    - github_actions

embedded_var_names: []
templates:
  plan_title: "{{if eq .ExitCode 0}}✅ No changes detected by Terraform (exit code 0){{else if eq .ExitCode 2}}📝 Changes to apply{{else}}❌ Plan failed{{end}}"

terraform:
  plan:
    disable_label: false
    plan_patch: true
    when_add_or_update_only:
      label: add-or-update
    when_destroy:
      label: destroy
    when_no_changes:
      label: no-changes
    template: |
      ## 📊 Terraform Plan - `{{.Vars.target}}`

      {{template "plan_title" .}}
      {{if .Link}}[CI link]({{.Link}}){{end}}

      {{if .Result}}
      ### 📋 Plan Summary
      {{wrapCode .Result}}
      {{end}}

      {{if .CreatedResources}}
      ### 🆕 Create
      {{range .CreatedResources}}- {{.}}
      {{end}}{{end}}

      {{if .UpdatedResources}}
      ### 🔄 Update
      {{range .UpdatedResources}}- {{.}}
      {{end}}{{end}}

      {{if .DeletedResources}}
      ### 🗑️ Delete
      {{range .DeletedResources}}- {{.}}
      {{end}}{{end}}

      {{if .ChangedResult}}
      <details><summary>🔍 Plan Details (Click to expand)</summary>
      {{wrapCode .ChangedResult}}
      </details>
      {{end}}

      {{if .ChangeOutsideTerraform}}
      <details><summary>⚠️ Objects have changed outside of Terraform</summary>

      _This feature was introduced from [Terraform v0.15.4](https://github.com/hashicorp/terraform/releases/tag/v0.15.4)._
      {{wrapCode .ChangeOutsideTerraform}}
      </details>
      {{end}}

      {{if .Warning}}
      <details><summary>⚠️ Warnings</summary>
      {{wrapCode .Warning}}
      </details>
      {{end}}

      ---
      **Directory:** `{{.Vars.target}}`
      **Last Updated:** {{now | date "2006-01-02 15:04:05 MST"}}

この設定ファイルのポイント:

  • 視覚的な分かりやすさ: 絵文字とセクション分けで一目で理解できる
  • 詳細の折りたたみ: planの詳細は<details>で隠して、必要な時だけ展開
  • ラベル自動付与: 変更の種類に応じてPRラベルを自動設定
  • ディレクトリ情報の明記: どのTerraformスタックの結果かを明確化

【追記】設定ファイルの検証と修正

記事公開後、tfcmtの作者から設定ファイルに誤りがあることを指摘いただきました。JSON Schemaでの検証を教えていただき、以下のコマンドで確認しました:

ajv --spec=draft2020 -s json-schema/tfcmt.json -d tfcmt.yml

検証の結果、以下の3つの誤りが見つかりました:

1. 存在しないci項目を記載していた

tfcmt.yml invalid
[
  {
    instancePath: '',
    schemaPath: '#/additionalProperties',
    keyword: 'additionalProperties',
    params: { additionalProperty: 'ci' },
    message: 'must NOT have additional properties'
  }
]

ciという設定項目はtfcmtには存在しませんでした。

2. plan_patchの記載場所が間違っていた

tfcmt.yml invalid
[
  {
    instancePath: '/terraform/plan',
    schemaPath: '#/additionalProperties',
    keyword: 'additionalProperties',
    params: { additionalProperty: 'plan_patch' },
    message: 'must NOT have additional properties'
  }
]

terraform.planの下ではなく、トップレベルに記載する必要がありました。

3. when_no_changesに必須項目disable_commentがなかった

【再追記】 作者からPR#1925でJSON Schemaの誤りだったと訂正いただきました。disable_commentは必須ではありません。

元のエラーメッセージ:

tfcmt.yml invalid
[
  {
    instancePath: '/terraform/plan/when_no_changes',
    schemaPath: '#/$defs/WhenNoChanges/required',
    keyword: 'required',
    params: { missingProperty: 'disable_comment' },
    message: "must have required property 'disable_comment'"
  }
]

WhenNoChangesブロックにはdisable_commentの指定が必須でした。 このエラーはJSON Schema側の問題で、すでに修正が反映済みです。

これらの指摘を受けて設定ファイルを修正しました。基本的な動作はしていたため、JSON Schemaでの検証を怠っていました。「動いているから大丈夫」という油断が落とし穴。 YAML Language Serverの設定をして入力補完等しておくのが良さそうです。

OSSの作者から直接フィードバックをいただき、申し訳なくも感じながら記事を書いて良かった!と思えました。(AIが生成したコードは動作確認だけでなく、ちゃんと検証ツールでのチェックが必要という教訓ですね...)

実際のPRコメント例

tfcmtを導入すると、以下のようなコメントがPRに自動投稿されます:

## 📊 Terraform Plan - `environments/staging`

📝 Changes to apply
[CI link]
### 📋 Plan Summary
Plan: 1 to add, 1 to change, 1 to destroy.

### 🆕 Create
- aws_iam_role_policy.secrets_manager_access

### 🔄 Update
- aws_s3_bucket.terraform_state

### 🗑️ Delete
- aws_s3_bucket_public_access_block.legacy_bucket

🔍 Plan Details (Click to expand)
  # aws_s3_bucket.terraform_state will be updated in-place
  ~ resource "aws_s3_bucket" "terraform_state" {
        id     = "terraform-tfstate"
      ~ versioning {
          ~ enabled    = false -> true
            mfa_delete = false
        }
        # (9 unchanged attributes hidden)
    }

  # aws_iam_role_policy.secrets_manager_access will be created
  + resource "aws_iam_role_policy" "secrets_manager_access" {
      + id     = (known after apply)
      + name   = "secrets-manager-access"
      + policy = jsonencode({
          + Statement = [
              + {
                  + Action   = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
                  + Effect   = "Allow"
                  + Resource = "*"
                  + Sid      = "SecretsManagerAccess"
                },
            ]
          + Version   = "2012-10-17"
        })
      + role   = "secrets-manager-access"
    }

  # aws_s3_bucket_public_access_block.legacy_bucket will be destroyed
  - resource "aws_s3_bucket_public_access_block" "legacy_bucket" {
      - bucket                  = "old-terraform-state" -> null
      - block_public_acls       = true -> null
      - block_public_policy     = true -> null
      - ignore_public_acls      = true -> null
      - restrict_public_buckets = true -> null
    }
--- **Directory:** `environments/staging` **Last Updated:** 2025-08-27 14:30:15 UTC

今後の改善点

基本的なtfcmt導入は成功しましたが、運用していく中でいくつか改善していきたいポイントが見えてきました。

コスト影響の可視化

Terraformでリソース変更する際のコスト影響も分かるとより良いレビューができそうです。Infracostとの連携で、コスト変更の自動表示をしていきたいと検討中。

tfactionでさらなる機能拡充

tfcmtの導入が成功したことで、次のステップとしてtfactionの導入を検討しています。

tfcmtとtfactionの違い

  • tfcmt: Terraform planの結果をPRコメントに投稿するツール(今回導入したもの)
  • tfaction: Terraform CI/CDワークフロー全体を管理する包括的なフレームワーク

tfactionはsuzuki-shunsuke/tfactionが開発したTerraform 向けの CI/CD フレームワークで、GitHub Actions を基盤に高度な運用を実現します。tfcmt(および github-comment、reviewdog)と統合して PR に結果を通知しつつ、マネージドサービスに依存せず次のような機能を提供します。:

  • plan と apply の実行管理
    • PRで terraform plan を実行し、デフォルトブランチへのマージで terraform apply を実行
  • GitHub のレビューやブランチ保護を活用した承認ゲート連携
    • 承認はGitHubの仕組み、apply実行はtfactionが連動
  • Policy as Code の統合
    • conftest(OPA)や各種リンターと連携
  • ドリフト検知
    • 定期的な plan により差分を検出し、Issue管理
  • セキュリティスキャンとの連携
    • tfsec、trivy、tflint などをサポート
  • その他
    • tfmigrate 対応、Monorepo向け動的ビルドマトリクス、.terraform.lock.hcl 自動更新、フォーマット自動化、terraform-docs によるドキュメント生成 など

tfactionの導入時には、ツール管理の観点からaquaの採用も検討しています。aquaは同じ作者によるツールバージョン管理システムで、CI/CD環境で必要なツール(terraform、tflint、tfsecなど)のバージョンを宣言的に管理できます。これにより、ローカル開発環境とCI環境でのツールバージョン統一が簡単になります。

導入を検討している機能:

  1. Terraform Apply自動化: auto-applyや承認フローを使った安全な自動適用(現在マルチアカウント移行の対応中で、ローカルapply運用からの完全移行後に実装予定)

  2. コスト可視化:Infracostの連携で、インフラ変更のコスト影響の自動計算

  3. セキュリティ強化:

    • Policy as Codeでの承認ルール設定
    • production環境向けの複数承認者による承認フロー
    • drift detectionによる設定ドリフトの自動検出
  4. コード品質向上:

    • lintやセキュリティチェックの自動化
    • terraform fmtやterraform validateの自動実行
    • tflintによるコード品質チェック
    • tfsecやCheckovによるセキュリティスキャン
    • 各種チェックの結果をPRコメントに統合表示
  5. スケジュール実行: cron機能による定期的なdrift検出とplan実行

  6. マルチリージョン対応: matrix strategyを使った複数リージョンでの並列実行

学びと今後の展開

Claude Codeと壁打ちすることで得られた学び

  1. 設計フェーズでのAI活用: 実装前の要件整理や技術選定でAIと対話することで、見落としがちな観点を発見できた

  2. 段階的な改善アプローチ: 完璧を求めず、まず動かしてから改善するアプローチをAIが後押ししてくれた

  3. ドキュメント化の重要性: AIとの対話ログが、そのまま導入手順書や運用ガイドになる

まとめ

tfcmtとClaude Codeを使って、Terraform運用の課題を改善できました:

  • レビュー時間の短縮
  • 変更内容の理解度向上
  • 複数環境の影響範囲の明確化
  • エラー対応の効率化

Claude Codeとの「壁打ち」による設計フェーズが特に有効でした。一人で考えていては見つからなかった課題や改善点を、AIとの対話を通じて発見できました。

最初から完璧な設定を目指すのではなく、AIと対話しながら段階的に改善していくアプローチで、期待以上の結果を得ることができました。

「見えない問題を見える化すること」が重要だと思いますが、tfcmtのようなツールとAIエージェントの組み合わせは、その作業を効率化する良い手段だと感じています。

Terraform運用で似た課題をお持ちの方は、Claude Codeと相談しながらtfcmt導入を検討してみてはいかがでしょうか。