mcpField
T
terraform-skill

terraform-skill

github.com/daymadeクラウド・DevOps1.5k718 回インストール今日更新
リポジトリ

terraform-skill について

terraform-skillは、デプロイやプロビジョナーを管理するチーム向けのTerraform運用スキルです。変更前の成果物検証、変更範囲の制御、よくあるデプロイ障害の診断を重視します。

主な機能

  1. 01タスク別の安全手順案内
  2. 02本番変更前の成果物検証
  3. 03保存済みプランの識別と昇格管理
  4. 04プロビジョナーと状態の障害診断

活用シーン

  1. 01本番変更前のデプロイ安全性確認
  2. 02プランに変更なしでもサービス停止時の調査
  3. 03バックエンド状態の保存失敗からの復旧

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/daymade/claude-code-skills --skill terraform-skill

インストール後、Claude Code で/terraform-skillを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
multi-env-isolation.md
pre-deploy-validation.md
release-safety-and-environment-parity.md
zero-to-deploy-checklist.md
tests/
check-compose-precedence.sh

詳細

Terraform リリースとプロビジョナーの安全性

一見正しく見える Terraform ワークフローによって、検証されていないバイト列が公開されたり、変更の影響範囲が広がったりするのを防ぎます。プランの件数、成功を示すラッパー、プロセスの完全性よりも、ユーザーのビジネス上の目的と実際の変更範囲を優先します。

タスクの振り分け

  • リリース、共有ゲートウェイ、保存済みプラン、ステージング受領証、本番昇格の場合:承認または変更の前に release-safety-and-environment-parity.md と pre-deploy-validation.md を読みます。
  • 環境の分離(キャッシュされた初期化、環境の廃止、共有データバックエンド、DNS の所有権、状態、スナップショットを含む)の場合: multi-env-isolation.md を読みます。
  • デプロイ前チェックまたはバリデーターの場合: pre-deploy-validation.md を読みます。
  • 新しいインスタンスまたは空のデータディスクの場合: zero-to-deploy-checklist.md を読みます。
  • 原因が特定できているプロビジョナーの症状が 1 つの場合:以下の該当するパターンを使います。

運用上の契約

  1. リポジトリの正規のリリースエントリーポイントを使います。共有ゲートウェイの公開で標準 CI が必要な場合、エージェントは既存の認可範囲内でその CI を開始できます。通常のリリース、復元、ロールバック、静的コンテンツの転送には、すべて管理された実行環境と既存のラッパーを使います。ローカルでの Terraform apply、root SSH/SCP、ヘルパー、コンソールからの書き込みで代用してはいけません。ディスパッチする前に、実装済みのワークフローと対応する入力を確認します。lint またはイメージビルドのみを行う CI は、デプロイ手段ではありません。実行手段がない場合にブロックされるのは本番への書き込みであり、準備や読み取り専用の診断ではありません。
  2. プロバイダーのリソース、イメージの作成、cloud-init、または構成管理を優先します。HashiCorp は、Terraform ではプロビジョナーによる副作用を予測可能な形でモデル化できないため、目的に特化した代替手段を使い切ることを推奨しています。プロビジョナーがどうしても必要な場合は、対象アーティファクト、対象、ロック、検証、読み戻しを明示します。
  3. 対象ランタイムに書き込めるすべてのリソースまたは復旧ツールを洗い出します。1 つの書き込み手段にのみ適用されたバリデーターでは、同じ共有サービスに書き込む別の手段を保護できません。
  4. ステージングと本番で、必須キーのスキーマを共通にします。値が異なるのは構いませんが、一方の環境では必須、もう一方では任意、デフォルト値あり、未設定、または空値が許可されるキーを作ってはいけません。
  5. 最初の本番書き込みまたは再起動の前に、候補のバイト列、Compose でレンダリングされた環境、イミュータブルなランタイムイメージを検証します。デプロイ後のチェックも残します。損害を検出することはできますが、最初の不正な変更を防ぐことはできません。
  6. 保存済みプランは実行可能なアーティファクトとして扱います。レビュー済みのソース/アーティファクトの識別情報にひも付け、そのファイル自体を適用します。apply の成功はステージング受領証にはなりません。必要なすべての本番検証が成功した後にのみ、昇格の証跡を記録します。
  7. 常設の認可を含め、ユーザーの本番環境への認可を、最後の取り消し可能な時点で尊重します。期限、PLAN_DIGEST、CONFIRM_*、エージェントの推論は、認可の代わりにはなりません。認可済みの契約が標準 CI である場合、個人の署名や実行ごとの人による承認を追加してはいけません。
  8. 要求された結果を検証したら停止します。単一サービスの修正を、スタック全体のドリフト解消、復旧設計の変更、無関係な強化へと広げてはいけません。

