跳到主要内容

State 状态管理

这个系列从 核心概念与架构 讲到 安装与快速上手、HCL 语言基础,apply 已经跑过几轮了。每轮跑完,目录下都会多出一个 terraform.tfstate——平时没人正眼看它,但它才是 Terraform 真正的大脑:改动是怎么算出来的、哪些资源归 Terraform 管,全在这一个文件里。这篇把 State 讲透:它是什么、为什么本地 state 撑不住团队协作、远程后端和锁怎么配、terraform state 子命令的实战用法,以及绕不开的敏感信息问题。

环境说明

文中示例基于 Terraform 1.9+,操作系统 macOS 或 Linux。远程后端以 AWS S3 + DynamoDB 为例;其他后端(阿里云 OSS、腾讯 COS、GCS)概念相同,只是参数名有差异。

State 是什么​

一句话:State 是 Terraform 维护的一本账,记录「配置里声明的期望」和「云上真实存在的资源」之间的映射关系。

你在 main.tf 里写了:

resource "local_file" "hello" {
filename = "./hello.txt"
content = "hello terraform"
}

apply 之后,Terraform 把「local_file.hello 这个地址对应现实里的哪个资源、它的实际属性是什么」记进 terraform.tfstate。下次 plan 时,Terraform 拿配置(期望)和 state(已知的现实)对账,算出要增、改、删什么——plan 输出的每一行都来自这次对账。

为什么说它是 Terraform 的核心:

  • 没有 state,Terraform 就不知道哪些资源归它管。云上有 100 台机器,它怎么知道哪 3 台是这份配置建的?靠 state 里的映射。
  • 性能。每次 plan 都把云上资源全量查一遍,又慢又容易撞 API 限流;state 是一份本地缓存。
  • 记录元信息。资源之间的依赖顺序、count / for_each 展开后的实例归属,都在里面。

和 Ansible 的无状态设计 对比最直观:Ansible 每次执行都现连现查,playbook 跑完什么也不留;Terraform 则必须记住现实,state 丢了等于失忆。

打开看一眼(精简过,真实文件更长):

{
"version": 4,
"terraform_version": "1.9.8",
"serial": 12,
"lineage": "4b9e6f1a-9c2d-4a1e-b3f7-2e8c5d6a1f09",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "local_file",
"name": "hello",
"provider": "provider[\"registry.terraform.io/hashicorp/local\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"filename": "./hello.txt",
"content": "hello terraform",
"file_permission": "0777"
}
}
]
}
]
}

几个字段值得认一下:

字段含义
serialstate 的版本号,每次变更 +1,防止旧副本覆盖新副本
lineage这份 state 的「血缘 ID」,创建时生成,state push 时校验,防止把 A 项目的 state 推给 B 项目
resources资源清单:type.name 对应配置里的地址,instances 里存实际属性
outputsapply 后缓存的输出值,所以 terraform output 不用联网就能出结果
每次运行都会对一遍账

默认情况下 plan 和 apply 会先 refresh:拿 state 里记的 ID 去云上查实际属性,发现人工改动就把 state 更新成现实。所以 state 不是死板快照,而是每次运行都和现实校准一遍的账本。

本地 State 的问题​

默认配置下 state 落在当前目录的 terraform.tfstate,一个人折腾没问题,进了团队就全是坑:

  • 协作互相覆盖。你在改网络,同事在加机器,两人各拿自己本地的 state 去 apply——后跑的那个人根本不知道对方的变更存在,直接把对方刚建的资源当成「多余的」删掉。
  • 丢了就失控。terraform.tfstate 被误删、笔记本损坏,Terraform 瞬间失忆:云上资源还在,但它不知道该管谁了。要么一个个 terraform import 捞回来,要么手动清掉重建,都是体力活。
  • 敏感信息明文。数据库密码、私钥只要被 Terraform 管理,就会明文躺在 state 里(后文细说)。本地文件谁都能打开,再一不小心提交进 git 就是事故。

结论很简单:个人玩具可以本地 state,团队项目第一天就该上远程后端。

远程后端 Backend​

Backend 决定 state 存哪、怎么加锁,在 terraform 块里声明。最经典的组合是 S3 + DynamoDB:

terraform {
backend "s3" {
bucket = "my-tfstate-bucket" # 存 state 的 S3 桶
key = "prod/terraform.tfstate" # 桶内路径,按项目/环境区分
region = "ap-southeast-1"
dynamodb_table = "terraform-state-lock" # 锁表,防并发写
encrypt = true # 服务端加密
}
}

