↓ 跳过正文

小内存机器的无人值守更新:只装安全源、给 apt 划边界、并真跑一次验收

·3965 字·8 分钟
目录

上一篇 512MB 小内存云主机整机失联 里最有效的一条加固,是给 apt-daily / apt-daily-upgrade 套内存 cgroup 上限。这次换一台机器把这套做法补完整:464MB 内存、1 vCPU 的 KVM 小机,重装成 Debian 13 后重建,让无人值守的安全更新变成范围明确、资源有边、可验收的日常任务。文中数字来自 2026-10-05 的实机操作与回读,环境是 Debian 13 (trixie)、内核 6.12、systemd 257、unattended-upgrades 2.12、apt 3.0。

一、出厂就已经开着,第一个坑是那把 dpkg 锁
#

Debian 13 出厂自带 unattended-upgrades 2.12,并且已经 enabled。机器第一次开机后不久,安全更新任务就会自己跑起来。

重建过程里我先撞上的是它的副作用:新机首开机,apt-daily-upgrade 正在跑,我这时装 sing-box 的 deb 包,dpkg 报锁被 /usr/bin/python3.13 占着。问题不在锁,在我把 dpkg -i 的 stderr 重定向掉了,错误没显示出来,命令“成功”返回,二进制却不存在。同样的动作连挂两次才发现。

正确判据是看两条命令的输出:

fuser /var/lib/dpkg/lock-frontend                      # 有输出 = 有人持锁
systemctl is-active apt-daily.service apt-daily-upgrade.service

两个服务都 inactive、fuser 没有输出,才算真的没人跑。有两个更顺手但其实不对的写法:

pgrep -f 'apt-get|unattended-upgrade'   # 会匹配到执行这条命令的 shell 自己
pgrep -x unattended-upgr                # 会命中常驻的辅助进程

第二条命中的是 unattended-upgrade-shutdown --wait-for-signal。它是个长期挂着的辅助进程,不是在升级,按它判锁会一直以为锁被占着。

二、把范围收到安全源:apt.conf.d 里的列表是追加,不是覆盖
#

默认允许的范围比你以为的宽。/etc/apt/apt.conf.d/50unattended-upgrades 里默认开着三条:

"origin=Debian,codename=${distro_codename}-updates";
"origin=Debian,codename=${distro_codename},label=Debian";
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";

注释里还给了 -proposed-updates 的样例,默认没启用。中间那条 label=Debian 指的是整个 stable 源,不是安全源。

这台机器的目标是只装安全更新:小机器上升级次数越少,内存峰值出现的次数越少;stable 源的常规更新可以等登录维护时批量做。第一版我把自定义写进了编号更大的 /etc/apt/apt.conf.d/52-uu-local.conf,以为后加载会覆盖前面的。但 Origins-Pattern 是列表型选项,apt 对列表是追加合并,默认那条照样生效,dry-run 出来的白名单里 label=Debian 还在。

改法只有动默认文件本体,注释掉那一行,自己的文件只留开关:

 # /etc/apt/apt.conf.d/50unattended-upgrades
-        "origin=Debian,codename=${distro_codename},label=Debian";
+        // disabled-by-ops: "origin=Debian,codename=${distro_codename},label=Debian";
         "origin=Debian,codename=${distro_codename},label=Debian-Security";
         "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
cat > /etc/apt/apt.conf.d/52-uu-local.conf <<'EOF'
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "false";
Unattended-Upgrade::SyslogEnable "true";
Unattended-Upgrade::SyslogFacility "daemon";
EOF

验收看 dry-run 的这一行,它直接打印有效白名单:

unattended-upgrade --dry-run --debug 2>&1 | grep '^Allowed origins'
# Allowed origins are: origin=Debian,codename=trixie,label=Debian-Security,
#   origin=Debian,codename=trixie-security,label=Debian-Security

只看到这一行还不够,因为“剩下的确实是安全源”和“别的源被彻底排除”是两回事。debug 输出里每个候选包都带来源标记,这才是范围收缩的证据:

