LabHub
学习 学习路径 课程

Terraform/OpenTofu 基础

声明式基础设施,与状态文件这本账

在 LabHub 中继续学习

一句话总结

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 中的 typename 连接起来得到的 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 递增。接着创建一个临时资源,只删除该资源,并导出状态中保留的地址列表。最后不再复制值,而是使用引用,让依赖关系记录到状态中。