S3 负责存文件,DynamoDB 负责加锁(下一节讲锁),encrypt = true 让 state 落盘时服务端加密,这是底线。认证走 AWS 标准链(环境变量、~/.aws/credentials、IAM role),别把密钥写进配置文件。

1.9 起 S3 后端可以不用 DynamoDB

Terraform 1.9 给 S3 后端加了原生锁文件:把 dynamodb_table 换成 use_lockfile = true,锁直接以 .tflock 文件的形式放在 S3 桶里,省掉维护 DynamoDB 表这一步。新项目可以用,老项目继续用 DynamoDB 也没问题。

不想自己维护桶和表的话,HashiCorp 官方的 HCP Terraform(原 Terraform Cloud)是另一种后端,顺带提供 Web UI、团队权限和远程执行,这里不展开。

配好 backend 块后重新初始化,从本地 state 迁移到 S3 的完整过程:

terraform init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. No existing state was found in the newly
configured "s3" backend. Do you want to copy this state to the new "s3"
backend? Enter "yes" to copy and "no" to start with an empty state.

Enter a value: yes

输 yes,Terraform 把本地 state 推上 S3,之后所有读写都走远程。迁移成功后本地那份 terraform.tfstate 可以删掉(建议先备份再删)。-migrate-state 是通用迁移开关,换后端、换 key 都用它。

backend 块里不能用变量

backend 配置在变量加载之前解析,key = "${var.env}/terraform.tfstate" 这种写法会直接报错。想参数化就用部分配置:backend 块里留空字段,init 时用 -backend-config="key=prod/terraform.tfstate" 传入;或者干脆按环境拆目录,每个环境一份 backend 配置——这是下一篇的主题。

State Lock​

state 共享之后紧接着的问题:两个人同时 apply 会怎样?

假设 A 和 B 基于同一个 state 各自 plan 完,A 先写回,B 后写回——B 的 state 里没有 A 刚建的资源,下次 plan 就会把它们当成「该删的」。典型的并发覆盖事故。

锁的作用就是串行化:谁拿到锁谁操作,其他人排队。配好 DynamoDB 锁之后,后来者会看到:

Acquiring state lock. This may take a few moments...

Error: Error acquiring the state lock
...
Lock Info:
ID: 5f2b8c1e-7a3d-4e9f-b2c6-8d1a3e5f7c09
Path: my-tfstate-bucket/prod/terraform.tfstate
Operation: OperationTypeApply
Who: liangyou@macbook-pro
Version: 1.9.8
Created: 2026-10-25 09:30:15.123456789 +0800 CST

正常情况下等对方 apply 完即可。但异常情况会留下死锁:apply 跑到一半断网、进程被 kill、CI 任务超时被杀——锁没人释放,后面所有人被挡在门外。CI 环境里这尤其常见,值得给锁等待加个告警。

确认持锁方真的死了之后,用 force-unlock 强制释放,锁 ID 从报错信息里抄:

terraform force-unlock 5f2b8c1e-7a3d-4e9f-b2c6-8d1a3e5f7c09
force-unlock 之前必须确认锁是死的

执行前务必确认持锁进程真的不在了:问同事、查 CI 任务状态。对方还在正常 apply 时强抢锁,等于亲手制造并发覆盖事故现场。

State 子命令​

terraform state 一族是直接操作 state 的手术刀,日常六个够用:

子命令干什么典型场景
list列出 state 里全部资源地址看这份 state 都管着什么
show查看某个资源的完整属性排查资源的实际状态
mv改资源在 state 里的地址重构配置时改名而不重建
rm把资源从 state 里摘除让 Terraform 不再管这个资源
pull把远程 state 拉到本地输出备份、排查、审计
push把本地 state 推到远程手动修复后回写(慎用)

list / show:先看清现状​

terraform state list
local_file.hello
module.network.local_file.topology
terraform state show local_file.hello
# local_file.hello:
resource "local_file" "hello" {
content = "hello terraform"
file_permission = "0777"
filename = "./hello.txt"
id = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
}

show 输出的是 state 记下的真实属性。它和配置对不上时,就该怀疑有人在控制台手改了。

mv:改名不重建​

重构配置时最常见的需求:资源挪进 module、改个更有意义的名字。直接改配置再 apply,Terraform 会认为「旧的删了、建个新的」——对数据库这类资源就是灾难。mv 把 state 里的地址改掉,Terraform 就知道还是同一个东西:

# 资源改名
terraform state mv local_file.hello local_file.greeting

