mcpField
T
terraform-skill

terraform-skill

github.com/daymade云与 DevOps1.5k718 次安装今天更新
代码仓库

关于 terraform-skill

terraform-skill 面向管理部署和 provisioner 的 Terraform 团队,提供操作安全指导。它侧重在变更前验证准确制品、限制修改范围,并排查常见部署故障。

主要功能

  1. 01按任务查找安全指导
  2. 02变更前验证制品
  3. 03管理保存计划的身份与晋级
  4. 04排查 provisioner 和状态故障

适用场景

  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 工作流发布未经验证的字节,或扩大变更的影响范围。应优先考虑用户的业务目标和实际变更范围,而非计划数量、绿色包装器或流程是否完整。

确定处理路径

操作约定

  1. 使用仓库的规范发布入口。共享网关发布需要标准 CI 时,Agent 可在现有授权范围内发起该 CI;常规发布、恢复、回滚和静态内容传输均使用受控执行器及现有包装器。不要改用本地 Terraform apply、root SSH/SCP、辅助程序或控制台写入。派发前核实已实现的工作流和支持的输入;仅执行 lint 或构建镜像的 CI 并非部署通道。缺少部署通道会阻止线上写入,但不妨碍准备工作或只读诊断。
  2. 优先使用提供商资源、镜像烘焙、cloud-init 或配置管理。HashiCorp 建议优先穷尽专用替代方案,因为 Terraform 无法以可预测的方式建模配置器副作用。确有必要使用配置器时,应明确指定其制品、目标、锁、验证和回读。
  3. 清点所有能够写入目标运行时的资源或恢复工具。只附加到一个写入方的验证器,无法保护同一共享服务的其他写入方。
  4. 为预发布和生产环境采用同一套必需键架构。值可以不同,但不能让某个键在一个环境中为必需,而在另一个环境中为可选、有默认值、不存在或允许为空。
  5. 在首次线上写入或重启之前,验证确切的候选字节、Compose 渲染后的环境以及不可变的运行时镜像。也要保留部署后检查:它们可以检测损坏,却无法阻止首次错误变更。
  6. 将保存的计划视为可执行制品。将其绑定到已审核的源代码/制品身份,并应用该确切文件。成功应用并不等于预发布回执;只有所有必需的线上验证器均成功后,才能记录晋级证据。
  7. 在最后一个可逆节点遵守用户对生产环境的授权,包括持续有效的授权。截止时间、PLAN_DIGEST、CONFIRM_* 或 Agent 的推断都不能代替授权。当授权约定为标准 CI 时,不要添加个人签名或要求每次运行都经人工批准。
  8. 请求的结果经验证后即停止。不要将单一服务修复扩大为全栈漂移协调、恢复方案重设计或无关的加固工作。

配置器陷阱(症状 → 修复)

使用这些源自事故的症状模式,选择下一项可证伪检查。在将历史原因纳入当前诊断之前,先确认当前源代码和运行时。

terraform plan 显示“No changes”,但网站无法访问

症状:浏览器出现 ERR_SSL_PROTOCOL_ERROR 或类似错误,terraform plan 显示 No changes,但服务实际上已损坏。

解读:No changes 不是实时健康检查。Terraform 会刷新由 provider 管理的属性,但 provisioner 的远程文件和容器副作用不会自动体现在这些属性中。网站可能在一次已获授权的 apply、其他 IaC 写入者操作或带外更改之后损坏;仅凭这一症状无法判断具体发生了什么。HTTP 重定向或健康的容器并不能证明受影响的 HTTPS 路由可正常工作。

诊断:

  1. 探测受影响的主机名和用户访问路径,然后检查已加载的路由和实时文件。
  2. 将确切的已部署源代码/制品以及状态中记录的哈希(如有)与实时哈希进行比较。查看该目录所有写入者实际执行的 provisioner 命令和发布日志。mtime 相同以及已部署的 .tftpl/.static 文件都是需要检查归档和复制路径的线索,但不能证明发生了带外写入:正常解压归档也会产生这些情况。
  3. 还原每个写入者可能使用的文件集以及删除/排除范围。特别是,有效的 rsync --delete 可能会删除候选文件集中未包含的其他所有者路由,同时保留语法有效的配置。检查所有现有主机名,而不只是主 API。

恢复:使用仓库的规范发布入口及其 plan/apply 包装器;如果要求使用标准 CI,则通过同一个 CI 发起受支持的恢复或回滚。替换之前,根据当前实时基线验证候选内容,并明确指出哪些文件/资源可能发生更改。冻结的旧提交即使可复现,也仍可能回滚较新的路由、策略、环境或镜像。只恢复已获授权的范围;如果当前写入者无法保留其他所有者的文件,就应停止。不要默认采用先大范围替换、再执行第二次同步的修复方式。

保存的 plan 会冻结 Terraform 计划中的值,但不会冻结 apply 时由 provisioner 读取的任意文件或外部下载内容。请确认实际实现会使用经过验证的不可变制品,然后再声称恢复操作将发布这些字节。完成已获授权的 apply 后,独立验证受影响的用户访问路径以及此前正常工作的路由。实时哈希检查可以检测漂移,但仅凭这些检查无法确定写入者,也无法防止删除。