プロビジョナーの落とし穴(症状 → 対処)

インシデントから得られた症状パターンを使い、次に原因を否定できるチェックを選びます。過去の原因を現在の診断に当てはめる前に、現行のソースとランタイムを確認します。

terraform plan に「No changes」と表示されるのに、サイトにアクセスできない

症状:ブラウザーに ERR_SSL_PROTOCOL_ERROR などが表示されます。terraform plan には No changes と表示されますが、実際にはサービスが壊れています。

解釈:No changes は稼働状態のヘルスチェックではありません。Terraform はプロバイダーが管理する属性をリフレッシュしますが、プロビジョナーが変更したリモートファイルやコンテナーへの副作用は、これらの属性には自動的に反映されません。承認済みの apply、別の IaC 書き込み元、または Terraform 外部での変更の後にサイトが壊れることがあります。この症状だけでは、どれが発生したかは特定できません。HTTP リダイレクトやコンテナーが正常であっても、影響を受けた HTTPS のルートが機能する証明にはなりません。

診断:

  1. 影響を受けたホスト名とユーザーのパスをプローブしてから、読み込まれたルートとライブファイルを調べます。
  2. デプロイされた正確なソース/アーティファクトと、存在する場合は state で追跡されるハッシュを、ライブのハッシュと比較します。そのディレクトリに書き込むすべての書き込み元について、実際のプロビジョナーコマンドとリリースログを確認します。共有の mtime やデプロイ済みの .tftpl/.static ファイルは、アーカイブとコピーの経路を調べる手がかりですが、Terraform 外部での書き込みの証拠ではありません。通常のアーカイブ展開でも同様の状態になることがあります。
  3. 各書き込み元が対象とするファイル一式と、削除/除外の範囲を再構成します。特に、有効な rsync --delete であっても、ある書き込み元の対象から除外された別の所有者のルートが削除されることがあります。その場合も設定自体は構文的に有効なままです。メイン API だけでなく、既存のホスト名をすべて確認します。

復旧:リポジトリの標準リリースエントリーと、その plan/apply ラッパーを使用します。標準 CI が必要な場合は、同じ CI を通じて、サポートされている復旧またはロールバックを開始します。置き換える前に、候補を現在のライブベースラインと照合し、変更される可能性のあるファイル/リソースを正確に明示します。古いコミットは再現可能でも、新しいルート、ポリシー、環境、イメージをロールバックしてしまうことがあります。承認された範囲だけを復元し、現在の書き込み元が他の所有者のファイルを保持できない場合は中止します。広範囲な置き換えの後に別途同期する方法を、標準の修復手順にしないでください。

保存された plan が固定するのは Terraform の計画値であり、apply 時にプロビジョナーが読み取る任意のファイルや外部ダウンロードではありません。復旧によってそのバイト列が公開されると断言する前に、実際の実装が検証済みの不変アーティファクトを使用することを確認してください。承認済みの apply の後は、影響を受けたユーザーパスと、以前は機能していたルートを個別に検証します。ライブハッシュの確認でドリフトは検出できますが、それだけでは書き込み元を特定できず、削除を防ぐこともできません。

ステージングでは apply が正常に完了するのに、本番環境で port is already allocated が発生する

症状:Compose のデプロイはステージングでの検証に合格しますが、本番環境の apply が docker compose up で失敗し、Bind for 127.0.0.1:<port> failed: port is already allocated が表示されます。

根本原因:ステージングと本番環境の同等性でカバーされるのは設定であり、ホストポートの割り当てではありません。ステージングでは空いているポートが、本番環境のホストでは別のサービスにすでに使用されていることがあります。汚染された null_resource が残るため、再試行では通常の再 apply ではなく、置き換えフロー(CONFIRM_REPLACE)を使用する必要があります。

診断:

ssh root@<target-host> 'ss -ltnp | grep <port>'   # find the real occupant
docker ps --format "{{.Names}} {{.Ports}}" | grep <port>

予防:モジュールでホストポートをバインドする前に、設計時に対象ホスト上でその正確なバインドを確認します。ステージングの状態からポートが空いていると推測してはいけません。使用頻度の低い高番ポートを優先し、割り当てをモジュールのコメントとデプロイ手順書に記録します。また、コンテナーでホストローカルのプローブ対象が必要な場合、コンテナー内の 127.0.0.1 はそのコンテナー自身を指すことに注意してください。ホストローカルの対象は、コンテナー化されたモニターではなく、ホストレベルのプローブ(systemd スクリプトなど)で使用します。