# 资源挪进 module
terraform state mv local_file.hello module.app.local_file.hello

# 平铺资源改成 for_each(bash/zsh 下单引号必须加)
terraform state mv local_file.hello 'local_file.hello["web"]'

改完跑 terraform plan,输出应该是 No changes——账本只是换了个记法,现实纹丝不动,这就对了。

团队协作用 moved 块更稳

state mv 是本地一次性操作,团队其他成员的 state 不会自动跟上。更稳的做法是在配置里写 moved 块,声明「旧地址 → 新地址」,apply 时自动迁移,跟着 git 分发给所有人:

moved {
from = local_file.hello
to = module.app.local_file.hello
}

迁移稳定一段时间后把 moved 块删掉即可。临时救急用 state mv,正式重构用 moved 块。

rm:让 Terraform 放手​

某个资源不想再用 Terraform 管了——移交别的团队、改手动维护——用 rm 把它从 state 里摘掉:

terraform state rm local_file.hello

划重点:这只动账本,不删真实资源。摘掉后 Terraform 对它失忆,云上那个文件、那台机器照常活着,以后归你手动管。记得同时把配置里对应的 resource 块删掉,否则 plan 会想把它再建一遍。反方向的操作是把已有资源纳入管理:terraform import 或 import 块。

pull / push:直接读写 state​

# 备份一份到本地(带日期戳)
terraform state pull > backup-$(date +%Y%m%d).tfstate

# 把手动修过的 state 推回远程
terraform state push repaired.tfstate

pull 安全,随便用——排查、审计、定期备份都靠它。push 是最后手段:绕过锁和 refresh 直接覆盖远程 state,只有手工修过 state 文件(极少见)才用,推之前先 pull 一份备份是底线。lineage 对不上时 push 会直接拒绝,这是防呆设计,别想着绕过。

敏感信息问题​

必须直面的现实:state 里的敏感值是明文存的。

数据库密码、API key、私钥——只要出现在资源属性或输出值里,就会明文躺在 tfstate 中。给 output 标 sensitive = true 只是让 CLI 不打印:

output "db_password" {
value = random_password.db.result
sensitive = true # 只影响 CLI 显示,state 里照样明文
}

所以防护要做在 state 的存储和访问上:

  • 后端加密:S3 开 encrypt = true,桶层面再确认默认加密策略;落盘加密是底线。
  • 收紧访问:state 桶和锁表的 IAM 只放给真正需要的人和 CI 角色——能读 state 就等于拿到全部敏感值,普通开发不该有读权限。
  • 不进 git:本地残留的 tfstate 第一时间加进 .gitignore(下节有现成的)。
  • 能不进就不进:敏感值尽量走「运行时注入、不落资源属性」的路子(比如动态凭据、临时密钥);绕不开的,接受「state 有明文」这个事实,把上面三条做到位。

常见坑​

控制台手改资源,state 失真。有人在 AWS 控制台改了一条安全组规则,Terraform 不知道,下次 refresh 才发现「配置和现实对不上」,plan 出一堆莫名其妙的变更;更糟的是 apply 会把人工改动冲掉。纪律只有一条:Terraform 管的资源,改动一律走代码。紧急情况手改了,回头立刻把配置补上。

tfstate 提交进 git。等于把基础设施全貌和全部敏感值公开。.gitignore 里至少要有这几行:

# Terraform
.terraform/
*.tfstate
*.tfstate.*
crash.log

已经提交了的话,git rm --cached 只是治标——历史记录里还有。敏感值按泄露处理(轮换密码、密钥),再用 filter-repo 之类的工具清历史。

多人共用一份本地 state。最原始的「协作」方式:tfstate 丢共享盘或 IM 群里传来传去。这和「没有锁、没有版本管理的远程后端」没区别,出事只是时间问题。老老实实上远程后端。

最佳实践小结​

  • state 是配置与现实的映射账本,Terraform 的核心;动它之前先想清楚后果
  • 团队项目第一天就上远程后端(S3 + 锁),别等出事再迁移
  • 锁卡住先确认持锁进程的死活,再 force-unlock
  • 重构改名优先 moved 块,state mv 用来救急;验收标准是 plan 出 No changes
  • state rm 只摘账本不删资源;state pull 定期备份;state push 能不用就不用
  • 敏感值在 state 里是明文:后端加密、收紧 IAM、不进 git,三件套配齐
  • 下一篇讲模块化与多环境组织,会看到「按目录拆环境」顺带把 state 隔离也解决了