一、先聊聊这个烦心事
在团队里折腾基础设施,最怕的一件事就是几个人同时跑部署命令。状态文件好比公用的账本,同时改账本肯定要出乱子。远程状态锁就有点像记账时先拿把钥匙锁上柜子,等记完再开锁。但有时候这个锁会因为各种原因打不开,或者明明没人操作了却还显示被锁着,这就让整个团队卡在那儿,谁也没法干活。
这种情况在团队协作中特别常见。尤其是当大家使用的是本地状态文件时,冲突会更明显。后来我们才改用远程状态存储,把状态文件放在了共享存储上,并用一把“电子锁”来控制。虽然这样避免了大部分问题,但锁本身也会闹情绪。这篇文章就来聊聊这些闹情绪的场景,以及怎么设计一套靠谱的方案,让团队在并行操作时状态保持一致。
二、状态锁是怎么工作的
远程状态锁的本质很简单:在执行“写状态”的操作前,先尝试在一个集中式的存储里创建一个“占用标记”。谁创建成功,谁就拿到了锁。操作结束,就把标记删掉。如果在创建标记时发现已经存在,就说明有人正在改状态,你得等着。
2.1 远程后端配置示例
我们用的技术栈是 HashiCorp Terraform 加上 AWS 上的 S3 和 DynamoDB。把状态文件放在 S3 上,同时在 DynamoDB 里放一张锁表。下面的代码展示了 backend 的配置:
# 技术栈:Terraform + AWS S3/DynamoDB
terraform {
backend "s3" {
# 存放状态文件的桶名称
bucket = "my-team-state-bucket"
# 每个项目一个路径,避免互相干扰
key = "prod/network/terraform.tfstate"
# 桶所在的区域
region = "us-east-1"
# 开启加密,保证传输和静态存储都是安全的
encrypt = true
# 指向我们提前建好的 DynamoDB 锁表
dynamodb_table = "terraform-lock-table"
# 如果拿不到锁,最长等待 5 分钟
lock_timeout = "5m"
}
}
这段配置里最关键的就是 dynamodb_table。Terraform 在需要写状态之前,会往这张表里插入一条记录,写完再删掉。lock_timeout 表示如果别人占着锁,最多等多久,超过就报错。这个参数能解决一部分“偶尔撞车”的问题,但不能解决所有问题。
2.2 DynamoDB 锁表长什么样
锁表其实很简单,里面一般只有一个主键叫 LockID。我们可以用 AWS 命令查看表里现在有没有锁记录:
# 查看锁表里所有记录,这里假设表名是 terraform-lock-table
aws dynamodb scan \
--table-name terraform-lock-table \
--region us-east-1
如果没有输出内容,说明没有任何锁,状态是干净的。如果输出了一条记录,里面会包含一个 LockID,它看起来像一个长长的路径,并且包含一段随机字符串,这就是当前持锁人的身份标识。实际上,Terraform 在锁记录里还会存一个 ID 字段,那个字段才是解锁时要用的随机 UUID。
三、锁异常是怎么发生的
正常流程下,锁的创建和释放都很快,快到几乎感觉不到。但是现实里总有些意外情况。
第一种情况是有人把 apply 命令强行停掉了,比如按了 Ctrl+C,或者电脑突然断电。Terraform 进程还没来得及清理锁,锁就留在了表里。这种是最常见的锁残留。
第二种情况是网络出了问题。明明操作已经完成了,但删除锁标记的请求没发出去,或者发了没成功,结果锁就残留下来。尤其是办公室网络不稳定的时候,这种事情一周能遇到好几回。
第三种情况比较低级,但也很常见:某位同事觉得锁是多余的,手动去 DynamoDB 里把记录删了,或者直接把整个表清空了。这样就会造成多个操作同时进行,最后状态文件互相覆盖,这个后果比锁卡住更严重。
还有一种情况是权限问题。比如某个人只有写 S3 的权限,没有写 DynamoDB 的权限,那么他在获取锁的时候就会一直报错,虽然状态文件本身没有锁,但他每次操作都会失败。这类问题很容易被误判成“锁异常”。
当锁出现异常时,你可能会看到类似这样的报错信息:
# 这是 Terraform 在等待锁时输出的典型错误
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
ID: 6334d760-1234-4d80-9c2c-6f1f8b0c2e5a
Path: my-team-state-bucket/prod/network/terraform.tfstate
Operation: OperationTypeApply
Who: zhangsan@example.com
Created: 2025-01-15 10:23:45.678 +0000 UTC
看到这串东西先别慌,它只是告诉你锁被谁拿走了。重要的是接下来怎么判断这个锁是“活的”还是“死锁”。
四、怎么诊断锁异常
发现问题后,先别急着强行解锁。第一步是确认到底是谁持有了锁,以及他的操作是否还在进行。我们可以直接查 DynamoDB,有时候也需要和同事沟通确认。
4.1 查看状态文件当前有没有锁记录
最直接的方法是用 terraform force-unlock?先别急,那只是用来解锁的。实际上查看锁记录可以用 AWS CLI 查询 DynamoDB 表。由于状态文件路径会包含在 LockID 里,我们可以做精确查询:
# 查询指定状态文件的锁记录,LockID 就是状态文件路径本身
aws dynamodb query \
--table-name terraform-lock-table \
--key-condition-expression "LockID = :lockid" \
--expression-attribute-values '{":lockid":{"S":"my-team-state-bucket/prod/network/terraform.tfstate"}}' \
--region us-east-1
注意 LockID 是表的主键,用 query 比 scan 更高效。如果查询有结果,里面会有完整信息,再判断持锁人是不是已经不在线了。如果查询结果为空,那就说明锁其实已经释放了,问题出在别的地方,比如你的网络配置或者权限配置。
4.2 确认没有人在操作后,再强制解锁
如果确认锁是残留的,那么可以强制删除。使用下面的命令:
# 把 <锁ID> 替换成锁记录里的随机 ID,而不是 LockID 路径
terraform force-unlock <锁ID>
# 为了避免交互式确认,可以加上 -force 参数
terraform force-unlock -force <锁ID>
要注意,强制解锁前,一定要确认没有别的进程正在执行 apply 或者 plan。否则两个人同时拿到锁,状态文件就危险了。这步操作需要谨慎,最好有团队中的资深成员来执行。因为一旦解锁失败,你可能会在完全竞争的情况下覆盖别人的变更。
五、保障并行操作状态一致性的完整方案
锁异常只是表面问题,真正要解决的是“团队并行操作时状态一致”这个大目标。下面这套方案是我们实践下来比较靠谱的做法。它不是某一个命令,而是一整套组合拳。
5.1 给后端配置加上更严谨的参数
刚才的 backend 里已经看到 lock_timeout。这个参数虽然简单,但能减少很多因为临时冲突导致的失败。比如两个人在很短的时间内都要改同一个状态,后者会等几分钟,而不是立刻报错。除此之外,还可以给 AWS provider 配置请求重试次数:
# 在 provider 里设置请求重试次数,避免网络抖动造成误判
provider "aws" {
region = "us-east-1"
max_retries = 5
}
注意,max_retries 针对的是所有 AWS 请求,包括锁表操作。这样遇到网络抖动时,Terraform 会多试几次,而不是直接判定为失败。同时,我们也会把 lock_timeout 设置成三到五分钟,兼顾效率和安全。
5.2 用工作区隔离不同环境的状态锁
如果你的团队有好几个环境,比如开发环境和生产环境,千万不要让所有环境共用同一个状态文件。Terraform 的工作区可以让每一个环境拥有独立的 key,这样它们的锁也是完全分开的。
# 创建开发环境工作区
terraform workspace new dev
# 创建生产环境工作区
terraform workspace new prod
# 切换到生产环境工作区
terraform workspace select prod
当使用 S3 后端时,工作区的名字会自动拼到 key 后面,例如原本的 prod/network/terraform.tfstate 会变成类似 prod/network/env:/prod/terraform.tfstate 这样的形式,实际格式会根据版本略有差异。这样开发和生产各锁各的,互不影响。更重要的是,一个人在开发环境里跑挂了,不会阻塞生产环境其他人发布版本。
5.3 在 CI 流水线里做状态一致性检查
很多团队直接在本地跑 terraform,这很容易忘掉锁的事。更好的做法是把 plan 和 apply 放到 CI/CD 工具里,比如 Jenkins、GitLab CI 或者 GitHub Actions。在流水线里,我们可以先检查目标状态文件是否被锁住,锁住了就直接失败,避免无谓的等待。下面是一个简单的检查脚本:
# 技术栈:Terraform + AWS + Bash 脚本
# 这个脚本用于检查 DynamoDB 锁表里是否有指定状态文件的锁记录
LOCK_TABLE="terraform-lock-table"
STATE_KEY="my-team-state-bucket/prod/network/terraform.tfstate"
# 用 AWS CLI 查询锁记录,注意这里查的是 LockID 主键
result=$(aws dynamodb get-item \
--table-name "$LOCK_TABLE" \
--key "{\"LockID\":{\"S\":\"$STATE_KEY\"}}" \
--region us-east-1)
# 如果 result 为空,说明没有锁,可以继续
if [ -z "$result" ]; then
echo "状态文件没有被锁,可以安全执行操作"
exit 0
else
echo "检测到锁记录,可能有人在操作,请稍后再试"
echo "$result"
exit 1
fi
这个脚本放在 CI 里,可以在 plan 之前执行,起到一个快速门禁的作用。不过要注意,即使这个脚本显示没有锁,也不能完全避免竞争,因为真正权威的判断还是会由 Terraform 在写状态时与 DynamoDB 交互来完成。
5.4 制定锁异常的应急流程
当锁真的卡住时,团队需要有统一的流程。我们的流程是这样:
- 先查 DynamoDB,确定锁的 ID、持锁人、创建时间。
- 联系持锁人确认是否还在操作。如果已经不在线,或者操作已经结束,再考虑强制解锁。
- 执行
terraform force-unlock,并且需要记录这次操作的原因,方便复盘。 - 解锁后,重新检查状态文件的完整性和当前的真实资源。用
terraform plan来确认没有漂移。 - 如果是经常出现锁残留,就要回头找根因,而不是每次手动清锁。
下面是应急脚本的核心部分:
# 从 DynamoDB 拿到锁记录后,提取其中的随机 ID 字段
# 注意:LockID 是路径,而 ID 才是 force-unlock 需要的锁 ID
LOCK_ID=$(aws dynamodb get-item \
--table-name terraform-lock-table \
--key '{"LockID":{"S":"my-team-state-bucket/prod/network/terraform.tfstate"}}' \
--region us-east-1 \
--query 'Item.ID.S' \
--output text)
# 如果锁 ID 不存在,说明没有锁,直接结束
if [ -z "$LOCK_ID" ] || [ "$LOCK_ID" == "None" ]; then
echo "没有找到锁,不需要清理"
else
echo "即将清除锁:$LOCK_ID"
# 执行强制解锁,注意这一步操作风险较高
terraform force-unlock -force "$LOCK_ID"
fi
我们一般会把脚本做成一个小工具,放在团队内部的运维平台里。执行之前会自动二次确认:当前持有锁的时间,以及最近一次操作时间。如果操作时间非常接近当前时间,就不允许直接解锁,防止误伤正在进行的部署。
5.5 防止滥用强制解锁
我们还要注意,force-unlock 不能乱用。它相当于把门锁撬了。如果门里面真的有人在写,你撬锁只会让两份写操作同时发生,最终状态文件混乱。因此,我们会在团队里约定一个规矩:强制解锁必须叫上第二个人一起确认,甚至在多人环境里需要评审。这个规矩听起来很死板,但真的能避免很多事故。我们经历过一次因为误解锁导致状态文件合并失败,最后只能手动修复资源,花了整整一个下午。
5.6 定期清理和监控锁表
DynamoDB 锁表一般不会自动清理。长时间运行的锁记录会越积越多,虽然不影响功能,但会让人分不清哪些是活的,哪些是死的。我们可以用定时任务,每天扫描锁表,把超过一定时间的锁记录汇总成报告发给团队负责人:
# 扫描锁表,把关键信息以表格形式打出来,方便人工判断
aws dynamodb scan \
--table-name terraform-lock-table \
--region us-east-1 \
--query "Items[*].{LockID:LockID.S, ID:ID.S}" \
--output table
同时,还要给 DynamoDB 表配置告警,比如读写容量使用率超过 80% 时发邮件。因为锁表一旦被限流,Terraform 所有依赖锁的操作都会变慢,甚至超时。这部分监控成本很低,但收益非常大。
六、技术优缺点分析
这套方案的核心是“远程状态存储加远程锁”。优点很明显:首先,状态文件集中存放,不会因为个人电脑坏了而丢失;其次,锁机制能挡住大多数并发写冲突;再次,每次写状态都会留下记录,方便审计;最后,使用 AWS 管理服务维护成本低,不需要自己搭 etcd 或者 Consul。
缺点也需要心里有数:第一,S3 和 DynamoDB 虽然便宜,但不是零成本,对于小团队来说多了两项开销;第二,如果 DynamoDB 的读写容量不够,在高并发时自己会成为瓶颈;第三,强制解锁是一把双刃剑,操作不当会破坏状态文件;第四,如果网络不稳定,Terraform 本身的重试和等待逻辑也会让部署变得很慢。
相比其他方案,比如用 Consul 做后端,或者在 Git 仓库里管理状态文件,S3 加 DynamoDB 的优点是简单、托管、和 AWS 资源配合好。缺点是对云厂商有依赖,如果有一天迁移到别的云,这个方案也得跟着调整。不过对于大多数团队来说,这个取舍是值得的。
七、注意事项和经验总结
最后再唠叨几个重点。
- DynamoDB 表一定要开启按需计费,或者配置足够的预置容量。否则表被限流时,锁操作一样会失败。
- 锁表本身要设置好权限,不让所有开发人员都能删表。最好是只有 CI 服务或少数管理员有删除权限。我们用的是 IAM 角色隔离,普通开发者只能查询,不能写。
- 遇到“无法获取锁”的报错,先深呼吸,不要直接清空表。很多事故都是因为一气之下把锁表删了。一旦删掉,所有并发保护都会失效。
- 团队里要约定好:不要在主分支上直接跑 apply,尽量走 MR/PR 和 CI 流程。这样锁的获取和释放都有日志,出了问题可以追溯。
- 定期检查状态文件和锁记录,把异常消灭在萌芽期。
- 如果经常出现锁残留,请检查是不是有人在操作时使用了一些会在后台持有锁的编辑器插件,或者某个 CI 作业卡住了。
- 最后,强推“先 plan 再 apply”的习惯。plan 阶段也会读取状态,但不能保证在 plan 和 apply 之间状态没变。所以还是需要锁来兜底。
总结一句话:远程状态锁不是一个复杂的东西,但它是团队协作的最后一道防线。我们要学会在它出问题时冷静处理,更要在平时做好配置和流程,让锁尽量不要出问题。毕竟,真正重要的不是“怎么强行打开锁”,而是“如何让团队里的每个人都能安心地做并行修改,同时保证状态文件永远是对的”。
评论
围绕“Terraform远程状态锁异常?保障团队并行操作下状态一致性的方案”参与讨论