リモート実行中に docker: not found が発生する

プロビジョナーが SSH 接続した時点で、cloud-init による Docker のインストールがまだ完了していません。

provisioner "remote-exec" {
  inline = [
    "cloud-init status --wait",
    "command -v docker >/dev/null || { echo 'FATAL: Docker not ready'; exit 1; }",
  ]
}
rsync: connection unexpectedly closed が local-exec で発生する

この症状から、Terraform に普遍的な制限があると推測しないでください。2 つ目の SSH クライアントは、接続先の接続数上限、SSH ポリシー、または競合するデプロイによって接続できないことがあります。local-exec はローカルで実行し、そこで不変のアーティファクトをパッケージ化してから、Terraform が管理するアップロード、または専用のデプロイシステムを使ってください。各 apply に一意のリモートステージングパスを割り当ててください。並行する apply 間で /tmp/src.tar.gz を共有しないでください。

provisioner "local-exec" {
  command = "tar czf /tmp/src-${self.id}.tar.gz --exclude=node_modules --exclude=.git -C ${path.module}/../../.. myproject"
}
provisioner "file" {
  source      = "/tmp/src-${self.id}.tar.gz"
  destination = "/tmp/src-${self.id}.tar.gz"
}
provisioner "remote-exec" {
  inline = ["tar xzf /tmp/src-${self.id}.tar.gz -C /data/ && rm -f /tmp/src-${self.id}.tar.gz"]
}

macOS の BSD tar:--exclude はソース引数より前に指定する必要があります。

cloud-init status がいつまでも "running" と表示される

apt-get -y は debconf のダイアログを抑制しません。iptables-persistent などのパッケージは、TTY のプロンプトで停止します。

- |
    echo iptables-persistent iptables-persistent/autosave_v4 boolean true | debconf-set-selections
    echo iptables-persistent iptables-persistent/autosave_v6 boolean true | debconf-set-selections
    DEBIAN_FRONTEND=noninteractive apt-get install -y iptables-persistent

問題が確認されているパッケージ:iptables-persistent、postfix、mysql-server、wireshark-common。

コンテナーログに EACCES: permission denied が表示され、コンテナーが Restarting になる

ホストのボリュームディレクトリは root 所有ですが、コンテナーは非 root(uid 1001)で実行されます。docker compose up の前に修正してください:

mkdir -p /data/myapp/data /data/myapp/logs
chown -R 1001:1001 /data/myapp/data /data/myapp/logs

UID を確認するには、Dockerfile で adduser.*-u または USER を grep します。

プロビジョナーが失敗するが、診断出力がない

フェイルファストの動作は維持し、set -e を無効にするのではなく、失敗時に診断情報を出力してください。そうしないと、先行するコマンドの失敗が、後続の正常なヘルスチェックによって上書きされることがあります。

provisioner "remote-exec" {
  inline = [
    "set -eu",
    "trap 'rc=$?; if [ $rc -ne 0 ]; then docker logs myapp --tail 20 2>&1 || true; docker ps --format \\\"table {{.Names}}\\\\t{{.Status}}\\\" || true; fi; exit $rc' EXIT",
    "docker compose up -d",
    "sleep 15",
    "docker ps --filter name=myapp --format '{{.Status}}' | grep -q healthy || exit 1",
  ]
}
apply 後に Failed to save state / Failed to persist state to backend が発生する

apply は実行されており、最後のバックエンドへのアップロードだけが失敗しています(状態バケットへのタイムアウトがよくある原因です)。まず apply ログで確認してください。すべてのリソースが Creation complete、Modifications complete、または Destruction complete と報告しており、エラーがこの 2 つだけであることを確認します。その場合、副作用はすべてすでに発生しています。プロビジョナーは実行され、リモートへの書き込みは反映され、コンテナーは再作成されています。しかし、バックエンドには以前の状態が保持されたままで、Terraform は実行されたディレクトリ(ラッパーの場合はシェルのディレクトリではなく、ルートモジュールディレクトリ)に新しい状態を errored.tfstate として書き込みました。apply を再実行しないでください。古い状態をもとに実行されるため、Terraform 自身が警告するように状態の分岐が発生し、失敗した実行ですでに作成または置換されたリソースが、再度作成または置換されます。

リポジトリのラッパーが state サブコマンドをそのまま渡す場合(運用契約 1)は、そのラッパーを通して、該当ディレクトリから復旧を実行してください。それ以外の場合は、同じバックエンドと認証情報が使用されるよう、ラッパーが設定する環境とまったく同じ環境で実行してください。取得したコピーはリポジトリの外に置いてください:

