Generated by Claude Code on DeepSeek V4 pro

把家里那台 headless 服务器从 Ubuntu 24.04 原地迁移到了 Debian 13(trixie)。一次 in-place 的发行版转换:换 apt 源,然后 dist-upgrade。这篇文章记录完整流程,包括踩到的坑和对应的解法。

#背景

这台机器从 Ubuntu 20.04 一路升到 24.04,累计了一些沉积,比如服务器上还装着 Kubuntu 的 plymouth 主题。它日常由一个自治回路托管(定时采集、判断、执行),所以迁移期间第一件事是冻结这个回路,避免它在我操作到一半的时候自己跑 apt upgrade:

1
ops stop 'debian migration'

#准备

备份。 这台机器每周一通过 rsync 把根分区同步到数据盘,作为还原点。动手前手动刷新了一次:

1
sudo sysbak   # rsync -aAXH --delete / /mnt/k2/Ubuntu

清单导出。 把包状态、crontab、NetworkManager 连接、ssh 配置、网络路由、mdadm 信息等 58 个文件导到数据盘,用于事后对照:

1
2
3
4
dpkg --get-selections > dpkg-selections.txt
nmcli -t con show > nmcli-connections.txt
systemctl list-unit-files --state=enabled > units-enabled.txt
# ... 以及 crontab、fstab、blkid、ss -tlnp 等

源手术。 清空所有 Ubuntu 源,写入 Debian 源(这里用国内镜像):

1
2
3
4
# /etc/apt/sources.list
deb https://mirrors.bfsu.edu.cn/debian/ trixie main contrib non-free non-free-firmware
deb https://mirrors.bfsu.edu.cn/debian/ trixie-updates main contrib non-free non-free-firmware
deb https://mirrors.bfsu.edu.cn/debian-security/ trixie-security main contrib non-free non-free-firmware

第三方源逐个处理:Docker 仓库已失效直接删;NodeSource 删(node 实际走 nvm,不依赖);jellyfin 切到同镜像的 debian 路径;Google Chrome 天然兼容直接留。

一个预埋的坑:Ubuntu 打包的 debian-archive-keyring 停在 2023 年,没有 trixie 的签名钥匙,apt-get update 会报 NO_PUBKEY。而 trixie 的新版 keyring 又因为源未签名装不上。解法是从镜像手动下载 .deb 并校验 SHA256:

1
2
3
curl -sO https://mirrors.bfsu.edu.cn/debian/pool/main/d/debian-archive-keyring/debian-archive-keyring_2025.1_all.deb
sha256sum debian-archive-keyring_2025.1_all.deb # 与 Packages 索引比对
sudo dpkg -i debian-archive-keyring_2025.1_all.deb

模拟。 先跑一遍 dry-run 审查规模,重点看删除清单里有没有命根子:

1
2
3
apt-get -s dist-upgrade > plan.log
# 3062 upgraded, 319 newly installed, 39 to remove, 5 not upgraded
# 审查 '^Remv':sshd/NetworkManager/systemd/grub/mdadm/nginx 全部健在

被删的都是 Ubuntu 特供:plymouth-kubuntu 主题、ESM 版 imagemagick、空转的 php8.3(trixie 只有 8.4)、virtualbox 的 Qt6 依赖链等。

预下载。 3382 个包约 7GB,全部备到本地再动手,避免中途断网:

1
apt-get --download-only -y dist-upgrade

#执行:四关

第一关:usr-merge 分流命名冲突。 这是 Ubuntu → Debian 转换的必经之坑。两边对 /bin、/lib、/lib32 等目录都做了 dpkg diversion,但目标命名不同,Debian base-files 的 preinst 直接撞车退出:

1
2
dpkg-divert: error: 'diversion of /lib32 to /.lib32.usr-is-merged by base-files'
clashes with 'diversion of /lib32 to /lib32.usr-is-merged by base-files'

解法是把 Ubuntu 风格的 6 个顶层分流和 13 个 per-file 分流(libreadline、libc6 等的历史残留)全部移除,注意用 --no-rename,避免把目标目录搬回符号链接上:

1
2
3
for d in /bin /sbin /lib /lib32 /lib64 /libx32 /libo32; do
dpkg-divert --remove --no-rename --package base-files "$d"
done

第二关:Ubuntu 拆分包与 Debian 合并包撞车。 Ubuntu 把 python3-rospkg 拆成 python3-rospkg + python3-rospkg-modules 两个包,Debian 是合并的,文件归属冲突:

1
2
trying to overwrite '/usr/lib/python3/dist-packages/rospkg/__init__.py',
which is also in package python3-rospkg-modules (1.5.0-1)

