1. 为什么需要 pivot_root:chroot 的边界在哪里
如果说容器技术里有一个"既熟悉又陌生"的系统调用,pivot_root 绝对排得上号。很多人在看 Docker、runC 或者 Kubernetes 相关原理时都会碰到这个词,但真正动手用过的人不多。它和 chroot 看起来都跟"根目录切换"有关,但本质差距巨大,可以说 chroot 解决的是"怎么让进程看到一个新根",而 pivot_root 解决的是"怎么让进程彻底离开旧根"。两者之间的差距,恰恰是容器安全模型的基石。
1.1 chroot 的两个致命缺陷
先聊聊 chroot。chroot 的原理本质上只是把当前进程的根目录路径引用换掉,内核里对应的操作是修改进程的fs_struct->root,让路径解析从新的目录开始。听起来很直接,但这里面埋了两个大坑。
第一个坑:进程仍然持有对旧根的引用。你在 chroot 之前打开了一个目录 fd,或者你的当前工作目录还在旧根下,那么即使在 chroot 之后,你依然可以通过fchdir或..跳回旧根目录。这就是经典的 chroot 逃逸。攻击者拿到容器内任意一个进程的权限后,只要有办法获得一个宿主目录的 fd,就能绕过 chroot 的边界。
第二个坑:chroot 不改变挂载命名空间。它只是改变了路径解析的起点,但那些已经挂载在旧根下的文件系统,对进程来说仍然可见。比如宿主机的/proc、/sys、/dev等挂载点,在 chroot 后如果新根目录里没有对应的挂载点,进程就看不到,但如果有办法通过 fd 访问旧根,这些敏感信息照样能拿。
所以 chroot 更像是一个"视觉欺骗"工具,它让进程看到的目录结构变了,但底层的挂载关系和文件系统引用都没变。这就好比你从宿舍搬到了操场,但你衣柜钥匙还在宿舍门上,随时可以回去拿东西。
1.2 容器技术对"根"的硬性要求
容器运行时面临的现实问题比"逃逸"更紧迫。当容器引擎启动一个容器时,它要确保容器内的进程从一开始就看不到宿主机的挂载点、文件系统、设备节点。这靠 chroot 是做不到的,因为 chroot 依赖进程的自觉——如果容器内没有旧根的 fd,那确实"看起来"安全,但这属于运气,不是协议。
更关键的是,现代容器运行时通常会给容器建立全新的 mount namespace,然后在里面挂载一份新的 rootfs。在 mount namespace 中,这个新 rootfs 是唯一合理的根。这时你需要一个系统调用,能把当前进程的根挂载点整体替换成新的挂载点,并且把旧挂载点摘下来,动作要原子、彻底、不可逆。pivot_root 就是干这个的。
我在实际写容器运行时相关工具时,对这一点感触特别深。你如果用 unshare 创建了新的 mount namespace,然后只做一个 chroot,你会发现宿主机上的某些挂载仍然能穿透进来(比如通过打开的文件描述符)。可在 pivot_root 之后,这个 namespace 里的进程根挂载点已经换成了新 rootfs,旧根在 mount table 上被彻底移走了,连路径都找不回来。这个差别在安全审计里是硬件级的、不可绕过的。
2. pivot_root 的系统调用语义与内核行为
pivot_root 这个系统调用的名字很直白,就是"旋转根"。它定义在 Linux 内核的 fs/namespace.c 里,原型是:
int pivot_root(const char *new_root, const char *put_old);第一个参数是新根目录的路径,第二个参数是存放旧根的路径。调用成功后,当前挂载命名空间的根挂载点会被替换为new_root对应的挂载点,而原来的根挂载点会被移动到put_old目录下。注意这里说的是"挂载点",不是"目录"——pivot_root 操作的对象是挂载点,而不是简单的路径字符串。这是理解整个机制的关键。
2.1 系统调用的参数与内核路径
在内核实现中,pivot_root 会先解析new_root对应的挂载点,然后检查它是否满足前置条件(后面会细讲),接着把根挂载点相关的vfsmount结构重新链接,再把旧根挂到put_old指定的路径下。整个动作在 VFS 层完成,期间会有锁保护,所以对用户态进程来说是原子的。
调用成功后,chdir("/")就变得重要了,因为虽然根挂载点换了,但进程的当前工作目录不一定自动跟着切到新根的"/"上。所以在跑完 pivot_root 之后,紧接着几乎总是跟着一个chdir("/"),让进程的工作目录也落到新根里。runC 代码里就是这么干的:
if err := unix.PivotRoot(rootfs, rootfs+"/.pivot_root"); err != nil { return err } if err := unix.Chdir("/"); err != nil { return err }顺序不能反:先 pivot_root,再 chdir("/")。如果你先 chdir 到旧根的某个目录,再执行 pivot_root,虽然也能切,但那个目录可能已经被移动了,存在混乱的风险。
2.2 挂载点切换与 chroot 的本质区别
chroot 修改的是进程的"根目录指针",pivot_root 修改的是"整个 mount namespace 的根挂载点"。这两者的区别可以类比为:chroot 是给进程戴了一个 VR 眼镜,看到的世界变了,但脚下的地板还是原来的;pivot_root 是直接把地板换成了新的,旧地板被搬到仓库里去了。
从内核数据结构来看,chroot 作用于fs_struct->root,只影响单个进程(或者通过CLONE_FS共享 fs_struct 的一组线程)。而 pivot_root 作用于mount_namespace->root,影响的是整个 mount namespace 下的所有进程。这也是为什么 runC 在创建容器时,要先通过 clone/unshare 隔离出新的 mount namespace,再在这个 namespace 内执行 pivot_root——这样只有容器内的进程会受影响,宿主进程完全无感。
还有一个隐蔽的区别:chroot 之后的进程,其/proc/self/mountinfo里根挂载点仍然是宿主机的根设备;而 pivot_root 之后,根挂载点变成了新 rootfs 对应的设备或绑定的目录。做容器逃逸检测、取证分析时,看/proc/PID/root和/proc/PID/mountinfo的差异就能判断出进程是经过 chroot 还是 pivot_root 隔离的。
2.3 前置条件:为什么内核要限制这么多
pivot_root 不是你想调就能调,内核设了一堆前置条件,任何一个不满足直接返回错误码。我梳理了一下,实践中最常见的几个:
new_root必须是一个挂载点,不能只是一个普通目录。这意味着你要先对新 rootfs 目录执行 mount(可以是 bind mount,也可以是真实文件系统的 mount),否则 pivot_root 返回 EINVAL。put_old必须位于new_root之下或者是其子目录,这是为了保证旧根能被完整地收纳进新根下,不会出现交叉引用。- 当前进程的根目录不能与
new_root的父目录相同。这个条件有点绕,简单说就是你不能把自己正在用的根再拿来切,得先换个地方。 - 要满足
new_root和put_old不在同一个文件系统上的场景限制,不过实际应用里通常都是 bind mount 出来的新根,这个条件天然满足。 - 如果当前挂载传播属性是 shared 且没有先改成 rprivate,pivot_root 可能返回 EBUSY。这个坑我后面专门展开说。
这些限制乍看很繁琐,但每条都能对应到具体的防止滥用场景。内核这种"严格检查"的风格在安全敏感的系统调用上很常见。你在写相关代码时,一定要把每个错误码都当回事,尤其是 EINVAL 和 EBUSY 的出现频率最高。
3. 一次完整的 pivot_root 实战:从宿主到新根
光讲理论很容易飘,真正动手跑一遍才踏实。我下面给出一个在普通 Linux 宿主机上手动模拟容器切换根的流程,不需要容器运行时,只需要 root 权限和一个不算太老的内核。这一步做完,你对 pivot_root 的理解会比看十篇文章还深。
3.1 准备一个最小的新根目录
首先准备新的根文件系统。最简单的方式是用 busybox 做一个 mini rootfs,或者直接 bind mount 当前系统的一部分目录。我这里为了演示省事,直接 bind mount 根目录:
mkdir -p /mnt/newroot mount --bind / /mnt/newroot注意 bind mount 之后,/mnt/newroot就是一个挂载点了,这满足了 pivot_root 的"new_root 必须是挂载点"要求。如果你线上环境里要用干净的最小 rootfs,可以用 busybox 的静态编译版本丢进去,再把proc和dev挂上去,但演示阶段 bind 宿主根就够了。
紧接着要处理挂载传播问题。在大多数发行版上,/的挂载传播属性是 shared,这种状态下 pivot_root 很可能失败。所以先改成 rprivate:
mount --make-rprivate /这一步是用 unshare 进入新 mount namespace 之后才需要做。如果你直接在宿主的原始 namespace 上改,会影响整个系统。正确姿势是先unshare -m进入一个新的 mount namespace,在这个 namespace 里再操作。
3.2 演示脚本与逐步拆解
下面这个脚本展示了一次完整的 pivot_root 切换过程。我用unshare进入新的 mount namespace,然后在新 namespace 里执行 pivot_root:
#!/bin/bash # demo_pivot_root.sh set -euxo pipefail # 1. 进入新的 mount namespace unshare -m bash -c ' # 2. 让当前 namespace 内的挂载传播变为 rprivate mount --make-rprivate / # 3. 准备新根目录,bind mount 根文件系统 mkdir -p /mnt/newroot mount --bind / /mnt/newroot # 4. 在新根下创建 put_old 目录,用于收纳旧根 mkdir -p /mnt/newroot/oldroot # 5. 执行 pivot_root pivot_root /mnt/newroot /mnt/newroot/oldroot # 6. 切换工作目录到新根 chdir / # 在 bash 里用 cd / cd / # 7. 卸载旧根(此时旧根挂在新根的 /oldroot 下) umount -l /oldroot rmdir /oldroot # 8. 验证 echo "After pivot_root:" cat /proc/self/mountinfo | head -5 ls / '每一步都值得拆开理解。
第 2 步为什么要改成 rprivate?因为在新 mount namespace 里,挂载点默认继承了父 namespace 的传播关系。如果根是 shared,pivot_root 在新旧根之间做移动时,内核会因为传播关系检查失败而返回 EBUSY。改成 rprivate 等于告诉内核:这个 namespace 里挂载点之间的变更不要再向外面扩散了。
第 4 步的/mnt/newroot/oldroot就是 put_old。pivot_root 执行前,旧根是整个文件系统树的根挂载点;执行后,旧根被移动到这个目录下。你可以把整个过程想象成一次"换血手术":新根被抬上来当根,旧根被塞到角落里等着被卸载。
第 7 步的umount -l用了 lazy 卸载,这很重要。因为当前进程的工作目录可能还在旧根上,或者有打开的文件句柄引用旧根,直接 umount 会返回 target is busy。lazy 卸载则会把旧根从挂载树上摘掉,等引用全部释放后再真正回收资源。这在容器退出、删除容器根目录时也经常用到。
3.3 验证是否真的切换成功
脚本跑完之后,最直接的验证方式是看 mountinfo 的第一行:
cat /proc/self/mountinfo | grep " / / "如果 pivot_root 成功,根挂载点的路径显示应该是指向/mnt/newroot对应的设备源。你再对比一下宿主机上的输出,会发现两边根挂载点已经完全不同。另一个验证方式是ls -l /proc/self/root,它显示的符号链接目标就是新根目录。如果你在新根里放了一个特殊的标记文件,比如/marker,那么ls /marker能看到,而在宿主机上/marker可能不存在(或者内容不同)。这个标记法是我在调试不同容器 rootfs 时最常用的手段——简单直接,一目了然。
实际运行这段逻辑时需要注意,unshare -m后 mount namespace 是新的,但你仍然是宿主 root 用户,所以权限上没问题。另外,不要尝试把put_old放在new_root之外的地方,内核直接会报 EINVAL。这个错误在开发初期我踩了不知道多少次。
4. 高频踩坑与排查思路:mount 传播、EBUSY 与 put_old 的处理
pivot_root 的 API 本身只有两个参数,但实战中几乎每个人都会在它附近栽跟头。我把这几年在容器运行时、沙箱方案和系统初始化脚本里遇到的坑分类整理一下,这些都是文档里不一定写得明白、但排查时一定会用到的东西。
4.1 MS_REC 与挂载传播的坑
很多人在准备 new_root 时,只 bind mount 了一个目录,结果 pivot_root 之后发现新根里面空荡荡的。比如你 bind mount 了宿主机的/,但新根里看不到/home、/var这些原先挂载出来的子挂载点。原因很简单:bind mount 默认不递归。
你要把整个文件系统树都带过去,需要在 bind mount 时加上--rbind而不是--bind:
mount --rbind / /mnt/newroot在代码里对应MS_BIND | MS_REC。runC 里准备 rootfs 时,如果涉及将宿主目录 bind 进容器,同样要考虑要不要递归。递归与否直接决定了新根里的挂载拓扑完整性。
不过加--rbind之后还会引入另一个问题:新根里会带着宿主机上的一大堆挂载点,比如/proc、/sys、/dev。这些如果在容器里没做隔离,会造成安全问题。所以专业的容器运行时会在 pivot_root 之前先把这些敏感目录处理好,要么卸载,要么用 namespace 内的 tmpfs 重新挂载。
我的建议是:如果你是做沙箱类项目,尽量用真实的最小 rootfs,不要 bind 宿主根;如果你只是想快速验证 pivot_root 行为,--rbind /最省事,但要知道它带了什么副作用。
4.2 EBUSY 的根因分析
EBUSY 可能是 pivot_root 最让人头疼的错误。触发它的原因不止一个,我挨个说。
第一个是前面提过的挂载传播问题。根挂载点是 shared 时,pivot_root 会拒绝执行,因为内核无法确定传播关系是否会引入环路。解决办法就是mount --make-rprivate /,这一步几乎成了 pivot_root 的前置标准动作。如果你用了 systemd 系的发行版,默认根挂载传播大概率是 shared,所以这一步基本是必须的。
第二个是 new_root 或 put_old 上有挂载。如果 put_old 目录本身是一个挂载点,或者 put_old 下面还有 active 的挂载点,内核也会返回 EBUSY。常见于你之前挂载了什么文件系统忘记卸载,或者put_old目录被系统进程占用。排查方式是看 mountinfo 里 put_old 路径下还有没有挂载记录:
grep "/oldroot" /proc/self/mountinfo第三个是 new_root 与当前根指向同一个挂载点。如果你直接pivot_root / /或者pivot_root /mnt/newroot /mnt/newroot/oldroot但/mnt/newroot的挂载源就是/,在没有改传播属性时也可能触发 EBUSY。对这个场景,我建议先确认 mountinfo 里new_root的设备号、挂载源是否与当前根一致。
排查 EBUSY 有一个通用的调试思路:执行 pivot_root 之前,先把 mountinfo 完整 dump 出来,对照检查 root 挂载点的传播属性、new_root 和 put_old 的挂载状态。很多时候问题一目了然,比盲目试错快得多。
4.3 put_old 的两种经典姿势
put_old 怎么放,业界有两种主流做法,各有应用场景。
第一种是 create-and-umount 姿势:在 new_root 下创建一个子目录,比如/mnt/newroot/oldroot,pivot_root 之后立刻卸载:
mkdir -p /mnt/newroot/oldroot pivot_root /mnt/newroot /mnt/newroot/oldroot umount -l /mnt/newroot/oldroot rmdir /mnt/newroot/oldroot这种姿势适合明确知道 new_root 路径的场景,逻辑直白。但有一个小问题:目录名会短暂暴露在容器内。某些安全审计工具可能会注意到.pivot_root或oldroot这种目录的存在。
第二种是 runC 采用的优雅姿势:先把当前根 bind mount 到一个临时目录,然后让 new_root 就是当前根目录,put_old 也是当前根目录:
mount --bind / /mnt/newroot pivot_root /mnt/newroot /mnt/newroot乍一看很奇怪,把旧根放进旧根里。但内核允许 put_old 等于 new_root,执行后旧根挂载点被移到/mnt/newroot这个目录下,而/mnt/newroot本身就是新的根挂载点。这样 put_old 和 new_root 重叠,省掉了卸载时对路径的依赖。runC 源码里用的路径是 rootfs 加一个隐藏目录,本质逻辑一样。
两种姿势没有绝对优劣。第一种对初学者更友好,第二种更贴近生产环境。如果你在写自己的容器运行时,我建议先实现第一种,跑通后再切换到第二种,这样对行为差异会有更具体的感知。
4.4 在 systemd 和容器运行时环境下的特殊处理
真实环境中,你不会直接在一个干净的 shell 里调 pivot_root,更多时候是要在 systemd 或者已经跑着容器进程的环境里操作。这就引出了两个特殊问题。
第一个是 systemd 的 mount namespace 管理。systemd 会为大量服务创建独立的 mount namespace,并用MountFlags=shared等参数控制传播。如果你在一个 systemd 管理的服务脚本里执行 pivot_root,系统原有的一些挂载点(比如/run、/tmp)可能还在新根之外挂着,造成奇怪的不可见问题。处理思路是先明确你所在 namespace 的挂载传播属性,再决定是否要手动重挂关键目录。
第二个是容器内 PID 1 的特殊性。容器的 PID 1 进程执行 pivot_root 后,这个 namespace 里其他进程的根也跟着全换了,因为挂载点是 namespace 级共享的。如果你的容器里跑着多个进程,其中某个进程持有旧根的 fd 或 cwd 在旧根下,它在 pivot_root 之后如果尝试访问旧根路径,可能会触发非常诡异的行为。比如执行cd / && ls看似正常,但getcwd返回的路径和新根里的路径完全对不上。我遇到过的一个实际案例是,某个 Go 程序在容器启动时后台协程还在往旧根的日志文件里写数据,pivot_root 之后文件句柄依然有效,但路径已经不可解析了。这类问题很难排查,唯一的建议是:在容器启动早期,尽量让所有进程的打开文件句柄都基于新根,不要在 init 阶段持有旧根的引用。
5. 实际应用:理解 runC 源码中 pivot_root 的调用方式
讲完手工操作和踩坑,最后落脚到真实生产代码。runC 是整个 OCI 容器运行时的事实标准,它启动容器的核心路径里就有一段 pivot_root 调用,代码量不多,但每一行都有讲究。
5.1 runC 的 rootfs 准备流程
runC 启动容器时,rootfs 的构建分几步:从镜像解压出 rootfs 目录、挂载 proc/sys/dev 等虚拟文件系统、把 rootfs 做成一个挂载点、创建 put_old 目录,然后调用 pivot_root。这中间有一个细节很多人没注意:rootfs 本身要先通过 bind mount 成为一个挂载点。
unix.Mount(rootfs, rootfs, "", unix.MS_BIND|unix.MS_REC, "")这一步对应了我前面说的"new_root 必须是一个挂载点"的条件,而且用的是 MS_REC,确保 rootfs 内部的子挂载点也被保留。接下来 runC 会设置挂载传播属性,防止宿主机的挂载变化穿透容器边界,然后再执行 pivot_root。整个顺序是有严格依赖的:bind mount 必须在前,rprivate 必须在 pivot_root 之前,否则都会失败。
5.2 关键代码中的边界处理
runC 中执行 pivot_root 的函数在libcontainer/rootfs_linux.go里,核心逻辑如下:
func pivotRoot(rootfs string) error { oldDir := filepath.Join(rootfs, ".pivot_root") if err := os.MkdirAll(oldDir, 0700); err != nil { return err } if err := unix.PivotRoot(rootfs, oldDir); err != nil { return fmt.Errorf("pivot_root %s %s: %w", rootfs, oldDir, err) } if err := unix.Unmount(oldDir, unix.MNT_DETACH); err != nil { return fmt.Errorf("unmount pivot_root dir %s: %w", oldDir, err) } if err := os.RemoveAll(oldDir); err != nil { return err } return nil }这段代码有两个值得注意的地方。
第一,put_old 目录用了一个隐藏名字.pivot_root,并且在执行完 pivot_root 后立即以MNT_DETACH(lazy unmount)方式卸载,再用RemoveAll清理目录。这样容器内最终看不到任何旧根残留。
第二,如果 pivot_root 失败,runC 会回退到 chroot。这是为了兼容那些不支持 pivot_root 或者内核禁止 pivot_root 的环境(比如某些容器嵌套场景)。但这个回退其实是安全性的降级,runC 在容器配置文件里也会标注这一点。如果你在自己做运行时,尽量不要提供 chroot 回退选项,毕竟 chroot 的隔离强度差很多。
5.3 这套机制对日常开发的启发
pivot_root 不只在容器运行时用到。我自己在本地调试沙箱工具、做系统级测试、甚至做 PDF 分析用的隔离环境时,都会用到这套"新 mount namespace + 新根 + pivot_root"的组合。它比 chroot 安全得多,也比完整跑一个虚拟机轻量得多。
另外一个启发是:理解 pivot_root 之后,你会发现很多容器"不可见""隔离"其实不是魔法,而是内核里几个路径解析和挂载操作的组合。把 mount namespace 和挂载点这两个概念拆清楚,再去看 runC、containerd、LXC 的代码,很多之前觉得抽象的参数(比如CLONE_NEWNS、MS_PRIVATE、MNT_DETACH)都会变得具体起来。
就我个人的实际体会来说,pivot_root 是那种"原理五分钟、排错两小时"的系统调用。如果你刚开始接触,建议一定自己在虚拟机里跑一遍上面的脚本,故意制造几个错误(比如不改 rprivate、pv 到普通目录、put_old 放在 new_root 外),把错误码和 mountinfo 对应起来。这套手感建立之后,再看任何运行时源码里的 pivot_root 调用,都不会再觉得神秘了。