Checking: bash=5.2.37-2+b10 ([<Origin component:'main' archive:'stable'
  origin:'Debian' label:'Debian' isTrusted:True>])

bash 这样来自 stable 的包会被列出来检查,但不在白名单里,所以不会被装。

Automatic-Reboot 要单独想一次。Debian 的默认值是 false,装完内核更新不会自己重启。上一篇那两台机器的结论是保留 true,因为站主没有定期登录机器的习惯,关掉之后内核更新就永远不会生效。这一台留 false,因为它会不定期登录做维护。两个选择都对,判据只有一条:装了内核更新却永远不重启,等于没装。

三、给这些任务划 cgroup 边界
#

OOM 打死网络栈的根因,是升级进程和别的常驻进程共用同一池内存。解决办法是让升级任务超出预算时死在自己的 cgroup 里,不影响别人。

for u in apt-daily apt-daily-upgrade; do
  mkdir -p /etc/systemd/system/${u}.service.d
  cat > /etc/systemd/system/${u}.service.d/20-memory-limit.conf <<'EOF'
[Service]
MemoryMax=250M
MemoryHigh=200M
CPUQuota=60%
IOWeight=50
Nice=10
EOF
done
systemctl daemon-reload

五项各自的用途:

  • MemoryMax=250M:硬上限。超了就在自己的 cgroup 内被 OOM 掉,被杀掉的只是升级任务。这台机器总内存 464MB,日常占用 170MB 上下,250M 足够完成解压;上一篇那两台 512MB 的机器用的是同一个值,跑完整批安全更新没出过事。
  • MemoryHigh=200M:软上限。过了先限速回收内存,不直接杀进程,给硬上限留缓冲。
  • CPUQuota=60%:这台机器只有 1 vCPU,apt 解压和 zstd 会长时间占满 CPU。不加这条,升级期间同机的代理进程会被挤到超时。它管的是 CPU,和内存上限一样属于“别影响别人”。
  • IOWeight=50、Nice=10:磁盘和 CPU 调度上主动让路,优先级给常驻服务。

回读的时候有个细节:CPU 配额的属性名不是 CPUQuota,是 CPUQuotaPerSecUSec,名字写错会看到空值,还以为没生效。

systemctl show apt-daily-upgrade.service --value \
  -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSec -p IOWeight -p Nice
# 262144000 209715200 600ms 50 10

600ms 就是 60%,也就是每 1 秒里最多用 600 毫秒 CPU。

四、把触发时间摊开,并确认定时器没有假死
#

小机器上两个 apt 任务撞在同一分钟里跑,内存峰值会叠在一起。给触发时间加随机延迟:

mkdir -p /etc/systemd/system/apt-daily.timer.d /etc/systemd/system/apt-daily-upgrade.timer.d
cat > /etc/systemd/system/apt-daily.timer.d/20-spread.conf <<'EOF'
[Timer]
RandomizedDelaySec=1h
Persistent=true
EOF
cat > /etc/systemd/system/apt-daily-upgrade.timer.d/20-spread.conf <<'EOF'
[Timer]
RandomizedDelaySec=2h
Persistent=true
EOF
systemctl daemon-reload

升级那个用 2h,让它比刷新索引的那次更晚、更分散。

改完必须查 NEXT 不是 -:

systemctl list-timers 'apt-daily*' net-watchdog.timer --no-pager
NEXT                         LEFT  LAST                         PASSED   UNIT
Mon 2026-10-05 12:26:17 UTC  1min  Mon 2026-10-05 12:24:17 UTC  40s ago  net-watchdog.timer
Mon 2026-10-05 18:40:35 UTC    6h  Mon 2026-10-05 11:42:41 UTC  42min    apt-daily.timer
Tue 2026-10-06 07:19:41 UTC   18h  Mon 2026-10-05 11:59:59 UTC  24min    apt-daily-upgrade.timer

