Skip to content

基础设施即代码 ​

改一台服务器的端口,登录控制台点几下就能完成。困难发生在下一次:生产和测试为什么不一样?是谁把数据库改成了单可用区?重建环境时,哪些步骤必须重复,哪些资源绝不能重建?基础设施即代码(Infrastructure as Code,IaC)要把这些决定写成可审查的配置,让工具比较配置与已有资源,再按明确的计划修改它们。

这篇文章沿一条交付链讲 Terraform / OpenTofu、Helm 和 Kustomize:先创建基础设施,再生成 Kubernetes 对象,最后由集群运行应用。读完应能分清谁负责哪层状态,读懂创建、修改、替换和删除的含义,并知道本地检查通过后还缺哪些上线证据。

实际验证环境是 Go 1.27.2、Linux/amd64,Terraform 1.13.4、Helm 3.19.0,以及 kubectl 1.34.1 内置的 Kustomize 5.7.1。三个 CLI 均从官方发行地址下载并核对官方 SHA256。实验只生成本地计划和清单,没有执行 apply、安装 release 或连接集群。OpenTofu 的迁移、加密与许可证部分来自官方资料,没有执行跨工具迁移。版本号用于限定证据,不表示这些版本是最新版本。

先认清三种工作:管理资源、管理发布、加工清单 ​

工具主要输入做的工作保存什么状态不能由它单独保证什么
Terraform / OpenTofu资源配置、变量、provider、已有 state通过 provider 读写目标系统,计算并执行资源变更资源地址与实际对象的对应关系、属性及依赖等应用业务正确;每次变更都能无损回滚
Helmchart、values、release 名称渲染 Kubernetes 清单,并管理一次安装的版本与升级release 的版本、配置、清单等发布记录数据库数据恢复;hook 外部副作用撤销;所有 CRD 的完整生命周期
Kustomize原始 Kubernetes YAML、base、overlay、patch合并和变换清单,输出最终 YAML构建本身没有 Helm 式 release 历史对象已经部署;控制器已经把对象实现;业务已经健康

表中是通常的职责划分,不是工具的能力围墙。Terraform 有 Kubernetes、Helm 等 provider,可以把后两层也纳入一次运行。但这样做会把基础设施权限、应用发布节奏和失败范围绑在一起。是否值得合并,要看谁负责这些对象、谁有权限执行,以及失败时能否独立恢复。1 9 15

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TB
    I["基础设施配置 + 变量 + 固定依赖"] --> T["Terraform / OpenTofu\n读 state、查询实际资源、生成 plan"]
    T --> P["审查与授权"]
    P --> A["apply 经 provider 调用基础设施 API"]
    A --> C["网络、数据库、集群等资源"]
    A --> S["state backend\n记录对象身份与属性"]
    S --> T
    H["chart + values"] --> HR["Helm 渲染 / release 操作"]
    K["base + overlay + patch"] --> KR["Kustomize build"]
    HR --> API["Kubernetes API\n保存期望状态"]
    KR --> D["另一个执行者提交清单"] --> API
    API --> CT["Kubernetes 控制器\n持续观察与协调"]
    CT --> W["Pod、Service 等实际状态"]
    W -. "状态反馈" .-> CT
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TB
    I["基础设施配置 + 变量 + 固定依赖"] --> T["Terraform / OpenTofu\n读 state、查询实际资源、生成 plan"]
    T --> P["审查与授权"]
    P --> A["apply 经 provider 调用基础设施 API"]
    A --> C["网络、数据库、集群等资源"]
    A --> S["state backend\n记录对象身份与属性"]
    S --> T
    H["chart + values"] --> HR["Helm 渲染 / release 操作"]
    K["base + overlay + patch"] --> KR["Kustomize build"]
    HR --> API["Kubernetes API\n保存期望状态"]
    KR --> D["另一个执行者提交清单"] --> API
    API --> CT["Kubernetes 控制器\n持续观察与协调"]
    CT --> W["Pod、Service 等实际状态"]
    W -. "状态反馈" .-> CT

这里最容易混淆的是“持续”。单次 Terraform apply 完成后,普通 CLI 不会常驻监视控制台里的所有修改。Kustomize build 结束后,也没有一个 Kustomize 进程持续修正副本数。Kubernetes 的控制器则持续协调集群对象。GitOps 控制器可以定期重新渲染、比较和提交配置,但那是新增的运行者,不能从“用了 YAML”推导出来。

Terraform / OpenTofu:先知道对象是谁,再决定怎么改 ​

配置是期望,state 是对象身份,实际系统仍需查询 ​

