决定把服务器从 Debian 11 推到 12 之前,我最担心的三件事
跨大版本升级发行版,是个人站长能做的最危险的操作之一。它不像重启一个服务,出错了改回来就行;它会把八百多个软件包一次性换掉,包括内核、glibc、OpenSSL、PHP、数据库。2026 年来看,Debian 11(bullseye)已经进入 LTS 后期,2026 年 8 月底停止常规支持之后只剩有限的 LTS 窗口,很多 PPA 和第三方源已经只发 bookworm 的包了,升级是迟早的事。
这篇文章记录我把一台跑着 Nginx + PHP 8.1 + MySQL 8.0 + 十几个个人站点的 VPS 从 Debian 11 升到 12(bookworm)的完整过程。我把最要紧的东西放在前面:先讲怎么准备回滚,再讲实际执行的顺序,最后讲那些升级完一个月后才浮现的坑。
第一步不是升级,是准备回滚
升级失败的定义不是「报错」,而是「服务器起不来了,而你没有退路」。所以真正的工作量有一半花在准备阶段。
快照优先,其它都是补救
如果你的 VPS 商支持磁盘快照,先关机做一份完整快照,再开机升级。这是唯一能在内核起不来时救你的手段。Hetzner、Vultr、DigitalOcean、阿里云轻量都支持,通常几毛到几块钱,比事后找数据恢复便宜太多。
没有快照能力的机器(比如某些小商家的 NAT VPS),退而求其次做两件事:
# 1) 整盘打包(不含 /proc /sys /dev,排除挂载点)
apt install -y rsync
mkdir -p /backup
rsync -aHAX --numeric-ids --delete \
--exclude={/proc,/sys,/dev,/run,/tmp,/var/run,/backup} \
/ /backup/rootfs/
# 2) 记下引导与分区信息(恢复时靠它重建)
lsblk -f > /backup/lsblk.txt
blkid > /backup/blkid.txt
cat /etc/fstab > /backup/fstab.txt
tar -czf /backup/etc.tar.gz /etc
dpkg --get-selections > /backup/packages.txt注意 --numeric-ids 配合 -aHAX,才能保留 ACL、xattr 和数字 UID,否则恢复后 sudo、postfix 这类依赖 setuid 和属主的服务会出问题。这个备份要拷到本机或者另一台机器上,放在同一块盘等于没备份。
数据库单独导一份逻辑备份
文件级备份在跨版本场景下不一定能直接用(InnoDB 的 redo 格式与 MySQL 版本绑定),所以逻辑备份必须有:
mysqldump --single-transaction --quick --routines --triggers \
--default-character-set=utf8mb4 --all-databases \
| zstd -19 -o /backup/alldb-$(date +%F).sql.zst顺手演练一次还原:在临时实例里 zstd -dc | mysql 跑一遍,确认文件能解开、能导入。没验证过的备份不算备份,这条在别的文章里说过很多次,但跨版本升级时它是最后一根救命绳。
第二步:在升级前把第三方源清干净
Debian 大版本升级失败的最高频原因,不是 Debian 自己的包,而是 第三方 apt 源还在给旧 codename 发包。升级到 bookworm 之后,源列表里那些写着 bullseye 的第三方仓库会导致依赖无法满足,apt full-upgrade 直接卡死。
# 列出所有 apt 源
grep -rhE '^(deb|deb-src)' /etc/apt/sources.list /etc/apt/sources.list.d/ \
| sort -u
# 典型输出:
# deb https://packages.sury.org/php/ bullseye main ← 必须换 bookworm 或移除
# deb https://repo.mysql.com/apt/debian/ bullseye mysql-8.0-lts
# deb https://deb.nodesource.com/node_18.x nodistro main ← 用 nodistro,不绑 codename处理原则:官方源随系统换 codename;第三方源先看是否已经提供 bookworm 版本,有就换,没有就注释掉、等升级完成后再装。宁可在升级窗口里少一个 PHP 源,也不要在 full-upgrade 中途因为 Depends: php8.1-common (= 8.1.x) but it is not installable 而进退两难。
还有一个特别常见的坑:把官方源和第三方源混装在同一个 sources.list 文件里,然后用 sed 全文件替换 codename,结果把第三方源的地址也改了,导致 404。正确做法是用一个明确的替换清单,逐条确认。Debian 12 之后推荐用 deb822 格式(/etc/apt/sources.list.d/debian.sources),升级时只要改里面的 Suites: 一行。
第三步:腾空间、保住 SSH、开 tmux
升级过程要下载并解包上千个包,临时空间需求通常是 2~4GB。先检查:
df -h / /var /boot
apt clean
# /boot 尤其关键:新内核 + initramfs 至少需要 200MB 空余
apt -y autoremove --purge很多人栽在 /boot 空间不足,内核装到一半失败,重启后进了旧内核但没有配套模块,网络起不来。如果 /boot 是独立分区且只有 256MB,升级前一定清掉老内核:
apt list --installed 'linux-image-*'
# 保留当前运行的和一个次新的,其余 purge然后是 SSH 保命。升级会重启 sshd,如果配置写错了你就彻底失联。三个习惯动作:
# 1) 先开一个 tmux 会话,所有升级动作都在里面做
tmux new -s up
# 2) 另开一个终端保持登录状态,全程不关
# (升级过程中 SSH 掉了,这个连接还在)
# 3) 确认有第二块键盘:VPS 控制台的 VNC/串口能登录
# 并验证 root 密码可用(或已注入 SSH key)Debian 12 的 sshd 默认配置有一处变化值得提前知道:PermitRootLogin 的默认值仍是 prohibit-password,但 OpenSSH 9.x 默认禁用了 ssh-rsa(SHA-1)签名算法。如果你的本地客户端很旧,或者某台备份机用的是老版本 PuTTY,升级后会直接连不上。提前在 /etc/ssh/sshd_config 里显式写上需要的算法,或者升级客户端。
第四步:正式升级的五个命令
# 1) 全面更新到当前版本最新(这一步在 bullseye 里做完)
apt update && apt -y full-upgrade
# 2) 改源:bullseye → bookworm
sed -i 's/bullseye/bookworm/g' /etc/apt/sources.list
# deb822 格式的:改 /etc/apt/sources.list.d/debian.sources 里的 Suites
# 3) 更新索引(这一步会报很多 404 和 GPG 错误,先把第三方源关掉)
apt update
# 4) 最小化升级:先只升 dpkg 和 apt 自身
apt -y install --only-upgrade dpkg apt libc6
# 5) 全量升级
apt -y full-upgrade --allow-downgrades第 4 步单独升 dpkg 和 libc6 是一个经验动作。跨版本时 dpkg 的依赖解析逻辑有变,先把它换到新版本,后面的 full-upgrade 会顺利很多。
--allow-downgrades 是必要的:Debian 12 里有些包版本号比 bullseye 时代的第三方包低(比如你从 sury 装过更新的 PHP),不降级就会大量报「will not be installed」。
全量升级过程中会被问几十个问题,绝大多数直接回车(保持当前配置)就是对的。只有几类需要停下来想:
- php.ini、nginx.conf、my.cnf:选「keep the local version currently installed」(保留本地版本),升级完再手工合并新配置里值得要的项。
- grub、sshd_config:同上,保留本地。特别是 sshd,别让包的默认配置覆盖你的端口和认证设置。
- /etc/sysctl.conf、/etc/default/grub:保留本地,避免内核参数和引导参数被重置。
第五步:别急着重启,先做离线验收
升级完成、但还没重启的这段时间,服务器跑的是旧内核。这正是最好的验收窗口:新装的用户态程序已经就位,但系统还在旧内核上跑,如果发现严重问题,还有机会退回去。
# 检查是否有半配置状态的包
dpkg -l | awk '$1 !~ /^ii/ && NR>5 {print}' | head -30
# 修复(如果上一步有输出)
dpkg --configure -a
apt -f install
# 检查被保留未升级的包
apt-mark showhold
apt list --upgradable
# 看有没有会自动删除关键包
apt -s autoremove | head -40apt -s autoremove 的模拟输出一定要看。跨版本后「自动安装」标记可能变化,某些 linux-image 或 libapache2-mod-* 会被判为不再需要,一执行就删掉正在用的东西。
然后验证服务:
# 逐项列表,别只看 systemctl --failed
systemctl list-units --state=failed
systemctl is-active nginx php8.2-fpm mysql ssh cron
# 配置文件有没有语法问题(新版本可能变严格)
nginx -t
php-fpm8.2 -t
mysqld --validate-config # MySQL 8.0.16+
# php 模块是否还在(跨版本后 sury 源重装会丢扩展)
php -m | tr '\n' ' '重启之后:五个迟到的坑
坑 1:PHP 版本被动跳级
Debian 12 官方源里的 PHP 是 8.2。如果你原来用的是 Debian 11 官方源的 7.4 或 sury 的 8.0,升级完 php8.2-fpm 会被装上,但你的 Nginx 配置里还写着 fastcgi_pass unix:/run/php/php7.4-fpm.sock;,结果是 502。处理办法:要么把 Nginx 的 socket 路径改到 8.2,要么从 sury 装回旧版本(前提是 sury 支持 bookworm,现在支持 8.1~8.4,7.4 已经不再提供)。
更麻烦的是 PHP 扩展:php7.4-redis 不会自动变成 php8.2-redis,得重新装一遍,而且在 8.2 上要重新编译过。升级前先用 dpkg --get-selections | grep php 把所有 php 相关包记下来,升级后对照补齐。
坑 2:MySQL 被要求升到 8.0 但数据目录权限变了
Debian 12 提供 MySQL 8.0 和 MariaDB 10.11。如果你从 MariaDB 10.5 升上来,跨大版本的数据目录不兼容,必须导出导入,不能只 apt 升级。而 MySQL 8.0 升级后 AppArmor 的 profile 更新,可能把 datadir 挡住:
journalctl -u mysql -n 50 | grep -i denied
# 若有,检查 /etc/apparmor.d/usr.sbin.mysqld 是否覆盖你的 datadir 路径坑 3:/etc/nginx/mime.types 被更新,行为变了
新版本的 mime.types 会补充新的扩展名。这类变化一般无害,但如果你的 include mime.types 用了通配路径或者自己 COPY 过旧文件,就会不一致。nginx -t 通过不代表线上行为一致,重启后要抽查 CSS/JS/字体/woff2 的 Content-Type。
坑 4:systemd 单元变得严格
Debian 12 的 systemd 252 对某些过时指令会警告甚至报错,比如 StartLimitInterval(改用 StartLimitIntervalSec 放在 [Unit] 段)、ProtectKernelTunables=yes 与自定义 sysctl 冲突。systemd-analyze verify 能提前发现:
systemd-analyze verify /etc/systemd/system/*.service 2>&1 | head -30坑 5:OpenSSL 3 拒绝部分旧证书和算法
Debian 12 用 OpenSSL 3.0,默认安全级别提到 2,意味着 1024 位 RSA 证书、SHA-1 签名会被直接拒绝。如果你还在跟某个旧接口对接,或者自签证书是老方式生成的,升级后会连不上。定位方式:
openssl s_client -connect example.com:443 </dev/null 2>&1 | grep -E 'Verify|error'
# error:0A00018E:SSL routines::ca md too weak → 就是这个问题回滚:真正发生时该怎么选
如果重启后起不来,按这个顺序判断:
- 能进 GRUB 菜单:选旧内核启动。旧内核通常还在(除非 autoremove 删了),起来之后先用
dpkg --configure -a和apt -f install修。这样能救回大多数「新内核不认网卡驱动」的情况。 - 进不了 GRUB:用 VPS 的救援模式(rescue system)挂载磁盘,chroot 进去修。先看到底是 grub 配置坏了还是 /boot 被写满。
- 修不动:直接回滚快照。这也是为什么第一步要做快照。
- 没有快照:用
/backup/rootfs/恢复用户态,用packages.txt对照重装。这条路要 4 小时以上,且很可能丢配置。
从经验看,90% 的跨版本升级事故都发生在「升级完成到服务起来」之间,而且主因是配置不兼容而不是系统坏掉。所以真正的价值不在升级命令本身,而在验收清单。
我的验收清单(可照着抄)
- SSH 能登录,sudo 正常,root 密码可用
systemctl list-units --state=failed为空nginx -t、PHP socket 存在、站点首页 200- MySQL 能连、
SHOW VARIABLES LIKE 'character_set_server'仍是 utf8mb4 - 所有站点逐个 curl:状态码、正文长度、
<title>正确 - HTTPS 证书链完整、
curl -I无 warning - 定时任务:
systemctl list-timers和 crontab 都在,且日志有执行记录 - 磁盘:
df -h与df -i均正常 - 日志:journalctl 里升级时间之后没有新的 err 级记录
- 备份任务在升级后跑成功一次(不要以为它还会按老配置跑)
- 32 位/64 位兼容:老的自编译程序(如果有)重新
ldd检查一遍
后记:值不值得升
这台机器最后用了 50 分钟完成升级,重启后一次通过,只有 PHP 版本和 mime.types 两处需要修。但我准备了将近两个小时,写了两页纸的清单。这个比例是正常的,也是应该的。
对个人站长来说,升级发行版的真正理由往往不是「想要新特性」,而是「旧版本不再有安全更新」。如果站点还在 Debian 10 或者更早,且没有快照、没有备份、没有验收清单,与其硬升,不如先花一个周末把这些基础补齐,再谈升级。服务器运维的次序是:先能恢复,再谈变更。