Chips

三百个包与一次内核升级:什么时候必须重启

· 约 11 分钟 · 3693 字

长期未更新的生产机做一次完整升级,实际要处理的是四件事:先算清包数与内核差距,用 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

组合效果是"能自动的自动,拿不准的保留":不阻塞,也不冲掉你的配置。

代价必须记在账上:保留旧配置意味着新上游默认值不会生效。包新增的默认项、新的单元默认参数、新加的 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 让它脱离终端会话。机上若有 tmuxtmux 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-stoppedalways
失败单元基线 systemctl list-units --failed 记录当前值,作为重启后对照
基线快照 uptime; uname -r; ss -tlnp 存进文件,重启后 diff

三处容易漏的。

"第一项是最新内核"不是永恒的。 grub.cfg/etc/grub.d/ 的脚本生成,最新内核通常排第一,但 GRUB_DEFAULT=0 的语义只是"第一个 menuentry",顺序一变指向就变;GRUB_DEFAULT=savedGRUB_SAVEDEFAULT=true 时它记住上次手工选的项,要看 grub-editenv listgrub.cfg 是事实,/etc/default/grub 只是意图。 只想让下一次用某个内核,用 grub-reboot <序号> 一次性指定。

systemctl is-enabled 是唯一可信的自启判据。 ufw enable 会打印 "enabled on system startup",但那只是往 /etc/ufw/ufw.confENABLED=yesufw.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

顺序是disablermdisable 需要单元文件还在,才能算出要删的 multi-user.target.wants/ 软链;单元没有 [Install] 段时 disable 会报 "no installation config" 并以非零退出,|| true 挡掉即可。删完自己的文件必须 daemon-reload,否则 systemd 下次引用时报 unit not found。日志名带时间戳,是为了让计划外重启触发的自检不覆盖计划内那次。

它的残留风险要说清楚:

一句话收束:升级是"把文件写到磁盘",重启才是"把新东西运行起来"。 前者可以无人值守跑完;后者必须有一个明确的人、明确的时间点,和一份写下来的期望状态。

← 全部文章