设配置里有一个数据库,地址是 module.orders.aws_db_instance.main。云平台给这个数据库分配的是它自己的标识。工具需要记住这两个名字的对应关系,才能在下次运行时知道应该更新哪个数据库。这个映射就是 state 的重要用途。state 还保存属性和依赖信息,例如配置里删掉一个资源以后,工具仍需要知道怎样按依赖顺序删除它。1

因此,state 既不能随意丢掉,也不能当成实时监控数据。有人在控制台改过资源后,state 中的记录可能已经过时。正常 plan 会通过 provider 刷新已知对象,再比较期望与观测结果;关闭刷新会放弃这部分证据。2

名词回答的问题一个具体例子
resource希望工具管理哪个对象?创建并维护一个数据库实例
data source需要从目标系统读取什么已有信息?查询已有镜像或网络的信息
provider谁知道目标 API 和字段语义?云厂商 provider 将资源动作翻译成 API 请求
state配置地址对应哪个真实对象?某个配置地址已经绑定实例 ID
backendstate 放在哪里,怎样访问与协调写入?本地文件或具有相应能力的远程后端
plan从本次观测状态变到配置,需要哪些动作?原地更新一个对象,或先删再建
apply谁实际执行这些动作?Terraform 调用 provider,再记录结果

配置里的资源引用还能表达依赖。若子网配置使用 VPC 资源的 ID,工具可以由这个引用建立依赖图,先获得 VPC ID,再处理子网。不要把文件里的先后行号理解成执行顺序。depends_on 适合表达工具无法从值引用中看出的依赖;无差别添加它,会让本可并行的动作串行化,也会扩大未知值的传播范围。8

plan 能预告动作,不等于已经把资源改好了 ​

本文的最小例子只用内置 terraform_data,让 Terraform 对一份输入做真实的配置检查和计划计算,不使用云 provider。10

hcl
resource "terraform_data" "spec" {
  input = {
    replicas = var.replicas
    port     = 8080
  }
  lifecycle {
    prevent_destroy = true
  }
}

完整配置里要求 replicas 是正整数。本地 init -backend=false、validate、plan 的实际输出节选如下:

text
Success! The configuration is valid.
  # terraform_data.spec will be created
Plan: 1 to add, 0 to change, 0 to destroy.

这里的 will be created 是计划,不是创建完成。没有执行 apply,因而不能据此说已有一个被管理对象。把输入改成 -var=replicas=0,计划阶段就失败:

text
Error: Invalid value for variable
replicas must be a positive integer.

验证规则能挡住已知的输入错误,却不知道真实云账户有没有配额、某个规格在某区域是否有容量、执行身份能不能创建资源。这些要通过对应 provider、API 和实际执行验证。即使计划阶段能读取云资源,执行时目标系统也可能变化。

常用动作的区别可以这样记:2 3

动作已经做了什么仍没有证明什么
fmt按语法格式整理文件配置语义正确
init初始化依赖与后端等工作目录信息将要执行的变更安全
validate检查配置内部的语法与一致性实际账户、权限、容量均满足
plan根据本次输入、state 和查询结果提出动作动作已执行;以后重算仍相同
plan -out=...保存供以后执行的具体计划文件可以公开;审批可以省略
apply 保存的计划执行已保存的计划业务健康;数据可以回滚

直接运行不带计划文件的 apply,会重新规划。传入保存的计划文件时,Terraform 把这个文件视为要执行的计划,不再另问一次批准。因此流水线需要在执行这个文件之前完成授权,并让审批绑定到同一份计划、代码、变量、目标账户和环境,而不是让人看完一段日志后又执行另一个计划。3

state 锁解决并发写入,不能锁住整个云平台 ​

两个人同时从相同 state 开始修改,会各自认为自己拥有同一份最新记录。远程后端如果支持 state 锁,工具可以在相关操作期间取得锁,让第二个运行等待或失败,避免同时写坏这一份状态。并非所有后端都支持锁;锁的能力要查所选 backend。4

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 运行 A
    participant B as 运行 B
    participant S as 支持锁的 state backend
    participant C as 云 API
    A->>S: 请求锁
    S-->>A: 获取成功
    B->>S: 请求同一 state 的锁
    S-->>B: 等待或失败
    A->>C: 按计划修改资源
    A->>S: 写入已完成动作的 state
    A->>S: 释放锁
    Note over B,C: 控制台操作或另一个 state 的运行不受这把锁保护
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 运行 A
    participant B as 运行 B
    participant S as 支持锁的 state backend
    participant C as 云 API
    A->>S: 请求锁
    S-->>A: 获取成功
    B->>S: 请求同一 state 的锁
    S-->>B: 等待或失败
    A->>C: 按计划修改资源
    A->>S: 写入已完成动作的 state
    A->>S: 释放锁
    Note over B,C: 控制台操作或另一个 state 的运行不受这把锁保护