Staging apply 成功,但 production 报错 port is already allocated

症状:Compose 部署通过 staging 验证,但 production apply 在执行 docker compose up 时失败,报错 Bind for 127.0.0.1:<port> failed: port is already allocated。

根本原因:Staging/production 配置一致性涵盖的是配置,不涵盖主机端口分配。Staging 上空闲的端口可能已被 production 主机上的其他服务占用。失败后会留下一个被 taint 的 null_resource,因此重试必须通过 replace 流程(CONFIRM_REPLACE),不能直接重新 apply。

诊断:

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

预防措施:在模块中绑定主机端口之前,应在设计阶段检查目标主机上的确切绑定情况——绝不能根据 staging 推断端口空闲。优先选择不常用的高位端口,在模块注释和部署文档中记录端口分配;如果容器需要主机本地探测目标,请记住容器内的 127.0.0.1 指向容器本身:主机本地目标应由主机级探测器(systemd 脚本)检查,而不是由容器化监控器检查。

remote-exec 中出现 docker: not found

provisioner 通过 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 存在普遍限制。第二个 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。

Provisioner 失败但没有诊断输出

保留快速失败行为;发生失败时附加诊断信息,而不是禁用 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,且错误只有这两项。此时所有副作用都已发生——provisioner 已运行、远程写入已生效、容器已重建——但后端仍保存着之前的状态,而 Terraform 将新状态写入了运行目录中的 errored.tfstate(如果通过包装器运行,则是其根模块目录,而不是你的 shell 所在目录)。不要重新运行 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 匹配——可能是其他写入者修改了它、该文件来自更早的一次运行,或者你指向了不同的 workspace 或后端:停止操作并进行协调。绝不要添加 -force;它会覆盖远程状态。如果 push 因与 apply 相同的传输错误而失败,请稍后重试;在远程 serial 变更之前,errored.tfstate 仍可使用。成功 push 后,将 errored.tfstate 移出工作目录(放到临时空间),避免之后被当作实时状态读取;它和拉取的副本都包含完整状态,包括机密,因此通过回读验证后应将它们删除。然后阅读包装器的执行步骤,并手动运行 apply 命令之后的每一步——持久化新设置、执行 apply 后验证。包装器的失败退出状态掩盖了远程更改已经生效这一事实。

容器处于 Restarting 状态——缺少数据库表

数据库迁移未包含在 provisioner 中。PostgreSQL 的 docker-entrypoint-initdb.d 仅在数据目录为空时运行。请显式创建数据库并运行迁移:

# 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
Compose 使用了意外的值,尽管存在 .env

Compose 插值时,调用 shell 的优先级高于 --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 官方端点验证 Token,然后执行该版本所需的确切区域操作或提供商路径。

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}

不要止步于此本地断言。将所有运行时必需键放入同一个架构中,要求每个环境文件都提供相同的键集合,渲染确切的 Compose 服务环境,并在修改线上文件前,使用完整环境运行确切部署的 Caddy 镜像。Caddy 的 {$VAR} 展开可能会在解析前变成空令牌;Caddyfile 默认值不能检查环境变量是否齐全。

OAuth 登录失败:Social sign in failed

Casdoor 的 init_data.json 包含硬编码的重定向 URI。--createDatabase=true 只会在首次创建数据库时应用 init_data,而不会在重启时应用。请在 provisioner 中通过 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)一致,而不是应用根域名。

多环境隔离

创建第二个环境之前,请在 .tf 文件中搜索硬编码名称。完整矩阵见 references/multi-env-isolation.md。

环境隔离不意味着配置契约可以不一致。为每个环境保留同一份必需键清单和相同的验证路径。预发布环境和生产环境可以使用不同的域名、凭据、实例规格和功能值;但对于某个运行时键是否必需、可选、允许为空或会被静默设为默认值,二者必须保持一致。

应用时将失败(全局唯一):

资源范围修复方法
SSH 密钥对区域"${env}-deploy"
SLS 日志项目账户"${env}-logs"
CloudMonitor 联系人账户"${env}-ops"

DNS 重复陷阱:两个环境在同一个 Cloudflare 区域中为同一名称创建 A 记录 → 生成两个独立的记录 ID → DNS 轮询 → 约 50% 的流量会指向错误的实例。修复方法:使用子域名隔离(staging.example.com)或单独的区域。请记得为 Caddy 服务的所有子域名创建 DNS 记录(例如 auth.staging、minio.staging)。

快照交叉污染:未筛选的 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——这会改变其状态地址并导致漂移。

部署前验证

审批或修改之前,请阅读并执行 pre-deploy-validation.md。该文档规定发布者关卡、 验证阶段、新计划授权以及绑定目标的晋级证据。

从零到部署

全新磁盘会暴露所有隐式依赖。请参阅 references/zero-to-deploy-checklist.md。

在获准暂停服务或首次写入线上数据之前,还应使用 pre-deploy-validation.md 检查凭据权限并做好发布者准备。全新主机检查清单负责引导依赖、执行顺序和内存。

评论

支持 Markdown