做容器或者嵌入式系统的人,迟早会在启动脚本里撞见pivot_root这个名字。我第一次认真研究它,是因为用 initramfs 引导 Linux 时,无论如何都切不到真正的根文件系统,系统卡在“VFS: Cannot open root device”之类的问题上。后来搞清楚了:我们需要的不只是“把根目录换个路径”,而是要把整个挂载树的根换成另一块文件系统,这不是chroot能做的事,正是pivot_root的看家本领。
本文围绕pivot_root命令和同名系统调用展开,说说它和chroot的本质差异、在内核里到底发生了什么、在容器运行时和 initramfs 里的经典用法,以及我在实际调试中踩过的坑和完整的排查过程。希望你看完之后不仅能照着写脚本,遇到“Invalid argument”这类报错时也知道该往哪个方向查。
1. 从一次容器启动失败说起:隔绝不彻底引发的安全焦虑
1.1 chroot 为什么不够用
早些年做容器方案时,团队用的隔离方式就是chroot:把进程的根目录切到 rootfs 目录里,看起来好像已经和宿主机文件系统“无关”了。但实测中你会发现,chroot改变的只是当前进程fs_struct里的 root 路径,挂载树本身纹丝未动。换句话说,宿主机挂在/data、/proc上的那些 mount point,在 chroot 环境里依然看得见摸得着。
更麻烦的是chroot的逃逸风险。如果进程在 chroot 之前保留了一个指向旧根目录文件系统的文件描述符,之后用fchdir(fd)就能直接跳出“监狱”。历史上还出现过利用嵌套 chroot 加../../..路径穿越的老牌绕过方式,现代内核虽然加了防护,但单靠chroot做安全隔离始终不让人放心。
当时我们容器里跑的是第三方业务进程,一旦容器内拿到 root 权限,一个ls /看到宿主机目录结构,安全隐患就很明显。后来翻 runc 源码才发现,主流的容器运行时根本不屑用chroot收尾,它们用的是pivot_root:先把容器 rootfs 挂载好,再通过pivot_root把整个挂载树的根切到容器内,宿主机那些挂载点在切换后彻底不可见,旧根被挂到一个临时位置后立刻卸载。
1.2 pivot_root 到底是什么
pivot_root是 Linux 提供的一个系统调用,同时 util-linux 也封装了一个同名命令行工具。它的核心动作是:把当前进程所在挂载命名空间的根文件系统挂载点换成new_root所指向的挂载点,同时把原来的根挂载点挪到put_old目录下。
这样做的好处非常直接:切换之后,整个挂载命名空间里的所有进程只要访问/,看到的就是新根文件系统的内容,旧根已经变成新根下面的一个挂载点,可以按需卸载。这个过程发生在挂载层,而不是简单的路径替换,所以它确实解决了chroot隔绝不彻底的问题。
从实用角度看,pivot_root主要有三大使用场景:容器运行时(runc、containerd 等)、initramfs 启动流程、系统救援或迁移工具。这三个场景虽然目的各不相同,但本质诉求一致:让系统“真正换一个根”。
1.3 为什么标题叫“pivot_root 命令”却总在说系统调用
这里要澄清一个容易混淆的点。内核提供的pivot_root是系统调用,而我们在 shell 里执行的pivot_root是 util-linux 里的命令。命令行工具本质上是对系统调用的封装,但它在调用之后还多做了一步:把当前进程的根目录chroot到new_root,并把工作目录切到/。
所以你在 initramfs 脚本里写pivot_root /newroot /newroot/oldroot,执行完这个命令后,当前 shell 已经处于新根环境里了。而如果用 C 直接调syscall(SYS_pivot_root, ...),系统调用本身会更新挂载树,但调用者的根目录和 cwd 处理要看内核行为,后续往往还需要手动chdir("/")。理解这层关系,读各种脚本和源码时才不会懵。
2. 挂载点层面的换根:pivot_root 和 chroot 到底差在哪
2.1 一个“换门”与一个“换房”的差别
拿生活场景打个比方。chroot是给进程换了一扇“假门”:门上的牌子写的是/,但房子还是原来那套,你透过窗户依然能看到旧房间里的东西,甚至找到门路还能走回去。pivot_root是直接把整个房子的地基挪了,旧房子整体搬走,新房子从地基开始就是另一套,你看不到任何旧房间的残留。
从内核数据结构上说,进程的根目录信息存在fs_struct里,里面有 root 和 pwd 两个关键引用,各自指向一个(dentry, vfsmount)组合。chroot只改了fs_struct.root的 dentry 路径,vfsmount 还是指向原来的根挂载点;pivot_root直接改了挂载命名空间里那棵挂载树的根挂载点,连 vfsmount 层次都换了。
2.2 可见范围的差异
chroot影响的只是当前进程以及它后续 fork 出的子进程。你在一台服务器上chroot /some/root开个 shell,另外开一个终端还是能看到完整文件系统,这不影响其他任何进程。pivot_root则不同,它作用于整个挂载命名空间,只要两个进程共享同一个 mount namespace,切根之后所有进程访问/都会看到新根。
在现代 Linux 里,容器运行时一般会先clone出一个新的 mount namespace,再在这个新的命名空间里执行pivot_root。这样切换就不会波及宿主机,但命名空间内部是整体生效的。如果你在全局命名空间里直接执行pivot_root,那全系统根目录都会被切换,生产环境里千万别这么干。
2.3 安全性层面的对比
| 方案 | 改变挂载树根 | 影响范围 | 逃逸难度 | 典型用途 |
|---|---|---|---|---|
| chroot | 否 | 单进程 | 较低,有 fd 或特殊权限即可绕过 | 构建环境、简单沙箱 |
| chroot + 各种加固 | 否 | 单进程 | 中等,需大量手工防护 | 传统 ftp/jail 场景 |
| pivot_root | 是 | 整个挂载命名空间 | 高,旧根需显式卸载 | 容器运行时、initramfs |
这张表是我选型时的核心参考。容器场景里之所以默认pivot_root,是因为它天然切断了进程回看宿主机挂载树的路径。但要注意,pivot_root也不是万能的,它只是文件系统隔离的一环,完整的容器隔离还需要 pid namespace、mount namespace、capabilities、cgroups 等一起配合。
2.4 新旧根的“交接”逻辑
pivot_root(new_root, put_old)成功执行后,新根挂载点成为挂载树的根,旧根挂载点被摘下来挂到put_old目录上。这个过程中有个细微但关键的点:put_old必须在new_root里面,或者至少切换后还能通过路径访问到。否则切根完成,你永远找不到旧根挂载点在哪,旧根就会一直占着底层设备,导致后面umount和释放设备都出问题。
这也是为什么经典脚本里总会在/newroot下先mkdir -p oldroot,然后把put_old指定为/newroot/oldroot。切根之后,这个目录在路径上变成了/oldroot,直接umount /oldroot就能把旧根清理掉。
3. 动手实操:pivot_root 的标准调用流程与四大约束
3.1 标准初始化流程
假设我们要从 initramfs 切换到硬盘上的真实根分区,手动版的完整流程大概是这样的:
#!/bin/sh # initramfs 里的 init 脚本片段 # 1. 先挂载必要的虚拟文件系统 mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t devtmpfs devtmpfs /dev # 2. 挂载真实根分区 mount /dev/sda1 /newroot # 3. 在 new_root 内部创建旧根的暂存目录 mkdir -p /newroot/oldroot # 4. 切换根 pivot_root /newroot /newroot/oldroot # 5. 切回根目录并执行真正的 init exec chroot / /sbin/init步骤 5 里的exec chroot / /sbin/init看着有点冗余,因为pivot_root命令本身已经做了chroot到新根的工作。但我习惯保留它,原因有两个:一是确保当前工作目录干净地落在/,避免 cwd 还指向旧目录导致后续卸载旧根时出现 “device busy”;二是当你不确定手里的工具版本具体封装到什么程度时,补一次chroot /是最稳妥的兜底。
3.2 四个必须要满足的约束条件
实践下来,pivot_root对调用环境有比较严格的前提。我总结了四条,几乎所有的报错都能归结到这四条上。
| 约束条件 | 说明 | 违反时的典型报错 |
|---|---|---|
| 调用者必须有 CAP_SYS_ADMIN 权限 | 这是 mount 系列操作的通用权限要求 | Operation not permitted |
| new_root 必须是一个挂载点 | 不是普通目录,必须已经 mount 了某种东西 | Invalid argument |
| 当前进程不能处于 chroot 环境中 | 调用进程的根必须位于当前挂载命名空间的全局根上 | Invalid argument |
| put_old 必须位于 new_root 之内 | 切根后旧根要能够被访问到并卸载 | Invalid argument(或路径不可达) |
第三条是很多人忽略的。如果你先在一个chroot环境里试pivot_root,内核直接返回Invalid argument。原因是pivot_root要求调用进程没有被 chroot 过,它的根目录必须就是当前挂载命名空间的根。调试容器时,我常常先在宿主机上手动复现流程,一旦忘记这层限制,就会被这个报错卡住半天。
3.3 最常见的误操作:new_root 不是独立挂载点
默认情况下,根文件系统挂载在/上面,那/newroot这个目录如果不经过任何mount操作,它只是根文件系统里的一个普通目录,不是一个挂载点。直接对它执行pivot_root /newroot /newroot/oldroot,内核会因为 new_root 不是挂载点而拒绝。
解决办法有两种。如果/newroot要挂载独立的设备(比如硬盘根分区、NFS、tmpfs),直接mount /dev/sda1 /newroot就能让它成为一个挂载点,符合条件。如果只是想拿现有目录做切换实验,就得先 bind mount:
mkdir -p /newroot mount --bind /newroot /newroot这样/newroot自己被绑定成了一个挂载点,再用它做pivot_root才不会再报那个错。我在不少教程里看到有人省略这一步,直接对普通目录做 pivot_root,结果就是一脸懵地对着 “Invalid argument” 发呆。
3.4 pivot_root 命令与系统调用的差异再强调
命令行工具pivot_root的具体行为在不同 util-linux 版本里略有差异,但大体逻辑是:先执行系统调用pivot_root,然后chroot到 new_root,再chdir("/")。所以脚本里写完pivot_root之后,原则上已经可以继续跑后续命令了。
如果直接用 C 或 Rust 等语言调用系统调用,就没有这些自动收尾。以 C 为例:
#include <sys/syscall.h> #include <unistd.h> #include <stdio.h> int main(void) { // 先确保 chdir 到 new_root 内部,或直接用绝对路径 if (syscall(SYS_pivot_root, "/newroot", "/newroot/oldroot") < 0) { perror("pivot_root"); return 1; } chdir("/"); // 此时旧根挂在 /oldroot,可以卸载 // umount2("/oldroot", MNT_DETACH); return 0; }注意系统调用成功后,调用者的 roott 已经被切换到新根,但 cwd 不一定。如果你在调用前chdir到了某个旧根的路径,调用后这个路径可能已经指向新根的不同位置,所以最好在成功后立即chdir("/"),并尽快处理旧根的 umount。
4. 内核视角:pivot_root 在挂载树里究竟翻动了什么
4.1 挂载树与挂载命名空间的基本盘
理解pivot_root,绕不开 Linux 的挂载树模型。整个系统的挂载点不是扁平排列的,而是一棵以全局根为根的树。每个挂载点可以是某个文件系统,也可以叠在另一个挂载点的子目录上。挂载命名空间(mount namespace)则是这棵树的一个独立视图。
每个进程创建时都可以带上CLONE_NEWNS标志,从而拥有自己独立的挂载命名空间。在这个命名空间里,你mount新的设备不会影响其他命名空间,反过来也一样。容器之所以能拥有独立的/tmp、/proc,底层就是靠这个机制。
4.2 内核在 pivot_root 里做了什么
pivot_root系统调用在内核里的实现主要在fs/namespace.c。核心逻辑可以概括成两步:第一步检查各种约束条件,第二步在挂载树里“动手术”。
动手术的过程大致是:把当前根的挂载点从挂载树中摘除,放到put_old指向的位置;再把new_root指向的挂载点提升为挂载树的根。注意这里不是简单改两个指针,内核要处理挂载点的父子关系、传播事件、引用计数等一系列细节。
调用完成之后,进程的fs_struct.root也会被更新为新的根挂载点。这就是为什么pivot_root成功后,当前进程访问/直接就是新根,不需要额外chroot——内核已经顺手做了。但出于兼容性和收尾习惯,用户态代码通常还是会在后面补一个chdir("/"),确保 cwd 不指向一个已经“失效”的路径。
4.3 旧根去哪了:put_old 的真实处境
内核把旧根挂载点放到put_old之后,旧根并没有被卸载,它只是换了个位置继续存在。如果你切根之后不管它,旧根就一直挂在树上,底层设备没法安全释放,umount也会提示 “target is busy”。
正确的做法是尽快卸载旧根。标准姿势是:
umount /oldroot # 或者用延迟卸载方式 umount -l /oldroot在容器场景里,卸载旧根还有一个作用:防止容器内的进程通过put_old路径重新访问宿主机文件系统。如果旧根一直挂在那里,虽然容器里的用户看不见宿主机挂载,但一个有经验的攻击者可能猜到/oldroot路径,直接走这个路径去翻宿主机文件。所以 runc 在pivot_root(".", ".")之后会立刻umount2(".", MNT_DETACH),不给任何残留机会。
4.4 传播类型的影响
挂载传播(mount propagation)是pivot_root最容易踩的隐性坑。Linux 的挂载点可以是 private、shared、slave 或 unbindable 等传播类型。如果new_root所在的挂载点属于 shared 类型的传播组,pivot_root进行挂载树重排时可能受到传播事件干扰,导致调用失败。
容器运行时普遍的做法是先对整个挂载命名空间做一次“私有化”:
mount --make-rprivate /这个操作把所有现有挂载点的传播类型递归设为 private,相当于在命名空间里切断所有跟外部的传播关系。之后再挂载 rootfs、再pivot_root,就不会有传播干扰。我自己手动复现 runc 启动流程时常忘记这条,结果就是同样的代码在有的环境能跑,在有的环境报错,最后查了半天才发现是挂载传播类型的问题。
5. 容器运行时与 initramfs:两个最典型的落地场景
5.1 runc 的 pivot_root(".", ".") 技巧
runc 在启动容器时并不是简单执行pivot_root /rootfs /rootfs/oldroot,而是用了一个非常精妙的变体:先把当前目录切到 rootfs 内部,然后调用pivot_root(".", ".")。
这一步乍看很反直觉,new_root 和 put_old 指向同一个目录,内核怎么处理?实际上内核允许这种重叠用法。调用之后,新根挂载点成为根,旧根挂载点也被挂载到同一个路径上,二者在新根里的同一位置叠在一起。由于新根挂载点在上层,旧根被遮住了。紧接着 runc 执行一条umount把下层旧根摘掉,新根内容就完整显露出来了。
// runc 启动流程中简化后的核心代码思路 unix.Mount(rootfs, rootfs, "", syscall.MS_BIND, "") os.Chdir(rootfs) unix.Mount("", "", "", syscall.MS_PRIVATE|syscall.MS_REC, "") if err := unix.PivotRoot(".", "."); err != nil { // 某些环境下会回退到 chroot return err } unix.Unmount(".", syscall.MNT_DETACH) os.Chdir("/")为什么 runc 要绕这么一圈?直接用两个不同目录不更简单吗?答案是兼容性和简洁性。pivot_root(".", ".")事先不需要知道 rootfs 内部的具体结构,不依赖在 rootfs 里预先创建 oldroot 目录,代码路径更统一。
但自己写类似逻辑时要特别小心:调用pivot_root(".", ".")之前,当前工作目录必须在新根挂载点的根目录;调用后要立刻 unmount 这个“叠在一起的旧根”,否则路径语义会混乱。顺序错了,轻则目录内容残缺,重则直接报错。
5.2 initramfs 里的 switch_root 封装
initramfs 场景下,内核先把自己指定的 initramfs 作为根文件系统挂载起来,然后执行里面的/init。这个 init 进程需要挂载真实的根分区,再完成换根。手工操作就是前面演示的pivot_root流程,但更常见的做法是直接调用 busybox 提供的switch_root工具。
switch_root可以理解成pivot_root的“脚本化封装”:它会检查新旧根、把旧根上的挂载点全部卸载或迁移、再执行真正的 rootfs 里的 init。典型用法:
exec switch_root /newroot /sbin/initswitch_root和直接pivot_root的一个关键区别是:switch_root假定旧根(initramfs)不再需要保留,它会把旧根上残留的挂载点清理干净再切换;而pivot_root只是把旧根挂到 put_old,是否卸载由调用者决定。如果你只想“先切根、后处理”,用pivot_root;如果你确定旧根不需要了,用switch_root一步到位。
5.3 系统救援与迁移场景
除了容器和嵌入式启动,pivot_root在系统救援里也很有用。比如从 U 盘启动一个精简 Linux 环境,挂载硬盘上原有的系统分区到/mnt/system,执行pivot_root切换到那个系统。这样做的意义和 chroot 完全不同:chroot 只能让你在原有救援环境里访问那个系统,而 pivot_root 能让整个救援环境“变成”那个系统,包括所有挂载点关系都会重新映射。
有些系统迁移工具也会用类似思路:挂载新系统的根分区到临时目录,执行换根后在新的环境里继续做内核模块安装、bootloader 配置等操作。相比 chroot 方式,换根之后/proc、/sys等虚拟文件系统的挂载方式更接近真实启动状态,不容易出现 chroot 里“路径都对但行为不对”的诡异问题。
6. “pivot_root: Invalid argument”完整排查链路:我们项目踩过的真实坑
6.1 整个排查过程的起点
去年做一个 ARM 嵌入式项目时,我给目标板定制了 initramfs,启动脚本里写了标准的pivot_root流程。烧录之后串口打印直接报错:
pivot_root: Invalid argument第一反应是查脚本语法,结果发现脚本完全没问题,平台也支持这个系统调用。接着在 shell 里手动执行同样的命令,同样报错。这基本说明问题出在环境条件上,而不是代码本身。
6.2 第一步:确认 new_root 是不是挂载点
我先执行了:
findmnt /newroot没有任何输出,说明/newroot根本不是挂载点。我这才意识到,脚本里虽然执行了mount /dev/mmcblk0p1 /newroot,但真正跑起来时那块分区因为设备驱动还没加载,mount 失败了,而脚本没有检查 mount 返回值,继续往下执行,最终在pivot_root那里暴露了问题。
这给我一个很重要的经验:initramfs 脚本里每条 mount 指令都必须检查返回值,或者至少在执行pivot_root前用findmnt确认 new_root 确实挂上了。嵌入式环境里设备节点出现有延迟,/dev/mmcblk0p1可能在你 mount 时根本不存在,脚本不会自动等待。
6.3 第二步:检查 put_old 的位置
修复了 mount 失败问题后,pivot_root依然报Invalid argument。这次我仔细检查了路径。脚本里写的是:
pivot_root /newroot /newroot/oldroot但我在执行之前只创建了/newroot,忘了在/newroot内部创建oldroot目录。结果put_old指向的路径根本不存在,内核自然拒绝。
这里补一个容易混淆的点:pivot_root的语法中,内核要求的不是一个“挂载点”作为 put_old,而是一个目录。这个目录必须存在,并且最好位于 new_root 内部。我之前理解成 put_old 也要是一个挂载点,走了不少弯路。实际上 put_old 只需要是一个普通目录,内核会把旧根挂载点挂到这个目录上。
6.4 第三步:检查当前进程是否被 chroot 过
解决了 put_old 之后,在 initramfs 的 init 脚本里执行不再报错,但我在另一个测试环境里想手工复现时,依然遇到Invalid argument。这次的问题是我为了调试方便,先在脚本里对某个临时目录执行了chroot /mnt,然后又在这个环境里调用pivot_root。
内核明确禁止在 chroot 状态下执行pivot_root。原因前面也提过:如果进程的根已经不是全局挂载树的根,pivot_root重排挂载树时无法确定该以哪个根为基准。这个限制在源码里是以check_mnt之类的逻辑体现的,调用点一旦处于 chroot 环境,直接返回EINVAL。
调试时怎么确认当前进程没有 chroot 过?可以用/proc/self/mountinfo看根挂载点信息,或者直接看根目录的 inode 编号:
stat -c '%d:%i' /如果这个值和你预期的全局根挂载点不一致,说明进程很可能处于 chroot 环境。
6.5 第四步:挂载传播类型这个隐藏雷区
最后一个雷区是挂载传播。在我们另一个 x86 测试环境的脚本里,同样的流程有时候成功、有时候失败,非常诡异。后来在成功和失败的两种环境里分别执行:
findmnt -o TARGET,PROPAGATION /发现失败环境的根挂载点传播类型是shared,成功环境是private。由于根挂载点是 shared,pivot_root想把它从挂载树里挪到 put_old 位置,相当于在传播组里动一棵子树的根,内核认为这个操作不能安全完成,就返回了EINVAL。
解决办法是在调用pivot_root前把目标挂载点或整个命名空间设为 private:
mount --make-rprivate /这个操作在容器运行时中是标配,但在普通 initramfs 脚本里很容易被忽略。尤其是如果你的 initramfs 是从一个完整系统中复制的,根挂载点可能继承了 shared 传播类型,一到目标机器上就跑挂。加上这一行之后,问题彻底消失。
6.6 常见报错与排查速查表
| 报错信息 | 根因 | 排查方向 |
|---|---|---|
| pivot_root: Invalid argument | new_root 不是挂载点 | findmnt /newroot,必要时 bind mount |
| pivot_root: Invalid argument | put_old 路径不存在或不在 new_root 内 | 检查目录是否存在,确认路径层级 |
| pivot_root: Invalid argument | 当前进程处于 chroot 状态 | stat -c '%d:%i' /对比全局根 |
| pivot_root: Invalid argument | 挂载点传播类型为 shared | findmnt -o PROPAGATION /,执行mount --make-rprivate / |
| pivot_root: Operation not permitted | 缺少 CAP_SYS_ADMIN | 检查是否 root、capsh 查看能力集 |
| pivot_root: Device or resource busy | 有进程的 root/cwd 还停留在旧根 | 切换后尽快chdir("/"),再延迟卸载 |
这套排查思路后来被我写进了公司的容器调试手册。遇到任何跟挂载树相关的诡异问题,先按这个顺序查,基本都能定位。尤其是“new_root 必须是挂载点”和“传播类型”这两条,是最容易被忽视又最常见的两个元凶。
7. 我的个人体会:pivot_root 是理解 Linux 挂载机制的一把钥匙
用了这么多年pivot_root,我的一个很深的体会是:它不只是“换根”的实用工具,更是理解 Linux 挂载命名空间、挂载树和隔离原理的最佳入口之一。搞懂了 pivot_root 为什么要求 new_root 是挂载点、为什么必须处理传播类型、为什么旧根要立刻卸载,你对 Linux 整个挂载体系的认知会上一个台阶。
最后再分享一个小技巧:写 initramfs 或容器启动脚本时,不要直接在pivot_root后面接业务逻辑,一定要在脚本头部加上set -e,并给 mount 指令做返回值检查。我踩过的所有坑,几乎都是因为某一步 mount 失败了但脚本还在继续跑,最后在 pivot_root 这个环节才爆出一个晦涩难懂的报错。提前暴露出问题的真实位置,比事后对着 “Invalid argument” 猜谜要省事得多。