锁不会阻止有人在云控制台改同一个资源,也不会替两个不同 state 协调同一个对象。把同一个数据库导入两套配置,是两个团队争夺所有权,不能靠“双方都开了锁”解决。官方要求一个真实对象只绑定到一个被管理的资源实例。1

发现锁没释放时,先确认原运行是否还在执行。force-unlock 不是常规解锁手段;在原运行仍活着时强行开锁,可能重新制造并发写入。恢复流程还要保存 state 的历史版本,核对实际资源后重新规划,不能只把旧 state 文件盖回去就假定云平台也退回去了。4

敏感值被遮住,不表示没有写进文件 ​

数据库密码即使在变量上标了 sensitive = true,也不能据此判断 state 和计划中没有密码。sensitive 主要控制展示与传播标记;普通持久资源的敏感属性仍可能存入 state。计划文件同样需要受限存储,不能作为普通流水线日志公开。5

处理这一点有三个不同层次:尽量不把长期秘密放入可持久化字段;需要保存的数据由后端加密、限制访问、保留审计;本地计划和导出 JSON 也按敏感文件控制保留时间。Terraform 的 ephemeral、write-only 等能力有版本与 provider 支持条件,不能简单给任意字段加一个标记就生效。OpenTofu 1.10 文档提供 state / plan 加密能力,但启用加密又增加了密钥保管、轮换和恢复要求;丢失解密能力并不会被“代码都在 Git”补救。5 25

漂移、导入、重构与销毁:同样是“有差异”,处理方式不同 ​

漂移(drift)指真实资源偏离配置。例如事故处理中临时把数据库规格调大了。下一次 plan 发现差异时,不能立刻断定应该调回去。先确定这次修改是不是仍然需要:需要,就更新配置;不需要,才考虑让实际资源恢复到配置。refresh-only 主要用于把外部变化反映到 state 和输出里,它不会替你改好配置,也不等于自动接受这种变化为长期设计。2

看到的情况应先确认的原因合理的处理方向容易造成的错误
实际规格与配置不同人工应急变更,还是误操作?接纳为新配置,或审批后修复漂移没看原因就回退应急容量
对象已经存在,state 没记录原来由人工或另一套系统维护?明确唯一所有者后 import直接 create,得到重复对象或名称冲突
只改了资源 / module 的地址是代码重构,还是确实要替换对象?用支持的 moved 记录地址迁移被解释成删除旧对象、创建新对象
配置里删除了资源块停止管理,还是确实销毁?区分解除管理与删除实体以为删代码不会删真实数据库
字段变更显示 replacement该字段不能原地改,还是 provider 行为变化?审查替换顺序、数据迁移、容量与停机把 replacement 当普通 update

import 建立已有对象与配置地址的联系;支持配置式 import 的版本还能把导入纳入计划。导入以后仍要检查配置是否与真实对象相符,否则下一次计划可能立即提出修改。它也不会替你把整个云账户转成一套结构良好的模块。6

moved 则解决地址变化。工具能够知道“旧地址现在叫新地址”,通常就不必把重命名当成删除再创建。它解决的是对象身份的延续,不会把本来不支持原地修改的资源属性变成可原地修改。7

prevent_destroy 能拒绝配置中受保护资源的删除计划,但规则必须仍存在于配置中。如果整个资源块被移走,规则也跟着消失,不能继续阻止销毁。create_before_destroy 改的是替换顺序:先创建新对象,再删除旧对象。它仍可能受唯一名称、配额、费用和应用切换方式限制,不能承诺零停机。关键数据还需要服务端删除保护、可恢复备份和真正的恢复演练。8

“先创建后删除”也含有删除 ​

Terraform 的 JSON 计划用动作数组表示替换。本文写了一个小型 Go 检查器,只找资源动作中的删除,故意不把它包装成完整上线审批器。对下面这个人工构造的反例:

json
{"format_version":"1.2","resource_changes":[{"address":"demo.database","change":{"actions":["create","delete"]}}]}

实际输出及程序退出码是:

text
REVIEW deletions: demo.database=create,delete
exit=1

如果只检查 actions == ["delete"],就会漏掉先建后删和先删后建两种替换。官方 JSON 格式也特意说明,扫描动作中的 delete 可以发现这些情况。11

检查器对实际生成的本地创建计划输出:

text
PASS deletion check only; no apply performed

