尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

pivot_root 命令详解:从容器隔离到 initramfs 换根实践

pivot_root 命令详解:从容器隔离到 initramfs 换根实践 做容器或者嵌入式系统的人迟早会在启动脚本里撞见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 permittednew_root 必须是一个挂载点不是普通目录必须已经 mount 了某种东西Invalid argument当前进程不能处于 chroot 环境中调用进程的根必须位于当前挂载命名空间的全局根上Invalid argumentput_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 mountmkdir -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。由于根挂载点是 sharedpivot_root想把它从挂载树里挪到 put_old 位置相当于在传播组里动一棵子树的根内核认为这个操作不能安全完成就返回了EINVAL。解决办法是在调用pivot_root前把目标挂载点或整个命名空间设为 privatemount --make-rprivate /这个操作在容器运行时中是标配但在普通 initramfs 脚本里很容易被忽略。尤其是如果你的 initramfs 是从一个完整系统中复制的根挂载点可能继承了 shared 传播类型一到目标机器上就跑挂。加上这一行之后问题彻底消失。6.6 常见报错与排查速查表报错信息根因排查方向pivot_root: Invalid argumentnew_root 不是挂载点findmnt /newroot必要时 bind mountpivot_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” 猜谜要省事得多。
返回列表