
一次网络超时的 apply、同事在CI运行期间从本地执行了 terraform apply、周五晚上一个手滑的 state rm:结果都是一样的。状态文件不再反映真实的 infrastructure,plan 提议删除生产数据库,整个团队都被卡住了。
这时候很容易冒出"重新来过"的冲动:清空 state、全量重新 import,甚至更糟的——让 Terraform 重新创建资源。在生产环境上,这是最糟糕的决定。这篇文章详细讲解真正有效的恢复操作,按正确的顺序逐个尝试,从最常见的情况开始:阿里云OSS(对象存储)+ OTS(表格存储)作为 backend。
Terraform 的 state 不是一个缓存文件,它是一个数据库。它包含了代码中资源地址(如 module.rds.alicloud_db_instance.main)与 provider 真实 ID(如 prod-pg-01)的对应关系,再加上最后一次 refresh 时读取的属性。三个特性让它变得脆弱:
serial 做版本控制。 Terraform 拒绝写入 serial 不大于已存储值的 state,也拒绝写入 lineage(state 的 UUID)不同的任何 push。这是一个保护机制,但在恢复备份时也可能变成一堵墙。Error acquiring the state lock 卡住。典型的状态不一致原因:apply 中断(部分资源已创建但 state 未写入)、在控制台手动修改、重构模块时没用 moved、在 Terraform 外部删除资源、或并发双重写入。
在任何会写入的命令之前,先稳住局面。
冻结:禁用所有 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
使用阿里云 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"}}'
-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"}}'
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。
如果 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 rm 和 state mvstate 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 命令不同,它们不会因为值班工程师不是当初修复的人而失效。
从 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_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 在中途崩溃时,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 限制)。plan 的 CI 角色只需要 s3:GetObject 和 ots:GetRow。只有 apply 角色才需要 s3:PutObject 和 ots:PutRow/DeleteRow。ephemeral 资源(Terraform ≥ 1.10/1.11)处理密码,通过 External Secrets Operator 管理 Kubernetes secret 而不是用 kubernetes_secret。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;永远不要基于猜测来写入。-md5 记录,那是手动恢复后报莫名其妙错误的常见原因。terraform state push 恢复,而不是通过 OSS 控制台:digest 和 serial 会自动处理。moved、removed 和 import 可版本化、可在 PR 中审查、可重复执行;state mv/rm 只是应急工具。-target 是恢复模式,不是正常工作模式:最后一定要跑完整的 apply。state 损坏事件真正教会我们的是:不是那一串命令序列有多复杂,而是整个生产环境依赖单一 monolithic state 会把任何小问题放大成团队级阻塞。按 blast radius 分割 state——网络、数据、 workloads——才是最好的保险。