迁到 OpenShift 后因权限被拒而崩溃
本实验不是在 OpenShift 上,而是在 k3s 上模拟
即使是单节点安装,OpenShift 也至少要求 8 vCPU、16GB 内存、120GB 存储(OCP 4.21 文档),所以无法在这台 VM(8GiB)上运行。 因此,在 VM 的 k3s v1.36.4+k3s1 之上,把 OpenShift 的 restricted-v2 SCC 会填入 Pod 的值(范围内的任意 UID、root 组、fsGroup) 亲手填入,以制造同样的现象。k3s 既不认识 SCC,也不认识项目注解,所以注解只用作计算的依据——在最后一步你还会确认这一点。
目标
复现写入 root 所有目录的镜像在任意 UID 下因权限被拒绝而崩溃,把镜像改为 root 组和 g=u,使它能以范围内任意 UID 启动, 并区分 fsGroup、root initContainer 和只读根文件系统在这个问题上各自能做什么、不能做什么。
为什么重要
在 Kubernetes 上运行良好的镜像,第一次在 OpenShift 上崩溃,多半是因为用户。OpenShift 不相信镜像中的 USER,而是以按项目分配的 较大 UID 来运行容器。这样设计是为了即使存在容器逃逸漏洞,也不能在主机上成为有意义的用户。由于无法提前知道那个用户, 也就不能把镜像内文件的“所有者”对上它,官方指南是改为给始终所属的 root 组授予权限。试图通过 Pod 配置绕过的 做法(fsGroup、以 root 执行 chown 的 initContainer),大多会被策略阻止,或者对镜像目录没有效果。
步骤
- 创建命名空间
ocp-sim,并加上标签pod-security.kubernetes.io/enforce=restricted、pod-security.kubernetes.io/warn=restricted,以及注解openshift.io/sa.scc.uid-range=1000680000/10000、openshift.io/sa.scc.supplemental-groups=1000680000/10000。然后在/root/ocp/project.json中写入uid_range(注解值原样)、default_uid(restricted-v2 的 MustRunAsRange 默认会选的 UID)、max_uid(范围的最后一个 UID)。 - 用
/root/ocp/legacy.yaml创建 Podlegacy。镜像为localhost/ocp-app:legacy(已提前放入 VM),Pod 的 securityContext 为 runAsNonRoot true、runAsUser 为第 1 步的 default_uid、runAsGroup 0、fsGroup 1000680000、seccompProfile RuntimeDefault,容器为 allowPrivilegeEscalation false、capabilities drop ALL、terminationMessagePolicy: FallbackToLogsOnError。如果发生了一次以上的重启,在/root/ocp/crash.json中写入uid、restart_count、error(终止消息中包含 Permission denied 的那一行,原样)。 - 用遗留镜像创建一个只执行
sleep 86400的 Podprobe,使用与第 2 步相同的 securityContext,写入/root/ocp/probe.yaml,并让它处于 Ready。在其中检查身份,并在/root/ocp/identity.json中写入uid、gid、groups(id -G 的数字排序后的数组)、whoami_ok(whoami 是否成功)、home(HOME 环境变量的值)、home_writable(能否写入该 HOME)。 - 编写
/root/ocp/app/Containerfile,使用与遗留镜像相同的基础镜像和脚本,把/app改为 root 组(GID 0)所有,让组权限与所有者权限相同(g=u),然后指定数字 USER(非 0 的值)。用 buildah 构建localhost/ocp-app:fixed,导入 k3s 的 containerd(k8s.io 命名空间),然后在/root/ocp/build.json中写入tool、image、image_id(k3s crictl inspecti的 status.id)、user(镜像配置中的 User)。 - 用修复后的镜像创建 Pod
fixed-a(runAsUser 为 default_uid)和fixed-b(runAsUser 为 max_uid),使用与第 2 步相同的 securityContext,分别写入/root/ocp/fixed-a.yaml和/root/ocp/fixed-b.yaml,并让两者都处于 Ready。在/root/ocp/arbitrary.json中以 Pod 名称为键,写入uid(容器内的 id -u)和data(/app/data的소유자UID:그룹GID:권한8진수(占位符依次为所有者 UID、组 GID 和八进制权限),例如:0:0:755 格式)。 - 试验在不修改遗留镜像的情况下硬撑的两种方法。(1)用
/root/ocp/legacy-emptydir.yaml创建 Podlegacy-ed(与第 2 步相同,但在/app/data上挂载 emptyDirdata),并让它处于 Ready。(2)用/root/ocp/legacy-init.yaml应用 Podlegacy-init,其中 root(runAsUser 0)的 initContainerfix-perms会对/app/data执行 chown,并把输出保存到/root/ocp/init-denied.txt。在/root/ocp/alternatives.json中写入fsgroup_fixed_image_dir(带有 fsGroup 的 legacy Pod 是否成功写入了镜像目录)、emptydir_data(legacy-ed 内/app/data的그룹GID:권한8진수(占位符依次为组 GID 和八进制权限))、root_init_admitted(legacy-init 是否被接受)。 - 创建只在修复后的镜像上加入
readOnlyRootFilesystem: true的 Podro-bare,写入/root/ocp/ro-bare.yaml,观察失败;然后创建在相同配置上把 emptyDir 挂载到/app/data(名称 data)和/tmp(名称 tmp),并设置环境变量HOME=/tmp的 Podfixed-ro,写入/root/ocp/fixed-ro.yaml,并让它处于 Ready。在/root/ocp/readonly.json中写入bare_error(ro-bare 终止消息中包含 Read-only 的那一行,原样)、home(fixed-ro 内的 HOME)、home_writable、etc_writable(fixed-ro 内能否写入 /etc)。 - 在
/root/ocp/report.json中写入root_cause(第 2 步失败的原因:image-dir-owner、missing-capability、selinux三者之一)、whoami_ok_here(在这个 k3s 上,以任意 UID 运行时 whoami 是否可用)、openshift_runtime_adds_passwd_entry(OpenShift 文档是否说 CRI-O 会把任意 UID 写入 /etc/passwd)、fsgroup_fixes_image_dir、k3s_applies_uid_range(这个 k3s 是否会给没有 runAsUser 的 Pod 填入注解中的 UID)、uid_range_default(由注解计算出的默认 UID)、running_uids(当前处于 Ready 的 fixed-a 和 fixed-b 的 UID 排序后的数组)。
参考
- kubeconfig:
/etc/rancher/k3s/k3s.yaml。构建工具只有 buildah(没有 docker 和 podman)。 - 迁移镜像:
buildah push <이미지> docker-archive:<파일>:<이름>→k3s ctr -n k8s.io images import <파일>→k3s crictl images(占位符依次为镜像、文件和名称) - 终止消息:
kubectl -n ocp-sim get pod <이름> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.message}'(占位符为 Pod 名称) - 常见错误:在镜像内像
chown 1001这样对某个特定 UID 做适配。OpenShift 不会以那个 UID 运行。 - 常见错误:用同一个标签重新构建之后,没有把它导入 k3s。Pod 用的是 containerd 中的旧镜像。
- OCP 4.21 镜像编写指南 · OCP 4.19 SCC 管理
把命名空间布置成 OpenShift 项目的样子
创建命名空间 ocp-sim,并加上标签 pod-security.kubernetes.io/enforce=restricted、pod-security.kubernetes.io/warn=restricted,以及注解 openshift.io/sa.scc.uid-range=1000680000/10000、openshift.io/sa.scc.supplemental-groups=1000680000/10000。然后在 /root/ocp/project.json 中写入 uid_range(注解值原样)、default_uid(restricted-v2 的 MustRunAsRange 默认会选的 UID)、max_uid(范围的最后一个 UID)。
uid-range 注解只接受一个 <시작>/<길이> 块(占位符依次为起始值和长度)。MustRunAsRange 把范围的最小值作为默认值。这台 VM 的 k3s 不会读取这个注解,所以在后面的步骤中要把这个值直接填入 Pod。
迁移到 OpenShift 之后,因权限被拒绝而崩溃
用 /root/ocp/legacy.yaml 创建 Pod legacy。镜像为 localhost/ocp-app:legacy(已提前放入 VM),Pod 的 securityContext 为 runAsNonRoot true、runAsUser 为第 1 步的 default_uid、runAsGroup 0、fsGroup 1000680000、seccompProfile RuntimeDefault,容器为 allowPrivilegeEscalation false、capabilities drop ALL、terminationMessagePolicy: FallbackToLogsOnError。如果发生了一次以上的重启,在 /root/ocp/crash.json 中写入 uid、restart_count、error(终止消息中包含 Permission denied 的那一行,原样)。
restricted-v2 SCC 在 OpenShift 上会填入的值,这里靠手动填入。遗留镜像的 Containerfile 位于 /root/ocp/app/Containerfile.legacy。使用 FallbackToLogsOnError 后,日志的末尾部分会作为 Pod 状态中的终止消息保留下来,即使容器重新启动也不会消失。
以 /etc/passwd 中不存在的用户身份生活
用遗留镜像创建一个只执行 sleep 86400 的 Pod probe,使用与第 2 步相同的 securityContext,写入 /root/ocp/probe.yaml,并让它处于 Ready。在其中检查身份,并在 /root/ocp/identity.json 中写入 uid、gid、groups(id -G 的数字排序后的数组)、whoami_ok(whoami 是否成功)、home(HOME 环境变量的值)、home_writable(能否写入该 HOME)。
即使以镜像的 /etc/passwd 中不存在的 UID 启动容器,containerd 也不会阻止,但它无法告知用户名和主目录。根据文档,OpenShift 的 CRI-O 会把任意 UID 的条目写入 /etc/passwd——你在这里看到的样子,就是这一差别。能否写入,请用 test -w 确认。
从镜像这一侧修复:root 组和 g=u
编写 /root/ocp/app/Containerfile,使用与遗留镜像相同的基础镜像和脚本,把 /app 改为 root 组(GID 0)所有,让组权限与所有者权限相同(g=u),然后指定数字 USER(非 0 的值)。用 buildah 构建 localhost/ocp-app:fixed,导入 k3s 的 containerd(k8s.io 命名空间),然后在 /root/ocp/build.json 中写入 tool、image、image_id(k3s crictl inspecti 的 status.id)、user(镜像配置中的 User)。
buildah 构建的镜像只存在于 buildah 的存储中。用 buildah push <이미지> docker-archive:<파일>:<이름>(占位符依次为镜像、文件和名称)导出,再用 k3s ctr -n k8s.io images import <파일>(占位符为文件)导入。可执行文件也必须具有组执行权限,任意 UID 才能执行它。
能否以范围内的任意 UID 启动
用修复后的镜像创建 Pod fixed-a(runAsUser 为 default_uid)和 fixed-b(runAsUser 为 max_uid),使用与第 2 步相同的 securityContext,分别写入 /root/ocp/fixed-a.yaml 和 /root/ocp/fixed-b.yaml,并让两者都处于 Ready。在 /root/ocp/arbitrary.json 中以 Pod 名称为键,写入 uid(容器内的 id -u)和 data(/app/data 的 소유자UID:그룹GID:권한8진수(占位符依次为所有者 UID、组 GID 和八进制权限),例如:0:0:755 格式)。
OpenShift 会以每个项目不同的 UID 运行同一个镜像。只在某一个特定 UID 下才能工作的镜像,迁移的那一刻就会再次出问题。请用 stat -c '%u:%g:%a' 查看。
为什么 fsGroup 和 root initContainer 不是答案
试验在不修改遗留镜像的情况下硬撑的两种方法。(1)用 /root/ocp/legacy-emptydir.yaml 创建 Pod legacy-ed(与第 2 步相同,但在 /app/data 上挂载 emptyDir data),并让它处于 Ready。(2)用 /root/ocp/legacy-init.yaml 应用 Pod legacy-init,其中 root(runAsUser 0)的 initContainer fix-perms 会对 /app/data 执行 chown,并把输出保存到 /root/ocp/init-denied.txt。在 /root/ocp/alternatives.json 中写入 fsgroup_fixed_image_dir(带有 fsGroup 的 legacy Pod 是否成功写入了镜像目录)、emptydir_data(legacy-ed 内 /app/data 的 그룹GID:권한8진수(占位符依次为组 GID 和八进制权限))、root_init_admitted(legacy-init 是否被接受)。
fsGroup 是改变挂载到 Pod 的卷的组所有权的机制,并不会改变镜像的文件系统。emptyDir 可以写入,但 Pod 消失时它也会一起消失。restricted 标准和 restricted-v2 SCC 都不允许 UID 0。
在只读根文件系统上只开放需要写入的地方
创建只在修复后的镜像上加入 readOnlyRootFilesystem: true 的 Pod ro-bare,写入 /root/ocp/ro-bare.yaml,观察失败;然后创建在相同配置上把 emptyDir 挂载到 /app/data(名称 data)和 /tmp(名称 tmp),并设置环境变量 HOME=/tmp 的 Pod fixed-ro,写入 /root/ocp/fixed-ro.yaml,并让它处于 Ready。在 /root/ocp/readonly.json 中写入 bare_error(ro-bare 终止消息中包含 Read-only 的那一行,原样)、home(fixed-ro 内的 HOME)、home_writable、etc_writable(fixed-ro 内能否写入 /etc)。
只读根文件系统会暴露镜像往哪里写入。为每个写入路径配一个卷,像 HOME 这样工具默认使用的位置也要改指向可写路径。不要删除 ro-bare,把它留作证据。
迁移之前检查镜像的清单
在 /root/ocp/report.json 中写入 root_cause(第 2 步失败的原因:image-dir-owner、missing-capability、selinux 三者之一)、whoami_ok_here(在这个 k3s 上,以任意 UID 运行时 whoami 是否可用)、openshift_runtime_adds_passwd_entry(OpenShift 文档是否说 CRI-O 会把任意 UID 写入 /etc/passwd)、fsgroup_fixes_image_dir、k3s_applies_uid_range(这个 k3s 是否会给没有 runAsUser 的 Pod 填入注解中的 UID)、uid_range_default(由注解计算出的默认 UID)、running_uids(当前处于 Ready 的 fixed-a 和 fixed-b 的 UID 排序后的数组)。
k3s 是否使用注解,可以把去掉 runAsUser 的 Pod 用 --dry-run=server -o json 提交,然后看返回的 spec 中 runAsUser 是否被填入来确认。评分器也会用同样的方法和前面步骤的记录重新计算。