编程 Kubernetes 1.36 安全范式革命:User Namespaces 原生 GA 与 CEL 准入策略,把"容器逃逸"打成哑弹

2026-07-23 04:19:52 +0800 CST views 8

Kubernetes 1.36 安全范式革命:User Namespaces 原生 GA 与 CEL 准入策略,把"容器逃逸"打成哑弹

如果你在 2026 年还在用 runAsUser: 0 跑生产 Pod,又在节点的 ps aux 里看到一堆 root 进程,那么这篇文章就是写给你的。Kubernetes 1.36(代号 Haru)把两件酝酿了数个版本周期的安全能力同时推到了 GA:容器级的 User NamespacesMutatingAdmissionPolicy。前者让"容器里的 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_RAWCAP_SYS_MODULECAP_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.24PodSecurityPolicy(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 这类语句,只有 mapfilterallexists 这类函数式算子,这恰恰保证了它的"无状态、可推理"。

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 中,UserNamespacesSupportMutatingAdmissionPolicy 两个特性门控默认开启且锁定(不再能通过 --feature-gates 关掉)。这释放了一个强烈信号:这些是"默认安全"的底座,不是可选项,而是未来的强制基线。

3.2 kubelet 侧的 User Namespace 分配器

每个节点需要一块 UID/GID 池。1.36 的 kubelet 内置了一个 userns 分配器,逻辑大致是:

  1. 读取节点 /etc/subuid/etc/subgid 定义的可用范围(例如 100000:65536)。
  2. 为每个启用 userns 的 Pod 分配一段不重叠的区间(例如 Pod A 拿 100000–1065535,Pod B 拿 1065536–1131071)。
  3. 通过 id-mapped mount(内核 5.12+ 的 mount_setattr 配合 MOUNT_ATTR_IDMAP)把容器的根文件系统挂载映射成这段区间,使容器内看到 0–65535,落盘时变成映射后的 UID。
  4. 如果是多容器 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 往往要 privilegedCAP_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 WebhookMutatingAdmissionPolicy (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 准入):

方案P50P99
Go Webhook(连接池)3.2 ms12 ms
CEL 进程内0.25 ms0.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 之后的最终对象,按这个事实写表达式。
  • 灰度策略validationActionsWarn 观察日志,确认没有误伤合法负载,再切 Deny

六、总结展望:默认安全的列车已经发车

6.1 不可逆的趋势

把 1.36 的三件事连起来看——User Namespaces GA、MutatingAdmissionPolicy GA、Ingress NGINX 退役——背后是同一个方向:让"安全"从"工程师的自觉"变成"平台的默认值"。你不需要记得加 runAsNonRoot,平台帮你映射;你不需要维护 Webhook,声明式表达式就够了;你不需要特权端口,Gateway API 替你解耦。这种"安全左移、默认收敛"的范式,未来只会更彻底。

6.2 迁移清单(可直接抄的作业)

  1. 节点准备:在所有 worker 上配好 /etc/subuid/etc/subgid,确认内核 ≥ 5.12 以支持 id-mapped mount;用 cat /proc/self/uid_map 验证 userns 可用。
  2. 灰度开启 userns:先对个别命名空间打标签,Pod 加 securityContext.namespaceOptions.userns: "",观察卷权限、hostPath 兼容性。
  3. 策略平移:把现有 mutating webhook 逻辑逐条评估,能用 CEL 表达的先迁(sidecar 注入、默认安全上下文、默认资源限制),迁完就下线对应 webhook。
  4. 校验兜底:用 ValidatingAdmissionPolicy + validationActions: [Deny] 锁死底线(非 root、镜像白名单、副本上限)。
  5. 保留 Webhook 的边界:只把"需要外部状态/跨对象联动"的逻辑留在 Webhook,并设 failurePolicy: Ignore + 超时保护,避免拖垮控制平面。
  6. 可观测:盯紧 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 设计文档,搞清楚 applyConfigurationpatch 两种变更写法的取舍;其三,把本文的四条策略落到测试集群,跑一遍压测,亲手把那张延迟表复现出来——只有你自己测过的数字,才算数。


参考资料:Kubernetes 1.36 官方发布说明(代号 Haru)、KEP User Namespaces for Pods、ValidatingAdmissionPolicy / MutatingAdmissionPolicy API 文档、Linux user_namespaces(7) 与 mount_setattr(2) 手册。文中性能数据为同节点部署场景的示意基准,实际数值取决于集群规模与具体实现,请以你自己的压测为准。

推荐文章

Vue3中的v-for指令有什么新特性?
2024-11-18 12:34:09 +0800 CST
php strpos查找字符串性能对比
2024-11-19 08:15:16 +0800 CST
ElasticSearch简介与安装指南
2024-11-19 02:17:38 +0800 CST
Vue3中如何处理权限控制?
2024-11-18 05:36:30 +0800 CST
JavaScript设计模式:观察者模式
2024-11-19 05:37:50 +0800 CST
使用Python提取图片中的GPS信息
2024-11-18 13:46:22 +0800 CST
filecmp,一个Python中非常有用的库
2024-11-19 03:23:11 +0800 CST
如何实现生产环境代码加密
2024-11-18 14:19:35 +0800 CST
程序员茄子在线接单