1. 事故现场:一次再平常不过的 apt upgrade,systemd 升级却挂了
1.1 故障现象与报错信息
Ubuntu 22.04 服务器,跑着几个容器服务,平时一直老老实实做安全更新。那天照例执行 sudo apt update && sudo apt upgrade,大部分软件包顺利装完,轮到 systemd 的时候,终端突然刷出一段红色报错:
code复制Preparing to unpack .../systemd_249.11-0ubuntu3.13_amd64.deb ...
Unpacking systemd (249.11-0ubuntu3.13) over (249.11-0ubuntu3.12) ...
dpkg: error processing archive /var/cache/apt/archives/systemd_249.11-0ubuntu3.13_amd64.deb (--unpack):
unable to rename '/usr/lib/systemd/system/rc-local.service.dpkg-tmp': Invalid cross-device link
Errors were encountered while processing:
/var/cache/apt/archives/systemd_249.11-0ubuntu3.13_amd64.deb
E: Sub-process /usr/bin/dpkg returned an error code (1)
关键词就是 Invalid cross-device link,中文环境通常显示为“无效的跨设备链接”。这个错误在很多刚接触 Linux 的人眼里属于“天书报错”,实际上它对应的系统调用是 rename(2) 返回的 EXDEV,errno 为 18。内核的语义很明确:你试图把一个文件从一个文件系统重命名到另一个文件系统,这个动作在内核层面不被允许。
当时我第一反应是磁盘满了。df -h 看了一眼,根分区剩余空间充足,/run、/tmp 也正常。接着怀疑是 systemd 包本身损坏,于是重新执行 apt install -f、apt download systemd 再手动 dpkg -i,结果依旧在原地打转。此时系统已经处于半配置状态,systemd 的版本信息停留在旧版本,但 dpkg 数据库里已经记录了“正在解包新版本”的中间状态。
1.2 为什么偏偏是 systemd 中枪
很多包升级失败,重试一次就能过,但 systemd 不一样。它不是一个“装个二进制就完事”的普通软件包,安装过程中要处理大量 /usr/lib/systemd/system 下的 unit 文件、/etc/systemd 下的符号链接、/run 下的运行时目录,postinst 脚本里还会调用 systemctl daemon-reload、udevadm control --reload-rules 等命令。任何一个环节对文件系统边界的要求比较苛刻,就容易在这次升级里暴露潜在问题。
dpkg 为了保证安装原子性,不会直接覆盖目标文件。它先把 .deb 里的文件解包成带 .dpkg-tmp 后缀的临时文件,放在目标目录下,写完后通过 rename() 改成正式文件名。这个设计在正常情况下没有任何问题,因为临时文件和正式文件位于同一个目录,必然在同一个文件系统内。但如果文件列表中存在某些需要先移动旧文件到备份目录、或者需要跨目录更新链接的场景,临时文件和目标路径就可能落到不同的文件系统实例上,内核便直接拒绝。
这次报错指向的是 /usr/lib/systemd/system/rc-local.service,这个文件本身并不特殊,特殊的是机器上存在跨设备边界。换句话说,不是 systemd 包“坏了”,而是这台机器的文件系统布局让 dpkg 的基础工作根本无法完成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因剖析:bind mount 与跨设备链接的“孽缘”
2.1 rename() 和 EXDEV:内核到底在拒绝什么
要理解这个报错,先要理解 Linux 文件系统的基本挂载模型。每个文件系统实例都有自己独立的 inode 表,目录项只是 inode 的“入口”。rename() 本质上是修改目录项、更新 inode 引用计数,整个过程在同一个文件系统实例内部完成,不涉及数据拷贝。一旦源路径和目标路径分属两个不同的文件系统,内核无法通过修改目录项来完成“移动”,直接返回 EXDEV。
你可以想象搬家公司的场景:同一栋楼内从 301 搬到 302,物业只需要改一下门牌;但如果要从 A 楼搬到隔壁 B 楼,就必须把家具先搬下楼、运过去、再搬上楼。如果两栋楼之间没有通道,搬家工人只能拒单。EXDEV 就是内核在说“跨楼了,搬不了”。
dpkg 之所以不使用“复制文件内容 + 删除旧文件”的方式,是为了避免中断风险。复制过程中如果断电,目标文件可能处于半写状态;而 rename() 是原子操作,要么成功要么失败,不会留下不完整的文件。这是包管理器的正确设计,但反过来也意味着它对文件系统边界非常敏感。
2.2 bind mount:让同一个路径跨设备
bind mount 是 Linux 里极其强大的功能:它能把一个目录树“镜像”到另一个挂载点。比如把宿主机 /home/user/code 挂载到容器里的 /workspace,或者把数据盘挂到某个子目录。它和普通挂载的区别是,bind mount 不一定要挂载整个块设备,直接把某个目录“复制一份映射”到别处。
问题在于,bind mount 会改变一个路径对应的文件系统实例。举个例子,原本 /usr/local 只是根分区下的普通目录,你为了放自编译软件,在 /etc/fstab 里加了一行:
code复制/dev/sdb1 /usr/local ext4 defaults 0 0
挂载后,/usr/local 就整体变成了 /dev/sdb1 上的独立文件系统。表面上路径仍然以 /usr 开头,看起来和 /usr/bin、/usr/lib 是一家人,实际上它们已经属于两个完全不同的文件系统实例。只要 dpkg 需要在 /usr/local 和 /usr/lib 之间做任何移动操作,内核就会毫不客气地返回 EXDEV。
排查 bind mount 最直接的方法是看挂载表:
bash复制mount | grep bind
findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS | grep -E "(/usr|/var|/run)"
正常情况下应该能看到所有挂载点和对应源设备,重点观察关键路径是否被独立挂载。
2.3 我这次的情况:一条 fstab 引发的血案
复盘我这台机器,问题出在大概两个月前。当时为了把 Python 自编译环境和手工安装的工具链放到数据盘上,我把 /usr/local 配置成了一个独立的 ext4 挂载点。安装时没有异常,日常跑服务也没有异常,直到系统安全更新把 systemd 纳入升级列表。
升级过程中,dpkg 需要处理 systemd 包里的几百个 unit 文件。当它解包到某个依赖符号链接或旧文件备份的文件时,临时目录在 /usr/lib/systemd(根分区),而某些辅助操作需要进入 /usr/local(数据盘),这个“跨楼”瞬间触发 EXDEV。
用 df -T 可以快速验证:
code复制df -T /usr/lib/systemd/system /usr/local
输出里只要看到两行的“文件系统类型”或“挂载源”不同,就基本锁定根因。如果还想更实锤,可以用 strace 观察 dpkg 到底卡在哪个系统调用:
bash复制sudo strace -f -e trace=rename -o /tmp/dpkg-rename.log dpkg --unpack /var/cache/apt/archives/systemd_*.deb
grep -E "EXDEV|Invalid cross" /tmp/dpkg-rename.log
不过在我的场景里,mount 输出已经把问题写得明明白白,没必要再上 strace。
2.4 顺带解释:为什么网上很多 case 是 systemd
许多人遇到这个报错后去搜索,发现中招的几乎都是 systemd,极少是其他包。原因主要有两个。
第一,systemd 包的文件数量非常庞大。.deb 里包含 /usr/lib/systemd/system 下的成百上千个 service、target、socket、timer 单元文件,升级时 dpkg 要对这些文件逐个执行“旧文件备份 / 新文件落位”的流程。文件数量越多,踩中文件系统边界的机会就越大。
第二,systemd 的升级脚本会做大量“跨目录动作”。它不仅要维护 /usr/lib/systemd,还要处理 /etc/systemd、/run 下的符号链接,以及 initramfs 的更新。只要这些目录中有任何一个和 /usr/lib 不在同一个文件系统实例,升级脚本里某个不起眼的 mv 就会让整个过程功亏一篑。
平时不做文件系统调整的机器,几乎不会遇到这种问题。但一旦做过目录级 bind mount 或者给关键路径单独分区,隐患就埋下了。这也是为什么“Ubuntu 22.04 systemd 升级失败”和“dpkg 无效的跨设备链接”这两个关键词常常一起出现。
3. 修复实战:从诊断到恢复的完整操作记录
3.1 第一步:先稳住局面,别让半途的 systemd 搞坏更多
看到 dpkg 报错后,第一件事不是急着重试,而是把现场固定下来。
先检查 dpkg 的状态:
bash复制dpkg --audit
grep -A 8 "^Package: systemd$" /var/lib/dpkg/status
如果 systemd 在 status 文件里的状态是 hc(Half-Configured)或 hU(Half-Unpacked),说明升级过程确实中断了。此时建议先备份 dpkg 的状态文件,防止后续操作把数据库搞坏:
bash复制sudo cp /var/lib/dpkg/status /var/lib/dpkg/status.bak.$(date +%Y%m%d%H%M%S)
同时确认没有其他 apt 进程在跑:
bash复制ps aux | grep -E "apt|dpkg" | grep -v grep
如果之前已经执行过 dpkg --configure -a 并且失败,dpkg 锁可能被某个残留进程占用,要等它释放或者先处理掉。
3.2 第二步:确认并消除跨设备边界
这是修复的核心动作。用 df -T 确认关键路径的设备归属:
bash复制df -T /usr /usr/local /var/lib/dpkg /run
看到 /usr/local 与 /usr 的设备源不一致后,我可以确认是 bind mount 或独立挂载导致的跨设备边界。接下来有两种处理思路:临时卸载,或者保留挂载但让 dpkg 绕开它。
临时卸载最干净,前提是确认没有进程正在使用 /usr/local 下的文件:
bash复制sudo fuser -vm /usr/local
# 如果没有输出,说明没有进程占用
sudo umount /usr/local
卸载之后,/usr/local 会露出根分区下原本的目录内容。如果是 bind mount 而不是独立分区,umount 同样会把挂载摘掉,露出被覆盖前的原始目录。数据不会丢,它仍然在原来的设备上,升级完再挂回去就行。
注意:如果机器是云主机,某些厂商的初始化脚本会把 /var/lib/docker、/var/cache 等目录挂到独立数据盘,这类挂载一般不会影响 systemd 升级。需要重点关注的是 /usr、/lib、/etc、/var、/run 这些系统关键路径。只要它们不是独立分区或 bind mount,通常不会触发报错。
3.3 第三步:用 TMPDIR 让 dpkg 回到“同一设备”
如果挂载点无法卸载,比如容器环境、或者系统正在依赖 /usr/local 下的运行服务,可以尝试把 dpkg 的临时文件路径强制指向与目标文件系统相同的目录:
bash复制export TMPDIR=/var/tmp
sudo systemctl stop systemd-udevd 2>/dev/null || true
sudo dpkg --configure -a
这里选 /var/tmp 而不是 /tmp,是因为很多机器把 /tmp 配成了独立的 tmpfs 或独立分区,如果 /var 和 /usr 在同一个根分区,TMPDIR=/var/tmp 就能让临时文件落在正确的文件系统上。
不过要诚实地说,TMPDIR 对 dpkg 解包临时文件有一定作用,但并不能覆盖所有场景。如果报错来源于 dpkg 的备份目录 /var/lib/dpkg/upstream 与目标目录跨设备,单纯设置 TMPDIR 不一定能解决。这个方案可以尝试,但不是百分百有效。
3.4 第四步:用 dpkg 的 force 选项强制恢复
如果 TMPDIR 方案无效,dpkg 会继续提示 systemd 处于半配置状态。这时可以配合几个 --force-* 参数强制配置:
bash复制sudo dpkg --force-remove-reinstreq --force-overwrite --force-bad-path --configure -a
这几个参数的含义分别是:
--force-overwrite:允许一个包覆盖另一个包已安装的文件,解决文件冲突问题。--force-bad-path:路径检查放宽,遇到符号链接导致的路径异常时可以继续。--force-remove-reinstreq:允许移除处于“需要重装”状态的包。
但 --force-* 不是银弹,它解决的是“dpkg 因为文件冲突或状态异常而拒绝继续”的问题,而不是“内核不允许跨设备 rename”的问题。如果根源还是跨设备,即使加了 force 参数,dpkg 也会在同一个位置再次失败。所以真正的杀手锏是下面这步。
3.5 第五步:手工解包覆盖(终极手段)
当 dpkg 死活卡在同一个 rename() 调用时,最可靠的办法是绕开 dpkg 的 rename 流程,直接用 dpkg-deb 解包,再用 cp -a 把文件复制到目标位置。复制文件夹内容是可以跨设备的,内核不限制 copy,只限制 rename。
操作流程:
bash复制mkdir -p /tmp/systemd-fix && cd /tmp/systemd-fix
apt download systemd
dpkg-deb -R systemd_*.deb rootfs
sudo cp -a rootfs/usr/* /usr/
sudo cp -a rootfs/etc/* /etc/ 2>/dev/null || true
sudo systemctl daemon-reexec
sudo dpkg --configure -a
这里解释几个关键点:
dpkg-deb -R 只是把 .deb 解包到指定目录,完全不涉及 rename() 和 EXDEV。cp -a 复制文件内容到目标目录时,内核会走“读源文件、写目标文件”的流程,即使源和目标不在同一文件系统,也能正常工作。文件落位之后,dpkg 再执行 --configure -a 时只需要更新数据库状态和运行 postinst 脚本,不再需要做跨设备移动,因此可以顺利通过。
需要提醒的是,这个操作有适用范围:我只建议对 systemd 这种明确报错、且文件列表相对可控的包这么做,不建议随手把任意包都强制覆盖一遍。覆盖 /usr/lib/systemd 和 /usr/bin/systemctl 这些文件时,运行中的旧 systemd 进程不会受影响(二进制文件在内存中已经加载),重启后才会加载新版本。
3.6 第六步:恢复挂载并验证系统状态
手工覆盖完成后,先把之前卸载的 /usr/local 挂载回去:
bash复制sudo mount -a
df -T /usr/local
然后继续收尾:
bash复制sudo apt -f install
sudo systemctl daemon-reexec
sudo systemctl --failed --no-pager
daemon-reexec 这一步非常关键。升级 systemd 后,PID 1 进程里跑的仍然是旧版本,必须通过 systemctl daemon-reexec 让 systemd 重新加载自身,否则后续服务管理可能报版本不匹配。最后建议重启一次,确认系统能以新内核、新 systemd 正常启动:
bash复制sudo reboot
重启后检查:
bash复制systemd --version
systemctl is-system-running
如果输出不再是 degraded,说明系统已经恢复到健康状态。
4. 复盘与延伸:升级崩溃时,那些和 dpkg 强绑定的“热知识”
4.1 dpkg-deb 报“意料之外的文件结束符”:多半是 deb 包损坏
和“无效的跨设备链接”一起被高频搜索的,还有这个报错:
code复制dpkg-deb: 错误: 在 /tmp/ros2-apt-source.deb 中读取归档的魔法版本数时遇到意料之外的文件结束符
dpkg: 处理归档 /tmp/ros2-apt-source.deb (--install)时出错
这通常是下载的 .deb 文件不完整。原因可能是网络中断、apt 缓存目录里的归档文件损坏、或者镜像源同步不完整。处理方法是清理缓存后重新下载:
bash复制sudo apt clean
sudo apt update
sudo apt install --reinstall -y 包名
如果手动安装某个 .deb 文件,重新下载时注意核对 SHA256 校验值。这个报错和跨设备链接没有直接关系,但经常出现在同一次升级事故的“连锁反应”里,因为一个包失败后,后续依赖它的包也跟着报各种奇怪错误。
4.2 dpkg 报“trying to overwrite ... which is also in package ...”
这个报错也很经典:
code复制dpkg: error processing archive xxx.deb (--install):
trying to overwrite '/usr/lib/systemd/system/xxx.service', which is also in package yyy
原因是两个不同的包安装了同一个路径的文件。常见于手动安装第三方 .deb 之后,又升级官方仓库里的同名包,或者某些仓库把同一份 unit 文件打进了多个包。
处理方式是加 --force-overwrite:
bash复制sudo dpkg -i --force-overwrite xxx.deb
sudo apt -f install
涉及 systemd 的 unit 文件冲突尤其常见,因为 /usr/lib/systemd/system 是很多服务类软件包的默认安装位置。如果冲突的是服务单元文件,覆盖完还建议 systemctl daemon-reload 让配置重新加载。
4.3 waiting for cache lock:dpkg 锁被占用
升级过程中偶尔还会碰到:
code复制Waiting for cache lock: could not get lock /var/lib/dpkg/lock-frontend
这不是文件系统问题,而是有另一个 apt/dpkg 进程还在运行。常见来源是 unattended-upgrades 自动更新、后台软件商店进程、或者你之前开着的另一个终端。先看进程:
bash复制ps aux | grep -E "apt|dpkg|unattended"
确认没有关键任务在跑之后,可以等锁释放。不到万不得已,不建议直接删除 /var/lib/dpkg/lock-frontend,因为锁文件存在的意义就是防止并发操作损坏 dpkg 数据库。如果确定没有任何相关进程残留,再考虑手动清理。
4.4 xinetd 和 systemd 的“代际冲突”
有些老系统还在用 xinetd 管理服务,升级 systemd 后偶尔会遇到服务“反复启动失败”或者被两个服务管理系统同时管理的状况。xinetd 的寄存方式是在某个固定端口上等待连接,而 systemd 的 socket 激活也有类似的监听能力,两者叠加时容易造成端口冲突或启动顺序混乱。
如果升级 systemd 后某服务起不来,先确认它是不是被 xinetd 托管:
bash复制ls /etc/xinetd.d/
systemctl status 服务名
建议慢慢把常用服务迁移到 systemd 原生 unit,减少升级时的摩擦。systemd 的 unit 文件里 Type=forking、Type=simple、Type=oneshot 等字段分别对应不同启动语义,迁移时注意对照原服务的行为修改。
4.5 其他高频搜索词提醒:别让一次升级变成重装系统
网上搜索这些报错的人,往往会连带搜到“Ubuntu 22.04 LTS 下载”“Ubuntu 22.04 server 安装教程”“输入法安装”“部署 solr”等内容。这些和当前问题没有直接关系,但从侧面说明一个现象:很多人在系统升级失败后,第一反应是重装。事实上,dpkg 的报错虽然吓人,本质上绝大多数是“文件系统布局”或“包状态不一致”问题,按文中的流程逐步排查,成功率很高。
如果真的走到重装那一步,记得下载官方镜像并核对校验值,部署完之后第一时间配置正规源,避免从半损坏的镜像站拿到残缺 deb 包,那才是给自己埋雷。
5. 预防方案:让类似事故从此远离你的服务器
5.1 升级前快照与空间检查
这次事故之后,我给自己定了一条规矩:任何涉及 systemd 或内核的大版本升级之前,先做两件事。
第一,检查磁盘空间:
bash复制df -h / /var /tmp
升级过程中 dpkg 需要临时文件空间,/var 下还要存放 .deb 归档,空间不足会导致各种莫名其妙的解包失败。
第二,做系统快照。云主机可以直接打快照,本地物理机可以用 LVM 快照或者干脆备份关键目录。快照不用天天做,但升级前做一个,能在出问题时 5 分钟内回滚,比什么修复技巧都省事。
5.2 别在关键路径上乱挂 bind mount
bind mount 是 Linux 的利器,但不应该被用在系统关键路径上,尤其是 /usr、/lib、/etc、/var 这些包管理器高度依赖的目录。如果确实有数据盘需要挂载,尽量选择 /opt、/srv、/data 这类应用层路径,或者只 bind mount 实际需要的子目录。
如果已经存在对 /usr/local 这类目录的独立挂载,建议在 /etc/fstab 里标注清楚用途,并定期评估是否还有必要。像我这次,数据盘挂在 /usr/local 上,表面上是“把自编译软件放到数据盘”,实际上完全可以用 /data/local 替代,再通过软链接或者 PATH 调整来满足需求。挂载点越贴近系统根路径,升级时爆雷的概率越大。
5.3 apt 与 dpkg 的日常卫生习惯
日常使用时养成这几个习惯,能减少大量升级问题:
- 定期
sudo apt clean,清掉/var/cache/apt/archives里的旧 deb,避免损坏归档残留。 - 尽量少用“手动下载 .deb + dpkg -i”的方式装第三方软件,优先使用官方仓库或可信 Launchpad PPA。手动安装的包一旦和官方仓库冲突,就可能出现“trying to overwrite”报错。
- 如果使用
unattended-upgrades自动安全
