☰
pivot_root 命令详解:从容器隔离到 initramfs 换根实践
2026/9/28 14:02:48 网站建设 项目流程

做容器或者嵌入式系统的人,迟早会在启动脚本里撞见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/init

switch_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 argumentnew_root 不是挂载点findmnt /newroot,必要时 bind mount
pivot_root: Invalid argumentput_old 路径不存在或不在 new_root 内检查目录是否存在,确认路径层级
pivot_root: Invalid argument当前进程处于 chroot 状态stat -c '%d:%i' /对比全局根
pivot_root: Invalid argument挂载点传播类型为 sharedfindmnt -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” 猜谜要省事得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询