Debian自动安全更新的边界:重启时机与高危包

一台机器打补丁,十台机器陪跑

个人站长手上的机器往往不多,两台到五台,但「打补丁」这件事的麻烦程度和机器数量不成正比。Debian 的安全更新一天可能来两三波,apt 源里 stable-updates 和 security 都会推送,每次 apt upgrade 都要重启一堆服务,运气不好碰到内核更新还得重启机器。手工做的话,一个月后你根本记不清哪台更新到哪天、哪台因为「正在跑备份」被跳过。

本文记录一套在 5 台 Debian 12 机器上跑了半年的自动补丁方案:unattended-upgrades 负责安全补丁自动装,apt 定时器负责索引刷新,自定义脚本负责「该不该重启」的判断,中央台账负责「谁漏了」的核对。重点不在于装什么包,而在于几个容易把机器弄挂的细节:自动重启的时机、内核与容器的交互、dpkg 锁冲突、以及最危险的——自动升级把 MySQL 主版本带跑了。

第一步:选定自动化的边界

Debian 的 unattended-upgrades 默认只自动安装 ${distro_id}:${distro_codename}-security 这一个源里的包。这个默认值其实相当保守,也相当正确:安全补丁优先自动化,功能更新留给人。

先看现状:

apt-config dump | grep -E 'Unattended-Upgrade::(Origins|Allowed|AutoFix|Mail|Remove)'

默认输出里 Allowed-Origins 只有 security 一行。要加的话,配置写在 /etc/apt/apt.conf.d/50unattended-upgrades(Debian 12 的模板里所有行都是注释掉的,改动量很小):

Unattended-Upgrade::Origins-Pattern {
        "origin=Debian,codename=${distro_codename},label=Debian-Security";
        "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
};

注意 Debian 12 起 label=Debian-Security 的写法与前面 codename-security 的组合有关,两个都写上是最稳的做法;只写一个在某些镜像源下会静默不升级(日志里显示「no packages found」而你以为在自动更新)。验证方法是手动跑一次 dry-run:

sudo unattended-upgrade --dry-run --debug 2>&1 | grep -E 'pkgs that look|Checking|Packages that will be upgraded'

要不要把 -updates 也加进来?建议不要。个人站的 stable-updates 主要是时区数据、证书根、部分浏览器相关包,量小但并非纯粹安全补丁;如果加进来,一次推送可能带上十几个包,风险面变大。缺时区数据这种事,一季手动看一次就够。

第二步:把「自动重启」关掉,改成「报告」

模板里这两行必须保持注释状态:

// Unattended-Upgrade::Automatic-Reboot "false";
// Unattended-Upgrade::Automatic-Reboot-Time "02:00";

自动重启对于跑数据库、跑容器、跑单实例 PHP-FPM 的机器来说,最大的问题是不可控的可用性损失:unattended-upgrades 判断「需要重启」的依据是 /var/run/reboot-required,这个文件在内核、libc、systemd 更新后都会被创建,但它不知道你的机器此刻在干什么。凌晨 2 点自动重启,如果恰好有个 4 小时的全量备份在跑,或者有个长事务,重启就是数据风险。

正确做法是让它报告,由人决定什么时候重启。开启邮件通知(或者像本文一样写进日志文件,由监控抓):

Unattended-Upgrade::Mail "root";
Unattended-Upgrade::MailReport "on-change";

MailReport "on-change" 只在实际升级了包、或出现错误时发信,不会每天一封骚扰信。缺 MTA 的话装 bsmtp 太重,直接用 mailutils 配本地转发到外部 SMTP 即可;或者更简单——只写 syslog,用现成的日志告警去抓 Packages that will be upgraded 关键词。

然后自己写一个「该重启了吗」的巡检脚本,把判断权交给业务状态:

#!/bin/bash
# /usr/local/bin/reboot-check.sh
if [ -f /var/run/reboot-required ]; then
    running=$(systemctl list-units --state=running --no-legend \
              | grep -cE 'mysql|maria|php.*fpm|nginx|docker')
    longtx=$(mysql -Nse "SELECT COUNT(*) FROM information_schema.innodb_trx
                         WHERE TIME_TO_SEC(NOW())-TIME_TO_SEC(trx_started)>300" 2>/dev/null || echo 0)
    echo "$(date '+%F %T') reboot_required services=$running long_trx=$longtx $(cat /var/run/reboot-required.pkgs | tr '\n' ' ')"
fi

挂到 cron 每小时跑一次,输出进一个日志文件。看到 long_trx=0 且服务都健康,就自己挑个低峰期重启。这套做法把「重启决策」从盲目定时变成有数据依据。

第三步:盯住 MySQL 和 PHP 这两个高危对象

这是本文最重要的一节。unattended-upgrades 虽然只装 security 源的包,但 security 源里确实包含 MySQL、MariaDB、PHP 的次版本更新,而次版本更新有可能改动行为。

真实踩到的两个例子:

  • MariaDB 10.11.x 的一个安全更新,把 sql_mode 的默认值里补了 STRICT_TRANS_TABLES 相关的修正,结果一个依赖「超长字段自动截断」的老插件开始报 Data too long,站点首页直接 500;
  • PHP 8.2 的一个安全更新附带改了 GD 的边界检查,一个用了十几年没更新的验证码插件拼图失败,报 imagecreatefrompng(): Argument #1 is not a valid PNG。

这两次都不是「更新出了问题」,而是更新暴露了原本不健壮的代码。但对你来说结果是一样的:机器半夜自己升了包,早上站点是白的。

所以关键包应该被排除在自动升级之外,改成「自动下载、人工安装」。用 apt-mark hold 是最直白的做法:

apt-mark hold mariadb-server mariadb-client php8.2-fpm php8.2-mysql php8.2-gd
apt-mark showhold

代价是这些包的安全补丁也不会自动装了,所以必须配一个提醒:每周对比一次「已 hold 的包有没有新版本」。用 apt list --upgradable 过滤:

apt list --upgradable 2>/dev/null | grep -E 'mariadb|php8' \
  && echo "held packages have updates - review manually"

更好的折中是用 Package-Blacklist(Debian 12 的 unattended-upgrades 支持),让它只跳过这几个包,其余照常自动升级:

Unattended-Upgrade::Package-Blacklist {
    "mariadb-server";
    "mariadb-server-10.11";
    "php8.2-fpm";
    "php8.2-mysql";
    "php8.2-gd";
};

注意 Package-Blacklist 用的是正则匹配,写 "php8" 会把所有 php 包都跳过,看似方便但会让 php8.2-common 里的安全问题长期不修。要写具体包名。

第四步:内核更新与容器的冲突

内核包(linux-image-amd64)在 security 源里更新频率不低。它不会重启机器,但会带来两个连带的麻烦:

  1. /boot 空间被吃掉:每次内核更新装一个新 linux-image-6.1.0-xx-amd64 + initrd,占 100~150M。/boot 一般只分 512M,攒到 4 个版本就满了,之后下一次 apt 操作会直接失败并留下半配置状态(详见后面第五条)。
  2. DKMS 与内核头文件不同步:如果你装了什么树外模块(ZFS、部分网卡驱动、WireGuard),内核升级时 DKMS 会自动编译,但 linux-headers 必须同时到位,否则编译失败,模块在新内核下加载不了,重启后直接失去网络。

对策:

# 1) 只保留当前和上一个内核
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";

# 2) 在 apt 的 hook 里做 /boot 水位检查
# /etc/apt/apt.conf.d/99boot-check
DPkg::Pre-Install-Pkgs {"/usr/local/bin/boot-space-check";};

Remove-Unused-Kernel-Packages 只会在「内核包已经不再被引用」时清理,不会删正在运行的内核,所以是安全的。但它不清理孤立的 initrd 和不再需要的 /boot 残骸,所以水位检查还是有必要:

#!/bin/bash
# /usr/local/bin/boot-space-check
avail=$(df --output=avail -BM /boot | tail -1 | tr -d ' M')
if [ "$avail" -lt 200 ]; then
    echo "BOOT SPACE LOW: ${avail}M left on /boot" >&2
    ls -la /boot | sort -k5 -n | tail -10 >&2
    exit 1   # 非 0 会让本次 apt 操作中止,而不是装到一半卡住
fi

退出码 1 让 apt 在解包前就停下,比装到一半 SPACE 不足要好得多——那种情况 dpkg 会留下 half-configured 状态,后面要靠 dpkg --configure -a 收拾。

第五步:dpkg 锁与异地并行升级

apt 同一时间只能有一个实例。多台机器上通常不会撞,但有两个场景会:

  • 你手工跑 apt upgrade,同时 unattended-upgrades 的 apt-daily-upgrade.timer 触发了;
  • 配置管理工具(Chezmoi / Ansible / 自定义脚本)也在跑 apt。

表现是 Could not get lock /var/lib/dpkg/lock-frontend - open (11: Resource temporarily unavailable)。正确处置是等,不是删锁文件。先确认是谁在占:

ps aux | grep -E 'apt|dpkg|unattended' | grep -v grep
sudo lsof /var/lib/dpkg/lock-frontend

真的确认进程已经僵死(比如 apt 进程不存在但锁还在,通常发生在断电或 OOM kill 之后),才移除:

sudo rm /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock
sudo rm /var/lib/apt/lists/lock
sudo dpkg --configure -a

顺带说一句:apt-daily.timer 和 apt-daily-upgrade.timer 默认带 RandomizedDelaySec,在多台机器上会分散触发,这本身是好事,不要为了「整齐」把随机延迟去掉——否则五台机器同时更新同一个镜像源,容易被限速、也容易同时重启服务。

第六步:中央台账,知道谁漏了

自动化最大的隐患是「静默失败」。unattended-upgrades 在磁盘满、锁冲突、源不可达时会退出并在 /var/log/unattended-upgrades/ 留下记录,但没人看就等于没发生过。

最省事的核对方式是让每台机器每天早上把自己的状态推到一个中央位置(rsync 到一个目录,或者 POST 到自建的一个小接口),内容只需要三行:

#!/bin/bash
# /usr/local/bin/patch-report.sh  由 cron 每天 07:10 执行
H=$(hostname -s)
OUT=/var/local/patch-report/$H.txt
{
  echo "host: $H"
  echo "time: $(date -Is)"
  echo "last_upgrade: $(stat -c %y /var/log/unattended-upgrades/unattended-upgrades.log | cut -d. -f1)"
  echo "reboot_required: $([ -f /var/run/reboot-required ] && echo yes || echo no)"
  echo "upgradable: $(apt list --upgradable 2>/dev/null | grep -c upgradable)"
  echo "held_updates: $(apt-mark showhold | wc -l)"
  grep -c 'ERROR' /var/log/unattended-upgrades/unattended-upgrades.log
} > $OUT
rclone copy $OUT remote:patch-reports/ --quiet

然后在任意一台机器上 rclone ls remote:patch-reports/,五台机器的状态一目了然。看三个字段就够:last_upgrade 超过 48 小时说明这台没在更新(可能 timer 挂了或源配错);reboot_required: yes 超过一周说明你一直没重启;ERROR 计数大于 0 就要去看日志。

验证与回滚

方案上线后要验证三件事,缺一不可:

  1. dry-run 能看到待升级包:如果输出 No packages found that can be upgraded unattended 而你明明有安全更新,就是 Origin 配置错了;
  2. 模拟一次真实升级:在测试机上 systemctl start unattended-upgrades 手动触发一次,看日志是否完整、邮件/日志通知是否到达;
  3. 模拟一次失败:把 /boot 水位阈值临时调到 100000,确认 hook 能让 apt 中止而不是装坏。

回滚方面,最重要的不是「怎么卸载补丁」,而是在自动更新之前就有一个能用的快照。云主机的磁盘快照、或者每周一次 rsync -aHAX 到另一台机器,成本很低但能在「补丁把 PHP 弄坏」时省下整晚。本文这套流程里,所有高危包都被排除了自动升级,所以真正需要回滚的场景很少;但内核和 libc 的更新无法通过 apt 回滚,只能靠快照。

最后提醒一句:不要为了让「更新率 100%」好看而把自动重启打开。个人站的可用性风险主要来自数据库和长任务,不在补丁本身;把重启时机握在手里,是这套方案和「一键全自动」方案最本质的区别。

Last modification:October 21st, 2026 at 09:24 pm

Leave a Comment