NEXT - 表示这个定时器以后不再排期,等于被悄悄停掉了。我们在自建单元上踩过这个坑:RandomizedDelaySec 和 Persistent=true 同时出现时,有过 NEXT 变成 - 的情况。Debian 自带的单元本身带着这两项,行为正常,但既然是手改的配置,改完就查一次。

五、加 swap 与内核参数
#

这是兜底里最便宜的一件:让突发内存压力有个去处,而不是直接触发 OOM。

fallocate -l 1G /swapfile
chmod 600 /swapfile && mkswap /swapfile >/dev/null && swapon /swapfile
grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab

cat > /etc/sysctl.d/99-small-vps.conf <<'EOF'
vm.swappiness = 20
vm.vfs_cache_pressure = 50
vm.min_free_kbytes = 16384
EOF
sysctl --system

swappiness=20 让这台机器尽量不把常驻进程换出去(代理进程被换出再换回会明显变慢),swap 只当突发缓冲。min_free_kbytes=16384 给内核留一点水位。

swap 的作用边界要清楚:它降低 OOM 发生的概率,不改变 OOM 发生时内核挑谁下手。真要根治还是加内存或者升规格。

六、网络栈的保命措施:先查现值,再决定加不加
#

上一篇的做法是给 systemd-networkd 加一个 drop-in,设 OOMScoreAdjust=-1000 和 Restart=always,让它在内存压力下几乎不会被 OOM 挑中,并且意外退出后自动起来。

这一台先查了现值:

systemctl show systemd-networkd -p OOMScoreAdjust -p Restart
# OOMScoreAdjust=-1000
# Restart=always

Debian 13 的 systemd 257 出厂就是这个值(systemd 247 之后这是默认),所以不需要再加 drop-in。上一篇那两台 Ubuntu 22.04 的机器当时查到的是 0 / on-failure,那里加的 drop-in 是必要且实测生效的。

这一步值得单独记一条:加固动作要在查过现值之后再决定,否则档案里会出现一条“我加了防护”的记录,而实际上什么都没改变。同一台机器上,OOMScoreAdjust 对已经在运行的进程要下次启动才生效,所以查值比强行重启网络服务更有意义,重启网络服务会短暂断网。

网络层还留一个看门狗兜底:

cat > /usr/local/sbin/net-watchdog.sh <<'EOF'
#!/bin/bash
LOG=/var/log/net-watchdog.log
if ! ip -4 route show default | grep -q .; then
  echo "$(date -Is) NO-DEFAULT-ROUTE" >> "$LOG"
  systemctl restart systemd-networkd 2>/dev/null
  sleep 10
  if ip -4 route show default | grep -q .; then
    echo "$(date -Is) recovered" >> "$LOG"
  else
    echo "$(date -Is) STILL-DOWN" >> "$LOG"
  fi
fi
EOF
chmod +x /usr/local/sbin/net-watchdog.sh

cat > /etc/systemd/system/net-watchdog.service <<'EOF'
[Unit]
Description=Network default-route watchdog
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/net-watchdog.sh
EOF

