我见过太多团队在 Policy-as-Code 这件事上翻车,翻车姿势还出奇地一致:安全团队拉个群,说要搞合规左移,照着文档装了个策略引擎,两周憋出四十多条规则,CI 里加一道门禁。上线第一个月研发被拦一百多次,群里天天吵;第三个月门禁悄悄改成只告警;第六个月策略仓库最后一次提交停在两个月前,README 顶上还挂着一行 TODO。
问题从来不在工具。OPA、Kyverno、Conftest、Checkov 这些开源件文档都写得挺清楚,跑通一个 demo 也就半小时。真正难的是把“策略”当成一份需要长期维护的软件资产来经营:谁写、谁审、怎么测、怎么发、怎么豁免、怎么退场。这篇就围绕企业落地 Policy-as-Code 的完整方案展开,流程、规范、工具链三块我都会拆开讲,适合正在做云原生治理、合规左移、平台工程的团队参考,也适合被门禁折腾过的研发同学看看对面到底在想什么。
1. Policy-as-Code 到底在裁决什么,先把这个想明白
1.1 从“人肉签字”到“机器判定”的那条分界线
传统变更管控的链路是这样的:研发提工单,安全或运维评审,评审人打开一堆 YAML 扫两眼,看不出问题就签字放行。这条链路的问题不在于人不负责,而在于评审人根本没法在几分钟内判断一份几十行的资源配置有没有风险——他不知道这个镜像来自哪个仓库,不知道这个 Service 会不会暴露到公网,更不知道这个 PVC 挂的存储类是不是允许生产用的。
Policy-as-Code 干的事,本质是把“能不能做”这个判断从人脑搬到代码里。判断的输入是被检查对象的结构化表示(Terraform plan 的 JSON、Kubernetes 的 API 对象、云资源的配置快照),输出是一个明确的三元组:通过与否、违反了哪条、证据是什么。它管的是事实,不管意图。一条策略能成立的前提是:存在一个机器可读的输入,里面包含了判定所需的全部信息。
一个特别常见的误解:以为上了策略引擎,人就不用管了。恰恰相反,策略引擎把人从“逐条看配置”里解放出来,转而去做“设计判定规则”和“处理例外”。前者是可复用的一次性投入,后者才是长期的日常。
1.2 三类最容易被误当成策略的东西
第一类是制度条文。“重要系统变更需报备”“上线前须完成安全评估”,这类规则没有可结构化的输入,机器判定不了。硬要写进策略引擎,最后只能做成一个让人手动勾选的表单字段,等于把签字仪式搬进了流水线,毫无意义。
第二类是个人偏好。“我不喜欢用 latest tag”“我觉得命名应该带业务域”,这类东西在写下来之前是某个人的习惯,写下来之后如果没经过组织层面的共识确认,就会变成研发眼中的无妄之灾。策略的合法性来自共识,不来自代码。
第三类是已经有工具在管的事。代码风格有 lint,依赖漏洞有 SCA,镜像扫描有漏洞扫描器。这些不该再包一层策略去重复判断,策略层应该做的是“消费这些工具的输出做组合判定”,比如“高危漏洞未修复的镜像不允许部署”,而不是自己再去解析镜像层。
1.3 一条规则能不能落地,用三个条件卡一下
我给团队做策略梳理时,通常用三个条件做筛子:
| 条件 | 含义 | 反例 |
|---|---|---|
| 输入可结构化 | 判定所需信息在某个 API 对象或配置文件中能拿到 | “该服务是否属于核心链路”——信息在 CMDB 里,但没同步进配置 |
| 判定无歧义 | 两个不同的人看同一个输入,结论一致 | “资源配置是否合理”——合理没有定义 |
| 结论可执行 | 违规后能给出明确的修改动作 | “请提升系统安全性”——改哪里? |
三个条件缺一个,这条规则就先别写。我见过最典型的翻车案例是有人写了条“禁止使用非官方镜像”,结果“官方”的定义散落在三个 wiki 页面里,策略里只硬编码了五个前缀,半年后业务方换了个合规仓库,全量阻塞。
2. 策略盘点:规则不在文档里,在历史工单和评审记录里
2.1 别从制度文档开始翻,从“本应该拦住”开始翻
大部分团队的策略盘点会从安全基线文档开始,这是个效率很低的入口。基线文档是写给审计看的,措辞抽象,跟实际可判定的条件之间隔着一层翻译。更有效的入口是事故和接近事故的记录:
- 过去一年的故障复盘,找出那些“如果有条规则能提前拦住就不会出事”的条目;
- 代码评审里反复出现的同类评论,比如同一个 reviewer 在二十个 PR 里都在说“记得加 resource limits”;
- 运维同学手工写的巡检脚本和 crontab 里的检查命令,这些是野生策略,价值极高;
- 云账号里的历史配置快照,反查哪些资源长期处于“不该有的状态”。
我参与过的一次盘点,最后产出的六十多条候选策略里,从基线文档里抠出来的不到二十条,剩下四十多条全来自工单和脚本。原因很简单:文档写的是理想状态,脚本和工单记录的是真实踩过的坑。
2.2 给每条策略打四个标签,而不是一个
很多人只给策略打“强制/建议”两档,这个粒度不够用。我建议至少四个维度:
- 级别:强制阻断 / 告警 / 观察记录;
- 适用范围:全集群、特定命名空间、特定标签的工作负载、特定云账号;
- 判定依据:来自哪条制度、哪次事故、哪个合规项,写清楚来源,后面评审和豁免都有据可依;
- 生命周期:永久基线、阶段性收紧、试验性策略。
“阶段性收紧”这一档特别容易被忽略。比如大促期间禁止变更核心服务的副本数,这种策略有明显的生效窗口,如果当初没标注,大促结束三个月后它还在拦人,就成了策略债。
2.3 先出表格,再写代码
盘点阶段的交付物应该是一张能评审的表格,而不是一堆 Rego 文件。表格字段我通常这样定:
| 编号 | 策略描述 | 判定条件 | 输入来源 | 执行点 | 级别 | 负责人 | 计划上线 |
|---|---|---|---|---|---|---|---|
| P-001 | 工作负载必须声明 CPU/内存 requests | spec.containers[*].resources.requests存在且非空 | Pod spec | 集群准入 | 强制 | 平台组 | 第 3 周 |
| P-002 | 镜像不得使用 latest 标签 | image不以:latest结尾且含显式 tag | Pod spec | 集群准入 + CI | 强制 | 平台组 | 第 3 周 |
| P-003 | 生产命名空间资源必须带 cost-center 标签 | metadata.labels["cost-center"]存在 | 任意资源 | 集群准入 | 告警 | 财务 IT | 第 5 周 |
表格过一轮跨团队评审,比直接甩代码过去评审的效率高一个量级。研发看得懂表格,看不懂 Rego;而策略一旦挡住研发,他们就一定会来看代码,那时候再解释成本就高了。
3. 工具链选型:先定执行点,再定工具
3.1 四个执行点,决定了你需要几套工具
策略要在哪里生效,比用什么工具重要得多。企业里通常有四个执行点,每个点的输入形态和时效要求完全不同:
IaC 扫描点:代码提交前或 PR 阶段,输入是 Terraform plan、Helm 渲染结果、K8s manifest 文件。特点是反馈快、成本低、能拦住问题于未然。这一层用 Conftest + OPA 或者 Checkov/tfsec 这类专用扫描器都行。
CI 门禁点:流水线里的强制卡点,输入通常是渲染后的完整 manifest。这一层适合放“阻断级”策略,因为此时改配置的成本还不高。
集群准入点:API Server 的 admission webhook,输入是提交到集群的实时对象。这是最后一道防线,也是研发感知最强的位置——被拦的时候人正在 kubectl apply。Gatekeeper 和 Kyverno 都在这一层。
运行时巡检点:定时扫描存量资源,找出那些在策略上线前就已存在、或者通过绕过路径进来的资源。这一层往往被忽略,但它是清理存量的唯一手段。
我的经验是,四个点不要一次全铺。先做 IaC 和 CI,让研发在写代码阶段就形成肌肉记忆,等策略稳定了再往准入层推。反过来的顺序会让研发觉得你在针对他。
3.2 OPA 路线和 Kyverno 路线,差异比想象中大
这两条路线的选择,本质是“通用性”和“易用性”的取舍。
OPA + Rego是通用策略引擎,不绑定 Kubernetes。它的优势在于一套策略语言可以同时覆盖 K8s、Terraform、API 网关、数据库访问控制等场景,策略可以通过 bundle 分发、decision log 集中收集。代价是 Rego 这门语言有门槛——它是声明式的,基于 Datalog,写惯了命令式代码的人第一次写会很不适应,尤其是处理“不存在”这类否定条件时特别容易写错。
Kyverno是 K8s 原生的,策略直接用 YAML 写,支持 validate、mutate、generate 三类操作。研发和运维看着眼熟,上手快。缺点是它主要在 K8s 生态内,跨出集群边界就不太适用了;复杂逻辑用 YAML 表达会比较绕。
一个粗略的判断标准:如果你的策略 80% 以上都在管 Kubernetes,团队里没有专人维护策略引擎,选 Kyverno;如果你的策略要横跨 IaC、云资源、K8s 多个层面,且愿意投入一两个人做策略平台,选 OPA。
3.3 扫描类工具别和策略引擎混为一谈
Checkov、tfsec、Terrascan 这类工具自带了几百条内置规则,开箱即用,很多人拿来当 Policy-as-Code 的全部。它们的问题在于内置规则是通用最佳实践,不一定匹配你的组织现状——比如它可能要求所有 S3 桶都开版本控制,但你的日志桶开了版本控制成本会翻三倍。
正确的用法是把它们当成“策略来源”而不是“策略终点”:内置规则里挑出你认可的,作为基线的起点;不认可的关掉;组织特有的规则自己写。同时要注意版本锁定,这类工具的内置规则集会随版本变化,某次升级后新增十条规则,CI 突然全红,这种事故很常见。
3.4 一张对照表把选型说清楚
| 执行点 | 典型工具 | 输入形态 | 反馈时机 | 适合放的策略 |
|---|---|---|---|---|
| IaC 扫描 | Conftest、Checkov、tfsec | plan JSON / manifest | 提交前 | 成本标签、资源规格、加密配置 |
| CI 门禁 | Conftest、OPA eval、Kyverno CLI | 渲染后 manifest | 合并前 | 镜像来源、副本数、探针配置 |
| 集群准入 | Gatekeeper、Kyverno | K8s API 对象 | apply 时 | 安全上下文、资源限制、网络策略 |
| 运行时巡检 | Cloud Custodian、定时 Job + OPA | 资源快照 | 定时 | 存量清理、漂移检测、闲置资源 |
4. 策略代码也是代码,工程化这步省不掉
4.1 仓库目录怎么切,决定了后面三年的维护成本
策略仓库最忌讳的就是所有规则平铺在一个目录里。我推荐的切法是按“执行点 + 领域”两级:
policy-repo/ ├── policies/ │ ├── kubernetes/ │ │ ├── workload/ │ │ │ ├── resources.rego │ │ │ └── image-source.rego │ │ └── network/ │ ├── terraform/ │ │ └── tagging.rego │ └── shared/ │ └── helpers.rego ├── tests/ │ └── kubernetes/workload/resources_test.rego ├── bundles/ │ └── bundle.yaml └── docs/ └── policy-catalog.mdshared目录放公共函数,比如“判断镜像是否来自内部仓库”这种被多条策略复用的逻辑。这一步很关键,我见过策略仓库里同一个判断逻辑被复制了七份,后来内部仓库地址变了,改了六份漏了一份,导致两个环境的判定结果不一致。
docs/policy-catalog.md是给研发看的策略目录,每条策略一句话说明加一个违规示例。这个文档不用手写,可以从策略注释里生成,但必须有。
4.2 策略分层:平台层管底线,业务层管特色
把所有策略混在一起管理,会带来一个死结:平台组想收紧基线,业务组说这条不适用。解决办法是分层:
平台基线层:全组织强制,不可豁免,数量控制在二十条以内。这一层只放“违反了就是安全事故”的规则,比如禁止特权容器、禁止 hostPath 挂载根目录、必须设置资源限制。
业务规则层:由业务团队在自己的命名空间范围内定义,平台组提供模板和测试框架。比如某个团队要求所有服务的日志必须输出到指定采集端,这是他们自己的事。
临时收紧层:带过期时间的策略,到期自动降级为告警。实现方式是在策略里读一个配置对象,里面记录生效窗口。
分层的实际价值在于责任归属清晰。研发被拦的时候,能立刻知道该找谁——基线层的问题找平台,业务层的问题找自己团队。
4.3 单测不写,策略迟早变成玄学
Rego 的单测框架opa test用起来很轻,一个策略配三到五个用例就能覆盖主要分支。关键是用例要包含反向场景,很多人只写“合规的输入应该通过”,不写“不合规的输入应该被拦住”,结果策略写得再错测试也是绿的。
package kubernetes.workload.resources import future.keywords.if # 检查所有容器都声明了 requests deny[msg] if { container := input.spec.containers[_] not container.resources.requests.cpu msg := sprintf("容器 %s 未声明 CPU requests", [container.name]) } deny[msg] if { container := input.spec.containers[_] not container.resources.requests.memory msg := sprintf("容器 %s 未声明内存 requests", [container.name]) }对应的测试:
package kubernetes.workload.resources test_missing_cpu_requests if { input := {"spec": {"containers": [{"name": "app", "resources": {"requests": {"memory": "128Mi"}}}]}} count(deny) == 1 } test_all_requests_present if { input := {"spec": {"containers": [{"name": "app", "resources": {"requests": {"cpu": "100m", "memory": "128Mi"}}}]}} count(deny) == 0 } test_empty_containers if { input := {"spec": {"containers": []}} count(deny) == 0 }第三个用例是边界场景,专门测空列表。这种用例看着没意义,但它能防住“用了_遍历但没处理空集合”导致的全量误报。
一个实操提醒:写 Rego 的时候,处理“字段不存在”比处理“字段值不对”难得多。
input.spec.foo在字段缺失时会 undefined,整个规则体静默失败,策略表现为“永远通过”。这类 bug 单测很难发现,因为测试输入里字段都在。对付它的办法是在策略顶部先做一次输入结构断言,或者在测试里专门造几条“缺字段”的输入。
4.4 策略升级会打死存量资源,版本和兼容要提前想
策略是有版本的,而且策略版本的升级是破坏性变更。你新加一条“必须设置 seccomp profile”,存量上千个工作负载立刻全部变成不合规。
处理这个问题的标准做法是“策略版本 + 存量豁免”双轨:
- 每条策略带一个 semver 版本号,破坏性变更升主版本;
- 新版本上线时同时提供一个“存量资源豁免名单”,名单里是策略生效前创建的资源的唯一标识(UID 或创建时间早于某时间戳);
- 策略里读这个名单,命中则跳过判定,但在 decision log 里记录下来,供后续清理。
Kyverno 里可以用validationFailureAction: Audit配合exclude资源选择器做类似效果;Gatekeeper 里可以用enforcementAction: dryrun加 constraint 的match.excludedNamespaces。核心思路一致:新策略不能一次性打断存量,只能拦住增量,存量靠时间清。
5. 门禁与豁免:策略不能成为研发的敌人
5.1 三档处置,别只有“拦”和“不拦”
策略的处置力度应该分三档,而且默认从最轻的开始:
| 档位 | 行为 | 适用阶段 | 研发感知 |
|---|---|---|---|
| 记录 | 只写 decision log,不影响流程 | 策略试验期 | 无感 |
| 告警 | PR 评论 / 工单 / IM 通知 | 推广期 | 弱 |
| 阻断 | 拒绝合并或拒绝 apply | 稳定期 | 强 |
一个新策略从记录走到阻断,我建议至少留四周。前三周收集数据,看命中率、看误报、看违规集中在哪些团队,第四周才开始告警。很多策略在这个过程里会被发现设计得不合理——比如命中率高达 40%,那多半是策略太严或者基线本身没跟上,硬推只会引发对抗。
5.2 豁免流程设计不好,整个体系就烂了
豁免是 Policy-as-Code 里最容易烂掉的一环,因为它天然是“破坏规则”的行为,如果处理得随意,策略就会形同虚设;如果处理得太死,业务方会绕过去。
我的做法是把豁免也做成代码:
apiVersion: policy.example/v1 kind: PolicyExemption metadata: name: legacy-batch-job spec: policyId: P-001 target: namespace: batch resourceName: legacy-importer reason: "存量任务,改造排期在下一季度" requestedBy: team-batch approvedBy: platform-owner expiresAt: "2025-06-30"三个要点:
豁免必须绑定到具体策略和具体对象,不能是“这个命名空间全部豁免”这种粗粒度。粗粒度豁免一旦出现,通常意味着这块区域从此脱离治理视野。
豁免必须有到期时间。没有过期时间的豁免等于永久例外,而永久例外会越积越多。到期后策略自动恢复生效,如果需要延期就重新走一次审批——这个摩擦是有意设计的。
豁免必须可审计。谁申请的、谁批的、理由是什么、什么时候到期,这些要能随时查出来。很多团队的做法是在策略里硬编码一个白名单数组,改一次要发一次版本,这种做法的下场通常是白名单被注释掉然后忘了删。
5.3 报错信息要写成人话,这是策略的“用户体验”
策略拦截时输出的信息,直接决定了研发对你的态度。对比两种写法:
# 写法一 deny[msg] { msg := "policy violation: P-001" } # 写法二 deny[msg] { not input.spec.containers[_].resources.requests msg := sprintf( "工作负载 %s 未声明资源 requests。请在 containers 下补充 resources.requests.cpu 和 memory。参考:https://内部文档/policy/P-001", [input.metadata.name] ) }第一种写法会让研发去翻文档、找负责人、问群里,平均解决时间是小时级;第二种写法当场就能改,分钟级。这条经验看起来很小,但对推广成败影响极大——研发对策略体系的印象,八成来自他被拦的那几次看到的提示。
好的人话提示包含四要素:哪个对象、违反了哪条、怎么改、去哪查。缺任何一个都会增加一次沟通成本。
6. 灰度上线与度量:策略体系也需要看诊指标
6.1 先跑审计模式,用真实数据校准阈值
任何策略上线前,都应该先在审计模式下跑够数据量。做法很简单:策略逻辑不变,处置档位设为“记录”,跑两到四周,然后看三个数:
- 命中次数:一周内被命中多少次。如果一条策略一周命中上千次,说明它不是策略问题,是基线问题——要么基线早就该改,要么你的策略选错了治理对象。
- 命中分布:集中在哪几个团队、哪几类资源。分布过于集中说明是个案,可以走豁免;分布很散说明是共性问题,值得推动。
- 误报率:抽样看被命中的对象,有多少是真违规。误报超过 10% 就必须先改策略再上告警。
我见过一个团队跳过这一步,直接上阻断,结果策略里把 initContainer 和主容器一起判,而他们的基础镜像恰好有个 initContainer 没设 requests,导致全集群新部署全部失败。这类问题的发现成本,审计模式下几乎为零,阻断模式下就是一次生产事故。
6.2 该盯的指标不多,但这几个必须有
策略体系的度量不需要太复杂,五个指标足够反映健康状况:
| 指标 | 定义 | 健康区间 | 异常信号 |
|---|---|---|---|
| 策略覆盖率 | 已纳管资源 / 全部资源 | > 90% | 长期低于 70% 说明有治理盲区 |
| 违规拦截数 | 单位时间阻断次数 | 稳定或下降 | 突然飙升多为策略变更导致 |
| 豁免存量 | 生效中的豁免数量 | 稳定 | 持续增长说明基线脱离实际 |
| 豁免平均存活期 | 从申请到关闭的天数 | < 90 天 | 超过半年说明豁免流程失效 |
| 策略变更失败率 | 上线后回滚的策略占比 | < 10% | 偏高说明测试覆盖不足 |
其中我最看重“豁免平均存活期”。这个数字直接反映策略和现实的距离——如果大量豁免活了半年以上还没关闭,说明这些策略要么设计得不合理,要么对应的技术债没人管,两种情况都需要重新审视。
6.3 策略债要定期清,不清就会反噬
策略债这个词是我自己习惯的叫法,指的是那些“已经不再产生价值但仍然在运行的策略”。来源通常有四类:
- 当初为了某次专项治理临时加的,专项结束了策略还在;
- 对应的技术债已经被其他手段解决了,策略变成冗余;
- 策略的判定条件和现实脱节,比如判断“镜像来自内部仓库”用的前缀早就废弃了,但因为没触发过所以没人发现;
- 豁免名单越拉越长,策略实际上只对少数新资源生效。
清理的节奏我建议是每季度一次,方法很朴素:拉出所有策略的最近 90 天触发记录,触发次数为零的策略逐条 review——是真的没有违规,还是判定条件失效了?前者可以考虑降级或下线,后者要修。
这个动作听起来琐碎,但不做的话,两年后你的策略仓库里会有一半是僵尸规则。而僵尸规则最危险的地方在于,它们平时不响,某天因为一个边缘输入突然响了,然后所有人一脸茫然地去查这条规则是哪来的、为什么要拦。
最后分享一个我自己踩过的坑:策略上线初期,我习惯把所有规则写在一个大文件里,觉得方便管理。后来某次要临时下线一条策略,因为规则之间共享了 helper 函数,改一处牵连三条,最后只能整包回滚。从那以后我就坚持一个原则——一条策略一个文件,公共逻辑进 shared,允许重复,不允许耦合。策略这种资产,可独立回滚比代码复用重要得多。