容器逃逸防护的层次化思路
Kubernetes 工作负载用户命名空间隔离:hostUsers 配置、验证与故障回滚
很多团队在 Pod 安全上下文里写上 runAsNonRoot: true,就觉得容器逃逸这事儿已经稳了。这个判断其实只对了一半。runAsNonRoot 管的是容器里那个进程别用 UID 0 跑,但它管不了内核眼里这个 UID 对应到宿主机上是谁。这就好比小区门口查了身份证,可进了楼之后谁拿钥匙开门,门禁系统并不关心。
用户命名空间补的正是这一层。它把容器内的 root 映射成宿主机上的非特权 UID,让“容器里是老大、宿主机上不是”变成默认状态。Kubernetes 里对应的开关就是 Pod 的 spec.hostUsers: false。这个字段从 v1.25 的 Alpha 一路走到 v1.33 默认启用、v1.36 转 GA 并锁定特性门控——从 v1.36 起,想靠关掉门控退出这条路已经走不通了,设置了也会被忽略且不报错。
两者拦的东西不一样
runAsNonRoot 是准入层的静态检查,防的是配置写错;hostUsers=false 是内核层的运行时隔离,防的是配置写对了但仍被攻破。前者拦的是“容器内以 UID 0 启动”,后者拦的是“逃逸之后在宿主机上拿到 root”。所以它们不是替代关系:runAsNonRoot 该留还得留,它缩小容器内的攻击面;hostUsers=false 是在此之上再缩一层爆炸半径。
一个容易被忽略的好处是,hostUsers=false 下 CAP_NET_ADMIN 这类能力会退化成命名空间内的能力,只对容器本地资源生效。那些“需要特权但不想给宿主机特权”的工作负载,正好落在这个区间里。
动手前先确认三件事
内核和运行时得支持用户命名空间,这是 linux 特性,非 linux 节点不适用;集群版本要看清,低于 v1.30 的集群需要显式开门控,行为和 GA 版本有差异;UID 范围默认是 0–65535,如果你的工作负载依赖更大的 UID,别想当然。
还有个性质挺实用:runAsUser、fsGroup 始终指容器内用户,卷挂载也按这些用户处理,所以同一个卷在启用和未启用用户命名空间的 Pod 之间可以共享,切换 hostUsers 不会把卷内文件所有权搞乱。
验证要落到两个视角
配置写对不等于映射生效。先确认 Pod 跑起来了,hostUsers 输出为 false;再进容器看 id,应该还是 uid=0(root);最后回到节点上,用 ps 看容器主进程的 UID,这时候应该是个非 0 的高位值。容器内是 root、宿主机上不是 root,两个同时成立,才算真生效。
批量核查时,可以把没有显式设置 hostUsers: false 的 Pod 列出来。但在默认启用的集群里,没显式设置不等于违规,这份清单更适合当“待确认”列表用。
出问题多半在这几处
卷写入报 Permission denied,通常是有宿主机侧进程绕过 Pod 直接读写同一路径,让所有访问方都走 Pod 挂载即可。NFS 挂载失败,是因为权限判定发生在服务端,服务端看到的是宿主机侧 UID,得在存储侧让导出策略接受映射后的 UID 范围。特权容器启动失败或返回 EPERM,则要判断它是否真需要特权——多数情况换成精确的 capability 列表就行,确实要宿主机级特权的,显式写 hostUsers: true 并单独建准入策略,别让它悄悄回退。
回滚靠字段,不靠门控
v1.36 之后门控不再是退路,真正的回滚手段是 Pod 级别的 hostUsers 字段加滚动更新。建议按单 Pod、单工作负载、命名空间的顺序逐级推进,每级都留观察窗口。DaemonSet 风险最高,先在一小批打了标签的节点上验证再放开。观察点别只看 Pod 是否 Running,启动成功率、重启次数、卷写入错误率、以及依赖特权能力的组件有没有报 EPERM,这些更能反映实际影响。
落地时,评估阶段先产出一份清单:谁依赖宿主机级特权、谁依赖基于 UID 的存储授权、谁在宿主机侧有按 UID 判断的运维脚本。推广从无状态、无特权、不依赖 NFS UID 授权的工作负载开始,逐步扩到有状态负载。例外要写得明明白白,别留在默认值里含糊过去。

参与讨论
原来runAsNonRoot只防了一半啊