Terraform 与 OpenTofu:从资源图、执行计划到可信状态治理
一次看似普通的网络标签修改,在合并请求里只改了一个字符串。流水线却计划重建网关、替换关联地址并删除旧规则;另一位同事同时从本机执行了 apply,远端 state 最后只保留了一条执行链的结果。资源暂时还能访问,团队却已经无法回答三个问题:哪份配置描述真实意图,哪次计划经过审批,下一次执行会不会把仍在使用的对象删掉。
这正是声明式基础设施工具真正处理的问题。Terraform 和 OpenTofu 都读取 HCL 配置,调用 provider 查询远端对象,结合 state 建立资源身份,再根据依赖图生成计划并执行变更。它们不是“把云 CLI 写进脚本”的另一种语法;价值来自期望配置、真实对象、历史状态和变更计划之间可核对的关系。任何一环失真,apply 成功也可能只是把错误更快地写进环境。
两套引擎相似,但不是同一个可执行文件
Terraform 的入口是 terraform,由 HashiCorp 发布;OpenTofu 的入口是 tofu,由 Linux Foundation 旗下项目维护。两者共享大量 HCL 语言和工作流概念,但发布节奏、源代码许可、registry、扩展能力、状态写入行为和托管生态会继续演进。把 tofu 写成 terraform 的 shell alias,会隐藏审计证据:日志看不出实际引擎,版本门禁无法判断,故障恢复也无法确认哪一方最后写过 state。
Terraform 1.5.x 及更早版本仍按当时的 MPL 2.0 发行;从 1.6.0 发行线开始,Terraform 主仓库采用 BUSL 1.1,并附带 HashiCorp 的额外使用授权。HashiCorp 将其描述为 source-available,而 OpenTofu 主仓库采用 MPL 2.0。不能把二者都笼统写成“开源且可任意商用”,也不能反过来把 BUSL 误读为“企业内部不能使用”。终端用户、内部平台、对外托管服务和基于源码再分发面对的约束不同。涉及竞争性托管、二次发行或嵌入产品时,应由法务根据 Terraform 主仓库许可证、HashiCorp 的许可说明与 OpenTofu 仓库许可证核定,而不是由技术团队自行推断。许可证也不构成配置或 state 兼容承诺;兼容性仍要由版本矩阵和只读计划证明。
工程上至少保留下面四项身份信息:
engine: terraform | opentofu
cli_version: 精确版本
provider_registry: 实际来源主机
state_writer: 最后获准写入 state 的流水线与运行编号如果团队需要两套引擎共同消费模块,兼容性必须成为测试矩阵,而不是口头承诺。OpenTofu 提供 .tofu 文件;它的 language 块从 OpenTofu 1.12 起才可用,Terraform 不识别该块,也不读取 .tofu 文件。OpenTofu 官方给出的跨引擎方式是分别维护 versions.tofu 与 versions.tf:OpenTofu 在二者同时存在时忽略后者,Terraform 则忽略前者。这只解决版本声明和解析入口,不能证明 provider 行为、计划结果和 state 回读完全相同,详见 OpenTofu 语言兼容设置。
安装时先固定发行入口和版本身份
开发机可以使用发行方包仓库或校验过的独立二进制,CI 更适合从组织批准的工具缓存或固定 digest 的基础镜像取用。不要用网页上的“最新版本”作为可重复构建条件;版本基线应写入版本管理文件、CI 镜像标签或工具安装动作的精确输入。
Terraform 的 Windows 入口以 HashiCorp 安装页列出的官方二进制为准;macOS 可使用 HashiCorp Homebrew tap,Linux 可使用官方软件源或校验签名的压缩包。安装后先确认可执行文件来源与版本:
Get-Command terraform | Format-List Source,Version
terraform version
terraform -helpOpenTofu 在 Windows 可通过官方列出的 winget 包安装:
winget install --exact --id OpenTofu.Tofu
Get-Command tofu | Format-List Source,Version
tofu version
tofu -helpmacOS、Linux、独立包和容器入口应从 OpenTofu 安装页选择。下载脚本不是天然可信边界:先保存、检查来源和签名验证逻辑,再执行;CI 中记录制品摘要。卸载时不仅要删除可执行文件,还要检查用户级 CLI 配置、插件缓存和登录凭据。Terraform 常见的是 terraform.rc 或 .terraformrc,OpenTofu 在 Windows 优先读取 tofu.rc,其他系统读取 .tofurc;两者都支持通过 TF_CLI_CONFIG_FILE 指向受控文件。删错配置会让后续 init 绕过内部镜像或失去凭据 helper。
代理环境需要分别验证三段链路:CLI 到 registry、CLI 到 provider 下载地址、provider 到目标 API。HTTPS_PROXY 能让前两段通,并不代表 provider 使用的 SDK 一定继承相同代理;企业 TLS 中间证书还必须进入运行 CLI 和 provider 进程所见的信任库。典型证据是:init 能发现版本却在下载包时超时,或 plan 已启动 provider 后才出现 x509: certificate signed by unknown authority。先用 terraform init 或 tofu init 区分 registry 故障,再用 provider 的调试日志定位目标 API,不能用关闭证书校验作为长期修复。
HCL 描述对象,state 保存对象身份
HCL 的基本语法是块、标签、参数和表达式。下面的配置不访问云平台,却包含一条完整的数据流:输入变量经过校验,locals 生成稳定名称,子 module 暴露输出,内置 terraform_data 资源把值纳入受管生命周期,根输出再把结果交给调用者。
terraform {
# 共享实验不在这里假定某一套引擎版本;团队项目必须在 CI 单独锁定。
}
variable "environment" {
description = "实验环境短名"
type = string
default = "dev"
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,11}$", var.environment))
error_message = "environment 必须是 2 到 12 位小写字母、数字或连字符。"
}
}
locals {
service_name = "catalog-${var.environment}"
labels = {
environment = var.environment
owner = "platform-demo"
}
}
module "identity" {
source = "./modules/identity"
name = local.service_name
labels = local.labels
}
resource "terraform_data" "release" {
input = {
service = module.identity.name
labels = module.identity.labels
}
}
output "release_manifest" {
description = "进入受管生命周期的发布元数据"
value = terraform_data.release.output
}子 module 的 modules/identity/main.tf 只做接口封装:
variable "name" {
type = string
}
variable "labels" {
type = map(string)
}
output "name" {
value = var.name
}
output "labels" {
value = var.labels
}这些对象承担不同职责:
variable 是根 module 或子 module 的输入契约,类型、默认值、校验和敏感标记在值进入资源图前约束它。locals 为当前 module 内部计算结果命名,减少重复表达式;它不是外部可覆盖参数。module 调用另一个配置目录。module 是封装和复用边界,不是进程、租户或安全边界。
resource 表示由引擎管理生命周期的对象;provider 定义其 schema,并执行创建、读取、更新、删除。data 读取既有信息但不声明其生命周期。例如云镜像查询、DNS 区查询或 terraform_remote_state 输出读取。output 暴露根 module 的自动化接口或子 module 的返回值;sensitive = true 只影响常规显示,不等于从 state 删除。
provider 块配置 provider 实例,例如区域、端点或别名;required_providers 声明来源和版本约束。不要把长期密钥直接写进 provider 块。
表达式引用同时构造依赖边。terraform_data.release.input 引用了 module.identity 的输出,引擎因此知道必须先求出 module 输出,再处理资源。只有依赖来自外部行为而没有任何数据引用时才使用 depends_on;滥加显式依赖会扩大未知值传播,减少并行度,让计划比真实关系更保守。可以用 terraform graph 或 tofu graph 导出 DOT,再检查关键资源是否存在预期边,但图中“有连线”不证明权限、配额和 API 最终一定成功。
用两个隔离目录跑通同一组无云实验
先创建 iac-lab-template,写入上面的根配置和子 module。然后复制为两个目录:
Copy-Item -Recurse .\iac-lab-template .\terraform-lab
Copy-Item -Recurse .\iac-lab-template .\opentofu-labTerraform 只在 terraform-lab 中执行:
Set-Location .\terraform-lab
terraform fmt -check -recursive
terraform init
terraform validate
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan
terraform output release_manifestOpenTofu 只在 opentofu-lab 中执行:
Set-Location .\opentofu-lab
tofu fmt -check -recursive
tofu init
tofu validate
tofu plan -out=tfplan
tofu show tfplan
tofu apply tfplan
tofu output release_manifestterraform_data 由内置 provider 提供,不需要云账号,也不下载第三方 provider。第一次计划应出现一个待创建资源,应用后输出包含 catalog-dev 与两项标签;再次运行 plan -detailed-exitcode 应返回退出码 0,表示成功且没有差异。退出码 2 表示成功但存在变更,退出码 1 才是错误。CI 不能把任何非零值都当失败,否则漂移检测会把“发现差异”误报成工具故障。
五个常用命令并不是同义的语法检查:
fmt 只统一 HCL 排版,不验证 provider 参数是否合法。init 初始化 backend、module 和 provider,并生成或更新依赖锁文件;backend 或来源变化后通常需要重新初始化。validate 检查配置内部语法和引用一致性,不访问全部远端 API,也不证明账号有创建权限。
plan 读取配置、state 与远端现状,给出动作图;它可能执行 data source 读取和 provider 刷新。apply 执行计划并写回 state。直接 apply 会现场重新生成计划并请求确认;把保存计划作为参数传入时会立即执行,不再二次询问。保存计划因此既是执行输入也是批准边界,只有计划摘要、目标 state、CLI 版本、provider lock、代码提交和执行身份全部匹配时才可进入 apply。
保存的 plan 是敏感制品。Terraform 官方明确指出 plan 文件包含完整配置、变量和计划值,终端中被隐藏的敏感信息仍可能以明文进入文件;OpenTofu 未启用 plan 加密时也存在同样风险。backend 初始化元数据也会进入计划文件:若把密码或短期 token 写进 backend 块或 -backend-config,不仅会泄露,还可能在审批结束、真正 apply 前过期。地址等非密钥参数可以放进受控配置,凭据应由 apply 运行时的环境变量或身份链重新提供。
tfplan、terraform show -json 的结果、state 和 provider 调试日志都不能进入 Git,也不应作为无限期 CI artifact。流水线需要限制下载者、缩短保留时间,apply 后删除制品,并用运行编号、代码提交、state 身份、CLI 版本和 plan 摘要建立关联。保存计划记录了生成它的引擎版本与配置快照,也绑定当时选择的 provider;不要换 Terraform/OpenTofu 版本、工作目录或 backend 后继续消费旧计划。ephemeral 变量不会持久化进计划,使用这类输入时必须按对应 CLI 的 apply 规则重新注入同一语义的短期值,不能把缺失值降级成长久 secret。
故意失败一次,才能看懂门禁停在哪里
先触发变量校验:
terraform plan -var 'environment=PROD!'
# OpenTofu 目录执行:tofu plan -var 'environment=PROD!'预期不会出现资源动作,而是在变量校验位置报告 environment 不满足规则,退出码为 1。这证明失败发生在配置求值阶段;若错误来自 provider 的 403,说明 HCL 已通过,问题在身份或授权;若错误是锁占用,则连刷新和计划都不应继续。把所有失败统一写成“plan 失败”会丢掉最关键的定位层次。
再给 terraform_data.release 增加销毁保护:
resource "terraform_data" "release" {
input = {
service = module.identity.name
labels = module.identity.labels
}
lifecycle {
prevent_destroy = true
}
}应用这次配置后执行:
terraform plan -destroy
# OpenTofu 目录执行:tofu plan -destroy计划应被 prevent_destroy 拒绝。这个保护只在配置仍存在且引擎能读到它时生效:有人删除整个 resource 块、改用另一份根配置、直接从 state 移除对象或在控制台删除真实资源,都可能绕过它。因此防误删还需要代码评审、策略门禁、远端 API 删除权限、资源侧删除保护和备份恢复,不能把一个 lifecycle 参数写成“绝不会删除”。
清理实验时,先提交并评审移除 prevent_destroy 的改动,再生成销毁计划:
terraform plan -destroy -out=destroy.tfplan
terraform show destroy.tfplan
terraform apply destroy.tfplan
Remove-Item -Force tfplan,destroy.tfplan -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue
Remove-Item -Force terraform.tfstate,terraform.tfstate.backup -ErrorAction SilentlyContinueOpenTofu 目录把命令前缀换成 tofu,其余文件名默认仍沿用 Terraform 生态约定。真实项目不能把删除本地 state 当清理:state 若仍代表远端对象,删除它只会让工具“忘记”对象,云资源与费用仍然存在。
state 不是缓存,而是资源地址与远端身份的权威映射
配置里有 resource "aws_instance" "api",远端平台里有一个实例 ID;state 把资源地址、实例 ID、属性快照、依赖元数据和版本信息关联起来。没有这份映射,引擎不知道现有对象是否由当前资源块创建,通常会计划再创建一个,而不是凭名称自动认领。
本地 backend 默认写 terraform.tfstate 和备份文件,适合单人实验,不适合多人长期协作。远端 backend 负责存储 state,并可选提供锁;不是所有 backend 都支持锁,必须逐项检查对应 backend 文档。Terraform 官方建议避免把 state 放进不支持安全访问控制和锁的普通版本库;OpenTofu 也明确说明 backend 是否锁定由具体实现决定。
backend 配置在 init 阶段生效,不能引用普通 variable。把地址之外的凭据留在环境变量、凭据 helper、OIDC 换取的短期身份或交互式初始化参数中,避免写入 .tf 和 .terraform backend 元数据。下面的 HTTP backend 使用部分配置,值由流水线注入:
terraform {
backend "http" {}
}$env:TF_HTTP_ADDRESS = 'https://state.example.invalid/iac/catalog-dev'
$env:TF_HTTP_LOCK_ADDRESS = 'https://state.example.invalid/iac/catalog-dev/lock'
$env:TF_HTTP_UNLOCK_ADDRESS = 'https://state.example.invalid/iac/catalog-dev/lock'
$env:TF_HTTP_LOCK_METHOD = 'LOCK'
$env:TF_HTTP_UNLOCK_METHOD = 'UNLOCK'
terraform init -reconfigure域名使用保留的 .invalid,不能直接运行。真实地址、认证方式和锁协议由 backend 服务决定。迁移 backend 时先拉取只读备份,冻结写入者,再运行 init -migrate-state 并核对 lineage、serial、资源数量与抽样对象;不要让旧 backend 和新 backend 同时接受 apply。
锁解决的是“同一份 state 同时写”,不解决“错误的人顺序写了两次”。团队执行应设置 -lock-timeout 等待短暂占用,超时后保留锁 ID、持有者、操作类型和时间证据。force-unlock 只能在确认原进程已终止、没有延迟写回后由 break-glass 角色使用;仅凭“流水线页面红了”就强制解锁,可能与仍运行的 provider 请求形成双写。
state 丢失实验可以在无云目录中安全观察。应用成功后先备份,再故意移除地址:
Copy-Item terraform.tfstate state.before-rm.json
terraform state rm terraform_data.release
terraform plan计划会把 terraform_data.release 视为待创建。真实云资源下,这可能产生重名错误,也可能创建第二份收费对象。恢复实验不要直接编辑 JSON:在确认没有后续写入后,用备份恢复整个本地 state,或使用 provider 支持的 import 把真实 ID 重新关联。远端 state 的 state push 会覆盖权威记录,必须先 state pull 备份,并核对 lineage 与 serial;强制覆盖属于事故恢复操作,不是日常同步手段。
workspace 只切 state,不能承担强隔离
Terraform CLI workspace 和 OpenTofu workspace 都允许同一工作目录保存多份 state。它适合临时预览环境、相同权限域中的短生命周期副本,或者开发者自己的无敏感实验:
terraform workspace new feature-login
terraform workspace show
terraform workspace list危险在于把 workspace 名称误当账号、租户或审批边界。同一工作目录中的 CLI workspace 共享 backend 配置,通常也共享 provider 凭据和代码;terraform.workspace 只是当前名称。HashiCorp 和 OpenTofu 都明确不建议用它处理需要独立凭据与访问控制的部署。开发、预发布、生产需要不同 IAM、不同 state ACL、独立并发队列或不同 blast radius 时,应拆分根配置实例与 backend,复用 module,而不是只运行 workspace select prod。
需要区分四种常见形态:
| 形态 | 状态与执行者 | 适合场景 | 首要风险 |
|---|---|---|---|
| 单人本地 | 本地 state,本机身份 | 无云实验、个人沙箱 | 文件丢失、无审计、凭据长期化 |
| 远端 backend | 集中 state 与锁,CLI 本地执行 | 小团队共享、已有 CI | 本机仍持有变更权限,锁与审批脱节 |
| 团队执行平台 | 远端 state、排队、审批、集中 runner | 多人协作与受控 apply | 平台权限过大、套餐能力与迁移成本 |
| 多环境独立根配置 | 每环境独立 backend、身份和流水线 | 强隔离、独立生命周期 | 模板漂移、重复配置、升级协调 |
架构选择依据不是资源数量本身,而是写入者数量、权限隔离、故障半径、审计要求和恢复目标。十个高权限生产对象也值得独立 backend;一千个一次性本地对象则未必需要团队平台。
provider 锁定、镜像与 module 来源是供应链问题
required_providers 同时声明逻辑名、来源地址和版本约束:
terraform {
required_providers {
example = {
source = "registry.example.invalid/platform/example"
version = "~> 3.4"
}
}
}根 module 应给 provider 设有上界的兼容窗口,.terraform.lock.hcl 记录实际选择和包校验值,并提交版本库。共享 module 通常只声明所需最低版本,让根 module 统一选择。init -upgrade 会重新求解依赖,不应混入普通配置变更;升级提交需要展示 lock diff、provider 变更日志、代表性计划和回退版本。
依赖锁文件只管理 provider,不锁远端 module。registry module 应指定 version,Git module 应固定不可变提交而不是可移动分支,内部镜像也必须保存来源、签名、摘要和准入记录。module 能包含 provider 资源声明,其权限等同执行者;“只是 HCL”并不代表无代码执行风险,provider 二进制、外部 data source、module 来源和 provisioner 都是供应链入口。
跨 Windows、macOS 和 Linux 的团队要预填各目标平台校验值:
terraform providers lock -platform=windows_amd64 -platform=linux_amd64 -platform=darwin_arm64OpenTofu 对应使用独立命令:
tofu providers lock -platform=windows_amd64 -platform=linux_amd64 -platform=darwin_arm64内网可分别用 terraform providers mirror 与 tofu providers mirror 建立文件镜像。Terraform 在 CLI 配置的 provider_installation 中支持 filesystem_mirror、network_mirror 和 direct;OpenTofu 也支持文件与网络镜像,并另外演进了 OCI 镜像能力。不要把某一方的配置文件原样复制给另一方后假定行为一致。
provider_installation {
filesystem_mirror {
path = "D:/iac/provider-mirror"
include = ["registry.example.invalid/*/*"]
}
direct {
exclude = ["registry.example.invalid/*/*"]
}
}镜像提高可用性,但镜像本身可能投毒。先从受信 origin 获取校验值,再让 lock 校验镜像包;如果上游提供密码学签名,还要审查签名者,而不是只看“checksum matched”。OpenTofu Registry 会提供 provider 包校验值,但“校验值匹配”不等于“发布者身份已经验证”;只有存在可验证签名时才有签名身份这层证据,要求所有 provider 都具备 GPG 证明的团队还应在 OpenTofu 运行环境启用 OPENTOFU_ENFORCE_GPG_VALIDATION=true。从文件镜像或网络镜像直接生成的 lock 条目只代表该镜像给出的包,不能自动冒充 origin registry 的官方签名集合。缓存目录只做不可变包复用,不允许多个作业在可写缓存中覆盖同版本二进制。
import 与 moved 解决的是身份接管和地址重构
已有对象进入 IaC 时,先写出目标 resource 配置,再使用配置驱动的 import 块,把远端 ID 关联到明确地址:
import {
to = example_service.catalog
id = "service-demo-001"
}
resource "example_service" "catalog" {
name = "catalog-dev"
}首次 plan 要区分“导入”与“导入后立即修改”。provider 的导入器可能只填部分属性;配置默认值、远端默认值和不可变字段不一致,会让下一步计划替换对象。导入前保存只读盘点,导入后要求 0 add, 0 change, 0 destroy 或逐项评审差异,不能看到 import complete 就结束。
重命名资源地址时使用 moved 块保存历史关系:
moved {
from = terraform_data.release
to = terraform_data.release_manifest
}
resource "terraform_data" "release_manifest" {
input = {
service = module.identity.name
labels = module.identity.labels
}
}有 moved 时,计划应显示地址迁移而不是销毁重建。故意删除 moved 后再 plan,会看到旧地址待删除、新地址待创建,这就是反向证据。对数据库、网关、密钥等有状态对象,这类“只是改名”的重建计划必须阻断。
moved 只描述同一配置、同一 state 内的地址变化,不能把对象直接送进另一份 state。跨 state 接管应冻结两端写入,在源配置用 removed 块和 destroy = false 留下“停止管理但不删除”的记录,在目标配置用 import 块接管,再分别检查计划。采用这套语法前要锁定版本基线:Terraform 的跨 state removed/import 流程要求 Terraform 1.7 或更高版本;OpenTofu 的 removed 块较早已能停止管理,而显式 lifecycle.destroy 语义从 OpenTofu 1.10 起提供。Terraform 官方把带 -state、-state-out 的 state mv 路径列为兼容保留的旧方式;terraform state mv 和 tofu state mv 更适合受控事故处理或旧版本兼容,不应替代可评审的 removed/import 迁移。无论哪种方式,两端 state 都必须先备份并核对对象唯一 ID。
从 Terraform 迁移到 OpenTofu 要按状态依赖图推进
迁移不是把命令文本全局替换。先清点 Terraform CLI 版本、provider 来源与锁文件、backend、workspace、远端 module、terraform_remote_state 读取关系、HCP/Enterprise 集成、策略和 CI 包装器。再为 state 做可恢复备份,在隔离分支安装 OpenTofu,执行 tofu init、tofu validate 和只读 tofu plan,确认没有非预期动作后才允许一个小变更写回。
OpenTofu 的 迁移指南强调先备份配置与 state,再初始化和验证。存在多份相互读取的 state 时,迁移顺序更关键:OpenTofu 能读取 Terraform 1.x state,但 Terraform 不保证可靠读取启用 OpenTofu 特性的 state。官方建议从“依赖叶子”开始,也就是先迁移读取上游 state、但不再被其他 Terraform 配置读取的配置,再逐层向上;这样仍在 Terraform 上的消费者不会过早读取 OpenTofu 写出的新格式。
迁移批次应保留这些证据:
配置提交 -> 原 state 摘要与 serial -> tofu init 输出
-> 无变更 plan -> 小变更 plan -> apply 运行
-> 下游读取验证 -> 回滚判断回滚不能承诺“随时换回 Terraform”。只要 OpenTofu 写入了 Terraform 不理解的 state 格式,使用了 .tofu、state encryption、provider functions 或其他差异能力,就可能越过可逆点。回退前停写、恢复迁移前 state 备份和配置提交,再由 Terraform 重新 init 与 plan;不允许两套引擎轮流写同一 state 来“比较结果”。长期双兼容只能通过两套独立 CI 的 fmt/validate/plan、明确的功能交集和状态写入单主来维持。
OpenTofu state encryption 不是 backend、ACL 和备份的替代品
OpenTofu 可以对本地或远端 backend 中的 state 与 plan 做应用层静态加密,也能为 terraform_remote_state 配置解密方法。它保护存储文件被直接窃取后的内容,但不防 state 损坏或丢失,不防攻击者回放旧快照,也不防有权运行 tofu 且持有密钥的人读取敏感值。加密 key 丢失则 state 不可恢复,因此密钥备份、轮换和灾难恢复必须先于启用。
已有明文 state 不能只加一段加密块后直接读取。OpenTofu 的迁移方式是暂时把 unencrypted 设为 fallback,让引擎读旧 state,并在下一次成功写入时改用新方法保存;确认 state 已重写后移除 fallback,并考虑 enforced = true。迁移前已经生成的明文 plan 不能靠这次 state 写入自动变成密文,应撤销其下载权限、删除原制品,并在启用 plan 加密后重新生成。下面仅展示结构,口令不应写进 HCL:
terraform {
encryption {
method "unencrypted" "migration" {}
key_provider "pbkdf2" "state_key" {
passphrase = var.state_passphrase
}
method "aes_gcm" "state" {
keys = key_provider.pbkdf2.state_key
}
state {
method = method.aes_gcm.state
fallback {
method = method.unencrypted.migration
}
}
}
}生产密钥应由 KMS 或受控密钥系统在运行时提供,执行身份只获得当前环境所需的解密和数据密钥权限。启用前完成“备份 state、备份密钥、离线恢复、旧 key fallback、重新写入、移除 fallback、丢 key 失败”演练。加密方法与 key provider 名称也进入密文元数据,随意重命名可能造成不可读;轮换要用新方法作为主路径、旧方法作为 fallback,待全部重写后再撤旧。
Terraform 本地 state 与保存 plan 不提供等价的 CLI 应用层加密配置。使用 Terraform 时应依靠远端 backend 的服务端加密、TLS、最小 ACL、审计、短期身份与受控执行环境,同时仍把 state 和 plan 当最高敏感级制品。选 OpenTofu 也不能因为有 state encryption 就放宽 backend ACL。
CI 要把计划、审批和执行绑定成一条证据链
推荐把目录结构固定下来,让 engine、环境实例和共享 module 的所有权可见:
infra/
modules/
service-identity/
live/
dev/
backend.hcl.example
main.tf
versions.tf
prod/
backend.hcl.example
main.tf
versions.tf
policies/
scripts/
iac-check.ps1
.terraform.lock.hclbackend.hcl.example 只保存非敏感键名和注入说明,不保存 token。环境实例拥有独立 backend、身份和流水线;module 只表达可复用资源组合。仓库的 CODEOWNERS 至少覆盖 provider 版本、backend、身份权限、网络边界、删除保护和共享 module。
一个可靠流水线分为两类运行:合并请求只执行 fmt -check、init -backend=false 或只读 backend 初始化、validate、静态检查与 speculative plan;受保护分支在获得环境锁后生成新的保存计划,把提交 SHA、engine 版本、provider lock 摘要、目标 state 身份和计划摘要写入运行元数据,策略与人工审批都指向该摘要,最后由同一执行链 apply 该计划。不要把数小时前的合并请求 plan 直接用于生产,因为期间可能发生漂移、provider 数据变化或另一条已批准变更。
engine: opentofu
working_directory: infra/live/dev
commands:
check:
- tofu fmt -check -recursive
- tofu init -input=false
- tofu validate
- tofu plan -input=false -detailed-exitcode
apply:
- tofu plan -input=false -out=tfplan
- policy-check tfplan
- tofu apply -input=false tfplan
artifacts:
tfplan:
access: restricted
retention: shortest-approved-window
identity:
method: oidc-short-lived示例中的 policy-check 是组织策略入口,不是内置命令。真实流水线还要校验计划里的删除数、替换数、未知敏感属性、高成本资源类型、跨区域变化和标签缺失。向 apply 传入保存计划本身就代表批准并立即执行,Terraform 会忽略此时附加的 -auto-approve;因此流水线必须在调用 apply tfplan 之前完成摘要核对和审批。只有不传保存计划、由 apply 临时生成计划时,-auto-approve 才负责跳过交互确认,而这种模式不适合作为需要计划级审批的生产入口。
OIDC 短期身份应把 issuer、audience、subject 与仓库、分支、环境绑定,权限只覆盖目标 state 和目标资源域。个人 access key 不应进入 CI secret。计划阶段可以使用只读加最小刷新权限,apply 阶段才提升到变更角色;如果 provider 在 plan 就需要额外读取权限,应明确列出,而不是直接给管理员。
漂移、并发、误删和成本要用可观察证据治理
漂移来自控制台手改、外部自动化、provider 默认值变化、对象被删除或 state 被错误修改。定时只读 plan -detailed-exitcode 能发现配置与远端不一致,但结果必须分类:计划更新配置、回退控制台改动、接管到 IaC,或确认是 provider 归一化噪声。长期忽略同一差异会让真正的删除混在噪声中。
高风险故障可以沿证据定位:
| 现象 | 第一证据 | 判断与处理 |
|---|---|---|
init 找不到 provider | source 地址、代理、CA、镜像目录、lock 平台哈希 | 先修来源与信任,不删除 lock 后盲目升级 |
plan 持续有同一字段差异 | provider schema、远端 API 返回、refresh 后 state | 判断默认值归一化或外部漂移,升级前保留两版 plan |
| 获取 state lock 超时 | 锁 ID、持有者、运行状态、backend 审计 | 等待或终止原运行;确认无写回后才 break-glass 解锁 |
| 计划大量 replace | provider/CLI 版本、资源地址、不可变字段、moved | 阻断 apply,检查重构丢失和 provider 行为变化 |
| state 有对象但远端不存在 | state show、provider read、平台审计 | 判断外部删除;决定重建或从 state 安全移除 |
| 远端有对象但 state 不认识 | 资产盘点、唯一 ID、导入后 plan | 先 import 并达到无差异,不让工具创建第二份 |
| 输出显示脱敏但制品泄露 | plan/state 下载记录、artifact ACL | 撤销凭据、删除制品、轮换 secret,不能只改输出掩码 |
成本治理要在创建前后各做一次。计划阶段根据资源类型、数量、规格、区域和生命周期做估算;apply 后用账单标签、实际运行时长和闲置检测核对。IaC 删除配置不等于费用归零:快照、保留 IP、对象版本、日志、流量和托管平台座席可能继续计费。临时环境必须有 owner、过期时间、销毁运行和销毁后账单复核;destroy 成功只说明 state 中受管对象完成删除,不证明所有旁路费用消失。
团队长期运行时,把责任放到对象上:module owner 维护接口和升级;平台 owner 维护 engine、provider 镜像、backend、策略与 runner;业务 owner 审批资源意图和成本;安全 owner 维护身份、state 访问与 break-glass;值班人员只按受控流程处理锁和失败运行。每次升级先在无敏感沙箱验证,再对代表性环境生成双版本 plan,观察 provider lock、资源替换、state 格式和执行时间,最后才扩大范围。
离开某个引擎或托管平台时,先回答 state 能否导出、锁如何接管、凭据如何撤销、审计和 plan 需要保留多久、provider/module 地址是否可迁移、策略是否依赖专有能力。退出演练的成功标准不是“新工具能 init”,而是新执行链能读权威 state、产生无非预期变更的计划、完成一次小变更与回退,并让旧平台失去 state 和资源写权限。
当配置、plan、审批、apply、state、漂移检测和成本账单能够用同一运行身份串起来时,Terraform 或 OpenTofu 才从个人命令行升级为团队基础设施变更系统。真正可靠的 IaC 不是命令执行得快,而是每一次变化都能说明由谁提出、对什么对象、基于哪份状态、经过什么判断、失败后如何停止,以及退出时怎样把控制权完整交出去。
