容器 09:组合隔离机制时启动和清理的顺序
前八篇分别讨论进程、namespace、身份、资源和权限。如果只把它们列在命令行上,很容易遗漏“父进程何时写映射”“谁等待子进程”“初始化失败后对象如何退出”。这篇用一个受限教学启动器展示顺序,并明确它没有实现的隔离,避免误当成生产容器。
一个可核查的局部链路
源码共用源码包内的 teaching_launcher.c 固定执行同目录内自编译的 process_probe,没有接受用户指定的命令行、镜像或任意路径。启动器记录外侧 UID/GID,先 unshare(CLONE_NEWUSER),将内部 UID/GID 0 映射到原外侧身份;在写 GID 映射之前向 setgroups 写 deny。随后建立新的 UTS、IPC namespace,在私有 UTS 中设置测试 hostname。fork 后只有子进程 exec 自编程序,父进程 waitpid 并返回子进程状态。设置顺序的核心是写映射时“当前进程处于哪个 namespace”,而不是把一串 unshare 选项当成 magic flag。
从当前源码画出真实的控制流
第一步在外侧读取实际 UID/GID;这两个数字是映射输入,不能在 unshare(CLONE_NEWUSER) 之后再读新 namespace 中显示的 0 并当作外侧 UID。第二步进入新 user namespace,将 /proc/self/setgroups 设为 deny,分别写入 uid_map 和 gid_map 的单一映射。这里运行的是同一个进程,并非父进程修改已运行子进程的身份;unshare 失败会立即报错,尚未进入后续 UTS 操作。第三步建立 UTS、IPC namespace,修改 hostname 只影响新 UTS 视图;IPC 视图虽不同,程序没有创建 System V IPC 对象来做行为对照,不能给它凭空补一行“IPC 隔离通过”。最后父进程 fork,子进程 execl 固定相对路径,父进程 wait 并将子进程状态写成自身退出状态。
execve 会替换进程映像,不会为这个子进程另分配一个新 PID;fork 先建立的 UID/GID 映射和 UTS/IPC namespace 继续影响新程序。已打开的文件描述符默认可能穿过 exec;这份启动器用 O_CLOEXEC 打开映射文件,避免这些 FD 无意留给探针,但标准输出仍传递给探针用于日志。成功 execl 的子进程不会执行其后的错误处理;只有 exec 返回失败时才走 perror 路径。这里的错误来自相对路径缺少可执行文件,也可能在别的环境来自解释器、动态库、权限或格式错误,不应拿一次 ENOENT 推断所有失败都等于文件本身不存在。
如果把映射交给父进程,必须先同步
当前源码在 fork 之前、由将要成为父进程的同一个进程完成映射,因而没有“父亲等子进程建立 namespace、孩子等父亲写映射”的双向等待问题。若以后改用 clone 直接建立新的 user 与 PID namespace,由父进程写子进程的 /proc/<child>/uid_map,就必须有同步机制:子进程在映射前不得开始依赖 UID/GID 的挂载、文件访问或 exec;父进程确认映射写入成功后再通知子进程,否则孩子可能先以未映射身份尝试初始化并报一个看似随机的权限错误。父进程写 map 失败时,也必须通知或终止等待中的子进程并回收它,不能让一个阻塞在管道上的孩子永久残留。
可复核的同步记录至少包括“父进程何时得到子 PID”“哪一方在什么 namespace 写 map”“映射成功或失败的 errno”“孩子什么时候跨过同步点”“父进程如何 wait”。若先给孩子放行,随后才写映射,即使偶尔启动成功也有竞态;重跑 100 次成功不能替代建立确切的 happens-before 关系。把同步信号编码成一个成功/失败字节,只对自己创建的 pipe 使用,遇 EOF 或父进程提前退出即走子进程失败路径,是比 sleep 固定时长更可靠的教学设计。不过当前源码没有这条跨进程握手路径,本段只是扩展版实现条件,不应记作已跑实验。
mount、PID 与 cgroup 的顺序为什么耦合
要真正运行在新的根文件系统里,先确认挂载操作发生在私有 mount namespace,再检查挂载传播,准备包含可信程序与运行时依赖的 rootfs,按所选方法切根并使旧根不可达。只改 UTS hostname 不会改变文件系统,更不会自动移除父进程早已打开的旧根 FD。进入新的 PID namespace 后,成为 PID 1 的是第一个创建的子进程而不是调用 unshare(CLONE_NEWPID) 的进程;挂载 procfs 应与新的 PID 视图对应,否则内部 ps 仍可能沿用旧列表。启动器必须明确谁负责转发 TERM、等待孤儿、在 PID 1 退出时如何处理其他工作进程。不能把已有的“fork 一次然后 wait”直接声称涵盖这些场景。
cgroup 资源约束通常要由持有委派子树的控制方先准备,再确保目标进程在开始消耗 CPU、内存或创建后代之前加入正确的组。若先 exec 探针、等服务已经处理一批请求后才迁进限额组,先前的资源使用并没有受该组约束;如果父子在不同 cgroup,还必须按实际内核计数归属解释统计与清理。受限 VM 实现时,应先核对 cgroup v2 可用的控制器、父层启用情况及目录写权限,再建立只属于实验的子组;当前云端 v1 只读不能跳过权限边界直接写宿主根组。PIDs、CPU 和 memory 限额可以分步加入,任何一项失败都要结束自建进程并删去自建组,而不是让程序以未受约束模式默默继续。
权限下降不能发生在“执行了以后”
若要在特权初始化后降低 capability、设置 no_new_privs 和 seccomp,必须在执行目标程序前确定顺序、进程身份和所需的系统调用集合。先装上不允许挂载的过滤器,再让同一子进程完成挂载,可能使正常初始化失败;先 exec 目标再考虑降低能力,就已经允许了一个不应出现的高权限执行窗口。Seccomp 还可能限制错误处理必需的 write/exit 系统调用,真实策略要逐个记录。08 的过滤器只限制自编演示进程的一个 syscall,不是可以直接复制到该启动器的生产级模板;这里没有实现 seccomp 或 LSM,也不把 CapEff=0 的外侧进程视为对不可信代码足够安全的封闭环境。
教学启动器的“可信”不是一个装饰语:源码固定执行 ./process_probe,但相对路径仍要求实验目录只由操作者控制,编译后的程序没有在加载时再次核验 digest。不能把 ./process_probe 换成来自用户上传目录的任意二进制后宣称 sandbox 仍成立;这个单独的手工实验省略了镜像认证、只读根、系统调用策略、设备过滤和审计等必要边界。在专用 VM 补全教学代码时,可记录构建源码的哈希与实际编译产物、限制测试目录权限,仍不能把它命名为可运行恶意代码的运行时。
本机的正常原始日志记录两次 gcc 编译退出 0、启动器运行退出 0、自有 UTS inode、子探针的 fork/exec 和 wait、最后删除实验目录退出 0。与 01 对照,探针子进程退出码为 7,探针父进程把“已正确回收并得到 7”转换为 0,启动器父进程又把探针父进程的 0 当作最终状态。看见 0 绝不代表所有进程的退出码都是 0。另在自建目录中刻意不放 process_probe,exec 失败原始日志显示子进程报 No such file or directory、父进程 wait 得 1、启动器退出 1,pgrep 无实验目录下遗留进程且目录清理返回 0。映射失败、完整 mount/cgroup 回滚与连续运行两次的无泄漏核查仍 NOT_RUN;复跑命令见运行说明。
两条真实记录能检验的是当前源码走过的分支:正常时两级 wait 都得到明确状态,缺文件时子进程通过显式失败路径离开,父进程没有丢失子进程退出状态。它们没有制造 mount、cgroup 或 netns 对象,所以原始日志中“删除临时目录成功”不等于“所有种类的内核资源已清理”。也不要只看本机 pgrep 的一次结果就推出整个节点没有任何相同名字的进程;应绑定本次自建目录、外侧 PID 和清理时间窗口。局部实验成功之外,完整 09 验收所需的“连续两次启动/失败注入后的对象清单”仍缺,后续 VM 不能在现有日志里补写预期行。
重要的是,这个程序尚未建立新 mount/PID/net namespace,也没有切换根、写 cgroup、配置 seccomp/LSM,更没有端口、镜像或日志生命周期管理。因此它连完整的 09 计划验收都未达到,更没有运行不可信负载的资格。完整教学启动器需在专用 VM 增加父子同步:父进程持有失败清理的控制权,子进程在映射及挂载设置成功后才 exec;任一步出错要终止并等待自己创建的进程、卸载自己创建的挂载、删除自己创建的 cgroup,并连续运行两次证明无泄漏。成熟 OCI runtime 还需处理大量这些代码没覆盖的规范状态。
失败清理应以创建顺序的逆序组织:只有当某步真正创建对象并记录其身份,清理栈里才加入对应动作;exec 尚未发生时不应杀一个尚未创建的进程,挂载尚未成功也不应尝试卸载共享目录。每一次回滚必须记录“创建动作及对象标识 → 出错时的 errno → 清理动作及返回码 → 外侧再次核验”;清理失败不能被新的脚本退出码覆盖。若文件描述符指向旧根、后台程序仍占据挂载点,就先从本次实验的进程关系排查而不是使用全局 umount -l 假装资源已经释放。这些策略需要在专用 VM 的实际代码与日志里落地,文字中的逆序表不能替代一次失败注入和复跑。
其中最容易漏掉的是父进程提早退出:子进程如果正在等待映射完成,父进程不再写管道时应从 EOF 路径结束,而不是依赖超时后继续以未映射身份 exec。另一种边界是成功启动后子进程很快退出,父进程必须原样记录它的真实退出状态,不将失败的初始化误当作运行期服务正常停止。启动与退出应该共享同一组对象 ID 和 PID 证据,否则“有进程”和“已清理”可能指向两次不同的运行。
把设置顺序画成可审计状态机,比只给出一条 unshare 命令更能检验正确性:每跨越一个初始化阶段,都列出该阶段已创建且需要回收的对象、尚未允许执行的操作和失败后应返回的状态。若重新运行时遗留旧 namespace 句柄或旧 cgroup,第二次成功也不能覆盖第一次的清理缺陷。
| 现象 | 首先查 | 前提 |
|---|---|---|
子进程 exec 失败 |
子 PID、errno、父 waitpid |
不应留下后台进程 |
| 映射失败 | uid_map、gid_map、setgroups 次序 | user namespace 权限有边界 |
| 启动器返回 0 | 各级进程退出码和回收 | 不等于沙箱完整 |
练习
- 先预测删除自有实验目录里的
process_probe后启动器怎样退出;在专用临时目录运行,核对 errno、父进程状态和目录清理,勿传入不可信程序。 - 列出启动器要达到完整 09 验收还缺哪些对象;在专用 VM 逐项补 PID/mount/net、cgroup 与权限限制,给每个配置失败点设计反向清理核查表。
上一篇:08:权限边界;下一部从 OCI 镜像制品出发,单独对照 bundle 与实际进程。相关阅读:Docker 完全指南,旧文不作为本程序日志。
系列总目录:从进程隔离到运行时与编排。