这句话只说明没有识别到删除动作。它没有检查数据库备份、开放端口、权限扩张、资源价格、输出中的敏感值或业务风险。未来不认识的动作、主版本不同的格式、缺失的动作信息会返回错误;新增的无关对象字段允许存在。测试也覆盖了这些边界。生产策略需要针对已固定版本的完整 plan 语义继续实现,尤其不能把未知属性值当成安全默认值。

把配置拆成模块,真正要固定的是依赖和边界 ​

module 是一组可复用配置,输入是变量,输出是其他配置需要的值。一个“服务网络”模块可以收敛子网、路由与输出接口,调用方只需要知道怎样连接它。好处是重复决定只维护一次;代价是模块升级会影响所有调用方,过度包装还会把重要选择藏到多层默认值后面。9

同一个根配置里的子模块仍属于这次运行的管理范围。拆目录不自动得到独立的 state、权限或故障范围。需要让网络团队与应用团队独立发布时,应进一步设计根模块、backend、执行身份和输出契约。反过来,拆得过碎会增加跨 state 依赖和执行顺序管理。这是组织与恢复边界的取舍。

需要固定的东西常用机制为什么只固定其中一个不够
CLI 支持的语法与行为required_version,并在流水线固定实际 CLI 版本范围约束不是自动安装版本
provider 来源与兼容范围required_providers 的 source、version同名短名称不能代替完整来源
provider 实际选择及包校验提交 .terraform.lock.hcllock 不负责固定远程 module 版本
远程 module 版本registry module 的精确 version,或可复现的源码引用只写宽范围可能下次取到不同实现
本次执行输入代码提交、变量、目标环境、执行身份与计划文件相同代码配不同变量仍可修改不同资源

锁文件记录 provider 的选择和校验摘要,常规 init 会尊重它;主动升级依赖需要重新审查差异。远程 module 不在 Terraform 这份依赖锁文件的版本锁定范围内,所以不能看到 lock 文件就宣称“所有依赖可复现”。12 14

Terraform 与 OpenTofu:相似的工作流,不作无限兼容承诺 ​

OpenTofu 官方把兼容现有 Terraform 配置作为目标,但迁移指南仍要求备份配置和 state、初始化、对比计划,并在出现意外差异时停止执行。这比“把命令名换掉就行”多了实质工作。13

维度本文能确认的内容迁移时应核对什么
配置与 provider共享大量语法和生态,OpenTofu 有官方迁移流程所用具体语言特性、provider 地址、版本与协议
state 与 backend迁移围绕已有 state 展开状态格式、后端支持、跨配置读取、加密特性与回退条件
新功能两个项目独立演进一边新增特性能否被另一边读取与执行
许可证Terraform 1.13.4 仓库 LICENSE 为 BSL 1.1;OpenTofu 1.10.0 为 MPL 2.0实际采用版本及使用方式对应的许可条件,不能由技术兼容推断

这里没有测试任意 Terraform 版本到任意 OpenTofu 版本的可逆转换,也不提供特定商业用途的许可判断。尤其启用了某一方特有的状态加密或语法后,应先证明恢复路径,再切换执行者;不要让两种 CLI 同时修改同一份状态。13 16

Helm:chart 是配方,release 是一次有名字的安装 ​

一个 chart 可以部署很多次。goapi 是 chart 名;orders-prod、orders-test 可以是两个不同 release。chart 里 Chart.yaml 描述包,values.yaml 提供默认值,templates/ 生成 Kubernetes 对象。chart 的 version 与应用镜像版本是两个概念,appVersion 本身只是应用版本说明。15

Helm 模板使用 Go template 语法,再加 Helm 提供的对象和函数。熟悉 Go 并不意味着熟悉全部 chart:.Values 是最终合并的配置,.Release.Name 来自这次安装,模板还能包含条件、循环和命名模板。抽象越多,复用越方便,但审核者越难直接看出最终会产生什么对象。因此,审 chart 源码之外还应审最终渲染结果。

本文模板的关键几行是:

yaml
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicas }}

默认 values 的 replicas 是 2。实际运行 helm template demo chart,输出节选如下:

yaml
metadata:
  name: demo
spec:
  replicas: 2

values 文件可以分层覆盖;多个 -f 文件对相同键的优先级是右侧较高,命令行赋值又有自己的覆盖规则。应固定这次发布使用的完整输入,避免一个手工 --set 只存在于某人的终端历史。需要字符串时还要防止数值、布尔值被按另一种类型解释。17

本例的 values.schema.json 要求 replicas 至少为 1。运行 helm template demo chart --set replicas=0,实际得到:

text
Error: values don't meet the specifications of the schema(s) in the following chart(s):
goapi:
- at '/replicas': minimum: got 0, want 1