terraform state pull > "${TMPDIR:-/tmp}/remote.json"
jq '{file: input_filename, lineage, serial}' "${TMPDIR:-/tmp}/remote.json" errored.tfstate
# expect: identical lineage, and the remote serial lower than errored.tfstate's
terraform state push errored.tfstate
terraform state pull | jq '{lineage, serial}'
# expect: serial higher than the remote's before the push (it need not equal
# errored.tfstate's; the backend may increment it). Then check the resources the
# apply created or replaced: `terraform state show <address>` carries the IDs in the apply log.

state push は、無関係な lineage、新しいリモート serial、内容が異なる同じ serial を拒否します(ローカルバックエンドを使った Terraform 1.5.7 で確認)。拒否された場合、リモートの状態はこの errored.tfstate と一致していません。別の書き込みによって変更された、以前の実行で残ったファイルである、または別のワークスペースかバックエンドを参照している可能性があります。停止して調整してください。-force は決して追加しないでください。リモートにある状態を上書きします。apply と同じ転送エラーで push が失敗した場合は、後で再試行してください。リモートの serial が変わるまでは、errored.tfstate を使用できます。push が成功したら、errored.tfstate を作業ディレクトリの外(スクラッチ領域など)に移動し、後でライブ状態として読み込まれないようにしてください。このファイルと取得したコピーには、シークレットを含む状態全体が保存されているため、読み戻しが成功したら削除してください。その後、ラッパーのレシピを確認し、apply コマンドの後に続くすべての手順(新しい設定の永続化、apply 後の検証など)を手動で実行してください。ラッパーの失敗終了コードによって、リモートの変更がすでに反映されていることが隠されています。

コンテナーが Restarting になり、データベーステーブルが見つからない

DB マイグレーションがプロビジョナーに含まれていません。PostgreSQL の docker-entrypoint-initdb.d は、データディレクトリが空の場合にのみ実行されます。DB を明示的に作成し、マイグレーションを実行してください:

# After postgres healthy:
docker exec pg psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='mydb'" | grep -q 1 \
  || docker exec pg psql -U postgres -c "CREATE DATABASE mydb;"

