ValidatingAdmissionPolicy — 不用引擎,API 服务器直接拦截
一句话总结
ValidatingAdmissionPolicy(以下简称 VAP)是一种在 API 服务器内部用 CEL 表达式检查请求的机制,不需要 Pod,不需要证书,也不需要网络往返,而把规则(策略)与适用范围(绑定)有意拆分成两个对象,正是这个设计的核心。
为什么需要它
基于 Webhook 的策略引擎很强大,但代价高昂。要启动引擎 Pod,要签发 TLS 证书并把 CA bundle 填进 Webhook 配置,要用副本和 PodDisruptionBudget 保证可用性,而一旦启用 failurePolicy: Fail,这个引擎的可用性就等于集群的可用性。为了设置一条“镜像标签如果是 latest 就拒绝”这样的一行规则,就要承担这一切是否值得,这个疑问由来已久。
Kubernetes 给出的回答是“简单的规则,让 API 服务器直接判定”。官方文档明确指出,VAP 是验证 Webhook 的进程内替代方案。把规则写成 CEL(Common Expression Language)表达式,API 服务器就会当场求值。没有网络调用,所以没有超时;没有引擎 Pod,所以也不会因为引擎死掉而让集群停摆。它在 Kubernetes v1.30 中成为正式(stable)功能,而本实验环境的 API 服务器正是这个 v1.30。
工作原理
一个策略最多由三个对象构成。
| 对象 | 包含什么 |
|---|---|
ValidatingAdmissionPolicy |
规则的抽象逻辑。查看哪些资源(matchConstraints),什么必须为真(validations) |
| 参数资源(可选) | 规则所用的值。例如允许的注册表列表、最大副本数 |
ValidatingAdmissionPolicyBinding |
把策略和参数绑在一起,并给出在哪里适用的范围 |
官方文档中有一句话写得很明确:策略和与之对应的绑定必须同时存在,策略才能发挥作用。没有绑定的策略,既不是语法错误,也没有警告,只是什么都不拦截。这是第一次使用的人最常踩的坑。
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: demo-binding-test.example.com
spec:
policyName: demo-policy.example.com
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
environment: test
由于做了拆分,同一个策略可以用不同的范围启用多次。测试命名空间的最大副本数为 3,生产命名空间为 100。策略 YAML 只有一份,绑定和参数有两份。修改规则时,碰的是逻辑而不是值,所以评审要轻松得多。
validationActions 附在绑定上。支持的值有三个。
| 值 | 违规时 |
|---|---|
Deny |
拒绝请求 |
Warn |
放行请求,并以警告形式通知客户端 |
Audit |
记录到审计事件中 |
Deny 与 Warn 不能同时使用(因为被拒绝的请求,已经通过响应正文和 HTTP 警告头告知了原因)。所以实际的引入流程是:先用 [Warn, Audit] 启用,统计几天,等所有被撞上的都处理完之后,只把绑定改为 [Deny]。策略正文一个字都不会变。这就是把策略与绑定拆开的第二个原因。
failurePolicy 附在策略上,默认值是 Fail。它决定表达式求值本身以错误结束时(比如字段不存在,却没用 has() 就去访问)是拒绝还是放行。文档中写着一条微妙的规则:failurePolicy 所定义的失败,只有在 failurePolicy 为 Fail 时才会遵循 validationActions。如果是 Ignore,这个失败就会被直接忽略。
生成人能读懂的拒绝消息,也是策略的工作。如果什么都不做,拒绝消息就会像 failed expression: object.spec.replicas <= 5 这样,原样打印出表达式原文。给 messageExpression 提供 CEL 表达式,就能生成带有实际值的句子,用 spec.variables 给长表达式起名字,就能用 variables.<이름>(占位符为变量名称)在多处重复使用。变量只在需要时才求值,所以也起到让昂贵的表达式只计算一次的效果。
spec:
variables:
- name: environment
expression: "has(namespaceObject.metadata.labels) ? namespaceObject.metadata.labels['environment'] : 'prod'"
validations:
- expression: "..."
messageExpression: "'only ' + variables.environment + ' images are allowed'"
matchConditions 比它更靠前一步。这里写的 CEL 条件如果为假,API 服务器就根本不评估策略。在排除系统服务账号的请求,或者只想查看带有特定标签的对象时使用。如果条件评估以错误结束,Fail 会在不评估策略的情况下拒绝请求,而 Ignore 会跳过策略并放行。
把值放到外面去的是 paramKind(策略一侧)和 paramRef(绑定一侧)。paramRef 中 name 或 selector 只能写一个,而 parameterNotFoundAction 是必填的。Allow 表示找不到参数时视为通过,Deny 则遵循策略的 failurePolicy。文档警告说,漏掉这个字段的绑定,会被忽略或出现意料之外的行为。
与 Webhook 引擎相比,得到什么、失去什么
| 维度 | VAP | Webhook 引擎 |
|---|---|---|
| 安装 | 无需安装。API 服务器内置 | Pod、证书、CA bundle、升级 |
| 可用性 | API 服务器就是引擎 | 引擎死掉,在 Fail 下写入会停止 |
| 延迟 | 进程内评估 | 每个请求一次网络往返 |
| 表达能力 | CEL。只看传入的请求和参数 | 任意代码。可以查询集群,也可以做变形和生成 |
| 报告 | 审计事件、警告 | 像 PolicyReport 这样的专用报告 CRD |
总结起来就是:可以用 CEL 表达的验证,下沉给 VAP;需要查询集群,或者需要变形、生成的,留在引擎中。这不是二选一的问题,而是为每条规则确定位置的问题。
在现场相遇的样子
第一,什么都没拦住。十有八九是没有创建绑定,或者 matchConstraints 与实际请求对不上。什么都匹配不到的策略,从表面上看与运行良好的策略没有区别。所以启用策略时,必须同时放入应该被拦住的样本和应该通过的样本来试一试。
第二,拒绝消息是表达式原文,导致咨询不断。开发者拿到 object.spec.template.spec.containers.all(c, ...),不知道该修复什么。用 messageExpression 生成“容器 web 的镜像 nginx:latest 不在允许的注册表范围内”这样的句子,不是出于亲切,而是让策略真正得到遵守的机制。
第三,类型检查仅供参考。创建策略时,API 服务器会预先检查表达式,并把结果留在 status.typeChecking 中。不过文档明确写出了局限。带有通配符的 matchConstraints 不会检查,组合很多时从第十一个开始会被忽略,不适用于 CRD,而且类型检查的结果不会改变策略的行为。这里没有错误,并不代表策略就是正确的。
第四,这个环境坦率的局限。kwok 集群的 API 服务器是真的,所以 VAP 的 Deny 和 Warn 都会真正生效,用 namespaceSelector 划分范围也能生效。不过 Pod 不会运行,而是被伪造成 Ready,所以被拦住的 Pod 是否真的没有启动,要通过 API 响应而不是容器来确认。
参考文档
- ValidatingAdmissionPolicy: https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/
- Kubernetes API 中的 CEL: https://kubernetes.io/docs/reference/using-api/cel/
- 准入控制器列表:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
- 动态准入控制(Webhook): https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/
下一项实验要做什么
在 kwok 启动的 v1.30 API 服务器上亲手挂上 VAP。先亲眼确认只创建策略而什么都拦不住,然后再挂上绑定,把 validationActions 从 [Warn, Audit] 换成 [Deny],观察同一个请求的响应如何变化。用 spec.variables 和 messageExpression 把拒绝消息改成人能读懂的样子,用 matchConditions 把特定请求整个排除在评估对象之外,用 paramKind/paramRef 把允许列表从策略中拿出去,然后给同一个策略挂上两个绑定,为各个命名空间设置不同的限制。