这是输入约束失败,不是集群拒绝。helm template 默认本地渲染,不能验证目标集群是否支持某 API、是否有对应 CRD、准入控制是否允许、镜像是否存在、调度是否成功。本例特意使用 example.invalid/goapi:demo,只为演示清单;它不是可运行的应用镜像。18

release 历史不是数据库时间机器 ​

Helm 3 默认使用 Kubernetes Secret 存 release 信息,release 记录也应按权限保护。升级时,Helm 计算并提交资源变更;Kubernetes 控制器再异步推进实际状态。--wait 可以等待指定资源条件,--atomic 在 Helm 3 upgrade 失败时尝试回滚并隐含 wait,但这仍不是跨 Kubernetes、数据库和第三方 API 的事务。19 20

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant H as Helm
    participant K as Kubernetes API / 控制器
    participant J as 数据迁移 hook
    participant D as 数据库
    H->>J: 执行 pre-upgrade Job
    J->>D: 修改表或数据
    D-->>J: 已提交
    J-->>H: 成功
    H->>K: 提交新应用清单
    K-->>H: 在等待窗口内未就绪
    H->>K: 尝试恢复旧版本清单
    Note over H,D: 恢复旧清单不会自动撤销已提交的数据变更
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant H as Helm
    participant K as Kubernetes API / 控制器
    participant J as 数据迁移 hook
    participant D as 数据库
    H->>J: 执行 pre-upgrade Job
    J->>D: 修改表或数据
    D-->>J: 已提交
    J-->>H: 成功
    H->>K: 提交新应用清单
    K-->>H: 在等待窗口内未就绪
    H->>K: 尝试恢复旧版本清单
    Note over H,D: 恢复旧清单不会自动撤销已提交的数据变更

这张图解释了一个具体失败:新应用没启动起来,但数据库迁移已经成功。即使回滚了镜像,旧应用也可能读不懂新结构。发布前需要设计数据库变更的前后兼容,例如先增加字段并允许旧代码继续工作,等旧版本退出后再清理。这个因果与使用哪一种发布工具无关。

hook 是带生命周期注解的 Kubernetes 对象,常见用途包括安装前检查或升级前后执行 Job。hook 创建的对象不按普通 release 对象统一管理,不能依赖 uninstall 自动清理;要设计删除策略或 Job TTL。hook 的成功只证明这个 Job 按自身条件结束,也不说明副作用可以逆转。21

CRD 又有单独边界。Helm 3 对 crds/ 目录里的 CRD 采用特殊安装流程:这些定义不做模板渲染,已有的不会像普通模板资源那样升级,Helm 不负责自动升级或删除它们。这是为了避免不经意的数据损失。涉及 CRD schema、conversion webhook 或对应自定义资源迁移时,要单独安排版本兼容与管理流程,不能把一次 release rollback 当成 CRD 数据恢复。22

Kustomize:保留原始对象,把环境差异写成变换 ​

如果同一个 Deployment 在测试环境运行 1 个副本,在生产环境运行 3 个,复制两套 YAML 很快就会失去同步。Kustomize 用 base 保存共同对象,用 overlay 引用 base 并说明差异。base 不必知道有哪些环境依赖自己。23

本文的生产 overlay 选择 Deployment api,把 /spec/replicas 改成 3:

yaml
resources:
  - ../../base
patches:
  - target:
      group: apps
      version: v1
      kind: Deployment
      name: api
    patch: |-
      - op: replace
        path: /spec/replicas
        value: 3

实际运行 kubectl kustomize kustomize/overlays/prod,得到的对象节选:

yaml
metadata:
  name: api
spec:
  replicas: 3

这里没有向 Kubernetes 发请求。命令只是输出最终清单。后续由 kubectl、GitOps 控制器或其他发布者提交,才进入 API 校验和协调流程。

Kustomize 的 patch 要明确补丁目标,也要明确补丁语义。JSON Patch 通过路径进行操作;数组下标指向的是位置,base 中插入一个容器就可能改变这个位置。战略合并补丁则依赖对应类型的合并规则和键,不能把它当成所有 YAML 列表都通用的深合并。对于副本数、镜像等常用变化,工具也提供专用变换,通常比长串位置路径更直观。23

比较维度HelmKustomize
复用的基本单位带参数、模板与依赖的 chart可直接阅读的对象集合和 overlay
环境差异怎样表达values 驱动模板分支或字段patch、images、replicas、名称等变换
复杂条件的代价模板可表达较复杂选择,也更难审读对现成对象做差异更直观,复杂组合可能堆积 overlay
发布记录Helm 自己可管理 release 历史构建层不提供 release 历史
适合的入口消费可配置的应用包,或维护稳定的参数接口自己掌握原始清单,环境间差异较局部