cat > /etc/systemd/system/net-watchdog.timer <<'EOF'
[Unit]
Description=Run network watchdog every 2 minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=2min
AccuracySec=15s
[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload && systemctl enable --now net-watchdog.timer

脚本里重启的对象按机器上实际在管的网络服务选(networkd、NetworkManager 或 ifupdown)。它只治“默认路由丢了”这一种情况,机器彻底卡死时它自己也跑不起来,所以是兜底不是主力。

代理进程给一个比升级任务更高的生存优先级:

mkdir -p /etc/systemd/system/sing-box.service.d
cat > /etc/systemd/system/sing-box.service.d/20-oom-priority.conf <<'EOF'
[Service]
OOMScoreAdjust=-300
EOF

内存真的不够时,先死的是更新任务,不是代理。

七、验收:真跑一次,而且要 detached
#

dry-run 只能证明白名单是对的,证明不了这个任务在 464MB 上能跑完。要证明就得让它真装一次。

真跑必须放在独立的瞬时单元里,因为一批安全更新里可能包含 openssh,重启 sshd 会掐断你正在用的会话:

systemd-run --unit=uu-once --collect /usr/bin/unattended-upgrade --debug

--collect 让单元跑完后自动清理。然后看结果:

systemctl show uu-once -p Result
journalctl -u uu-once | grep -E 'All upgrades installed|No packages found'

本次结果:

检查项 命令 结果
升级任务结果 systemctl show uu-once -p Result success
升级日志 journalctl -u uu-once All upgrades installed(util-linux、fdisk、bind9、libexpat1、libpng 等一批)
是否触发 OOM journalctl -b -k | grep -ciE 'Out of memory|Killed process' 0
内存与 swap free -m 已用 170MB / 464MB,swap 用 32MB
上限生效 systemctl show apt-daily-upgrade.service --value -p MemoryMax 262144000
失败单元 systemctl --failed --no-legend | wc -l 0
整机状态 systemctl is-system-running running
网络栈保护 systemctl show systemd-networkd --value -p OOMScoreAdjust -p Restart -1000 / always
看门狗 ls -l /var/log/net-watchdog.log 文件不存在
定时器 systemctl list-timers 三个都有 NEXT

看门狗那一行值得解释:脚本只在默认路由丢失时才写日志,日志文件不存在就说明它从没触发过,也就是默认路由一直正常。这种“没有日志即正常”的设计,比让它每分钟写一行心跳更容易判断状态。

八、留给后来者的清单
#

  • 新机器上装任何 deb 之前先等锁:fuser /var/lib/dpkg/lock-frontend 没有输出,且 apt-daily、apt-daily-upgrade 都 inactive。别用 pgrep,两种写法都会误报。
  • 别把 dpkg -i 的 stderr 丢掉。加上 cgroup 上限之后,因内存不足而失败的安装会更常见,看不到报错就会像这次一样连挂两次还不知道为什么。
  • Origins-Pattern 写在编号更大的新文件里不生效,必须注释 50unattended-upgrades 本体那一行。验收看 --dry-run --debug 的 Allowed origins,同时看 Checking: 行里的来源标记是否被排除。
  • apt-daily 与 apt-daily-upgrade 两个服务都加 drop-in:MemoryMax=250M、MemoryHigh=200M;1 vCPU 的机器再加 CPUQuota=60%、IOWeight=50、Nice=10。
  • 定时器加 RandomizedDelaySec 摊开触发时间,改完查 systemctl list-timers 的 NEXT 不是 -。
  • swap 先加,再谈别的加固;小机器上 swappiness=20 比默认值合适。
  • 动 systemd-networkd 之前先查 OOMScoreAdjust 和 Restart。新系统上很可能已经是对的,写一个不起作用的 drop-in 然后记成“已加固”比不写更糟。
  • 给代理进程 OOMScoreAdjust=-300,内存紧张时优先牺牲更新任务。
  • 验收要真跑一次,而且用 systemd-run 起独立单元跑,不要在 SSH 会话里直接跑。
  • 遗留的边界:apt 被限制在 250M 之后,个别大升级可能因为内存不足而失败,同时机器完好无事。这类失败没有独立告警(监控通常只报在线或离线),要覆盖它得自己加一个“升级任务失败”的探针。这是这套配置换来的确定性代价:牺牲的是一次升级的成功,换来的是机器不会因为升级失联。

九、碎碎念
#

无人值守更新有一处很别扭:它每天都会跑,平时没人看结果,出问题时又多半在你不在场的时间点。给它划边界的操作本身不复杂,难的是守住三个习惯,动手前查现值、改完真跑一次、把“应该生效了”换成一条命令的输出。这台机器从加固到验收花了不到半小时,换来的是升级任务从此不能影响整机;至于升级本身是否每次都成功,那是另一件事,得靠日志和探针去看。