三百个包与一次内核升级:什么时候必须重启
长期未更新的生产机做一次完整升级,实际要处理的是四件事:先算清包数与内核差距,用 full-upgrade 加非交互 conffile 策略跑完,判断"装了但没生效"并挑重启时机,最后清掉 rc 残留包和 depmod 生成的无主文件。
先算清规模,再决定什么时候动手
一台半年没动过的机器,apt-get update 之后第一件事不是升级,是量化。
apt-get update
apt list --upgradable 2>/dev/null | wc -l
apt-get -s full-upgrade | tail -6
-s 是模拟,不落盘。输出末尾的统计行才是真正要看的:
302 upgraded, 12 newly installed, 3 to remove and 0 not upgraded.
Need to get 481 MB of archives.
After this operation, 214 MB of additional disk space will be used.
三个数字里,to remove 多半是新旧内核交替带来的;只要它非零,upgrade 就做不完这件事。
接着单独把内核相关的挑出来:
apt list --upgradable 2>/dev/null | grep -E 'linux-(image|headers|modules)'
然后是最容易被忽略的一步:对比"正在跑的"和"已经装了的"。
uname -r # 当前运行
dpkg-query -W -f='${Package}\n' 'linux-image-*' | sort -V # 已装的全部
ls -l /boot/vmlinuz-*
# 示例输出
6.1.0-13-amd64 # uname -r
linux-image-6.1.0-13-amd64
linux-image-6.1.0-18-amd64
...
linux-image-6.1.0-32-amd64 # 已装到最新
两者相差十几个修订版,说明之前已经装过好几次新内核,只是从没重启。这台机器对内核升级并不陌生,陌生的是重启这个动作本身,风险点因此集中在重启那一刻,而不是再装一个新内核。
最后确认 /boot 余量。内核和它的 initramfs 都躺在 /boot,这个分区通常只有几百 MB:
df -h /boot /
文件系统 容量 已用 可用 已用% 挂载点
/dev/vda1 468M 213M 231M 48% /boot
一套内核大致是 vmlinuz 十兆级、initrd.img 三五十兆(驱动装得多会更大),加上 System.map / config-* / abi-*,粗算一百多兆。/boot 只剩一半时要留意:full-upgrade 会同时留下新旧两套,装不下会在 dpkg 配置阶段失败,那时麻烦已经开始。
upgrade 与 full-upgrade:区别不只是"能不能删包"
apt-get upgrade |
apt-get full-upgrade |
|
|---|---|---|
| 允许删除已装包 | 否 | 是 |
| 允许新增包 | 否 | 是 |
| 遇到依赖不满足时 | 保留旧版本,跳过该包 | 调整包集合以满足依赖 |
| 旧内核清理 | 不会发生 | 通常随之发生 |
upgrade 的保守有代价:被跳过的包不会告诉你它被跳过了。长期未更新的机器上,跑完看到一堆"已升级",但依赖新版库而卡住的包还停在原地,自动更新也不会碰它们。判断方法是看模拟里被保留的部分:
apt-get -s upgrade | grep -i 'kept back'
需要一致依赖集时,full-upgrade 才是正确工具;反过来,维护窗口里要"绝不删东西"就用 upgrade,并接受它做不完。
非交互跑法:conffile 选项与它的代价
dpkg 在遇到"配置文件被本地改过、包里的版本也变了"时会弹出交互式选择:
Configuration file '/etc/ssh/sshd_config'
==> Modified (by you or by a script) since installation.
What would you like to do about it ?
Y or I : install the package maintainer's version
N or O : keep your currently-installed version
无人值守的会话里,这个提示会让升级停在那里直到超时,dpkg 锁一直被持有。所以带两个选项:
export DEBIAN_FRONTEND=noninteractive
apt-get -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
full-upgrade
--force-confdef:你没改过该文件(等于包自带默认)时,静默采用新版本。--force-confold:你改过时保留现有文件,把新版本写成/etc/xxx.dpkg-dist。
组合效果是"能自动的自动,拿不准的保留":不阻塞,也不冲掉你的配置。
代价必须记在账上:保留旧配置意味着新上游默认值不会生效。包新增的默认项、新的单元默认参数、新加的 sysctl 值都只在 dpkg-dist 里躺着,升级后要专门花时间对:
find /etc -name '*.dpkg-dist' -o -name '*.dpkg-new' -o -name '*.ucf-dist'
dpkg-query -W -f='${Conffiles}\n' openssh-server
# 示例输出:第三列是状态
/etc/ssh/sshd_config <哈希> obsolete
/etc/ssh/ssh_config <哈希> obsolete
obsolete 表示"包已不再提供该配置文件,但磁盘上还留着";.dpkg-dist 表示"上游给了新版本,你保留了旧的"。两类都要人工比对后决定留哪份,再把 .dpkg-dist 删掉——留着的话,下次升级又会以它为参照,越积越乱。
为什么要分离会话跑,以及怎么判断"真的跑完了"
三百个包,光下载加解包加 initramfs 重建,十几分钟到半小时都正常。SSH 会话在升级中途断开时,SIGHUP 会杀掉前台进程组:dpkg 中途退出、/var/lib/dpkg/lock-frontend 锁没释放、数据库停在半事务状态。
所以必须脱离会话,让进程的父进程不是你的 shell:
setsid nohup env DEBIAN_FRONTEND=noninteractive \
apt-get -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
full-upgrade > /var/log/system-upgrade-20260914.log 2>&1 &
echo $! > /tmp/upgrade.pid
nohup 让进程忽略 SIGHUP,setsid 让它脱离终端会话。机上若有 tmux,tmux new -s upgrade 更好——能随时 attach 回看上下文,而不是只能 tail 日志。
跑的过程中看进度:
tail -f /var/log/system-upgrade-20260914.log
pgrep -a -f 'apt-get|dpkg|mkinitramfs|ldconfig'
fuser -v /var/lib/dpkg/lock-frontend
"日志不再滚动"和"跑完了"不是一回事。 需要的判据是下面这组,全部满足才算收尾:
# 1. 日志末尾出现 apt 的收尾统计行(而非停在某个 Get: 或 Setting up:)
tail -3 /var/log/system-upgrade-20260914.log
# 2. 没有 apt/dpkg 及其子进程
pgrep -a -f 'apt-get|dpkg|mkinitramfs' # 应无输出
# 3. 锁已释放
fuser /var/lib/dpkg/lock-frontend # 应无输出
# 4. dpkg 数据库没有半配置的包
dpkg --audit # 应无输出
# 5. 没有残留的事务日志
ls /var/lib/dpkg/updates/ # 应为空(除 . 与 ..)
# 6. 再模拟一次,应该无事可做
apt-get -s full-upgrade | tail -3
第 4、5 条最关键:/var/lib/dpkg/updates/ 里有文件,就说明上一次 dpkg 被打断过,后续 apt 操作都可能跑在不一致的数据库上。
装了不等于生效
升级收尾之后 uname -r 不会变——内核、glibc 这类东西,升级只是把文件写到磁盘,运行中的进程用的还是旧的那份。内核是显式的;glibc 更隐蔽:进程已把旧 libc.so.6 映射进地址空间,替换文件只改了目录项,进程还抱着旧 inode。
grep -o '/usr/lib/[^ ]*libc.so.6' /proc/1/maps | sort -u
带 (deleted) 后缀说明 PID 1 还在用已被换掉的旧库:
/usr/lib/x86_64-linux-gnu/libc.so.6 (deleted)
系统为这种状态提供了标准判据:
[ -f /var/run/reboot-required ] && echo NEED_REBOOT
cat /var/run/reboot-required # 固定文案:*** System restart required ***
cat /var/run/reboot-required.pkgs # 是哪些包导致的
# 示例输出
linux-image-6.1.0-32-amd64
libc6
这两个文件由包的 postinst 写入,/var/run 指向 tmpfs 上的 /run——重启后自然清除,"文件消失"本身就是重启成功的证据之一。
不要开自动重启。 这台机上跑着反向隧道、容器、防火墙守卫和 SSH,重启意味着连接全断、隧道重协商、容器重新拉起,任何没配持久化自启的东西永久消失。如果机器是因为某个故障才被想起来维护的,无人值守重启可能把"能用的旧状态"换成"起不来的新状态",还发生在凌晨三点。不重启的损失是"补丁没生效",自动重启的损失可能是"服务不可用且无人知晓"。
代价是重启这件事从此需要有人负责:什么时候、谁在、出问题怎么回退,要落到日历上。
重启前检查清单
重启前的每一项都是在回答"如果现在断电重启,它能不能回来"。
| 检查项 | 命令 | 期望 |
|---|---|---|
| GRUB 会启动哪个内核 | grep -m1 -A3 'menuentry ' /boot/grub/grub.cfg |
第一项是新内核 |
GRUB_DEFAULT |
grep -E 'GRUB_DEFAULT\|GRUB_SAVEDEFAULT' /etc/default/grub |
0,或若为 saved 则查 grub-editenv list |
| 新内核的 initrd 已生成 | ls -l /boot/initrd.img-<新版本> |
存在且时间戳与内核一致 |
| dpkg 数据库一致 | dpkg --audit |
无输出 |
| 关键服务开机自启 | systemctl is-enabled ssh docker ufw fail2ban |
全部 enabled |
| 容器重启策略 | docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -q) |
unless-stopped 或 always |
| 失败单元基线 | systemctl list-units --failed |
记录当前值,作为重启后对照 |
| 基线快照 | uptime; uname -r; ss -tlnp |
存进文件,重启后 diff |
三处容易漏的。
"第一项是最新内核"不是永恒的。 grub.cfg 由 /etc/grub.d/ 的脚本生成,最新内核通常排第一,但 GRUB_DEFAULT=0 的语义只是"第一个 menuentry",顺序一变指向就变;GRUB_DEFAULT=saved 配 GRUB_SAVEDEFAULT=true 时它记住上次手工选的项,要看 grub-editenv list。grub.cfg 是事实,/etc/default/grub 只是意图。 只想让下一次用某个内核,用 grub-reboot <序号> 一次性指定。
systemctl is-enabled 是唯一可信的自启判据。 ufw enable 会打印 "enabled on system startup",但那只是往 /etc/ufw/ufw.conf 写 ENABLED=yes;ufw.service 本身可能仍是 disabled,重启后防火墙不会起来。自定义的 PartOf= / Requires= 单元同样要逐个确认。
手工创建的容器最容易出问题:compose 拉起的容器策略写在 compose 文件里不会丢,docker run 手工起的容器策略只存在于容器自身,容器一删就没了。
还有一条顺序上的判断:新内核验证可用之前,不要删旧内核——它是唯一的一键回退路径。autoremove 放到重启确认无误之后再做。
重启后收尾
重启确认无误之后,才开始清理。顺序是先清包,再清包里管不到的东西。
apt-get -y autoremove --purge
autoremove 处理"曾作为依赖装上、现在不再被需要"的包,包括旧内核。但它有两个盲区。
盲区一:rc 状态的残留包。 删包时若配置文件被保留,dpkg 状态就是 rc——removed 但 config-files 仍在,包没有真正消失:
dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' | grep '^rc '
# 示例输出
rc linux-image-6.1.0-13-amd64
rc linux-modules-6.1.0-13-amd64
rc linux-headers-6.1.0-13-amd64
autoremove 不会再碰这些包。清理:
dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' \
| awk '/^rc / {print $2}' | xargs -r dpkg -P
先看清单再执行——里面可能混着想保留配置的包。-P 是彻底清除,配置文件一起删。
盲区二:/lib/modules/<旧版本> 里的 depmod 生成文件。 旧内核 purge 之后该目录可能还在,里面躺着一整套:
ls /lib/modules/<旧版本>/
modules.alias modules.alias.bin modules.builtin modules.builtin.bin
modules.dep modules.dep.bin modules.devname modules.softdep
modules.symbols modules.symbols.bin
这些不是包里的文件,是 depmod 安装时生成的。查一下就能看出来:
dpkg -S /lib/modules/<旧版本> # 命中包:包登记了这个目录
dpkg -S /lib/modules/<旧版本>/modules.dep # 找不到
dpkg-query -L linux-image-<旧版本>-amd64 | grep -c modules.dep # 0
目录属于包,目录里的文件不属于任何包。于是 autoremove 按包清单删文件,删不到它们;purge 后它们可能就留在原地。系统不会因此出错(没有对应 vmlinuz 的模块树不会被加载),但它们会干扰"到底还有几套内核"的判断。
找出所有没有对应内核的残留目录,确认后手工删:
for d in /lib/modules/*/; do
v=$(basename "$d")
[ -e "/boot/vmlinuz-$v" ] || echo "无对应内核: $d"
done
# 示例输出
无对应内核: /lib/modules/6.1.0-13-amd64/
确认无误后 rm -rf /lib/modules/<旧版本>,再对运行中的内核跑一次 depmod $(uname -r)。最后核对 ls /boot,确认 vmlinuz / initrd.img / System.map / config-* 与现存内核一一对应,且 grub.cfg 已被 update-grub 重新生成(purge 内核的 postrm 通常会调用,但别假定它一定跑了)。
重启不能只靠"应该会起来"
前面所有检查都只是"降低概率",没有一项能证明它真的会起来。
可行的做法是留一个一次性开机自检:重启后把关键状态落进日志文件,然后把自己删掉。用 systemd 单元而不是 @reboot cron——cron 的 @reboot 要求 cron.service 本身 enabled 且按时起来,而 systemd 是 init 自己,不存在"触发者没起来"这一层。
# /etc/systemd/system/reboot-check.service
[Unit]
Description=One-shot post-reboot self check
After=multi-user.target network-online.target docker.service
Wants=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/reboot-check.sh
#!/bin/sh
# /usr/local/sbin/reboot-check.sh
LOG=/var/log/reboot-check-$(date +%Y%m%d-%H%M%S).log
{
echo "== kernel: $(uname -r)"
echo "== uptime: $(uptime -p)"
echo "== reboot-required: $( [ -f /var/run/reboot-required ] && echo YES || echo no )"
echo "== failed units:"
systemctl --failed --no-legend
echo "== services:"
for u in ssh docker ufw fail2ban; do
printf ' %-12s %s\n' "$u" "$(systemctl is-active "$u")"
done
echo "== listeners:"
ss -tlnp
echo "== containers:"
docker ps --format ' {{.Names}} {{.Status}}'
echo "== disk:"
df -h / /boot
} > "$LOG" 2>&1
systemctl disable reboot-check.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/reboot-check.service
systemctl daemon-reload
systemctl daemon-reload
systemctl enable reboot-check.service
顺序是先 disable 再 rm:disable 需要单元文件还在,才能算出要删的 multi-user.target.wants/ 软链;单元没有 [Install] 段时 disable 会报 "no installation config" 并以非零退出,|| true 挡掉即可。删完自己的文件必须 daemon-reload,否则 systemd 下次引用时报 unit not found。日志名带时间戳,是为了让计划外重启触发的自检不覆盖计划内那次。
它的残留风险要说清楚:
- 失败模式偏向"重复"而非"丢失"。脚本若在自删前报错退出,单元仍是 enabled,下次开机再跑一遍、再写一份日志。这可以接受——宁可多一份日志,也不要静默地什么都没有;但如果你不看
systemctl --failed,它反复失败你也不会知道。 - 日志文件不会自己消失。
/var/log/reboot-check-*.log要人工清或事前配logrotate。一次性脚本的问题往往不是逻辑,是它留下的一次性产物。 - "自检"不等于"验证"。脚本只负责把状态摆出来,没人对着期望值读一遍,它跟不存在没有区别。前提是你先写下期望值:
uname -r该是哪个版本、哪几个单元该 active、监听面该有哪些、reboot-required该已消失;达不到时的回退动作(GRUB 里选回旧内核)也要提前想好。 - 自删本身有窗口。
rm之后、daemon-reload之前断电(概率极低)会留下指向不存在文件的软链,表现为一条 failed 日志,systemctl reset-failed加删软链即可。
一句话收束:升级是"把文件写到磁盘",重启才是"把新东西运行起来"。 前者可以无人值守跑完;后者必须有一个明确的人、明确的时间点,和一份写下来的期望状态。