策略已经应用,却什么都没拦下来
目标
用 Kubernetes 内置的 ValidatingAdmissionPolicy(CEL)创建检查 Pod 的规则,从警告提升到拦截,在消息中写入违规的值,并亲手加上例外、范围和参数。
为什么重要
ValidatingAdmissionPolicy 让你不必安装外部策略引擎,就能在 API 服务器内部完成验证。与 Webhook 不同,它没有网络往返,也没有证书,更不会因为引擎死掉而让集群停摆。代价是要多学一样东西——判定规则和适用范围是不同的对象。多亏这种分离,同一条规则可以对某些团队以警告方式、对另一些团队以拦截方式挂上,还可以只把规则的值放到 ConfigMap 中,不修改策略就能更改。反过来,如果不了解这种分离,就会在“策略明明应用了,却什么都拦不住”这种地方困很久。在实际工作中,第一次引入策略时,总是从 Warn 开始,看看撞上了什么,用 matchConditions 把系统组件排除在外,然后把范围收窄,再提升到 Deny。跳过这个顺序的策略,会让部署停摆并被回退,而之后就再也没有人重新启用它。
步骤
- 在
/root/vap中工作(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。在/root/vap/policy.yaml中编写admissionregistration.k8s.io/v1的 ValidatingAdmissionPolicyrequire-cpu-limit。matchConstraints.resourceRules匹配核心组("")v1pods的CREATE和UPDATE,validation 要求所有容器都具有resources.limits.cpu。再创建两个测试用 Pod——/root/vap/pod-nocpu.yaml中容器web-tier只有 memory 限制,/root/vap/pod-ok.yaml中容器web-full同时具有 cpu 和 memory 限制。应用策略之后,创建一个没有任何标签的命名空间team-open,用kubectl create --dry-run=server把pod-nocpu.yaml发送到那里,并把输出保存到/root/vap/01-nobinding.txt。 - 创建命名空间
team-warn,并加上标签cpu-limit=warn。在/root/vap/binding-warn.yaml中编写 ValidatingAdmissionPolicyBindingcpu-limit-warn——policyName为require-cpu-limit,validationActions为["Warn", "Audit"],matchResources.namespaceSelector.matchLabels为cpu-limit: warn。应用之后,用--dry-run=server把pod-nocpu.yaml发送到team-warn,并把输出保存到/root/vap/02-warn.txt(连同标准错误)。即使出现警告,Pod 也必须能够创建。 - 创建命名空间
team-deny,并加上标签cpu-limit=deny。在/root/vap/binding-deny.yaml中编写第二个绑定cpu-limit-deny——同一个policyName,validationActions为["Deny"],选择器为cpu-limit: deny。应用之后,把pod-nocpu.yaml发送到team-deny,并把输出保存到/root/vap/03-deny.txt,接着再发送pod-ok.yaml,确认它能通过。警告用的绑定(cpu-limit-warn)不要删除,保持原样——两个范围同时生效,才是实际推行时的样子。 - 修改
/root/vap/policy.yaml,在spec.variables中放入noCpu——计算没有 cpu 限制的容器的名称列表。把 validation 改为size(variables.noCpu) == 0,并用messageExpression代替message,生成包含这些名称和 Pod 名称的消息。然后在/root/vap/pod-two.yaml中创建具有两个容器的 Podprobe-two——web-nolimit根本没有resources,cache-ok同时具有 cpu 和 memory 限制。重新应用策略之后,把它发送到team-deny,并把拒绝消息保存到/root/vap/04-message.txt。消息中只能出现web-nolimit,不能出现cache-ok。 - 在
/root/vap/policy.yaml中加入spec.matchConditions。有两个条件——skip-kube-system-sa让请求者如果是以system:serviceaccount:kube-system:开头的服务账号,就跳过策略;skip-exempt-pods让带有标签cpu-limit-exempt: "true"的 Pod 被跳过。在/root/vap/pod-exempt.yaml中创建带有该标签的 Podprobe-exempt(容器web-tier,只有 memory 限制)。重新应用策略之后,在team-deny中确认两件事,并把输出保存到/root/vap/05-skipped.txt——(1)kubectl --as=system:serviceaccount:kube-system:replicaset-controller create -n team-deny -f pod-nocpu.yaml --dry-run=server,(2) 照常发送pod-exempt.yaml。两者都必须能创建。没有标签的pod-nocpu.yaml仍然必须被拒绝。 - 创建命名空间
vap-params和team-images(标签registry-check=on)。在vap-params中创建 ConfigMapallowed-registries,键allowed的值必须恰好是registry.internal/,ghcr.io/labhub/。在/root/vap/policy-registries.yaml中编写第二个策略allowed-registries——spec.paramKind为apiVersion: v1、kind: ConfigMap,用相同的 matchConstraints 匹配 Pod,拒绝镜像不以params.data['allowed']用逗号拆分出的列表中任何一项开头的情况。在/root/vap/binding-registries.yaml中编写绑定allowed-registries——validationActions为["Deny"],paramRef是那个 ConfigMap(name、namespace,parameterNotFoundAction: Deny),选择器为registry-check: "on"。在/root/vap/pod-img-bad.yaml中创建镜像为docker.io/nginx:1.27的 Podprobe-img-bad(容器web-img,包含 cpu 和 memory 限制),依次把pod-ok.yaml和pod-img-bad.yaml发送到team-images,并把两份输出保存到/root/vap/06-param.txt。 - 创建
/root/vap/scope.sh <네임스페이스>(占位符为命名空间)。用--dry-run=server把/root/vap/pod-nocpu.yaml发送到该命名空间,如果被拒绝,就输出一行DENY并以退出码 3 结束;如果只出现警告而创建成功,就输出WARN并以 2 结束;如果毫无提示地创建成功,就输出OPEN并以 0 结束。输出只有这一个词。不要通过读取命名空间名称或标签来判断,而要按实际请求的结果来判定——评分器会暂时修改team-open的标签,同时调用这个脚本,再恢复原样。创建之后,依次对team-deny、team-warn、team-open三处运行,把结果以<네임스페이스> <낱말>的形式(占位符依次为命名空间与词)每行一条,保存到/root/vap/07-scope.txt。 - 在
/root/vap/policy.yaml中加入第二条规则——变量noMem(没有 memory 限制的容器名称列表),以及检查size(variables.noMem) == 0的第二个 validation。这个 validation 的 messageExpression 中必须包含词memory和该容器的名称,而第一个 validation 的消息中必须包含词cpu和该容器的名称。在/root/vap/pod-mixed.yaml中创建 Podprobe-mixed——容器web-tier只有 memory 限制,cache-tier只有 cpu 限制。重新应用策略之后,依次把这个 Pod 发送到team-warn和team-deny,并把两份输出保存到/root/vap/08-two-rules.txt。Warn 一侧会同时提示两条规则,而 Deny 一侧只提示一条。
参考
- 先执行
export KUBECONFIG=/root/.kube/config和kubectl config use-context kwok-lab。这是 Pod 内 kwok 启动的真实 kube-apiserver v1.30.4,所有产物都放在/root/vap之下。 - kwok 不会真正运行 Pod。本实验所看的只有准入阶段,所以用
kubectl create -f <파일> --dry-run=server(占位符为文件)就足够了——它会照常经过准入,但不会留下对象。 - 修改策略和绑定之后,反映会晚一两次往返。如果结果还是和原来一样,请几秒后再发送一次。
- 常见错误:只应用了策略,漏掉了绑定。在绑定指向它之前,策略连评估都不会进行。
- 常见错误:把警告保存到文件时没有加
2>&1。警告是通过标准错误输出的。 - 常见错误:把 matchConditions 写成肯定形式。条件为真时策略才会查看请求,所以想要跳过的条件要写成否定形式。
- Validating Admission Policy · Common Expression Language in Kubernetes · Admission Controllers Reference
只应用策略,什么都不会发生
在 /root/vap 中工作(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。在 /root/vap/policy.yaml 中编写 admissionregistration.k8s.io/v1 的 ValidatingAdmissionPolicy require-cpu-limit。matchConstraints.resourceRules 匹配核心组("")v1 pods 的 CREATE 和 UPDATE,validation 要求所有容器都具有 resources.limits.cpu。再创建两个测试用 Pod——/root/vap/pod-nocpu.yaml 中容器 web-tier 只有 memory 限制,/root/vap/pod-ok.yaml 中容器 web-full 同时具有 cpu 和 memory 限制。应用策略之后,创建一个没有任何标签的命名空间 team-open,用 kubectl create --dry-run=server 把 pod-nocpu.yaml 发送到那里,并把输出保存到 /root/vap/01-nobinding.txt。
策略只定义“如何判定”,“在哪里适用”由另外的绑定对象决定。所以只应用策略,API 服务器连那个表达式都不会评估。在 CEL 中检查整个列表时使用 all(...),对可能不存在的字段,要先用 has(...) 检查。kubectl create -f <파일> --dry-run=server(占位符为文件)会让请求照常通过准入,但不留下对象。拒绝消息和警告,都与真实请求一样出现。
一挂上绑定,警告就开始出现
创建命名空间 team-warn,并加上标签 cpu-limit=warn。在 /root/vap/binding-warn.yaml 中编写 ValidatingAdmissionPolicyBinding cpu-limit-warn——policyName 为 require-cpu-limit,validationActions 为 ["Warn", "Audit"],matchResources.namespaceSelector.matchLabels 为 cpu-limit: warn。应用之后,用 --dry-run=server 把 pod-nocpu.yaml 发送到 team-warn,并把输出保存到 /root/vap/02-warn.txt(连同标准错误)。即使出现警告,Pod 也必须能够创建。
绑定决定“把这个策略用在哪里、以什么强度”。Warn 只通过响应头返回警告并放行请求,Audit 则在审计日志中留下注解。警告是通过标准错误而不是标准输出输出的,所以要加上 2>&1,才会一并写入文件。namespaceSelector 查看的是命名空间对象的标签。
同一个策略提升到 Deny,请求就被拦住了
创建命名空间 team-deny,并加上标签 cpu-limit=deny。在 /root/vap/binding-deny.yaml 中编写第二个绑定 cpu-limit-deny——同一个 policyName,validationActions 为 ["Deny"],选择器为 cpu-limit: deny。应用之后,把 pod-nocpu.yaml 发送到 team-deny,并把输出保存到 /root/vap/03-deny.txt,接着再发送 pod-ok.yaml,确认它能通过。警告用的绑定(cpu-limit-warn)不要删除,保持原样——两个范围同时生效,才是实际推行时的样子。
一个策略可以挂上多个绑定,每个绑定有自己的范围和自己的强度。所以“同一条规则,对某些团队是警告,对另一些团队是拦截”,无需复制策略就能做到。拒绝消息中会同时打印是哪个策略、哪个绑定拦住的——这是多条规则重叠时查找原因的线索。
拒绝消息会说明是哪个容器
修改 /root/vap/policy.yaml,在 spec.variables 中放入 noCpu——计算没有 cpu 限制的容器的名称列表。把 validation 改为 size(variables.noCpu) == 0,并用 messageExpression 代替 message,生成包含这些名称和 Pod 名称的消息。然后在 /root/vap/pod-two.yaml 中创建具有两个容器的 Pod probe-two——web-nolimit 根本没有 resources,cache-ok 同时具有 cpu 和 memory 限制。重新应用策略之后,把它发送到 team-deny,并把拒绝消息保存到 /root/vap/04-message.txt。消息中只能出现 web-nolimit,不能出现 cache-ok。
variables 是把一个表达式起个名字、再用 variables.<이름>(占位符为变量名称)重复使用的机制。它是惰性求值的,如果 validation 中没用到,就不会计算。用 filter(...) 只保留违规的,再用 map(...) 只取出名称,就得到了列表。字符串列表可以用 join(', ') 连接起来。如果 messageExpression 出错,API 服务器会悄悄退回到 message——如果消息没有变化,就要怀疑表达式。
让策略完全不去看某些请求
在 /root/vap/policy.yaml 中加入 spec.matchConditions。有两个条件——skip-kube-system-sa 让请求者如果是以 system:serviceaccount:kube-system: 开头的服务账号,就跳过策略;skip-exempt-pods 让带有标签 cpu-limit-exempt: "true" 的 Pod 被跳过。在 /root/vap/pod-exempt.yaml 中创建带有该标签的 Pod probe-exempt(容器 web-tier,只有 memory 限制)。重新应用策略之后,在 team-deny 中确认两件事,并把输出保存到 /root/vap/05-skipped.txt——(1) kubectl --as=system:serviceaccount:kube-system:replicaset-controller create -n team-deny -f pod-nocpu.yaml --dry-run=server,(2) 照常发送 pod-exempt.yaml。两者都必须能创建。没有标签的 pod-nocpu.yaml 仍然必须被拒绝。
matchConditions 会对 matchConstraints 选出的请求再过滤一次。只有所有条件都为真时才会评估 validations,所以“想要跳过”的条件要写成否定形式。request 中包含请求者信息(request.userInfo.username)。如果连控制器创建的 Pod 也拦住,集群就无法自我恢复,所以排除系统主体,是推行时的第一道安全装置。可以用 kubectl --as 假装是其他主体。
把规则的值放到 ConfigMap 中,不修改策略就能更改
创建命名空间 vap-params 和 team-images(标签 registry-check=on)。在 vap-params 中创建 ConfigMap allowed-registries,键 allowed 的值必须恰好是 registry.internal/,ghcr.io/labhub/。在 /root/vap/policy-registries.yaml 中编写第二个策略 allowed-registries——spec.paramKind 为 apiVersion: v1、kind: ConfigMap,用相同的 matchConstraints 匹配 Pod,拒绝镜像不以 params.data['allowed'] 用逗号拆分出的列表中任何一项开头的情况。在 /root/vap/binding-registries.yaml 中编写绑定 allowed-registries——validationActions 为 ["Deny"],paramRef 是那个 ConfigMap(name、namespace,parameterNotFoundAction: Deny),选择器为 registry-check: "on"。在 /root/vap/pod-img-bad.yaml 中创建镜像为 docker.io/nginx:1.27 的 Pod probe-img-bad(容器 web-img,包含 cpu 和 memory 限制),依次把 pod-ok.yaml 和 pod-img-bad.yaml 发送到 team-images,并把两份输出保存到 /root/vap/06-param.txt。
paramKind 决定“这个策略读取什么作为参数”,绑定的 paramRef 决定“其中具体是哪个对象”。所以同一个策略可以为每个团队挂上不同的值。CEL 的字符串有 split 和 startsWith,列表有 exists。找不到参数时怎么办,由 parameterNotFoundAction 决定——如果是安全规则,默认应该是拦截而不是放开。在本实验的后半部分,评分器会暂时修改 ConfigMap 的值再改回去。如果把允许列表写死在表达式里,就会在那时露馅。
同一个请求在各个命名空间得到不同的答案
创建 /root/vap/scope.sh <네임스페이스>(占位符为命名空间)。用 --dry-run=server 把 /root/vap/pod-nocpu.yaml 发送到该命名空间,如果被拒绝,就输出一行 DENY 并以退出码 3 结束;如果只出现警告而创建成功,就输出 WARN 并以 2 结束;如果毫无提示地创建成功,就输出 OPEN 并以 0 结束。输出只有这一个词。不要通过读取命名空间名称或标签来判断,而要按实际请求的结果来判定——评分器会暂时修改 team-open 的标签,同时调用这个脚本,再恢复原样。创建之后,依次对 team-deny、team-warn、team-open 三处运行,把结果以 <네임스페이스> <낱말> 的形式(占位符依次为命名空间与词)每行一条,保存到 /root/vap/07-scope.txt。
警告是通过标准错误输出的,所以必须合并输出才能看到。拒绝的退出码不是 0,警告的退出码是 0——先区分这两者,再看是否有警告。如果加上 set -e,脚本会在被拒绝时先死掉。准入是根据命名空间的标签来决定范围的,所以同一个清单会因位置不同而得到不同的答案。
把两条规则放进一个策略,Deny 和 Warn 就会告知不同的内容
在 /root/vap/policy.yaml 中加入第二条规则——变量 noMem(没有 memory 限制的容器名称列表),以及检查 size(variables.noMem) == 0 的第二个 validation。这个 validation 的 messageExpression 中必须包含词 memory 和该容器的名称,而第一个 validation 的消息中必须包含词 cpu 和该容器的名称。在 /root/vap/pod-mixed.yaml 中创建 Pod probe-mixed——容器 web-tier 只有 memory 限制,cache-tier 只有 cpu 限制。重新应用策略之后,依次把这个 Pod 发送到 team-warn 和 team-deny,并把两份输出保存到 /root/vap/08-two-rules.txt。Warn 一侧会同时提示两条规则,而 Deny 一侧只提示一条。
validations 是列表,每一项有自己的 expression 和自己的 messageExpression。变量由多个 validation 共用。Deny 在第一个失败的项上就结束请求,而 Warn 会把所有失败的都以警告返回——所以推行初期的 Warn 阶段,能一次性显示“需要把什么都修好”。如果两条消息无法区分,就没有人知道撞上的是哪条规则。