两者可以组合,例如 chart 渲染后再处理清单,但每多一层变换,就多一个需要固定版本和检查的输入阶段。无论如何组合,都应保留“最终提交的到底是什么”这份证据。

本地渲染成功,是很窄的一种成功 ​

本次实测遇到了一个值得保留的反例:在 Kustomize 5.7.1 中,对 /spec/does-not-exist 做 replace,构建没有报错,而是输出了:

yaml
spec:
  does-not-exist: 3
  replicas: 1

这不是 Deployment 的合法 spec 字段。这个本地实验只证明该版本的构建器允许产生这样的输出,没有连接 API server 验证服务器会拒绝还是在特定字段校验方式下处理它。不能把“构建返回 0”解释成“生产集群认可全部字段”。

再把路径改成缺失的父路径 /spec/missing/replicas,这次构建器才报错:

text
error: replace operation does not apply: doc is missing path: /spec/missing/replicas: missing value

因此检查应分层:渲染时检查输入和变换;针对目标 Kubernetes 版本做 schema 检查;具备授权的环境再做服务器端 dry-run,触发相应 API 和准入检查;真正发布后观察就绪条件与业务指标。server-side dry-run 仍会访问集群,也不证明调度、镜像拉取和外部依赖都正常。本文只执行了第一层。26

怎样把这些工具放进一条可恢复的交付流程 ​

先为对象分配唯一的管理者,再决定工具组合。如果 Helm 管应用的 Deployment,就不要同时让另一套 Terraform 配置改同一 Deployment 的镜像;如果 HPA 管副本数,固定 replicas 的配置也需要明确协调策略。多个控制者写同一字段,会把“自动修复”变成互相覆盖。Kubernetes 的 Server-Side Apply 提供字段所有权与冲突机制,但不能代替团队约定,也不是所有写入方式都采用相同语义。24

一条可审查的路径通常是:固定 CLI、provider、chart 和基础清单版本;验证输入;生成计划或清单;审查变化以及删除、权限、费用和数据影响;由目标环境的执行身份完成授权动作;检查资源状态和业务结果。失败时先查实际完成了什么,再重新计划恢复。云 API 的多步变更可能部分成功,不要把配置提交回退、状态文件回退和真实数据回退当成同一件事。

决定是否能执行的关键问题始终很具体:这次会改哪些对象?谁拥有它们?哪些动作不可逆?成功与恢复分别怎样验证?工具能把这些问题的证据整理得更清楚,但不能代替回答。

依据与验证范围 ​

下列官方文档于 2026-10-13 核对。持续更新的网页用于说明通用机制;本地结果只适用于前述固定版本。Helm 引用限定为 v3,不把这些参数与生命周期细节外推到所有大版本。

另核对了 OpenTofu 1.10 state / plan 加密 和 OpenTofu 1.10.0 LICENSE。Terraform 的依赖引用与显式依赖见 depends_on。这些链接补充各段版本范围,不表示已执行迁移、启用加密或接受任何部署环境的许可条款。

本地执行了 Go 单元测试、race、vet,Terraform fmt / init / validate / plan,Helm lint / template,以及 Kustomize build。正例与反例均在文末提供;验证脚本会检查预期失败确实发生。没有执行云资源创建、修改、导入、删除,没有执行 Helm release 升级或回滚,没有验证后端锁争用、CRD 迁移、服务端准入、业务可用性和备份恢复。

完整复现实验 ​

把以下文件按标题给出的相对路径放在同一实验目录中。前提是已具备 Go 1.27.2、Terraform 1.13.4、Helm 3.19.0、kubectl 1.34.1,以及 Bash。文件中的镜像是故意不可部署的示意地址。脚本只运行本地检查,不包含 apply、install 或 upgrade。

运行 bash verify.sh。CLI 不在 PATH 时,可以通过 TF_BIN、HELM_BIN、KUBECTL_BIN 指定本机路径。Go 检查器只依赖标准库;Terraform 例子只使用内置 provider,不下载云 provider。保存的计划放入临时目录,退出时清理。

go.mod ​

go.mod
module example.com/iaccheck

go 1.27.0

main.go ​

代码块 · 75 行 · 点击收起 / 展开
go
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strconv"
	"strings"
)

type Plan struct {
	Format  string            `json:"format_version"`
	Changes *[]ResourceChange `json:"resource_changes"`
}
type ResourceChange struct {
	Address string `json:"address"`
	Change  struct {
		Actions []string `json:"actions"`
	} `json:"change"`
}

