状态文件造出的东西,和它弄坏的东西
一句话总结
状态文件是连接代码与真实基础设施的唯一桥梁,同时也是处理不当就会让整个团队停摆的单点故障。
为什么需要它
状态文件存在有四个原因。第一是映射:把代码中写的资源名称与实际创建出来的资源标识符连接起来。第二是依赖追踪:要知道先删除什么、后创建什么,就必须记住资源之间的关系。第三是性能:如果每次都查询所有资源,API 调用会爆炸式增长,所以把最后一次看到的值当作缓存来使用。第四是协作:它是团队成员之间共享同一事实的基准点。
问题在于,这个文件可能以明文保存敏感信息。数据库密码或证书会原样写进去。所以加密的远程后端实际上是必需的。给输出加上 sensitive = true 只是在屏幕上做掩码,在状态文件里值仍然原样可见。如果把这两者混为一谈,就会产生“遮住了所以安全”的错觉。
工作原理
防止并发应用的机制就是锁。开始 apply 时,会有条件地创建一个锁条目,如果别人已经持有,就会报冲突错误。这里常常让人意外的是,等待时间的默认值是 0 秒,也就是立即失败。这是一种设计思路:与其悄悄地挂起,不如尽快失败,让人来查看情况。
锁也可能残留。网络断开、CI Runner 因超时而终止,或有人用 Ctrl+C 强行中断,都会只留下锁。虽然有强制解锁命令,但如果别人真的在操作,状态可能会被破坏,所以它是最后手段。必须先有确认这把锁是谁在何时持有的流程。
状态不是用手去改,而是用专用命令来处理。用 state mv 移动,用 state rm 将其移出管理,用 import 导入已经存在的资源,重构则用 moved 块来表达。尤其要准确地了解,state rm 只是把它移出管理,实际资源不会被删除。如果把它误认为删除命令,就会发生完全相反的事故。
规模变大之后,必须拆分状态。把所有东西都塞进一个文件,plan 会超过 10 分钟,还会触发 API 速率限制。按网络、计算、数据库、监控这样的组件来拆分,把爆炸半径(blast radius)降到最小。环境的隔离方式也有多种选择。工作区(workspace)没有代码重复,但隔离较弱、爆炸半径较大。按目录拆分会产生部分重复,但可以为每个环境设置单独的后端和 IAM,爆炸半径较小。所以基本做法是:生产环境按目录拆分,短期测试环境使用工作区。
在现场相遇的样子
漂移分为三种类型:属性被改变的配置漂移、在代码之外被创建或删除的存在漂移、引用关系被破坏的依赖漂移。原因也是固定的:在控制台手工变更、自动扩缩容这类系统自动变更、因升级 provider 而改变默认值,以及并行 apply。
检测至少每天运行一次,并附上严重程度规则。安全组或 IAM 策略的变更为 CRITICAL,删除或替换操作一律至少为 HIGH。不过,如果想检测所有漂移,误报(false positive)就会泛滥。自动扩缩容器管理的 desired_capacity 或 desired_size 这类字段,应该用 ignore_changes 排除,告警才能保持为信号。
最后是文化。在紧急故障响应中完全禁止登录控制台是不现实的。取而代之的是建立这样的文化:紧急变更后 24 小时之内把代码同步,并让自动检测来监督这件事。这是一种不靠规则禁止,而是让变更回到代码上来的设计。
状态文件丢失与被锁定时
状态既不是代码,也不是真实资源,而是第三种真相。这三者出现偏差的方式,就是 IaC 中所遇到的事故清单。
状态丢失后,资源还在,管理却消失了。Terraform 把不在状态中的资源视为“尚未创建”,所以在下一次 apply 时,会试图再创建一份相同的资源,要么因名称冲突而失败,要么在名称自动生成的资源上悄悄变成两份。恢复只有一条路:用 import 逐个找回来。所以远程后端必须同时开启版本控制和删除保护。
状态中会以明文保存密钥。如果通过变量传入了数据库密码,这个值就会原样写进状态文件。即使在输出中用 sensitive = true 遮住,保存下来的值也是一样的。这就是为什么必须把状态存储当作与密钥存储同等级别来对待。如果是 S3,要设置加密和访问策略;如果是本地,则先确认 .gitignore,确保它根本不会被提交。
没有锁,两个人会同时写下不同的未来。两个 apply 重叠时,后结束的一方会把抹掉了前者结果的状态写上去。结果是资源已经创建出来了,状态里却没有,这是最麻烦的一种状态。S3 后端用 DynamoDB 表加锁,GCS 和 Azure 则用自身的功能加锁。
锁残留时不要随意解开。CI 中途终止就会留下锁。force-unlock 只能在确实没有任何人在运行时才使用。如果把正在运行的 apply 强行解锁,就等于亲手制造出上一段所说的局面。应先确认锁信息中记录的人和时间。
手工改动会在下一次计划中暴露出来。因为着急而在控制台改动的设置,会在 terraform plan 中以要改回去的计划的形式出现。这时有两个选择:让代码符合现实,或让现实符合代码。如果选择第三种“先忽略”,那么下一个人无意中执行 apply 时,该资源就会被改回去。
terraform plan -refresh-only # 코드는 그대로 두고, 현실과의 차이만 본다
terraform state list # 상태가 아는 자원의 목록
terraform state show <주소> # 상태가 기억하는 속성
下一项实验要做什么
用真正的声明式工具(OpenTofu)依次引发本文中的事故。移动状态文件,看到同一台服务器变成两台;lineage 不同的状态在覆盖时被拒绝;用 import 找回丢失的资源;体验被正在应用中的状态锁拦住;把生命周期不同的层的状态拆开。在后续的实验中,找出用 sensitive 遮住的密钥在状态文件和计划文件的哪些位置仍以明文残留,并用状态加密来堵住。两项实验之后的测验,会检查状态文件的存在理由、敏感信息的处理、锁与强制解锁的风险,以及状态拆分和爆炸半径。