OpenShift 的任意 UID 与 SCC——镜像必须先准备好
一句话总结
OpenShift 运行容器时,使用的不是镜像中的 USER,而是按项目分配的任意 UID,并把该用户放入 root 组(GID 0);因此,只有把所写入的目录设为 root 组所有且组可写的镜像,才能原样迁移过去。
为什么需要它
在普通 Kubernetes 中,如果镜像没有 USER,就以 root 运行,有则以该用户运行。所以很多镜像都建立在“root 创建的目录由 root 来写入”这个假设之上。OpenShift 不接受这个假设。OCP 4.21 镜像编写指南说明,默认以任意分配的 UID 运行容器,理由是即使进程借助容器引擎漏洞逃逸,也无法在主机上获得高权限。
由于 UID 每次都可能不同,镜像作者无法提前知道“将以哪个用户运行”。所以指南以组而不是所有者为基准。容器用户始终是 root 组的成员,因此只要把写入的目录和文件设为 root 组所有,并授予组读写权限(可执行文件还要授予执行权限),无论出现什么 UID 都可以。指南中的例子是 chgrp -R 0 <디렉터리> && chmod -R g=u <디렉터리>(占位符为目录)。此外,这个用户没有特权,所以无法打开 1024 以下的端口。
先说明一点。OpenShift 的文档写明,即使是单节点安装也至少需要 8 vCPU、16GB RAM、120GB 存储,因此无法在只有 8GiB 的我们的实验 VM 上运行。本文中 OpenShift 的行为是从文档中确认的,下一项实验则在 k3s 上亲手构造同样的条件,来复现这些现象。
工作原理
选择 UID 的是 SCC(Security Context Constraints)。根据 OCP 4.19 SCC 文档,已认证用户默认使用的 SCC 是 restricted-v2,它具有以下性质。
runAsUser MustRunAsRange 범위를 네임스페이스 주석에서 가져온다
seLinuxContext MustRunAs MCS 레벨도 네임스페이스 주석에서
fsGroup MustRunAs supplemental-groups 주석, 없으면 uid-range 로
capabilities ALL 을 떨어뜨림 NET_BIND_SERVICE 만 명시적으로 더할 수 있다
seccomp runtime/default
allowPrivilegeEscalation 설정하지 않거나 false 여야 한다
该代码块中的韩文说明依次为:runAsUser 为 MustRunAsRange,范围取自命名空间注解;seLinuxContext 为 MustRunAs,MCS 级别同样取自命名空间注解;fsGroup 为 MustRunAs,取自 supplemental-groups 注解,没有则取自 uid-range;capabilities 会丢弃 ALL,只能明确添加 NET_BIND_SERVICE;seccomp 为 runtime/default;allowPrivilegeEscalation 必须不设置或为 false。
范围来自项目(命名空间)的 openshift.io/sa.scc.uid-range 注解,它只接受一个 <시작>/<길이> 块(占位符依次为起始值和长度)。如果 Pod 没有请求 runAsUser,范围的最小值就是默认值。不同版本之间也有差别。在 4.19 文档中默认是 restricted-v2,而在 4.20 和 4.21 SCC 文档中,强制使用用户命名空间(hostUsers: false)的 restricted-v3 被加为新安装的默认值。不论哪种,使用范围内的 UID 来运行这一点是相同的。
与 SCC 无关,Pod Security Admission 也会起作用。4.21 Pod 安全准入文档把两者解释为相互独立的两套机制。全局上强制 privileged,restricted 只用于警告和审计,而命名空间的警告和审计标签会根据服务账号可以使用的 SCC 自动同步。工作负载必须同时通过这两者。
在现场相遇的样子
这是在 k3s VM 上,亲手填入 restricted-v2 会填充的值(uid 1000680000、gid 0、fsGroup),并运行常见的遗留镜像(由 root 创建的 /app/data,权限 755)的结果(实测)。
id: uid=1000680000 gid=0(root) groups=0(root),1000680000
whoami: whoami: unknown uid 1000680000
HOME=/
startup failed: cannot write /app/data/started
/app/run.sh: line 6: can't create /app/data/started: Permission denied
对于这里常被尝试的三种变通办法,我在同一台 VM 上也测了一遍(实测)。
- 只给 fsGroup:上面的 Pod 已经有 fsGroup 了,但镜像内的
/app/data仍然是 root 755。fsGroup 是用来改变挂载到 Pod 的卷的组的机制。 - 用 emptyDir 覆盖挂载:它被创建为组 1000680000、权限 2777,所以可以写入。但代价是 Pod 消失时数据也会消失,而且镜像放在那个路径下的文件会被遮住。
- 用 root initContainer 做 chown:因
violates PodSecurity "restricted:latest": runAsUser=0而根本创建不出来。
把镜像用 chgrp -R 0 /app && chmod -R g=u /app 修改之后,无论用范围内的第一个 UID 还是最后一个 UID(1000689999),都能启动。此时又被卡了一次。脚本文件没有执行位的第一次构建,以 exec: "/app/run.sh": permission denied 报出 RunContainerError——这就是指南要求可执行文件具有组执行权限的原因。
whoami 失败和 HOME=/ 是在 containerd 上看到的情形。OpenShift 文档说明,CRI-O 可以把任意 UID 写入容器的 /etc/passwd,所以在 OpenShift 上,名称查询可能是可以成功的。不过 4.21 文档在同一处也写道,如果镜像中已经有 /etc/passwd,CRI-O 可能注入失败,从而无法解析正在运行的 UID,并警告说放开该文件权限的做法很危险。所以不能把“我们的 k3s 上 whoami 不能用”原样当成 OpenShift 的症状写进去。另一方面,对于向 HOME 写入缓存的工具,无论在哪里,把 HOME 指向一个可写路径都更安全。
实际工作中真正重要的事
- 迁移之前,先用任意 UID 运行镜像。 即使在普通集群中,把 runAsUser 设为较大的值、runAsGroup 设为 0,也能提前暴露在 OpenShift 上会出现的大部分权限问题。最可靠的做法是用范围内两个不同的 UID 各运行一次。
- 要修改的是镜像。 不要对特定 UID 做 chown,而是使用 root 组和 g=u,USER 要写成数字(文档说明,如果 S2I 镜像没有数字 USER,构建会失败)。
- 授予 anyuid 这类宽泛的 SCC 是最后的手段。 文档警告不要修改默认 SCC,而宽泛的 SCC 会让任意 UID 原本想防止的风险死灰复燃。
- 同时开启只读根文件系统,镜像往哪里写就会暴露出来。 为每个写入路径配一个卷的那份清单,本身就是运维文档。
下一项实验要做什么
在 k3s VM 上创建一个模拟 OpenShift 项目的命名空间,记录遗留镜像在任意 UID 下因权限被拒绝而崩溃的情况。依次完成身份检查、用 buildah 修复镜像并迁移到 k3s、用两个 UID 启动、对比 fsGroup、emptyDir 和 root initContainer、只读根文件系统,然后整理成报告。