如果你经常在 Ubuntu 22.04 上做定时安全更新,大概能想象我当时的心情:apt 升级列表里只有一个不起眼的 systemd 包,结果 dpkg 在解包阶段直接甩出一句 Invalid cross-device link,也就是“无效的跨设备链接”。如果你还遇到过 sudo dpkg --configure -a 反复卡死、waiting for cache lock 这类连锁问题,那这篇文章应该能帮你少走几个小时弯路。这次的根因很典型:系统里存在一处被 bind mount 覆盖的目录,导致 dpkg 在替换 systemd 文件时跨进了另一个文件系统,内核拒绝执行链接/重命名操作。下面是一份完整记录,从报错现场、底层原理、排查链路到最终恢复,全部贴出来,供同类环境的朋友参考。
1. 事故现场:一次常规 apt upgrade,systemd 升级直接让 dpkg 死锁
1.1 当时的升级日志和第一反应
那台机器是 Ubuntu 22.04.3 LTS,内核 5.15,跑着一些常驻服务,平时我也没太折腾它。那个周末例行执行:
bash复制sudo apt update && sudo apt upgrade
更新列表里出现了 systemd、libsystemd0、udev 等几个包,版本是从 249.11-0ubuntu3.11 升到 249.11-0ubuntu3.12。按说这种安全更新很常规,但 dpkg 跑到一半时直接中断,终端留了一段很关键的日志:
text复制Preparing to unpack .../systemd_249.11-0ubuntu3.12_amd64.deb ...
Unpacking systemd (249.11-0ubuntu3.12) over (249.11-0ubuntu3.11) ...
dpkg: error processing archive /var/cache/apt/archives/systemd_249.11-0ubuntu3.12_amd64.deb (--unpack):
unable to make backup link of './usr/lib/systemd/systemd' before installing new version: Invalid cross-device link
Errors were encountered while processing:
systemd
E: Sub-process /usr/bin/dpkg returned an error exit code (1)
第一反应当然是修复 dpkg 状态,于是我执行了:
bash复制sudo dpkg --configure -a
可惜结果还是一样,systemd 包依然在同一位置报同样的错。紧接着再尝试:
bash复制sudo apt --fix-broken install
apt 仍然会去配置 systemd,然后继续死在同一句 Invalid cross-device link 上,循环往复,状态卡死。这时候如果打开另一个终端执行 apt 相关命令,还会碰到 /var/lib/dpkg/lock-frontend 被占用的提示,整个系统的包管理基本瘫痪。
1.2 这条报错为什么让人一头雾水
“无效的跨设备链接”这个错误在 Linux 下其实不难理解,但放在这个场景里就很反直觉:dpkg 正在替换 /usr/lib/systemd/systemd 这个文件,而 /usr/lib/systemd 和 /usr/lib/systemd 下的文件明明看起来都在同一个目录树里,怎么会上来就说“跨设备”呢?
这让我第一反应去怀疑磁盘是不是出了问题,甚至怀疑是不是文件系统损坏导致 inode 异常。后来才意识到,问题根本不出在磁盘硬件,而是出在挂载布局上。要解释清楚这件事,得先搞清楚 dpkg 在安装包时到底对文件做了什么,以及 bind mount 是怎么把正常的目录树变成“伪跨设备”状态的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位根因:bind mount 是如何把 dpkg 的链接操作变成“跨设备”的
2.1 dpkg 安装文件的三个底层动作:解包、链接备份、改名替换
dpkg 在安装一个 deb 包时,并不像很多人想象的那样“直接覆盖文件”。它要保证中途断电、脚本失败时系统还能回滚,所以流程相当谨慎:
- 先把 deb 里的文件解压到临时目录。
- 对最终要替换的旧文件做一个“备份链接”或备份拷贝,确保新版本安装失败时能恢复。
- 把新文件通过 rename 或链接操作放到目标路径,替换旧文件。
这几个动作里,link() 和 rename() 系统调用都有一条硬性限制:如果操作涉及的两个路径不在同一个文件系统挂载点上,内核会直接返回 EXDEV,也就是英文提示 Invalid cross-device link。
拿硬链接举例。硬链接的本质是让两个目录项指向同一个 inode,而 inode 编号只在它所属的那个文件系统内唯一。系统如果要创建硬链接,必须确认源文件和目标文件在同一个挂载点上,否则文件系统之间无法互相引用对方的 inode,内核只能拒绝。这就好比你在一栋楼里可以给同一个房间开两个门牌号,但不能让 1 号楼的门牌号直接指向 2 号楼的房间,因为两栋楼的房间编号体系是互相独立的。
dpkg 要对旧文件做备份链接时,同样默认目标路径和源路径“应该”在同一个文件系统里。正常情况下这个假设没问题,可一旦某个目录被 bind mount 覆盖,假设就不成立了。
2.2 bind mount 制造了一场“目录树里的跨设备骗局”
bind mount 的语法很简单:
bash复制mount --bind /data/foo /var/lib/foo
或者写在 /etc/fstab 里:
text复制/data/foo /var/lib/foo none bind 0 0
它的作用是:把一个目录树“镜像”到另一个挂载点。执行完之后,你访问 /var/lib/foo 时看到的内容,实际上来自 /data/foo 所在的底层文件系统。
问题就在于,bind mount 并不保证两个路径在同一个文件系统上。很多人拿 bind mount 来“挪目录”:根分区空间不够,就把 /var/lib/foo 从根分区迁到另一块大容量磁盘上的 /data/foo,用 bind 挂载过去。这样一来,表面上 /var/lib/foo 还在原来的目录树位置,但它的底层设备已经变成了另一块盘。其他路径访问 /var 时走进的是根分区,访问 /var/lib/foo 时就跨到了另一块盘。目录树看着连续,实际文件系统已经割裂。
dpkg 并不关心目录树的“表面连续性”,它只认内核挂载信息里的设备号。当它尝试在某个被 bind mount 覆盖的目录里创建备份链接、或者把临时文件从 /var/lib/dpkg 区域移动到 /usr/lib/systemd 区域时,内核发现源和目标的设备号不同,直接拒绝,错误信息就是 Invalid cross-device link。
2.3 为什么偏偏是 systemd 这个包容易中招
很多 deb 包里也有跨目录替换文件的需求,为什么这次的导火索偏偏是 systemd?原因有两个。
第一,systemd 包体积大、文件多,安装在 /usr/lib/systemd、/etc/systemd、/lib/systemd 等多个关键路径。Ubuntu 22.04 已经完成了 usrmerge,/lib 是指向 /usr/lib 的符号链接,所以 systemd 的核心二进制实际都在 /usr/lib/systemd 下。只要这个路径恰好是 bind mount 的覆盖点,升级就会在 unpack 阶段炸掉。
第二,systemd 安装过程中会创建大量服务单元的符号链接和硬链接关系,比如各种 .target.wants/ 目录,用来表达“启动时启用哪些服务”。这些操作对文件系统的“同设备”要求更敏感,跨设备时失败的几率远高于普通数据包。
所以当你看到 systemd 升级失败、并且错误涉及跨设备链接时,别急着怀疑 deb 包坏了,先想想系统里有没有可疑的 bind mount,这是排查方向上的关键分岔点。
3. 排查链路:从报错到揪出隐藏 bind mount 的完整过程
3.1 先看挂载表:用 findmnt 找 bind 标记
我的排查从最基本的命令开始。先看当前系统里所有挂载点,重点关注有没有被 bind 过的目录:
bash复制findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS | grep -E 'bind|/usr/lib/systemd|/etc/systemd|/var/lib'
findmnt 比 mount 好用的一点是,它能比较清楚地展示 bind mount 的来源。如果某个目录是通过 bind 方式挂载的,SOURCE 列会出现类似 /dev/sdb2[/lib/systemd] 这样的形式,中括号里的路径表示源设备上的子目录。比如:
text复制TARGET SOURCE FSTYPE OPTIONS
/usr/lib/systemd /dev/sdb2[/lib/systemd] ext4 rw,relatime,bind
这一眼就能看出问题:/usr/lib/systemd 并不是根分区自带的目录,而是把另一块盘 /dev/sdb2 上的 /lib/systemd 子目录 bind 过来的。dpkg 看到 /usr/lib/systemd 的设备号是 sdb2,但 /var/lib/dpkg、/etc/systemd 可能都在根分区上,两边设备号不一致,于是跨设备错误必然发生。
我在这台机器上找到的正是类似场景:它把 /usr/lib/systemd 单独 bind 到了一块独立 SSD 上,本意是想让 systemd 的日志和相关状态文件不占系统盘空间,结果给后续升级埋了雷。
3.2 用 stat 对比设备号:确认跨设备不是错觉
仅凭 findmnt 的输出还不够,我想用一个更底层的证据确认“跨设备”。stat 命令可以打印路径所在设备的设备号:
bash复制stat -c '%d %n' /usr/lib/systemd/systemd /etc/systemd /var/lib/dpkg /usr/bin
输出的数字是文件系统设备号的十进制表示。如果两个路径的设备号不同,说明它们确实不在同一个文件系统上。我当时的输出大概是这样:
text复制64769 /usr/lib/systemd/systemd
64768 /etc/systemd
64768 /var/lib/dpkg
64768 /usr/bin
/usr/lib/systemd/systemd 的设备号是 64769,其他路径都是 64768,两者确实不同。这说明 /usr/lib/systemd 目录树里的文件来自另一个设备,dpkg 对它做备份链接时,会一头连着 64768 上的临时状态,另一头要落在 64769 上,内核当然直接拒绝。
注意,stat 里的 %d 是十六进制比较难读,也可以用 stat -c '%D' 查看十六进制设备号,但十进制数字更方便比较,看两个路径是否相等就够了。
3.3 再查 mountinfo 和启动脚本:搞清楚 bind mount 是谁加上的
确认了有 bind mount 后,下一步是找到它从哪里来。光知道现象还不够,如果只是临时 umount 掉,重启后很可能会被 /etc/fstab 里的配置重新挂上来,问题复发。
先看 /etc/fstab:
bash复制grep -E 'bind|systemd|usr/lib' /etc/fstab
有 bind 配置的话,fstab 的第五、第六字段通常是这样:
text复制/dev/sdb2 /usr/lib/systemd none bind 0 0
另一个可能来源是启动脚本或者 systemd mount 单元。排查时用:
bash复制systemctl list-units --type=mount --all | grep systemd
如果存在对应的 .mount 单元,例如 usr-lib-systemd.mount,用 systemctl status usr-lib-systemd.mount 查看详细信息,里面会列出挂载参数和来源设备。
还有一个隐蔽来源,是某些临时脚本直接执行了 mount --bind 而没有写入 fstab,这需要看当前进程里有没有相关脚本。可以用:
bash复制ps aux | grep -i 'mount\|bind'
如果是在容器或 WSL 环境里,检查宿主机的挂载表可能不够,还要用 nsenter 进入对应 mount namespace 才能确认,不过在纯 Ubuntu 22.04 物理机/虚拟机上,检查 fstab 和 mount 单元基本就够了。
4. 对症下药:安全卸载 bind mount 并恢复 dpkg
4.1 卸载 bind mount 前先确认有没有进程占用
找到元凶是 /usr/lib/systemd 被 bind mount 到 /dev/sdb2 之后,修复思路很直接:先把这层挂载卸掉,让该目录恢复到和系统根分区同设备的原始状态,再重试 dpkg。
但在 umount 之前,我得确认有没有正在运行的进程占用了这个目录或目录里的文件,否则卸载会出现 target is busy 的报错。检查命令:
bash复制lsof +f -- /usr/lib/systemd
fuser -m /usr/lib/systemd
如果一切正常没有占用,直接卸载:
bash复制sudo umount /usr/lib/systemd
这里有个关键点:bind mount 卸掉后,/usr/lib/systemd 目录会回到它原来的样子,也就是根分区上那个真实存在的目录,而不是直接变成空目录。bind mount 和普通磁盘挂载不一样,它并不改变目录的原始内容。所以不要担心卸载会把目录“删空”,这跟卸载 /data 那种独立分区是两回事。
如果系统提示目录被占用但你又确认这些进程不影响卸载,可以用懒卸载:
bash复制sudo umount -l /usr/lib/systemd
懒卸载会先把挂载点从目录树里摘除,等占用进程结束再清理底层资源。不过在 systemd 这种核心路径上,我建议能正常卸载就正常卸载,不要急着用 -l,因为懒卸载期间目录状态比较微妙,可能引发别的问题。
4.2 注释掉 fstab 里的 bind 配置,防止重启后复发
对这台机器来说,光卸载还不够。它的 bind mount 写在 /etc/fstab 里,所以我在确认卸载成功后的第一件事,就是注释掉对应行,避免重启后系统又把 sdb2 挂上来:
bash复制sudo cp /etc/fstab /etc/fstab.bak
sudo sed -i 's|^/dev/sdb2 /usr/lib/systemd none bind|# /dev/sdb2 /usr/lib/systemd none bind|' /etc/fstab
如果你不确定 sed 改得对不对,可以直接用编辑器打开 fstab,手动把那一行行首加上 #。然后执行:
bash复制sudo mount -a
这时系统会按新 fstab 重新检查挂载,确认没有报错即可。如果业务上确实需要那块独立 SSD 上的目录,正确的做法是换一个不影响系统包管理的关键路径挂载,或者干脆用普通挂载而不是 bind,这块内容后面复盘再细说。
4.3 重新执行 dpkg 配置,让 systemd 升级流程走完
挂载关系恢复正常后,重新回到包管理修复流程。这次顺序很重要:先让 dpkg 把之前中断的包配置完,再让 apt 把所有依赖理顺。
bash复制sudo dpkg --configure -a
如果一切正常,应该能看到 systemd 配置阶段的输出顺利通过,不再报 Invalid cross-device link。我当时的终端里出现了类似这样的过程:
text复制Setting up systemd (249.11-0ubuntu3.12) ...
Setting up udev (249.11-0ubuntu3.12) ...
Processing triggers for man-db (2.10.2-1) ...
Processing triggers for dbus (1.12.20-2ubuntu2.2) ...
等 dpkg 全部配置完成,再用 apt 补一轮依赖修复,确保没有其他包卡在更新队列里:
bash复制sudo apt --fix-broken install
sudo apt upgrade
这次 apt upgrade 应该能顺利跑完,不再中断。
4.4 升级后的完整性核验
修复不是看到 dpkg 不报错就算完,systemd 是系统核心服务,我还做了几项验证,确保升级后的 systemd 真的能正常工作:
bash复制dpkg -l systemd
systemd --version
systemctl is-system-running
dpkg -l systemd 的状态列应该是 ii,也就是 installed。systemd --version 应该显示 249.11-0ubuntu3.12,和升级目标一致。systemctl is-system-running 如果输出 running 或 degraded 都还好,degraded 说明有某个单元失败,需要单独排查;但如果输出 offline 或 failed,说明 systemd 自己都没起来,问题就大了,得先看 journalctl。
最后还检查了这次升级涉及的具体文件路径是否已经回到同一个设备:
bash复制stat -c '%d %n' /usr/lib/systemd/systemd /etc/systemd /var/lib/dpkg
三个路径的设备号已经完全一致,说明跨设备隐患已经根除。
5. 复盘:这种“跨设备链接”还能埋伏在哪些场景里
5.1 常见的会制造 bind mount 的操作
这次排错之后,我把 bind mount 的常见来源重新梳理了一遍,发现它其实比很多人以为的更普遍,尤其是在这些场景下:
- 系统盘空间不足时,有人会把
/var/lib/postgresql、/var/lib/docker、/home等目录 bind 到独立大容量盘,这类操作通常由第三方迁移工具生成,用户不一定记得。 - 日志系统改造时,为了把 journald 日志单独放一块盘,会有人这么写:
text复制/data/journal /var/log/journal none bind 0 0
单独看这个配置没有致命问题,因为 journald 升级时直接在目录内写文件,跨设备影响相对小。但如果哪天系统盘上这个路径还涉及 dpkg 处理,情况就复杂了。
- 安全加固软件或“一键优化工具”可能对
/usr、/etc、/var下的敏感子目录做只读 bind mount,初衷是防止恶意篡改。这种加固在平时也许有效,但系统升级时往往会成为 dpkg 的绊脚石。 - 容器环境里,宿主机的目录通过 bind mount 进容器,而容器的根文件系统如果又是 overlayfs,那么在容器里执行 apt upgrade 时,文件可能实际分布在多个层上,也会出现类似 EXDEV 错误。
所以不要觉得 bind mount 很少见,恰恰是那些“为了优化、为了安全”做的小改动,最容易在半年后的一次安全更新里突然爆发。
5.2 如果这个 bind mount 必须保留,应该怎么改
有些目录 bind mount 是有真实业务价值的,比如把大容量的存储目录 bind 到 Web 服务目录,或者把独立日志盘 bind 到 journald 路径。遇到这种情况,系统升级时也不可能真的把业务挂载永久删掉,那要怎么处理才安全?
我的建议是把 bind mount 换成“独立挂载点”方案,而不是 bind 挂载。举个例子,如果你想把 journald 日志放到独立大容量盘 /dev/sdb2,不要写成:
text复制/data/journal /var/log/journal none bind 0 0
而是直接把磁盘挂载到目标路径:
text复制/dev/sdb2 /var/log/journal ext4 defaults 0 2
这样 /var/log/journal 本身就是独立挂载点,内核的挂载边界非常清晰,dpkg 在操作其他路径时不会误以为“同一棵目录树却属于不同文件系统”。如果你确实没办法改变业务逻辑、只能 bind,那就必须在系统升级前手动 umount,升级完成后再按 fstab 挂回去,并把这种操作写进维护手册,别指望每次都记得。
5.3 两个顺带提醒:dpkg 卡死与 cache lock 的连锁反应
这次排错中还有两个现象值得单独提一下。一是 sudo dpkg --configure -a 会卡死。这不是 dpkg 自身卡住,而是它反复处理 systemd 包,每次都在 postinst 阶段失败退出,但 dpkg 又认为自己没配置完,于是不断重试。看起来就是终端长时间没动静,其实进程还活着。
二是另一个终端的 apt 命令会提示:
text复制waiting for cache lock: could not get lock /var/lib/dpkg/lock-frontend
这是 dpkg 实例还在前台跑,锁没释放导致的。遇到这种情况不要急着 kill -9 删锁文件,先确认前台 dpkg 进程状态:
bash复制ps aux | grep -E 'dpkg|apt'
确认没有其他 apt/dpkg 进程在跑之后,如果锁文件确实残留,才考虑手动清理,否则很可能把正在进行的写操作打断,造成更严重的包状态损坏。在这次的环境