// Deletions checks only the documented resource-change actions. It is not a
// complete policy engine and never applies a plan or contacts a cloud API.
func Deletions(data []byte) ([]string, error) {
	var p Plan
	if err := json.Unmarshal(data, &p); err != nil {
		return nil, fmt.Errorf("decode: %w", err)
	}
	major, minor, ok := strings.Cut(p.Format, ".")
	n, err := strconv.Atoi(minor)
	if !ok || major != "1" || err != nil || n < 0 {
		return nil, fmt.Errorf("unsupported format %q", p.Format)
	}
	if p.Changes == nil {
		return nil, fmt.Errorf("resource_changes missing or null")
	}
	var deletes []string
	for _, r := range *p.Changes {
		if r.Address == "" {
			return nil, fmt.Errorf("empty resource address")
		}
		key := strings.Join(r.Change.Actions, ",")
		switch key {
		case "no-op", "create", "read", "update":
		case "delete", "delete,create", "create,delete":
			deletes = append(deletes, r.Address+"="+key)
		default:
			return nil, fmt.Errorf("%s: unsupported actions %q", r.Address, key)
		}
	}
	return deletes, nil
}
func run(args []string) int {
	if len(args) != 1 {
		fmt.Fprintln(os.Stderr, "usage: iaccheck plan.json")
		return 2
	}
	b, err := os.ReadFile(args[0])
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return 2
	}
	d, err := Deletions(b)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return 2
	}
	if len(d) > 0 {
		fmt.Printf("REVIEW deletions: %s\n", strings.Join(d, "; "))
		return 1
	}
	fmt.Println("PASS deletion check only; no apply performed")
	return 0
}
func main() { os.Exit(run(os.Args[1:])) }

main_test.go ​

代码块 · 42 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"testing"
)

func TestActions(t *testing.T) {
	for _, tc := range []struct {
		actions string
		deletes int
		bad     bool
	}{
		{`["no-op"]`, 0, false}, {`["create"]`, 0, false}, {`["read"]`, 0, false}, {`["update"]`, 0, false},
		{`["delete"]`, 1, false}, {`["delete","create"]`, 1, false}, {`["create","delete"]`, 1, false},
		{`[]`, 0, true}, {`["future-action"]`, 0, true}, {`["delete","update"]`, 0, true},
	} {
		t.Run(tc.actions, func(t *testing.T) {
			p := fmt.Sprintf(`{"format_version":"1.2","resource_changes":[{"address":"demo.a","change":{"actions":%s}}]}`, tc.actions)
			got, err := Deletions([]byte(p))
			if (err != nil) != tc.bad || len(got) != tc.deletes {
				t.Fatalf("got %v, %v", got, err)
			}
		})
	}
}
func TestInput(t *testing.T) {
	for _, p := range []string{
		`{`, `{}`, `{"format_version":"2.0","resource_changes":[]}`,
		`{"format_version":"1.x","resource_changes":[]}`,
		`{"format_version":"1.2"}`, `{"format_version":"1.2","resource_changes":null}`,
		`{"format_version":"1.2","resource_changes":[{"change":{"actions":["create"]}}]}`,
	} {
		if _, err := Deletions([]byte(p)); err == nil {
			t.Fatalf("accepted %s", p)
		}
	}
	// Additional object properties do not change the small deletion check.
	if d, err := Deletions([]byte(`{"format_version":"1.99","future":true,"resource_changes":[]}`)); err != nil || len(d) != 0 {
		t.Fatal(d, err)
	}
}

fixtures/replace.json ​

json
{"format_version":"1.2","resource_changes":[{"address":"demo.database","change":{"actions":["create","delete"]}}]}

fixtures/unknown.json ​

json
{"format_version":"2.0","resource_changes":[]}

terraform/main.tf ​

hcl
terraform {
  required_version = "~> 1.13.0"
}

variable "replicas" {
  type    = number
  default = 2
  validation {
    condition     = var.replicas >= 1 && floor(var.replicas) == var.replicas
    error_message = "replicas must be a positive integer."
  }
}

# Built-in resource only: planning does not create infrastructure.
resource "terraform_data" "spec" {
  input = {
    replicas = var.replicas
    port     = 8080
  }
  lifecycle {
    prevent_destroy = true
  }
}

output "spec" {
  value = terraform_data.spec.output
}

chart/Chart.yaml ​

yaml
apiVersion: v2
name: goapi
version: 0.1.0
appVersion: "demo"
type: application

chart/values.yaml ​

yaml
replicas: 2
image: example.invalid/goapi:demo

chart/values.schema.json ​

