主题
基础设施即代码
改一台服务器的端口,登录控制台点几下就能完成。困难发生在下一次:生产和测试为什么不一样?是谁把数据库改成了单可用区?重建环境时,哪些步骤必须重复,哪些资源绝不能重建?基础设施即代码(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 读写目标系统,计算并执行资源变更 | 资源地址与实际对象的对应关系、属性及依赖等 | 应用业务正确;每次变更都能无损回滚 |
| Helm | chart、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
这里最容易混淆的是“持续”。单次 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 |
| backend | state 放在哪里,怎样访问与协调写入? | 本地文件或具有相应能力的远程后端 |
| 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 和实际执行验证。即使计划阶段能读取云资源,执行时目标系统也可能变化。
| 动作 | 已经做了什么 | 仍没有证明什么 |
|---|---|---|
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 的运行不受这把锁保护
锁不会阻止有人在云控制台改同一个资源,也不会替两个不同 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.hcl | lock 不负责固定远程 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: 2values 文件可以分层覆盖;多个 -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: 恢复旧清单不会自动撤销已提交的数据变更
这张图解释了一个具体失败:新应用没启动起来,但数据库迁移已经成功。即使回滚了镜像,旧应用也可能读不懂新结构。发布前需要设计数据库变更的前后兼容,例如先增加字段并允许旧代码继续工作,等旧版本退出后再清理。这个因果与使用哪一种发布工具无关。
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
| 比较维度 | Helm | Kustomize |
|---|---|---|
| 复用的基本单位 | 带参数、模板与依赖的 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.0main.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: applicationchart/values.yaml
yaml
replicas: 2
image: example.invalid/goapi:demochart/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: 8080kustomize/base/kustomization.yaml
yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yamlkustomize/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: 8080kustomize/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: 3kustomize/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: 3kustomize/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: 3verify.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.