site logo

Marico's space

Terraform:状态文件损坏的生存指南,安全修复不毁一切

服务器技术 2026-10-15 11:28:57 8

一次网络超时的 apply、同事在CI运行期间从本地执行了 terraform apply、周五晚上一个手滑的 state rm:结果都是一样的。状态文件不再反映真实的 infrastructure,plan 提议删除生产数据库,整个团队都被卡住了。

这时候很容易冒出"重新来过"的冲动:清空 state、全量重新 import,甚至更糟的——让 Terraform 重新创建资源。在生产环境上,这是最糟糕的决定。这篇文章详细讲解真正有效的恢复操作,按正确的顺序逐个尝试,从最常见的情况开始:阿里云OSS(对象存储)+ OTS(表格存储)作为 backend。

为什么 state 会坏,以及为什么它能让一切瘫痪

Terraform 的 state 不是一个缓存文件,它是一个数据库。它包含了代码中资源地址(如 module.rds.alicloud_db_instance.main)与 provider 真实 ID(如 prod-pg-01)的对应关系,再加上最后一次 refresh 时读取的属性。三个特性让它变得脆弱:

  • 它是按 workspace monolithic(单体式)的。 部分损坏会阻塞整个 state 的所有资源,不只是出问题的那个。
  • 它通过 serial 做版本控制。 Terraform 拒绝写入 serial 不大于已存储值的 state,也拒绝写入 lineage(state 的 UUID)不同的任何 push。这是一个保护机制,但在恢复备份时也可能变成一堵墙。
  • 它受外部锁保护。 如果持有锁的进程突然崩溃(CI runner 被杀、Pod 被驱逐、Ctrl-C 未被正确处理),锁会残留。所有人都会被 Error acquiring the state lock 卡住。

典型的状态不一致原因:apply 中断(部分资源已创建但 state 未写入)、在控制台手动修改、重构模块时没用 moved、在 Terraform 外部删除资源、或并发双重写入。

步骤 0:冻结、备份、诊断

在任何会写入的命令之前,先稳住局面。

冻结:禁用所有 Terraform 流水线(GitHub Actions、GitLab CI、ArgoCD 对相关资源的 auto-sync),并宣布暂停所有手动 apply。两个人并行修复同一个 state,等于保证再次损坏。

备份:terraform state pull 不加写锁,能获取一份准确的副本。

mkdir -p ~/tf-rescue && cd ~/tf-rescue
terraform state pull > state-$(date -u +%Y%m%dT%H%M%SZ).json
jq '{version, serial, lineage, resources: (.resources | length)}' state-*.json

记下 serial 和 lineage:这两个值在后续任何恢复操作中都是必需的。

只读诊断:plan -refresh-only 比较真实状态与 state,但不提出配置变更。这是被严重低估的诊断工具。

terraform plan -refresh-only -lock=false -out=drift.tfplan
terraform show -json drift.tfplan | jq -r ' .resource_drift[]? | "\(.address)\t\(.change.actions | join(","))"'

在 CI 中,-detailed-exitcode 返回 0(无差异)、1(错误)、2(检测到差异):

terraform plan -refresh-only -detailed-exitcode -lock=false

然后交叉对比 state 中的资源列表:

terraform state list | wc -l
terraform state show alicloud_db_instance.main | head -30

正确解锁

检查 OTS(表格存储)锁

使用阿里云 OSS 作为 backend 时,OTS 表的主键是 LockID(String 类型)。每份 state 在表中会有两条记录:锁本身和一个 digest。

aliyunOTS GetRow \ --instanceName terraform-locks \ --tableName terraform-locks \ --primaryKey '{"LockID":{"S":"tf-state-prod/env/prod/terraform.tfstate"}}' \ --columns-to-get '["Info"]' | jq .

输出会显示锁的 ID、操作类型、Who 和 Created。在解锁之前,确认进程确实已经死亡(Pod 已终止、CI job 已失败)。强制解锁一个活跃的锁,等于制造并发写入。

terraform force-unlock -force 9f3c1a2b-4f0e-4b7d-8c21-7f1a0d2e5c33

如果 backend 已经无法初始化,直接删除这条记录:

aliyunOTS DeleteRow \ --instanceName terraform-locks \ --tableName terraform-locks \ --primary-key '{"LockID":{"S":"tf-state-prod/env/prod/terraform.tfstate"}}'

Digest -md5 的陷阱

第二条记录,通常被忽略:LockID = "<bucket>/<key>-md5",带有一个 Digest 属性。Terraform 会把这个 digest 与 OSS 中文件的实际内容对比。如果你是通过 OSS 控制台恢复旧版 state(而不是通过 terraform state push),会得到:

Error: state data in S3 does not have the expected content.