# Idempotent migrations:
for f in migrations/*.sql; do
  VER=$(basename $f)
  APPLIED=$($PSQL -tAc "SELECT 1 FROM schema_migrations WHERE version='$VER'" | tr -d ' ')
  [ "$APPLIED" = "1" ] && continue
  { echo 'BEGIN;'; cat $f; echo 'COMMIT;'; } | $PSQL
  $PSQL -tAc "INSERT INTO schema_migrations(version) VALUES ('$VER') ON CONFLICT DO NOTHING"
done
.env があるのに Compose が予期しない値を使う

Compose の補間では、呼び出し元のシェルが --env-file やプロジェクトの .env より優先されます。そのため、以前にエクスポートされた値が、レビュー済みの環境設定を気付かれないまま上書きすることがあります。Compose が実際に使用した値を確認してください。環境ファイルを優先する場合は、環境変数の上書きを解除してください。

# Inspect interpolation inputs and the rendered model.
docker compose --env-file .env config --environment
docker compose --env-file .env config --format json > compose.rendered.json

# Make the reviewed env file authoritative for this key.
env -u DOCKER_WITH_PROXY_MODE docker compose --env-file .env build
TLS ハンドシェイクが失敗する:Invalid format for Authorization header

Caddy の Cloudflare DNS モジュールでは、Bearer 認証によるスコープ付き API Token が必要です。長さやプレフィックスだけを根拠に、認証情報の種類、有効性、権限を推測しないでください。Cloudflare の公式エンドポイントでトークンを検証してから、リリースで必要となるゾーン操作またはプロバイダーの処理経路を実際に実行してください。

curl -fsS https://api.cloudflare.com/client/v4/user/tokens/verify \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  | jq -e '.success == true and .result.status == "active"' >/dev/null

認証情報がない、または誤っている場合は、Cloudflare の現在のダッシュボード/API フローで最小権限の API Token を作成し、プロバイダーに必要なゾーンと操作だけを許可してください。変更される可能性がある権限グループ ID をコピーせず、公式の作成手順に従ってください: https://developers.cloudflare.com/fundamentals/api/get-started/create-token/.

ステージングでは TLS が失敗するが、本番環境では動作する — ドメインがハードコードされている

Caddyfile または compose にドメイン名が直接記述されています。ステージングの Caddy が本番環境の設定を読み込み、所有していないドメインの証明書を取得しようとして、ACME が失敗します。

Caddyfile:{$VAR} を使用します — Caddy は起動時に環境変数を評価します。

# WRONG
example.com { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} } }

# RIGHT
{$LOBEHUB_DOMAIN} { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} } }

Compose:${VAR:?required} を使用します — 未設定または空の場合は即座に失敗します。

# WRONG
- APP_URL=https://example.com

# RIGHT
- APP_URL=${APP_URL:?APP_URL is required}

Caddy が読み取れるように、環境変数をゲートウェイコンテナに渡します:

environment:
  - LOBEHUB_DOMAIN=${LOBEHUB_DOMAIN:?LOBEHUB_DOMAIN is required}
  - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?required for DNS-01 TLS}

このローカルのアサーションだけで終わらせないでください。実行時に必要なすべてのキーを 1 つのスキーマにまとめ、すべての環境ファイルで同じキー一式を必須にし、Compose サービスの正確な環境変数をレンダリングしてから、ライブファイルを変更する前に、完全な環境変数を指定してデプロイ対象と同じ Caddy イメージを実行してください。Caddy の {$VAR} 展開では、解析前に空のトークンになる場合があります。Caddyfile のデフォルト値は、環境変数がすべて揃っていることの確認にはなりません。

OAuth ログインが失敗する:Social sign in failed

Casdoor の init_data.json には、リダイレクト URI がハードコードされています。--createDatabase=true は初回のデータベース作成時にのみ init_data を適用し、再起動時には適用しません。プロビジョナーで SQL を使って修正してください:

# Replace production domain with staging in existing Casdoor DB
$PSQL -c "UPDATE application SET redirect_uris = REPLACE(redirect_uris,
  'example.com', 'staging.example.com')
  WHERE name='lobechat'
  AND redirect_uris LIKE '%example.com%'
  AND redirect_uris NOT LIKE '%staging.example.com%';"

AUTH_CASDOOR_ISSUER も確認してください。アプリのルートドメインではなく、Casdoor のサブドメイン(auth.staging.example.com)と一致している必要があります。

マルチ環境の分離

2 つ目の環境を作成する前に、.tf ファイルでハードコードされた名前を grep してください。全項目のマトリックスについては、references/multi-env-isolation.md を参照してください。

環境を分離しても、設定コントラクトに差異を設けてよいわけではありません。必要なキーのマニフェストを 1 つにまとめ、すべての環境で同じ検証経路を使用してください。ステージングと本番環境でドメイン、認証情報、インスタンスサイズ、機能の値が異なることは問題ありません。ただし、実行時のキーが必須、任意、空でもよい、または暗黙的にデフォルト値が設定されるものかについて、環境間で異なっていてはいけません。

apply 時に失敗します(グローバルに一意である必要があります):

リソーススコープ修正方法
SSH キーペアリージョン"${env}-deploy"
SLS ログプロジェクトアカウント"${env}-logs"
CloudMonitor の連絡先アカウント"${env}-ops"

DNS 重複の落とし穴:同じ Cloudflare ゾーンで、2 つの環境が同じ名前の A レコードを作成すると、それぞれ別のレコード ID が作られます。DNS ラウンドロビンにより、約 50% のトラフィックが誤ったインスタンスに送られます。修正するには、サブドメインで分離する(staging.example.com)か、ゾーンを分けてください。Caddy が配信するすべてのサブドメイン(例:auth.staging、minio.staging)の DNS レコードも忘れずに作成してください。

スナップショットの混入:フィルターのない data "alicloud_ecs_snapshots" は、アカウント内のすべてのスナップショットを返します。新しい環境が既存の 100GB スナップショットを引き継ぎ、40GB のディスク作成に失敗します。変数で条件を指定してください:

locals {
  latest_snapshot_id = var.enable_snapshot_recovery && length(local.available_snapshots) > 0
    ? local.available_snapshots[0].snapshot_id : null
}

データソースに count を追加しないでください — state のアドレスが変わり、ドリフトが発生します。

デプロイ前の検証

承認または変更の前に、 pre-deploy-validation.md を読み、記載された手順を実行してください。公開者向けゲート、検証フェーズ、新しい plan による認可、ターゲットに紐づく昇格の証跡は、同ファイルに従ってください。

ゼロからデプロイまで

新しいディスクでは、暗黙の依存関係がすべて明らかになります。references/zero-to-deploy-checklist.mdを参照してください。

承認済みのサービス停止や初回の本番書き込みの前に、認証情報の権限と公開者の準備についても pre-deploy-validation.mdを使用してください。新しいホストのチェックリストでは、ブートストラップの依存関係、順序、メモリを管理します。

コメント