用策略读计划 — 在应用前抓住销毁、替换和暴露范围
一句话总结
Terraform 和 OpenTofu 的计划(plan)是可以导出为 JSON 的结构化变更说明,对这份 JSON 施加策略,就能在资源被创建之前,拦住销毁、替换和公开范围。
为什么需要它
准入控制很强大,但它看的是已经构造好的请求。而世界上相当多的危险变更,根本不会经过 Kubernetes API。把安全组开放给 0.0.0.0/0、替换(replace)数据库、把存储桶改成公开,这些都是直接走云 API 的,不会出现在准入能看到的位置。
不过,这些变更有一个共同点:在应用之前有一个计划阶段。tofu plan 和 terraform plan 会先计算“要创建什么、删除什么、修改什么”。这份输出本来是给人读的,但也可以导出成机器可读的形式。这样,这份计划就变成了可以用策略检查的文档。如果说准入是集群的守门人,那么计划检查就是整个云的守门人。而且便宜得多——因为还没有什么需要回退的。
工作原理
流程只有两行。把计划保存为文件,再把它转换成 JSON。
tofu plan -out=tfplan.binary
tofu show -json tfplan.binary > plan.json
Terraform 也是一样(terraform show -json)。在输出的 JSON 中,策略所看的位置大多是 resource_changes 数组,每一项都包含 address、type、name 以及 change 对象。change 中的 actions 是判定的核心。文档明确给出的有效值如下。
actions |
含义 |
|---|---|
["no-op"] |
没有变化 |
["create"] |
新建 |
["read"] |
读取数据源 |
["update"] |
原地修改 |
["delete"] |
删除 |
["delete", "create"] |
替换。先删除再重新创建 |
["create", "delete"] |
也是替换,但先创建、后删除 |
替换被表示为含有两个元素的数组,是有意为之的。文档解释说,这样表示之后,调用方只需检查列表中是否含有 delete,就能抓住资源会消失的全部三种情形。策略的第一条规则就由此而来。如果 "delete" in actions,就要求人工批准。对于数据库或卷这类有状态的资源,尤其如此。
能问的不只有销毁。
- 替换:停机和数据丢失会同时到来。哪个字段会导致替换,也可以通过
change的replace_paths看到。 - 标签:是成本分摊和所有者追踪的依据。检查
after中是否有必填标签。 - 公开范围:安全组 CIDR、存储桶的公开设置、公网 IP 的分配。
- 规模与成本:实例类型、数量、磁盘大小的上限。
- 漂移:计划 JSON 中的
resource_drift会显示有人在代码之外手动修改了什么。
计划中有还不知道的值。这是计划策略最大的限制。资源 ID、生成的 ARN、随机后缀之类的值,只有应用了才能确定。JSON 用 after_unknown 来表示它,文档的描述很准确:它是与 after 结构相同、未知的叶子值为 true、已知的叶子值则完全省略的对象。所以编写策略时,规则是这样的。
- 不能因为没有
after.<필드>(占位符为字段),就断定为违规。要先看after_unknown.<필드>是否为true。 - 针对未知值的策略,与其写成“值应该是这样”,不如写成“这个字段在计划阶段应该是已知的”,这样更诚实。
- 像名称或标签这样写在代码里的值,通常在计划中就是已知的。所以在实际工作中,计划策略最管用的地方也在那里。
把判定转换成流水线结果——退出码并不总是真相。这是本模块最实用的部分。把策略工具接入 CI 时,人们自然会写 if ! tool scan ...; then exit 1; fi。可是确实存在找到违规却以 0 结束的工具。本实验环境中的 kyverno json scan 就是这样。违规会在给人读的输出中打印为 FAILED,也会留在 --output json 的 Violations 里,但是无论有没有违规,退出码都是 0(在实验镜像的 kyverno CLI 1.13.2 中亲自确认过)。如果不知道这一点而只相信退出码,流水线就会永远亮绿灯。
所以接入策略门禁时,顺序是这样的。
1) 도구를 일부러 실패할 입력으로 돌려 본다
2) 종료 코드를 확인한다 (echo $?)
3) 0 이면 종료 코드를 쓰지 않고 보고서를 파싱한다
4) 파싱 결과가 비어 있지 않은지도 확인한다 (형식이 바뀌면 0건으로 읽힌다)
5) 그 판정으로 파이프라인을 세운다
跳过第 2 项是事故的开端,跳过第 4 项,则会在工具升级的那天让门禁悄悄失效。
实际工作中使用的引擎有 Conftest(用 OPA 的 Rego 检查任意 JSON)、OPA 本身、kyverno-json 等等。这个实验 Pod 中没有 conftest 和 opa。取而代之的是用 kyverno CLI 的 kyverno json scan 做同样的事——把任意 JSON 文档作为载荷,用策略进行判定,这个结构是一样的。工具不同,学到的东西(计划 JSON 的形状、未知值、退出码的陷阱)是不变的。
在现场相遇的样子
第一,审批门禁真正拦下的那天。一份在 RDS 实例上带有 ["delete", "create"] 的计划提交到了 PR,门禁抓住了它,由人做了确认。修改一个字段是否会导致替换,只看代码是看不出来的,只有计划中才会显示。
第二,未知值引起的误报。必填标签检查只写成了查看 after.tags,结果在用局部变量计算标签的模块中,那个值在计划阶段还不存在,全部被判为违规。几天之内门禁就被关掉了。本应改成同时查看 after_unknown 的规则。
第三,悄悄被关掉的门禁。升级了工具,报告 JSON 中有一个键变了,导致解析结果总是空数组。被读成违规 0 条,绿灯亮了好几周。加上一行同时检查“结果是否为空”,就能防住这种情况。
第四,计划依赖状态。同样的代码,状态文件不同,计划就不同。把在 PR 中生成的计划几天后原样应用,这期间的变更就不会被反映。计划检查应该使用在部署之前重新生成的计划。
参考文档
- OpenTofu show: https://opentofu.org/docs/cli/commands/show/
- Terraform show: https://developer.hashicorp.com/terraform/cli/commands/show
- Terraform JSON 输出格式:https://developer.hashicorp.com/terraform/internals/json-format
- Conftest: https://www.conftest.dev/
- OPA 策略语言(Rego): https://www.openpolicyagent.org/docs/policy-language
- kyverno-json: https://kyverno.github.io/kyverno-json/latest/
- kyverno CLI 的 json 命令:https://kyverno.io/docs/kyverno-cli/usage/json/
下一项实验要做什么
用离线 provider 生成计划并导出为 JSON,然后对这份 JSON 施加策略。遍历 resource_changes,找出含有 delete 的条目,区分替换和单纯更新,编写抓住缺少必填标签的资源的规则。故意制造值在计划阶段还不存在的位置,确认不看 after_unknown 的规则会产生误报,然后修复它。最后把明显违规的输入放进工具,亲自打印出退出码,确认得到 0,再编写解析报告来撑起流水线的门禁。