这种情况,删掉 digest 记录即可:Terraform 会在下次 apply 时重新计算。

aliyunOTS DeleteRow \ --instanceName terraform-locks \ --tableName terraform-locks \ --primary-key '{"LockID":{"S":"tf-state-prod/env/prod/terraform.tfstate-md5"}}'

原生 OSS 锁(Terraform ≥ 1.11)

OTS 不再是必选项。OSS backend 支持通过 .tflock 对象和 If-None-Match 实现锁机制。

terraform { backend "s3" { bucket = "tf-state-prod" key = "env/prod/terraform.tfstate" region = "cn-beijing" encrypt = true kms_key_id = "arn:acs:kms:cn-beijing:111122223333:key/abcd-1234" use_lockfile = true }
}

手动解锁变成简单的 ossutil rm oss://tf-state-prod/env/prod/terraform.tfstate.tflock。

从 OSS 版本控制恢复

如果 state 文件被截断、空文件或 JSON 格式损坏,恢复是最快的路径。但这要求 bucket 已开启版本控制——如果没开,先停下来把版本控制打开再继续。

ossutil oss://tf-state-prod/env/prod/terraform.tfstate --versions --list | head -20 ossutil restore oss://tf-state-prod/env/prod/terraform.tfstate -v 3HL4kqtJlcpXroDTDmjVBH40Nrjfkd --output restored.tfstate jq '{serial, lineage, resources: (.resources | length)}' restored.tfstate

正确的做法是通过 state push 推送这个 state,而不是直接在 OSS 控制台覆盖:state push 会重新计算 digest 并遵守 backend 协议。

# serial 必须严格大于当前存储的 serial
CURRENT=$(terraform state pull | jq -r '.serial')
jq --argjson s "$((CURRENT + 1))" '.serial = $s' restored.tfstate > push.tfstate
terraform state push push.tfstate

如果 lineage 不同(中间重新创建了 state),terraform state push -force push.tfstate 可以强制覆盖。只有在确认目标 lineage 对应的是同一套 infrastructure 时才使用,否则你会覆盖掉另一个环境的 state。

立即通过 plan -refresh-only 验证。恢复版本之后创建的资源会显示为"待创建":这些就是孤儿资源,需要 import。

State 外科手术

state rm 和 state mv

state rm 从 state 中移除一个资源但不会销毁它。这是处理属性损坏资源的工具,先移除再重新 import。

terraform state rm 'module.network.alicloud_nat_gateway.this[0]'
terraform state rm 'kubernetes_manifest.app_route' # 从 state 移除,Route 保留

state mv 用来重命名地址——在重构后有用,但更推荐使用 moved 块(声明式、可版本化、可在 CI 中重放)。

terraform state mv 'alicloud_instance.web' 'module.frontend.alicloud_instance.web'
terraform state mv -state-out=../network/terraform.tfstate \ 'module.vpc' 'module.vpc' # state 拆分

moved 和 removed 块

moved { from = alicloud_instance.web to = module.frontend.alicloud_instance.web
} # Terraform >= 1.7:从 state 声明式移除,不销毁
removed { from = alicloud_oss_bucket.legacy_logs lifecycle { destroy = false }
}

这些块在 plan 时执行,可在 Git 中追踪、支持 PR 审查、可重复执行:与 CLI 命令不同,它们不会因为值班工程师不是当初修复的人而失效。

Import 孤儿资源

从 Terraform 1.5 开始,import 是声明式的,可以在 plan 阶段验证。

import { to = alicloud_db_instance.main id = "prod-pg-01"
} import { to = kubernetes_deployment_v1.api id = "prod/api"
} import { to = helm_release.ingress id = "openshift-ingress/nginx"
}
terraform plan -generate-config-out=generated.tf
terraform apply # 执行 import,不创建任何资源

-generate-config-out 会从真实对象生成 HCL 配置:一个有四十个属性的资源能节省大量时间。记得审阅生成的文件,里面经常有需要清理的计算值。

Kubernetes 和 OpenShift 场景

kubernetes_manifest 资源使用结构化 ID。比如下面这个 OpenShift Route,是在故障期间手动创建的:

apiVersion: route.openshift.io/v1
kind: Route
metadata: name: api namespace: prod labels: app.kubernetes.io/name: api app.kubernetes.io/managed-by: terraform
spec: host: api.apps.cluster.example.com to: kind: Service name: api weight: 100 port: targetPort: http tls: termination: edge insecureEdgeTerminationPolicy: Redirect

对应的 import:

import { to = kubernetes_manifest.api_route id = "apiVersion=route.openshift.io/v1,kind=Route,namespace=prod,name=api"
}

