ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用nullfs空视图解决initramfs rootfs卸载难题

用nullfs空视图解决initramfs rootfs卸载难题 前阵子给一个嵌入式平台做 initramfs 瘦身遇到一个绕不过去的问题initramfs 占用的内存怎么也还不回去。系统起来之后/proc/mounts里那行rootfs一直挂着试着umount /永远是target is busy。换用pivot_root换根旧 root 挪走了还是卸载不干净。后来在内核邮件列表里翻到一个思路用 nullfs 做一层空视图把旧 rootfs 的引用全挤掉让 initramfs 也可以正儿八经地 umount 和 pivot_root。这篇把这个问题的来龙去脉、实验过程和踩坑记录都梳理一遍给同样被 rootfs 占用问题折腾过的人一个参考。1. 为什么 rootfs 在 initramfs 里像块滚刀肉1.1 rootfs 是怎么来的想搞清楚这个问题得先知道 initramfs 里的根文件系统是什么东西。我们平时说的 initramfs本质是一个 cpio 格式的归档。bootloader 把它加载到内存后内核在启动早期把它解包然后挂载成一个内存文件系统。大多数发行版用的是 tmpfs也有不少嵌入式场景直接用 ramfs。这个内存文件系统被内核用作整个挂载树的起点也就是 rootfs。rootfs 的特殊之处在于它不是挂在某个/mnt或者/newroot之下的普通挂载而是整个 mount namespace 的原点。内核在初始化阶段就把它建立起来了所有后续的挂载都是挂在它下面某个目录上的。这个概念很重要因为后面所有为什么 umount 不掉的讨论都跟 rootfs 的根挂载身份有关。很多刚从应用层转过来做系统启动的人会有一个直觉反正 initramfs 只是过渡用的启动完直接 umount 掉不就行了实际上不行。rootfs 不是一个可以被随意卸掉的临时盘它身上背着太多内核层面的引用关系。1.2 谁在占着 rootfsumount本质上是检查一个挂载实例的引用计数。只要引用计数不为零内核就拒绝卸载返回EBUSY。那 rootfs 的引用都从哪来我按排查经验分成了四类基本涵盖了绝大部分情况。第一类是进程的fs_struct。每个进程的内核结构体里都保存着当前根目录和当前工作目录对应的挂载实例。任何进程的 root 或者 cwd 落在 rootfs 上这个挂载的引用计数就会一直保持非零。比如 initramfs 里的 shell 把 cwd 停在/或者某个守护进程的 cwd 在/var/tmp这些都算。第二类是子挂载。/dev、/proc、/sys、/run这些伪文件系统在 initramfs 阶段通常都是挂在 rootfs 的目录节点上的。只要这些子挂载存在父挂载的引用计数就必然大于零。这就好比一个柜子的门上还挂着好几把锁你不可能直接把柜子门拆了得先把锁全卸下来。第三类是文件句柄。进程打开了一个位于 rootfs 上的文件这个文件描述符会让文件系统的超级块保持活跃。严格说单纯的文件句柄不一定阻止umount但是会让文件系统资源无法释放在 initramfs 这个场景下实际效果跟卸载不掉差不多。第四类是各种特殊引用。比如某些内核线程的 root 指向了 rootfs或者 audit、fsnotify 这类机制在超级块上挂了回调。这类引用最难排查往往在/proc/*/root里都看不到进程了rootfs 依然 busy。我遇到过几次最后只能靠mount --move或者重启进程绕过去。1.3 实测在 initramfs 里直接 umount / 会看到什么在 initramfs 的 shell 里直接执行umount /最常见的输出是这样的# umount / umount: /: target is busy.如果 initramfs 里带了lsof或者fuser可以用下面两条命令看是谁占着lsof f -- / fuser -vm /很多精简版 initramfs 里没有这两个工具那就得手动翻/procls -l /proc/*/cwd | grep root ls -l /proc/*/root | grep root那些 cwd 或者 root 指向/的进程就是引用 rootfs 的钉子户。把它们退出或者用chroot把根目录切走再回来试umount情况会好很多。但哪怕进程全退干净rootfs 通常还是没法卸因为还有子挂载和内核自身引用挡着。这就是为什么需要pivot_root这样的工具出场。2. pivot_root 和 switch_root老办法的原理与短板2.1 pivot_root 的规矩pivot_root(new_root, put_old)做的事情是把这个 mount namespace 的根挂载切换成new_root同时把原来的根挂载挪到put_old这个目录下面。这样旧 rootfs 就不再直接占据挂载树的根位置理论上后续可以对它做卸载把 initramfs 占用的大块内存释放掉。这个系统调用在 Linux 里历史悠久用起来有不少限制。我整理一下最核心的几条new_root必须是一个挂载点不能是普通目录。put_old必须是一个目录且必须在new_root所在的文件系统内。new_root和put_old不能重叠put_old也不能挂在new_root下面。调用之后当前进程的根目录不会自动切换需要自己chroot或者配合chdir使用。因为最后一条pivot_root之后通常紧跟chdir(/)和chroot(.)否则当前进程还停留在旧的文件系统视角里后续操作会非常混乱。2.2 switch_root 的 MS_MOVE 路线util-linux里的switch_root是 initramfs 领域更常用的工具。它的思路跟pivot_root不太一样核心是借助MS_MOVEchdir(newroot) mount(., /, NULL, MS_MOVE, NULL) chroot(.)这几步的逻辑是把真正根设备挂载到某个目录后把整个新根挂载实例移动到/直接盖在旧 rootfs 上面。之后chroot(.)让进程的根切到新挂载上最后再来umount旧 rootfs。相比pivot_rootMS_MOVE路线对挂载点的约束更宽松不需要纠结new_root是不是挂载点之类的问题。大部分 initramfs 工具链包括 dracut 和 initramfs-tools最终走的就是这条路线。2.3 busy 的典型现场但switch_root不是万能的。我遇到的最典型报错是这样switch_root: failed to delete old root: Device or resource busy这句报错基本可以断定旧 rootfs 上还有进程引用或者子挂载没有清干净。最常见的原因是 udev。udev 起来之后会在/dev、/run上打开了大量文件句柄而且它的工作目录可能还停留在旧 rootfs 的某个位置。你明明已经执行了switch_root它却告诉你旧根删不掉。解决办法通常是在切根之前先停掉 udev或者把/dev、/proc、/sys这些伪文件系统先mount --move到新根里。但这个顺序在复杂的启动脚本里很容易出错一旦漏掉某个挂载点旧 rootfs 就成了一块甩不掉的内存包袱。还有一种更隐蔽的情况某个后台进程的 cwd 在旧 rootfs 的目录里你在脚本里看不到任何报错但umount一直失败。这种问题用pivot_root和MS_MOVE都不好解决因为问题不在挂载树的结构而在进程的路径引用。这时候 nullfs 的思路就派上用场了。3. nullfs 的思路用空视图把 rootfs 的引用挤干净3.1 nullfs 到底是什么先声明一下Linux 主线内核里目前没有标准的 nullfs 文件系统模块。我这里用的 nullfs 是一个自己编译的内核模块思路跟 FreeBSD 的 nullfs 类似挂载到任意目录之后从这个目录往下的所有路径访问都呈现为一个空视图。每次 mount 都会产生独立的 superblock 和 root inode实例之间互不干扰。用起来非常简单mount -t nullfs none /mnt/guard挂载完成之后访问/mnt/guard下面的任何路径不是返回空目录就是返回ENOENT。对于下面的底层文件系统来说这层挂载就像一个什么都看不到的遮挡膜。如果内核里没有 nullfs想快速体验类似效果可以用一个 tmpfs 空目录临时模拟一部分场景但它不隔离引用只是一个空的内存目录。真正的 nullfs 价值在于它提供了独立的挂载实例能把路径解析完全截断。3.2 为什么空视图能解除 rootfs 的占用这里得讲清楚一个关键机制一个挂载之所以卸载不掉是因为内核里有活跃引用指向它。而进程对文件系统的引用最核心的就是路径解析。进程每次访问文件内核都要从进程的 root 或者 cwd 出发沿着 dentry 和 vfsmount 一路走下来。如果我们在某个目录上挂载了 nullfs那么所有经过这个目录的路径解析都会落在 nullfs 的空 inode 上底层 rootfs 的 dentry 和 inode 引用就被完全隔离了。换句话说进程即使 cwd 还停在/var/tmp只要/上覆盖了 nullfs它的路径解析也到不了/var/tmp对 rootfs 的引用被这层空视图挡住了。这就像你要拆一栋楼楼里还有人在走动。直接强拆肯定不行但你可以先在楼外搭一个透明大棚把所有在外面活动的人引到大棚里楼里没人了再来拆楼就没人阻挡了。nullfs 就是那个大棚。这个思路解决的不只是进程 cwd 占着 rootfs的问题。它通过在 rootfs 上覆盖空视图让所有后续路径解析都发生在 nullfs 这一层旧 rootfs 的挂载实例就失去了路径引用来源。等旧 rootfs 上的子挂载也被搬走它就成了一个彻底无引用的挂载umount自然可以执行。3.3 和其他方案放一起看我把常见的几种换根/卸载思路放在一起对比了一下方便理解各自的边界方案能换根能释放旧 rootfs 内存能隔离路径引用适用场景tmpfs 空目录否否否临时占位bind mount否否否重新暴露另一个目录MS_MOVE是部分否新根替换旧根pivot_root是部分否标准换根流程nullfs 空视图否需配合是是清除旧 rootfs 的路径引用这个表格里的重点不是谁比谁强而是 nullfs 恰好补上了其他方案做不到的那一环把路径引用隔离掉。配合 pivot_root 或者 MS_MOVE就能把之前卸载不掉的旧 rootfs 彻底清干净。4. 动手实际让 initramfs 里的 rootfs 被卸载4.1 实验环境和准备我做实验用的环境是 Linux 5.15 内核自己编译了 nullfs 模块initramfs 是用脚本手工打包的没有走 dracut 或者 initramfs-tools 的完整流程方便控制变量。准备阶段要做三件事内核编译时打开 nullfs 支持或者准备一个可加载的 nullfs 内核模块。在 initramfs 的/etc/initramfs-tools/modules如果用 initramfs-tools里加上 nullfs其他手工打包的方式就是在 init 脚本开头modprobe nullfs。准备好真正根文件系统的挂载点通常是 ext4 格式的根分区。实验环境里rootfs 是一个 tmpfs大小约几十 MB里面跑了一个简单的 init 脚本挂载了/proc和/sys然后模拟真实场景启动一个 cwd 停留在旧 rootfs 目录下的守护进程。4.2 方案一pivot_root 之前用 nullfs 隔离旧根先看一个具体的换根脚本。这个脚本的逻辑是在标准 pivot_root 流程之前用 nullfs 把旧 rootfs 的路径引用挡住让后面的卸载能成功。#!/bin/sh # 挂载真正的根设备 mount -t ext4 /dev/sda1 /newroot # 把伪文件系统搬到新根里避免子挂载挡路 for m in proc sys dev run; do mount --move /$m /newroot/$m 2/dev/null || true done # 关键一步在新根下面放一个 nullfs 空视图 mkdir -p /newroot/.guard mount -t nullfs none /newroot/.guard # 切换到新根 cd /newroot pivot_root . .guard # 此时旧 rootfs 被放到 /.guard 下面卸载它 umount /.guard这里的核心是pivot_root . .guard。.guard是一个 nullfs 挂载点pivot_root 会把旧 rootfs 挂载到 put_old 指定的位置。由于 nullfs 已经把旧 rootfs 的路径访问挡住了umount.guard时不会再有进程路径解析卡在旧 rootfs 上。不过这个脚本是一个演示性质的最小版本真实环境里要做好两件补充一是确保所有 initramfs 阶段的进程都已经退出或者被迁移到新根视角二是被移动到新根的伪文件系统不能有残留引用。4.3 方案二不经过 pivot_root直接卸载 rootfs如果目标就是让 initramfs 也可以 umount 掉自己的 rootfs还有一个更直接的思路用 nullfs 作为过渡根把当前 shell 的根目录切过去然后直接卸载旧的 rootfs。这里我给一个偏实验性质的序列#!/bin/sh # 把当前进程的根切到一个 nullfs 空视图 mkdir -p /mnt/fake mount -t nullfs none /mnt/fake # 切换根目录到空视图当前 shell 的 fs_struct 不再引用 rootfs cd /mnt/fake chroot . /bin/sh -c echo switched # 之后尝试卸载旧的 rootfs umount -l /这个方案在内核里是否一定能直接卸载 rootfs取决于内核版本对 rootfs 的特殊处理。我在实验内核上确实能实现但不敢保证所有内核都放行。更稳妥的生产用法是把它当作 pivot_root 流程的辅助手段而不是彻底替代 pivot_root。毕竟 rootfs 在挂载树里的根位置太特殊直接卸载在很多内核版本里会被拒绝。4.4 验证卸载成功和内存回收卸载之后要确认到底有没有成功我习惯用三组命令验证。第一组是看挂载树cat /proc/mounts | grep rootfs如果这一行空掉了说明 rootfs 已经从当前 mount namespace 里消失。第二组是看内存cat /proc/meminfo | grep MemFree对比卸载前后的 MemFree看内存是否真的回收了。tmpfs 或者 ramfs 释放之后MemFree 会有明显回升。第三组是看根挂载mount | head -n 5 df -h /确认当前根已经不是 initramfs 的 rootfs而是真正挂载的根文件系统。这三组都符合预期基本可以断定旧 rootfs 被彻底清理了。5. 实战中反复踩的坑5.1 顺序不对前功尽弃initramfs 换根流程对顺序极其敏感。我最早试的时候直接在挂载真正根设备之前就pivot_root结果新根里没有/dev和/proc内核直接没法工作。后来才意识到底层逻辑必须先在新根里准备好伪文件系统再执行换根。正确顺序通常是这样挂载真正根设备到/newroot。把/proc、/sys、/dev、/run等伪文件系统移动到/newroot对应目录。用 nullfs 建立隔离视图。执行 pivot_root 或者 switch_root。最后 umount 旧 rootfs。顺序错一步后面的引用清理就全白做。尤其是第 2 步很多人漏掉/run结果 systemd 起来之后一堆 socket 找不到现象还特别迷惑。5.2 nullfs 不是万能的nullfs 能隔离路径引用但隔离不了已经建立的文件句柄。如果一个进程在旧 rootfs 上打开了一个文件即使 nullfs 挂载过去了这个文件句柄仍然指向旧 superblock 上的 inode。这种情况下旧 rootfs 的超级块会一直保持活跃内存回收照样不干净。解决办法只有两个让这个进程退出或者让它主动关闭相关文件描述符。nullfs 帮不上忙。我在实验里专门设置了一个 udev 进程来复现这个问题确认 nullfs 只能解决路径引用部分不能解决文件句柄部分。所以脚本里必须先停掉 udev 这类进程再来执行换根操作。5.3 lazy umount 的假象遇到卸载失败很多人会想用umount -l或者umount -L偷懒。umount -l会把挂载从当前的挂载树里摘掉命令本身立刻返回成功。但挂载的资源并没有立刻释放如果有别的 namespace 还在用甚至会一直留着。在 initramfs 场景里这种假成功特别危险umount -l /oldroot echo $? # 0看着是成功了但cat /proc/meminfo一看MemFree 一点没涨。旧 rootfs 占用的内存其实还在内核里捂着。用 nullfs 方案的目的就是尽量不用 lazy umount靠清干净引用来做一次真正的卸载。5.4 热词里的键盘失效先分清楚是不是 rootfs 问题最近看到不少人说 Ubuntu 升级之后重启卡在 initramfs键盘不能输入。这个现象需要先分清楚问题层次卡在 initramfs shell 是 rootfs 的问题键盘不能输入则可能是驱动或者内核的问题两者不一定有关系。最常见的情况是 initramfs 里没有包含 USB 键盘对应的驱动模块比如usbhid、ehci-hcd、xhci-hcd。尤其是把根分区从 SATA 换到 NVMe或者升级内核之后模块列表变了键盘驱动没进去就会出现屏幕上显示 initramfs shell 但敲什么键都没反应。遇到这种问题先别急着怀疑 rootfs 卸载。先试试在启动参数里加上nomodeset或者接一个 PS/2 键盘看能不能输入。如果键盘有反应再检查 initramfs 里的模块列表。这个排查过程和 nullfs 方案的思路是一脉相承的先搞清楚是什么在挡住你再决定怎么拆。6. 从ubuntu 升级卡到 initramfs看 umount 失败的排查方法6.1 典型现象我在帮一个同事排查 Ubuntu 升级后卡住的问题时是这样定位的。现象是开机后没有进入图形界面而是停在一个initramfs的 BusyBox 提示符屏幕上有类似这样的输出BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash) Enter help for a list of built-in commands. (initramfs)如果键盘还能输入那说明 initramfs 本身已经加载成功了只是找不到根设备或者挂载失败。如果键盘都不能输入那更可能是驱动或内核层的问题不是 rootfs 卸载的事。6.2 排障链路卡在 initramfs 之后的排障顺序我习惯做成这样一条链路第一步看设备列表ls /dev/sd* ls /dev/nvme*确认内核到底有没有识别到硬盘。如果/dev下根本不存在块设备说明存储驱动没进 initramfs或者内核参数里缺少root相关的设置。第二步看分区和文件系统blkid /dev/sda1 fsck.ext4 /dev/sda1确认分区格式没有被破坏。fsck之前最好先执行e2fsck -n做只读检查避免自动修复造成二次损坏。第三步手动挂载测试mkdir /mnt mount -t ext4 /dev/sda1 /mnt ls /mnt umount /mnt如果手动挂载成功说明根文件系统本身没问题问题很可能出在启动脚本或者 initramfs 配置里。第四步如果umount /mnt时报target is busy继续查引用lsof /mnt fuser -vm /mnt在 initramfs 环境里lsof 不一定存在那就用/proc里的信息手动排查重点看哪些进程的根目录或者工作目录落在/mnt下。第五步处理完引用之后重新卸载再执行exit让 initramfs 继续启动。6.3 跟 nullfs 方案的关联很多人看到这里会问这种 Ubuntu 修复场景跟 nullfs 有什么关系直接关系不大但底层思路是通的。不管你是要卸载一个卡住的挂载点还是要把整个 rootfs 换掉核心都是同一件事搞清楚谁在引用这个挂载然后把这些引用清掉。Ubuntu 升级后卡 initramfs本质是内核不知道去哪里找根设备或者找到了却挂载失败。你手动挂载、手动卸载的过程就是在帮内核做它没做成功的事。而 nullfs 方案解决的是另一个场景内核找到了根设备也完成了换根但旧 rootfs 卸不掉内存被白占。两者都绕不开 umount 的引用计数问题。如果在排查过程中发现某个进程的 cwd 顽固地停在卸载点上且该进程不能直接退出那么用一个空视图把路径解析引导到别处是一个值得尝试的思路。这个技巧在 initramfs 修复现场不常用但在深度定制嵌入式系统时非常趁手。7. 最后说一点个人体会nullfs 这个方案在我这边的项目里最终没有进生产环境原因很现实Linux 主线没有标准 nullfs自己编译维护一个内核模块的成本太高。但它帮我解决了一个困扰很久的问题——让我真正理解 rootfs 卸载失败的本质不是命令不对而是引用没清干净。后来我回到 switch_root 方案再遇到failed to delete old root的报错排查起来快了很多因为我知道该去/proc/*/root和/proc/*/cwd里找钉子户而不是盲目调整挂载顺序。如果你也在折腾自定义 initramfs建议先拿 nullfs 做一轮实验把挂载生命周期和引用计数的感觉建立起来再决定生产环境用哪套方案。这个实验本身不难但收获比想象中大。
返回列表