json
{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","required":["replicas","image"],"properties":{"replicas":{"type":"integer","minimum":1},"image":{"type":"string","minLength":1}}}

chart/templates/deployment.yaml ​

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicas }}
  selector:
    matchLabels:
      app: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}
    spec:
      containers:
        - name: api
          image: {{ .Values.image | quote }}
          ports:
            - containerPort: 8080

kustomize/base/kustomization.yaml ​

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml

kustomize/base/deployment.yaml ​

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: example.invalid/goapi:demo
          ports:
            - containerPort: 8080

kustomize/overlays/prod/kustomization.yaml ​

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - target:
      group: apps
      version: v1
      kind: Deployment
      name: api
    patch: |-
      - op: replace
        path: /spec/replicas
        value: 3

kustomize/overlays/unknown-field/kustomization.yaml ​

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - target:
      group: apps
      version: v1
      kind: Deployment
      name: api
    patch: |-
      - op: replace
        path: /spec/does-not-exist
        value: 3

kustomize/overlays/bad/kustomization.yaml ​

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - target:
      group: apps
      version: v1
      kind: Deployment
      name: api
    patch: |-
      - op: replace
        path: /spec/missing/replicas
        value: 3

verify.sh ​

代码块 · 49 行 · 点击收起 / 展开
bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TF_BIN=${TF_BIN:-terraform}
HELM_BIN=${HELM_BIN:-helm}
KUBECTL_BIN=${KUBECTL_BIN:-kubectl}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# Never use a cluster context or cloud backend in this local demonstration.
export KUBECONFIG="$tmp/no-kubeconfig" TF_DATA_DIR="$tmp/tfdata" CHECKPOINT_DISABLE=1
export HELM_CACHE_HOME="$tmp/helm/cache" HELM_CONFIG_HOME="$tmp/helm/config" HELM_DATA_HOME="$tmp/helm/data"
expect_fail() {
 local name=$1; shift
 set +e
 "$@" >"$tmp/negative.log" 2>&1
 local code=$?
 set -e
 cat "$tmp/negative.log"
 if [ "$code" -eq 0 ]; then echo "ERROR: $name unexpectedly passed"; exit 1; fi
 echo "$name: expected failure (exit=$code)"
}
go version
"$TF_BIN" version
"$HELM_BIN" version --short
"$KUBECTL_BIN" version --client -o json
go test -count=1 ./...
go test -race -count=1 ./...
go vet ./...
go build -o "$tmp/iaccheck" .
"$TF_BIN" -chdir=terraform fmt -check
"$TF_BIN" -chdir=terraform init -backend=false -input=false -no-color
"$TF_BIN" -chdir=terraform validate -no-color
"$TF_BIN" -chdir=terraform plan -input=false -no-color -out="$tmp/plan.bin"
"$TF_BIN" -chdir=terraform show -json "$tmp/plan.bin" > "$tmp/plan.json"
"$tmp/iaccheck" "$tmp/plan.json"
expect_fail terraform-invalid "$TF_BIN" -chdir=terraform plan -input=false -no-color -var=replicas=0
expect_fail replacement "$tmp/iaccheck" fixtures/replace.json
expect_fail unsupported-format "$tmp/iaccheck" fixtures/unknown.json
"$HELM_BIN" lint chart
"$HELM_BIN" template demo chart > "$tmp/helm.yaml"
grep -E 'name: demo$|replicas:|image:' "$tmp/helm.yaml"
expect_fail helm-invalid "$HELM_BIN" template demo chart --set replicas=0
"$KUBECTL_BIN" kustomize kustomize/overlays/prod > "$tmp/kustomize.yaml"
grep -E '^  name:|replicas:|image:' "$tmp/kustomize.yaml"
grep -q 'replicas: 3' "$tmp/kustomize.yaml"
"$KUBECTL_BIN" kustomize kustomize/overlays/unknown-field > "$tmp/unknown-field.yaml"
grep "does-not-exist: 3" "$tmp/unknown-field.yaml"
expect_fail patch-invalid "$KUBECTL_BIN" kustomize kustomize/overlays/bad
echo 'All local checks passed; no apply/install/upgrade/cloud API call performed.'

本次验证的结果 ​

Go 单元测试和 race 测试通过,go vet 无输出且退出码为 0。Terraform 的正常配置通过检查并生成创建计划;replicas=0 返回 1。检查器对替换返回 1,对未知 JSON 主版本返回 2。Helm 正例渲染副本数 2,replicas=0 返回 1。Kustomize 生产 overlay 输出副本数 3,未知字段反例构建成功,缺失父路径反例返回 1。脚本最终输出:

text
All local checks passed; no apply/install/upgrade/cloud API call performed.

保留各书来源与许可 · 示例运行条件以正文为准