删拆分包即可,旧基包对它的依赖是临时的,用 --force-depends

1
dpkg -r --force-depends python3-rospkg-modules python3-catkin-pkg-modules

衍生问题:python3-catkin-pkg 的 Ubuntu 版本号是 1.0.0-100,大于 Debian 的 1.0.0-2,apt 认为已装版本”更新”,永远不会替换它,而这个 Ubuntu 版依赖着刚删掉的拆分包。解法是显式安装 Debian 构建:

1
2
apt-get download python3-catkin-pkg=1.0.0-2
dpkg -i python3-catkin-pkg_1.0.0-2_all.deb

第三关:14 个包 configure 失败。 主事务跑完后有 14 个包因为依赖的新库没跟上而无法配置——例如 python3-apt 需要的 libapt-pkg7.0 没装、libgpgme11t64 还停在 Ubuntu 旧版。标准解法:

1
2
apt-get -f install -y
dpkg --configure -a

第四关:dovecot 2.3 → 2.4 的配置不兼容。 dovecot-core 的 postinst 要重启服务,服务起不来,包就 configure 不了:

1
2
doveconf: Fatal: Error in configuration file /etc/dovecot/conf.d/auth-system.conf.ext
line 10: passdb { } is missing section name

dovecot 2.4 里 passdb/userdb 必须命名。大部分配置其实已经被 postinst 的迁移器改好了,只有 auth-system.conf.ext 漏网:

1
2
3
4
5
6
7
8
# 2.3
passdb {
driver = pam
}

# 2.4
passdb pam {
}

改完 doveconf -n 零报错,服务正常起来。

另外 grub 也有一小关:Ubuntu 的 grub-efi-amd64-signed 版本号虚高(1.202.5+... vs Debian 的 1+2.12+...),把 grub 整个版本锁死了。apt 3.0 起降级需要显式允许:

1
2
3
apt-get install -y --allow-downgrades \
grub-efi-amd64/trixie grub-efi-amd64-bin/trixie grub-efi-amd64-signed/trixie
grub-install --target=x86_64-efi

#重启与善后

第一次重启后就进了 Debian 用户态(内核还是 Ubuntu 6.8,内核包不在升级计划里,相当于留了 grub 回退链)。

善后阶段值得记的是 apparmor 的排查。libvirtd 起不来,日志只有一句 cause is unknown。内核审计日志显示 profile 的 unix dgram socket 创建被拒绝:

1
2
apparmor="DENIED" operation="create" class="net" profile="libvirtd"
family="unix" sock_type="dgram" protocol=0 requested="create"

排查确认 profile 文件与 Debian stock 完全一致,但 Ubuntu 内核的 apparmor 补丁集与 Debian 用户态 4.1 的 unix socket 仲裁语义对不上。过渡方案是 complain 模式(只记日志不拒绝),等装上 Debian 内核后再验证:

1
2
3
aa-complain /etc/apparmor.d/usr.sbin.libvirtd
# ... 安装 linux-image-amd64 并重启后 ...
aa-enforce /etc/apparmor.d/usr.sbin.libvirtd

Debian 6.12 内核下,同一个 stock profile,零拒绝。问题确实出在内核侧。

其他善后:apache2 在升级时被 postinst 启用抢了 80 端口(升级前快照证明 80 属于 nginx),禁用即可;换掉了 grub 三件套和微码包;purge 了 25 个 Ubuntu 残留(whoopsie、ubuntu-pro、docker 全家等)和 11 个 ESM 版 ffmpeg 库;第二次重启后清掉旧 Ubuntu 内核。

最终状态:内核 6.12.105,0 升 0 装 0 删,磁盘占用从 609G 降到 545G,备份还原点从 613G 缩到 549G。

#几点观察

  • in-place 转换的可行性取决于几个兼容点:merged-usr(noble 和 trixie 都已合并)、t64 包名(两边一致)、netplan/NetworkManager 机制相同。这台机器恰好都满足,所以 39 个删除之外没有惊险。
  • 出问题最多的不是大包,是版本号比较:1.0.0-100 > 1.0.0-21.202.5 > 1+2.123.4.7-1build1 > 3.4.7-1。Ubuntu 的版本号策略在跨发行版转换时制造了大量”永远不升级”的孤儿。
  • 整个过程跑在一个 tmux 里,ssh 是唯一的管理通道。升级会重启 sshd(9.6 → 10.0),但已建立的连接不受影响,双会话(一个跑操作、一个做保命验证)是 headless 环境下最值钱的纪律。
  • 备份不是”有没有”的问题,是”敢不敢动手”的前提。