声明式基础设施,与状态文件这本账
一句话总结
Terraform 不是用来编写“执行这条命令”,而是用来声明“完成后基础设施应当呈现这种状态”。为了履行这项约定,工具会维护一本名为状态文件的账簿。
为什么需要了解这些
通过控制台点击创建服务器,会同时失去三样东西:重新创建同一环境的方法、谁在何时修改了什么的记录,以及开发环境与生产环境保持一致的信心。十台服务器还可以靠人记住,一百台就不可能了。dev、stage、prod 之间逐渐出现细微差异的配置漂移,正是从这里开始。
改用 shell 脚本后,又会出现新的问题。最初只有一句“创建它”,第二次执行却报“已经存在”,于是需要添加条件;资源属性可能已经改变,又需要添加比较逻辑。最终,脚本的一半都变成了检查当前状态的代码。声明式工具会接管这一半工作。人只需写出期望的样子,由工具计算当前状态与期望状态之间的差异。
真正的问题由此出现:工具如何知道“当前状态”?每次扫描整个云环境不仅缓慢,更重要的是,面对众多资源时,根本无法区分哪些是自己创建的。这个问题的答案就是状态文件。
工作原理
plan 会比较三个值。
| 值 | 位于何处 | 含义 |
|---|---|---|
| 期望状态 | .tf 代码 |
人所声明的内容 |
| 最后一次已知状态 | terraform.tfstate |
上次应用的结果 |
| 实际状态 | 提供商 API | 当前真正存在的内容 |
工具首先查询实际资源,通过 refresh 更新状态,然后比较更新后的状态与代码并生成计划。因此,如果没有状态文件,工具会把现有基础设施视为“第一次见到的资源”,试图重新创建全部内容。这正是状态文件比基础设施本身更需要谨慎保护的原因。
状态文件采用 JSON 格式,实际工作中真正需要阅读的字段并不多。
{
"version": 4,
"serial": 42,
"lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"resources": [
{ "mode": "managed", "type": "local_file", "name": "hello",
"instances": [ { "attributes": { "filename": "/root/out/hello.txt" },
"dependencies": ["random_pet.suffix"] } ] }
]
}
这里最重要的是,attributes 是最后一次应用结果的记录。如果状态中记录的值与实际资源值不一致,这本身就是漂移的定义。因此,阅读状态时,应养成同时确认“这个值是否与实际一致”的习惯。
version 是状态文件格式版本,当前为 4。serial 是每次状态变化时递增的编号,远程后端用它检测并发修改冲突。lineage 是表示该状态文件谱系的唯一标识符,用于防止误覆盖其他项目状态。把 resources 中的 type 与 name 连接起来得到的 local_file.hello,就是我们操作的资源地址。
另一方面,init 会读取代码中的 required_providers,下载提供商,并把最终确定的版本与哈希写入 .terraform.lock.hcl。这个锁文件应提交到代码仓库,以确保个人电脑与 CI 使用相同的提供商版本。
生产现场中的常见情况
第一,误将状态文件提交到 Git。 状态中会以明文保存数据库密码或令牌。此外,两个人分别执行 apply 会产生合并冲突,错误合并的状态可能让整批资源丢失。因此,实际工作通常会把状态存入启用了版本控制的 S3 等远程后端,并通过锁阻止并发 apply。在 AWS 上,S3 + DynamoDB 长期以来一直是标准组合。
第二,用不同谱系的状态覆盖原状态。 如果团队写错后端键,把自己的状态写到另一套栈的状态之上,下一次 plan 就会显示“将删除数百个资源”。此时能救命的是 S3 版本控制保存的旧版本。lineage 字段正是为了提前发现这类事故而存在。
第三,状态中的秘密。 OpenTofu 原生支持对状态文件和计划文件进行加密。Terraform 若要获得同等级能力,则需要付费服务。二者的命令名称和子命令结构几乎一致,因此在本实验中使用 tofu 学到的内容,可以直接迁移到 terraform。
下一次实验要做什么
在 /root/tf/first 中下载提供商并完成初始化,声明并应用一个文件。随后添加生成随机名称的资源,修改声明后再次应用,亲眼观察 serial 递增。接着创建一个临时资源,只删除该资源,并导出状态中保留的地址列表。最后不再复制值,而是使用引用,让依赖关系记录到状态中。