Kubernetes 1.36 安全范式革命:User Namespaces 原生 GA 与 CEL 准入策略,把"容器逃逸"打成哑弹
如果你在 2026 年还在用
runAsUser: 0跑生产 Pod,又在节点的ps aux里看到一堆root进程,那么这篇文章就是写给你的。Kubernetes 1.36(代号 Haru)把两件酝酿了数个版本周期的安全能力同时推到了 GA:容器级的 User Namespaces 与 MutatingAdmissionPolicy。前者让"容器里的 root"在主机视角下只是一个被映射的非特权 UID;后者让过去必须起一个 Go Webhook 服务才能做的注入、改写、校验,变成 kube-apiserver 进程内的一段 CEL 表达式。本文从攻击面讲起,把这两套机制的底层原理、API 形态、迁移代价和性能账算清楚,并给出能直接抄进集群的 YAML 与对照代码。
一、背景介绍:容器安全的阿喀琉斯之踵
1.1 为什么"容器里的 root"是生产集群的定时炸弹
很多人有个根深蒂固的误解:容器不是有 Namespace 隔离吗,跑 root 又能怎样?事实上,Linux 容器的隔离是"纵深防御"式的,而不是"硬隔离"式的。一个容器默认仍然和宿主机共享大量底层资源:
- 同一个内核:所有容器跑在同一个内核上。seccomp、AppArmor、capabilities 只是给系统调用加的"筛子",筛子一旦有洞(新的 CVE),隔离就会失效。
- UID 0 的语义是全局的:容器内的
root(UID 0)与宿主机的root(UID 0)在默认配置下是同一个数字。一旦攻击者通过内核漏洞(Dirty COW、Dirty Pipe 及其变体)实现容器逃逸,他拿到的就是主机上的真正 root,而不是什么"沙箱里的假 root"。 - 特权能力默认带一堆:即便不以 root 运行,容器默认也带着
CAP_NET_RAW、CAP_SYS_MODULE、CAP_DAC_OVERRIDE等能力,足够做很多破坏(比如加载内核模块、伪造 ARP)。
一个真实的攻击链是这样的:攻击者通过应用漏洞(如 Log4j、任意文件读)拿到容器内进程的权限 → 该进程是容器内的 root → 利用一个内核本地提权漏洞(逃逸)→ 因为容器内 root 等于主机 root,攻击者直接控制了节点 → 用节点上的 kubelet 凭证访问 API Server → 横向移动到整个集群。我们每天在 YAML 里随手写的 runAsUser: 0,本质上是在给这条攻击链的第一跳铺平道路。
这就是为什么行业反复强调 runAsNonRoot: true。但现实很骨感:大量官方镜像(尤其是历史镜像、某些中间件、数据库镜像)内部就是以 root 起进程、监听 80/443 端口,改造成非 root 需要重新打镜像、改监听端口、调 fsGroup,还可能遇到启动时写 /var/run 权限不足之类的问题,成本不低。于是"为了不折腾,先 root 跑着"成了普遍妥协——直到某天漏洞曝光,才发现整个集群都在裸奔。
1.2 历史的债务:从 PSP 到 PSA 再到 Webhook
Kubernetes 在"默认安全"这件事上的演进,本身就是一部踩坑史,每一个阶段都在修前一个阶段的坑:
| 阶段 | 机制 | 解决了什么 | 遗留的问题 |
|---|---|---|---|
| 1.x 早期 | 无强制约束 | —— | 默认 root,全靠工程师自觉 |
| 1.3 ~ 1.24 | PodSecurityPolicy(PSP) | 第一次有了集群级强制约束 | 配置反人类,一个错误的 PSP 能让整个命名空间无法创建 Pod;Policy 之间还可能冲突;1.25 被彻底移除 |
| 1.25+ | Pod Security Admission(PSA) | 三档基线(privileged / baseline / restricted)开箱即用,零额外部署 | 只做校验,不负责"改写",也无法表达业务相关的复杂策略 |
| 任意版本 | 自定义 Admission Webhook | 功能最强的"校验 + 变更",sidecar 注入、默认安全上下文都靠它 | 要自己写服务、管 TLS、扛可用性,且每次请求多一跳网络往返,是控制平面的隐式单点 |
PSA 解决的是"这个 Pod 能不能创建"的问题,但解决不了"能不能自动把不合规的改成合规"的问题。而工程里最常用的恰恰是"变更型"能力:给 Pod 自动注入 sidecar、补默认安全上下文、统一打标签、强制资源限制。这些在 1.36 之前几乎只能靠 Webhook。Webhook 最大的痛点不是写起来麻烦,而是它是你控制平面的一个外部单点——kube-apiserver 在处理 CREATE/UPDATE 时要同步 HTTP 调用你的 Webhook,Webhook 一旦挂了、慢了、证书过期了,整个集群的写入链路就可能被卡住。
1.3 1.36 到底想解决什么
把视野拉高,1.36 的安全思路非常清晰:把"默认安全"做成内核级别的原生能力(User Namespaces),把"变更型准入"做成控制平面内的声明式对象(MutatingAdmissionPolicy)。两者都不需要你额外起一套服务,也不引入新的网络单点。前者从根上削弱了"逃逸=主机沦陷"的等式,后者从运维上消灭了"为了安全反而引入新故障域"的悖论。
二、核心概念:两套机制到底在改什么
2.1 User Namespaces:给容器一个"平行宇宙"的 UID 空间
User Namespace 是 Linux 内核从 3.8 就支持的能力,但一直没在容器编排里被用透。它的本质是UID/GID 的映射表:在容器这个 user namespace 里,进程以为自己是 UID 0(root);但内核在把它落地到宿主机的文件系统操作时,会按照映射表把它翻译成宿主机上的一个非特权 UID(比如 100000+)。
画一张对照图就清楚了:
容器视角 (user namespace 内) 宿主机视角 (初始 user namespace)
┌─────────────────────────┐ ┌────────────────────────────────┐
│ UID 0 (自以为 root) │ ──映射──▶ │ UID 100000 (一个普通非特权用户) │
│ UID 1 │ ──映射──▶ │ UID 100001 │
│ UID 65534 (nobody) │ ──映射──▶ │ UID 165534 │
└─────────────────────────┘ └────────────────────────────────┘
关键点在于:映射的目标 UID(100000+)在宿主机上没有任何特殊权限。所以哪怕容器内的进程真的逃逸成功、拿到了"容器内 root",它在宿主机上对应的也只是个普通用户,无法读取其他租户的卷、无法操作宿主机进程、无法挂载宿主机的设备。这就是把"容器逃逸"打成哑弹的真正含义——逃逸依然在理论上可能发生,但 payload 在主机侧拿不到特权,攻击收益被压到极低。
映射关系写在宿主机的 /etc/subuid 和 /etc/subgid:
# /etc/subuid
containeruser:100000:65536
含义是:从 containeruser 用户起,分配 65536 个从 100000 开始的 UID 作为"子 UID 池"。Kubernetes 1.36 的 kubelet 在调度 Pod 时,会从节点池里切一段给这个 Pod 独占,并通过 id-mapped mount 把容器的根文件系统挂载成这段区间,使得容器内看到的是 0–65535,落盘时却变成映射后的 UID。
一个容易忽略的细节是:在 user namespace 内,setuid(0) 是成功的(因为映射后它就是"自己的 root"),但容器外看这个进程的真实 UID 是 100000+。这意味着很多"必须用 root 才能启动、启动后再 drop 到普通用户"的镜像,可以完全不改镜像就获得 rootless 的安全收益——这正是 User Namespaces 相比"强行改成非 root 镜像"最务实的地方。
2.2 CEL:把准入逻辑从"起服务"变成"写表达式"
CEL(Common Expression Language)是 Google 设计的一种轻量、无状态、沙箱化的表达式语言,最早用于 Istio 策略与 Authz。Kubernetes 从 1.26 起把它引入 ValidatingAdmissionPolicy(先验证、后变更),1.36 终于把它扩展到 MutatingAdmissionPolicy(变更)。
CEL 表达式跑在 kube-apiserver 的进程里,有几个决定性优点:
- 零网络往返:不像 Webhook 要 HTTP 调出去再回来,CEL 直接在当前进程内求值,延迟从毫秒级降到微秒级。
- 无外部依赖:不需要 Service、Deployment、证书、HPA,少了一整套要运维的东西,也少了一个故障域。
- 确定性:CEL 是纯函数式求值,给定请求对象就给定结果,没有副作用,便于推理、测试和审计。
- 资源受限:CEL 有执行步数/时间预算,防止一条表达式把 apiserver 拖死,比手写的 Webhook 代码天然更安全。
一个最简单的 CEL 验证表达式:
object.spec.securityContext.runAsNonRoot == true
object 是 CEL 为准入请求注入的变量,指向正在被创建或更新的 K8s 对象(这里是 Pod)。除了 object,你还可以用 request(请求上下文,如 namespace、userInfo)、namespaceObject(目标命名空间对象)、variables(自定义变量)等。CEl 没有 for/while 这类语句,只有 map、filter、all、exists 这类函数式算子,这恰恰保证了它的"无状态、可推理"。
2.3 Mutating 与 Validating 的分工与执行顺序
别搞混两者的职责边界,它们在准入生命周期里的顺序是固定的:先 Mutating,后 Validating。
- Mutating(变更):在对象落库前修改它。适合"补默认值""注入 sidecar""强制加安全上下文"。1.36 之前 mutating 几乎只能靠 Webhook;1.36 之后 CEL 也能做了。
- Validating(验证):只说"行/不行",不行就拒绝(返回 403 给调用方)。适合强约束,比如"镜像必须来自内网仓库""副本数不能超过 100"。
执行顺序带来一个工程技巧:你完全可以用一条 MutatingAdmissionPolicy 把 runAsNonRoot 补上,再用一条 ValidatingAdmissionPolicy 确保它确实被加上(防止别人在原始 YAML 里显式写 runAsNonRoot: false 绕过默认值)。Mutating 负责"温柔地修",Validating 负责"严格地卡",两者配合才是完整的安全闭环。
三、架构分析:1.36 在控制平面里到底改了什么
3.1 Feature Gate 默认开启且锁定
在 1.36 中,UserNamespacesSupport 与 MutatingAdmissionPolicy 两个特性门控默认开启且锁定(不再能通过 --feature-gates 关掉)。这释放了一个强烈信号:这些是"默认安全"的底座,不是可选项,而是未来的强制基线。
3.2 kubelet 侧的 User Namespace 分配器
每个节点需要一块 UID/GID 池。1.36 的 kubelet 内置了一个 userns 分配器,逻辑大致是:
- 读取节点
/etc/subuid、/etc/subgid定义的可用范围(例如100000:65536)。 - 为每个启用 userns 的 Pod 分配一段不重叠的区间(例如 Pod A 拿 100000–1065535,Pod B 拿 1065536–1131071)。
- 通过 id-mapped mount(内核 5.12+ 的
mount_setattr配合MOUNT_ATTR_IDMAP)把容器的根文件系统挂载映射成这段区间,使容器内看到 0–65535,落盘时变成映射后的 UID。 - 如果是多容器 Pod,所有容器共享同一段 userns 区间(容器间仍是"同一套 root"),但与其他 Pod 的区间互不重叠。
这带来一个工程后果:同一节点上不同 Pod 的文件彼此 UID 不冲突,即便它们都"以为"自己是 root。共享 emptyDir、hostPath 时也不会互相串权限——当然,hostPath 直接挂宿主机的真实路径时,因为宿主机文件系统不参与映射,会出现 UID 错位,这是迁移时要重点评估的坑(下文 5.4 详述)。
3.3 MutatingAdmissionPolicy 的 API 形态
变更型策略与绑定是"一对多"关系:MutatingAdmissionPolicy 定义"做什么、对什么做",MutatingAdmissionPolicyBinding 把它绑到具体的资源范围(命名空间或集群)。
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: default-security-context
spec:
# 1) 匹配约束:哪些资源/操作会进入这条策略
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
# 2) 匹配条件:用 CEL 进一步过滤(可选,支持多个)
matchConditions:
- name: not-in-system-ns
expression: >-
!(request.namespace in ['kube-system','kube-node-lease'])
# 3) 变更动作:用 CEL 描述对象该如何被改写
mutations:
- applyTo:
- pods
patch:
# patch.expression 返回一个标准 JSON Patch(RFC 6902)操作列表
expression: >-
[
{"op":"add","path":"/spec/securityContext/runAsNonRoot","value":true},
{"op":"add","path":"/spec/securityContext/allowPrivilegeEscalation","value":false},
{"op":"add","path":"/spec/securityContext/seccompProfile","value":{"type":"RuntimeDefault"}}
]
注意 mutations[].patch.expression 返回的是标准 JSON Patch 操作数组,而不是整个改写后的对象。这意味着你写的不是"新对象",而是"针对当前对象的 diff 指令",kube-apiserver 负责安全地 apply。相比 Webhook 直接返回整个改写后的对象,这种方式安全得多——你只能声明增量,难以误伤、误覆盖其他字段。多个 mutation 按顺序 apply,路径冲突时后者覆盖前者,因此组织策略时要注意顺序。
3.4 与"Ingress NGINX 退役""AI 工作负载"的同版本共振
1.36 同批还有两件大事,值得一并理解它们和安全范式的关联:
- Ingress NGINX 退役:传统 Ingress 控制器常需要
hostNetwork或特权端口(80/443),反过来又逼着 Pod 拿特权。随着 Gateway API 成熟与 Ingress NGINX 退役,新路径天然更契合 rootless + 非特权端口的部署,和 User Namespaces 是同一股"去特权化"潮流。 - AI 工作负载支持成熟:GPU 调度、Device Plugin、大模型推理 Pod 往往要
privileged或CAP_SYS_ADMIN才能碰 NVML/InfiniBand 设备。User Namespaces 让这些"必须特权"的工作负载在主机侧被降级映射,把风险收敛到"设备访问"本身,而不是"整个主机 root",显著缩小了 GPU 节点的爆炸半径。
3.5 一个常被忽略的点:多个策略的执行顺序
当集群里有多条 MutatingAdmissionPolicy 时,它们的执行顺序遵循:MutatingAdmissionWebhook 先于原生 Mutating 策略?不对——实际上原生 CEL 策略与 Webhook 交错执行,顺序由准入控制器的注册顺序和 matchConstraints 决定,而非简单的 YAML 书写顺序。正确做法是:不要依赖执行顺序来达成最终状态,而应该让每条策略幂等,并用一条 Validating 策略做最终兜底校验。这样无论顺序如何,结果都收敛到同一个安全状态。
四、代码实战:从零落地两条策略
下面所有 YAML 都假设你跑的是 1.36+ 集群,且 kubelet 节点已配置好 /etc/subuid、/etc/subgid,内核 ≥ 5.12。
4.1 实战一:给 Pod 开启 User Namespace(无需改镜像)
最干净的方式是声明式开启,业务镜像完全不用动:
# 01-rootless-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: rootless-web
namespace: demo
spec:
# 关键就这一行:声明使用节点自动分配的用户命名空间
securityContext:
namespaceOptions:
userns: "" # 空字符串 = 使用 kubelet 自动分配的一段 UID/GID 区间
containers:
- name: web
image: nginxinc/nginx-unprivileged:1.27
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
seccompProfile:
type: RuntimeDefault
验证映射是否真的生效:
# 进入容器看"自以为"的 UID
kubectl exec -it rootless-web -n demo -- id
# uid=0(root) gid=0(root) groups=0(root) <-- 容器内仍是 root
# 在节点上用 inspect 看真实映射
kubectl get pod rootless-web -n demo -o jsonpath='{.spec.securityContext.namespaceOptions}'
# {"userns":""}
# 在宿主机上查进程真实 UID(需在节点执行)
ps -eo uid,pid,comm | grep nginx
# 100032 ... nginx <-- 宿主机视角下只是个普通 UID
这就是"容器内 root、宿主机平民"的落地证据:应用代码一行不改,攻击者即便逃逸,在主机侧也只是个 100032 这样的普通用户。
4.2 实战二:用 MutatingAdmissionPolicy 替换 sidecar 注入 Webhook
过去注入 sidecar(链路追踪 agent、日志采集器)必须写一个 Go Webhook。下面用纯 CEL 实现"给 demo 命名空间的所有 Pod 自动注入一个只读代理 sidecar":
# 02-mutating-policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: inject-readonly-proxy
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
matchConditions:
- name: only-demo-ns
expression: request.namespace == 'demo'
- name: not-have-proxy
# 幂等:已注入的 Pod 不再注入,避免死循环
expression: >-
!('proxy' in object.spec.containers.map(c, c.name))
mutations:
- applyTo: ["pods"]
patch:
expression: >-
[{
"op": "add",
"path": "/spec/containers/-",
"value": {
"name": "proxy",
"image": "registry.internal/proxy:1.9",
"args": ["--listen=:4317"],
"securityContext": {
"readOnlyRootFilesystem": true,
"runAsNonRoot": true,
"allowPrivilegeEscalation": false
},
"resources": {
"requests": {"cpu": "50m", "memory": "64Mi"},
"limits": {"cpu": "100m", "memory": "128Mi"}
}
}
}]
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
name: inject-readonly-proxy-binding
spec:
policyName: inject-readonly-proxy
matchResources:
namespaceSelector:
matchLabels:
inject-proxy: "true"
绑定后,只要在带 inject-proxy=true 标签的命名空间里 kubectl apply 一个普通 Pod,apiserver 会在落库前自动把 proxy 容器加进去——全程没有起任何 Webhook 服务。matchConditions 里的 not-have-proxy 保证了幂等:已注入的 Pod 再次经过时不会被重复追加,从而避免与后续 Validating 策略或 Webhook 形成循环。
4.3 实战三:用 ValidatingAdmissionPolicy 做镜像仓库白名单
验证型策略是 CEL 的强项,比 Webhook 的拒绝响应更轻、更可观测:
# 03-validating-policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: restrict-image-registry
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
# 用 variables 提前算好"所有容器镜像的仓库前缀列表"
variables:
- name: imageHosts
expression: >-
object.spec.containers.map(c, c.image.split('/')[0])
+ object.spec.initContainers.map(c, c.image.split('/')[0])
validations:
- expression: >-
variables.imageHosts.all(h,
h in ['registry.internal','registry.internal:5000','docker.io/library'])
message: "所有镜像必须来自内网仓库 registry.internal(docker.io/library 除外)"
- expression: object.spec.replicas == null || object.spec.replicas <= 100
message: "Deployment/StatefulSet 副本数不得超过 100"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: restrict-image-registry-binding
spec:
policyName: restrict-image-registry
validationActions: [Deny] # Deny = 不合规直接拒绝;也可选 Warn / Audit
matchResources:
namespaceSelector:
matchLabels:
enforce-registry: "true"
variables 的引入让复杂表达式可以分步求值,也方便在绑定里复用。注意 CEL 里数组用 map/all/exists 这类函数式算子,没有 for 循环语句——这正是它"无状态、可推理"的来源。validationActions 支持 Deny(硬拒)、Warn(放行但告警)、Audit(只记录不拦截)三种,灰度期建议先 Warn 观察一段时间再切 Deny。
4.4 实战四:用 Mutating 给"没写资源限制"的容器自动补默认值
很多线上事故源于"忘了写 resources.limits 导致节点被挤爆"。用一条 Mutating 策略,给所有没设 limits 的容器自动补上保守默认值:
# 04-mutating-default-resources.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: default-cpu-limits
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
mutations:
- applyTo: ["pods"]
patch:
expression: >-
object.spec.containers.map(c, c.resources.limits == null)
.map((need, i) => need ?
{"op":"add","path":"/spec/containers/"+string(i)+"/resources/limits",
"value":{"cpu":"500m","memory":"512Mi"}} : null)
.filter(p, p != null)
这条策略演示了 CEL 的"带索引 map + 条件 patch"技巧:只给确实缺 limits 的容器补默认值,已有 limits 的容器原样保留。相比 Webhook 要反序列化整个 Pod 再重新编码,这里只描述差异,安全且高效。
4.5 对比:传统 Go Webhook 实现 vs 原生 CEL
为了让"运维减负"有体感,我们看同一件事(注入 sidecar)在 Webhook 模式下的代价。一个最小可用的 mutating webhook 服务端:
// webhook.go —— 传统方案需要的一整套东西
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
admissionv1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func mutate(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var review admissionv1.AdmissionReview
json.Unmarshal(body, &review)
pod := corev1.Pod{}
json.Unmarshal(review.Request.Object.Raw, &pod) // 1) 解码请求里的 Pod
pod.Spec.Containers = append(pod.Spec.Containers, corev1.Container{ // 2) 注入 sidecar
Name: "proxy",
Image: "registry.internal/proxy:1.9",
})
patch, _ := json.Marshal([]map[string]interface{}{{
"op": "replace", "path": "/spec/containers", "value": pod.Spec.Containers,
}})
resp := admissionv1.AdmissionReview{
TypeMeta: metav1.TypeMeta{Kind: "AdmissionReview", APIVersion: "admission.k8s.io/v1"},
Response: &admissionv1.AdmissionResponse{
UID: review.Request.UID,
Allowed: true,
Patch: patch,
PatchType: func() *admissionv1.PatchType {
t := admissionv1.PatchTypeJSONPatch
return &t
}(),
},
}
out, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, string(out))
}
func main() {
// 3) 还要自己管 TLS、证书、Service、Deployment、
// MutatingWebhookConfiguration、failurePolicy、HPA……
http.HandleFunc("/mutate", mutate)
http.ListenAndServeTLS(":8443", "tls.crt", "tls.key", nil)
}
对比两种方案的"代码 + 运维"账:
| 维度 | Go Webhook | MutatingAdmissionPolicy (CEL) |
|---|---|---|
| 代码量 | 数百行 + 依赖(k8s.io/*) | 一段 YAML 表达式 |
| 部署对象 | Deployment + Service + Secret + Configuration | 一个 Policy + 一个 Binding |
| 网络 | apiserver → webhook 同步调用(有 RTT) | 进程内求值(零 RTT) |
| 可用性 | webhook 挂 → 集群写入受影响 | 无外部依赖 |
| 证书 | 要签发/轮换 mTLS | 不需要 |
| 字段安全 | 手写容易重复注入、误覆盖 | JSON Patch 增量,天然受限 |
| 执行预算 | 取决于你写的代码 | CEL 有步数/时间预算兜底 |
结论很直接:凡是"能用 CEL 表达的策略,就不该再起 Webhook"。保留 Webhook 的场景只剩下 CEL 力所不及的:需要调用外部系统(查 CMDB、调风控 API)、需要跨对象状态、需要复杂多对象联动。
五、性能优化与真实踩坑:把账算细
5.1 延迟基准:省掉的不仅是一跳 RTT
每个走 Webhook 的准入请求,apiserver 要:序列化 AdmissionReview → 建 TLS 连接(或取连接池)→ HTTP POST → 等响应 → 反序列化。即便 Webhook 同节点部署,P99 也常在 5~20ms 量级,且受 webhook 自身 GC、锁竞争影响。
CEL 在 apiserver 进程内求值,一个中等复杂度的表达式通常在亚毫秒级(几十到几百微秒)。对"每次 CREATE/UPDATE 都触发"的高频路径(CI 流水线疯狂建 Pod、HPA 频繁扩缩容),这个差距会被放大成可观的 API 延迟下降。
同节点部署场景的示意基准(单条 Pod 准入):
| 方案 | P50 | P99 |
|---|---|---|
| Go Webhook(连接池) | 3.2 ms | 12 ms |
| CEL 进程内 | 0.25 ms | 0.9 ms |
测法建议:用 kubectl 批量创建 1000 个 Pod(启用策略 vs 禁用策略),对比 apiserver 的 apiserver_admission_webhook_admission_duration_seconds 与 CEL 求值耗时指标(若有暴露),别只看单次手测。
5.2 可用性:消灭一个隐式单点
更关键的是可用性。Webhook 是控制平面的隐式强依赖:证书过期 → 整个命名空间的 Pod 创建失败(若 failurePolicy: Fail);Webhook Pod 被驱逐/OOM → 写入卡顿;升级 webhook 时没处理好优雅退出 → 短暂 5xx → 创建抖动。换成 CEL 后,策略就是 apiserver 内存里的对象,没有"另一个服务"会挂。你失去的只是"热更新要重启服务"的灵活性,换来的是控制平面少一个故障域。
5.3 与 OPA/Gatekeeper、Kyverno 的取舍
很多团队用 OPA Gatekeeper 或 Kyverno 做策略。它们比原生 Webhook 强(有 ConstraintTemplate、有审计能力),但本质仍是"外部服务 + 网络调用"。我的建议分层:
- 集群级、高频、简单规则(非 root、镜像白名单、资源默认限制)→ 用 1.36 原生 CEL 策略,零依赖、零延迟。
- 跨对象、需查外部数据、要集中审计看板 → 保留 OPA/Gatekeeper 或 Kyverno。
- 要改对象内容且逻辑不复杂 → 用 MutatingAdmissionPolicy,别再为小事起 Webhook。
一条实践经验:先用原生 CEL 承接 80% 的常规策略,把 OPA/Kyverno 留给真正需要外部数据联动的 20%,整体控制平面的外部依赖和延迟都会明显下降。
5.4 真实踩坑清单
迁移到 User Namespaces + CEL 策略时,我踩过(或见过别人踩过)的坑:
- hostPath 的 UID 错位:hostPath 直接挂宿主机真实路径,不参与 id-mapped mount,容器内写的文件在宿主机上 UID 是映射后的 100000+,宿主机上的普通用户/其他 Pod 可能读不到。需要这类共享的,优先改用 emptyDir 或 CSI 卷。
- init 容器与 userns 的初始化顺序:init 容器也在同一 userns 内,如果 init 容器以 root 在映射区间里建了目录,主容器(同区间)能正常访问,但别指望宿主机侧用本机 root 去改这些文件。
- CSI 驱动兼容性:部分老 CSI 驱动在挂载时会假设主机 UID,遇到 userns 需要确认驱动版本支持 id-mapped mount。
- CEL 的
patch路径冲突:多条 Mutating 策略对同一路径做add,后者覆盖前者;对数组用/-追加最安全,避免硬编码索引。 - mutating 与 validating 的循环:mutating 改了对象后,validating 若基于"原始请求"判断会误判。记住 validating 看到的是 mutating 之后的最终对象,按这个事实写表达式。
- 灰度策略:
validationActions先Warn观察日志,确认没有误伤合法负载,再切Deny。
六、总结展望:默认安全的列车已经发车
6.1 不可逆的趋势
把 1.36 的三件事连起来看——User Namespaces GA、MutatingAdmissionPolicy GA、Ingress NGINX 退役——背后是同一个方向:让"安全"从"工程师的自觉"变成"平台的默认值"。你不需要记得加 runAsNonRoot,平台帮你映射;你不需要维护 Webhook,声明式表达式就够了;你不需要特权端口,Gateway API 替你解耦。这种"安全左移、默认收敛"的范式,未来只会更彻底。
6.2 迁移清单(可直接抄的作业)
- 节点准备:在所有 worker 上配好
/etc/subuid、/etc/subgid,确认内核 ≥ 5.12 以支持 id-mapped mount;用cat /proc/self/uid_map验证 userns 可用。 - 灰度开启 userns:先对个别命名空间打标签,Pod 加
securityContext.namespaceOptions.userns: "",观察卷权限、hostPath 兼容性。 - 策略平移:把现有 mutating webhook 逻辑逐条评估,能用 CEL 表达的先迁(sidecar 注入、默认安全上下文、默认资源限制),迁完就下线对应 webhook。
- 校验兜底:用 ValidatingAdmissionPolicy +
validationActions: [Deny]锁死底线(非 root、镜像白名单、副本上限)。 - 保留 Webhook 的边界:只把"需要外部状态/跨对象联动"的逻辑留在 Webhook,并设
failurePolicy: Ignore+ 超时保护,避免拖垮控制平面。 - 可观测:盯紧 apiserver 的准入延迟与 CEL 求值错误率,灰度期用
Warn而非Deny先收集误伤。
6.3 最后的提醒
User Namespaces 不是银弹。它解决的是"逃逸后拿不到主机特权"这一层,不解决镜像漏洞、不解决错误的 RBAC、不解决暴露在公网的错误 Service。安全是纵深防御,1.36 只是把你地基里最薄弱的那块水泥换成了钢板。但仅就这一块而言,它做得足够漂亮:用内核级别的 UID 映射,把"容器逃逸"从"主机沦陷"降级为"普通用户越界";用进程内的 CEL,把"变更型准入"从"起一套微服务"降级为"写一段表达式"。对每天和 YAML 搏斗的我们来说,这就是 2026 年最实在的范式红利。
6.4 延伸:下一步可以读什么
如果你打算深入,建议顺着三条线继续:其一,读内核的 user_namespaces(7) 与 mount_setattr(2) 手册,理解映射在 syscall 层的真实行为;其二,读 KEP "User Namespaces for Pods" 与 MutatingAdmissionPolicy 的 API 设计文档,搞清楚 applyConfiguration 与 patch 两种变更写法的取舍;其三,把本文的四条策略落到测试集群,跑一遍压测,亲手把那张延迟表复现出来——只有你自己测过的数字,才算数。
参考资料:Kubernetes 1.36 官方发布说明(代号 Haru)、KEP User Namespaces for Pods、ValidatingAdmissionPolicy / MutatingAdmissionPolicy API 文档、Linux user_namespaces(7) 与 mount_setattr(2) 手册。文中性能数据为同节点部署场景的示意基准,实际数值取决于集群规模与具体实现,请以你自己的压测为准。