注意:kubernetes_manifest 要求在 plan 阶段就能访问集群 API。如果无法连接(堡垒机、VPN 断开),plan 会在到达 state 之前就失败。在 OpenShift 集群上,先检查:

oc whoami --show-server
oc auth can-i get routes -n prod

Apply 中断的特殊情况

当 apply 在中途崩溃时,Terraform 通常会写一个本地的 errored.tfstate。别弄丢这个文件:它包含了失败前已创建的资源。

ls -l errored.tfstate
jq -r '.resources[].instances[].attributes.id' errored.tfstate | head
terraform state push errored.tfstate

如果这个文件不存在(CI runner 被销毁),通过 import 重建。按依赖顺序用 -target 定向:只在恢复模式下使用 -target:

terraform apply -target=module.network -lock-timeout=10m
terraform apply # 完整收敛,不要用 -target

最后一定要跑一个非定向的 apply:-target 会故意留下图中未被评估的部分。

安全最佳实践

State 文件中明文包含敏感数据:RDS 密码、私钥、ServiceAccount token、kubernetes_secret 内容。把它当 secret 对待,而不是构建产物。

  • 强制加密:encrypt = true + 专用的 kms_key_id,加上 bucket policy 拒绝任何非该密钥加密的写入(Condition 对 s3:x-amz-server-side-encryption-aws-kms-key-id 限制)。
  • 版本控制 + 非当前版本保留策略(≥ 90 天)并通过生命周期策略阻止删除;版本控制是唯一真正的备份。
  • 读写 IAM 分离:只执行 plan 的 CI 角色只需要 s3:GetObject 和 ots:GetRow。只有 apply 角色才需要 s3:PutObject 和 ots:PutRow/DeleteRow。
  • 不用静态密钥:通过 OIDC(GitHub Actions、GitLab)或 RAM Role for Service Account(RRSA)/ Pod Identity 实现。
  • 审计日志:在 state bucket 上开启操作审计日志。故障后,知道谁写了第 N-1 版本能节省数小时。
  • 减少暴露面:使用 write-only 参数或 ephemeral 资源(Terraform ≥ 1.10/1.11)处理密码,通过 External Secrets Operator 管理 Kubernetes secret 而不是用 kubernetes_secret。
  • 绝对不要在 CI 产物中 terraform state pull,也不要出现在日志中。TF_LOG=DEBUG 会暴露敏感属性。

检测层面,集群上跑一个 drift 检测 CronJob 效果比任何事后复盘都好:

apiVersion: batch/v1
kind: CronJob
metadata: name: terraform-drift-detect namespace: platform-ops
spec: schedule: "0 6 * * 1-5" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 5 jobTemplate: spec: backoffLimit: 1 template: spec: serviceAccountName: terraform-readonly restartPolicy: Never containers: - name: terraform image: hashicorp/terraform:1.11 command: ["/bin/sh", "-c"] args: - | set -e cd /workspace terraform init -input=false -lock=false terraform plan -refresh-only -lock=false -detailed-exitcode env: - name: ALICLOUD_REGION value: cn-beijing - name: ALICLOUD_ACCESS_KEY_ID valueFrom: secretKeyRef: name: terraform-creds key: access-key - name: ALICLOUD_SECRET_ACCESS_KEY valueFrom: secretKeyRef: name: terraform-creds key: secret-key volumeMounts: - name: workspace mountPath: /workspace securityContext: allowPrivilegeEscalation: false runAsNonRoot: true capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault volumes: - name: workspace emptyDir: {}

退出码 2 触发告警:你在周一早上发现 drift,而不是在半夜被叫醒。

总结:核心要点

  • 动手之前先备份:terraform state pull > backup.json,然后记下 serial 和 lineage。
  • 用只读方式诊断:plan -refresh-only -lock=false 和 -detailed-exitcode;永远不要基于猜测来写入。
  • 确认锁已死亡再强制解锁。 别忘了 DynamoDB/OTS 中的 -md5 记录,那是手动恢复后报莫名其妙错误的常见原因。
  • 通过 terraform state push 恢复,而不是通过 OSS 控制台:digest 和 serial 会自动处理。
  • 优先用声明式而非 CLI:moved、removed 和 import 可版本化、可在 PR 中审查、可重复执行;state mv/rm 只是应急工具。
  • -target 是恢复模式,不是正常工作模式:最后一定要跑完整的 apply。
  • State 是 secret:KMS 加密、版本控制、读写 IAM 分离、审计日志、持续 drift 监控。

state 损坏事件真正教会我们的是:不是那一串命令序列有多复杂,而是整个生产环境依赖单一 monolithic state 会把任何小问题放大成团队级阻塞。按 blast radius 分割 state——网络、数据、 workloads——才是最好的保险。