十月 Node 安全公告一次发了一批 CVE,修复版只存在于新的补丁发行版里。生产容器里的 node 由上游 base 镜像带进来,而 base 镜像重建了并不等于修好了:实测公告次日重建的 base 里仍是旧版本。这篇记录两天里的完整过程,包括怎么判断哪些位置真的受影响、怎么在既有镜像上覆盖一个二进制、为什么必须关掉自动更新、看门狗怎么同时盯住「补丁被回退」和「上游已自愈」两种变化,以及上游真的发版之后,自动退役是怎么做到带备份和回滚的。
一、先确认哪些位置真的受影响 #
公告按线给出三个修复版:22.23.4、24.22.1、26.12.1。20 线已经 EOL,没有补丁发行版,这条线上的运行时要靠升线或关停解决。
判断某个容器是否已经修好,判据只有一个,容器内的 process.version:
docker exec <容器> node -p 'process.version+" modules="+process.versions.modules'不要用 brotli 或 ares 的版本串当判据。brotli 的安全修复是 cherry-pick 进既有的 1.1.0/1.2.0 里的,版本号不变。这两个串可以用来做反向交叉校验:某个已确认更新的容器(我这边是管理平台自带的 24.22.1)的四元组 ares/undici/openssl 就是这次补丁集合的指纹,其余容器低于它即落后。
第二个常见误判是把镜像的重建时间当成已经修复。反代面板的 node 来自它的 base 镜像 nginxproxymanager/nginx-full:certbot-node,那张 base 在公告次日 15:48 UTC 重建过,里面只有 22.23.3,差一个小版本。也就是说那天即便从上游链路重建面板,也修不好,因为 apt 源还没跟上。
第三个要判的是暴露面。brotli 越界写那条是 High,我在面板的 /app/app.js 里看到 app.use(bodyParser.json()) 注册在 CORS 与 JWT 之前,body-parser 2.3.0 对 Content-Encoding: br 直接调用 zlib.createBrotliDecompress()。一个带 br 压缩体的 POST 在鉴权之前就进到解码器。公告没给触发细节,我不能断言这条漏洞必经此处,但入口是实测存在的,按可达处理。另外几条 High 打的是出站 TLS 校验与域名解析,而面板还要给一批域名续证书,暴露面同样不小。
把这些落成一张表之后结论很清楚:只有公网反代面板需要立即修,其余 11 个容器顺带清,3 个 node 20 的服务关停。两台机器加起来 12 个容器要换运行时。
二、覆盖一个二进制就够 #
node 官方 tarball 里的二进制是自包含的,检查两条前提就能直接覆盖容器的 /usr/bin/node:
docker exec <容器> ldd /usr/bin/node | grep -c libnode # 期望 0
docker exec <容器> node -p process.versions.modules # 与目标版本一致ABI 一致时原生插件不用重编。同 minor 的 modules 必然相同,22 线是 127,24 线是 137。
Dockerfile 全文如下:
FROM <上游镜像>:<tag>
ADD https://nodejs.org/dist/v22.23.4/node-v22.23.4-linux-x64.tar.xz /tmp/node.tar.xz
RUN tar -xJf /tmp/node.tar.xz -C /tmp \
&& cp /tmp/node-v22.23.4-linux-x64/bin/node /usr/bin/node \
&& /usr/bin/node -v && rm -rf /tmp/node*十几秒就能构建完。构建期撞了三个坑,第一个只有非 root 用户的镜像才会遇到:
ADD <url>落下来的文件是 root:0600。镜像里USER不是 root 时,下一步RUN tar -xJf直接报 Permission denied。改成ADD --chmod=644,临时USER root覆盖二进制,末尾再USER <原用户>还原,还原后docker image inspect --format '{{.Config.User}}'核一遍。- Alpine 容器先看
ldd $(command -v node)是ld-musl-x86_64.so.1还是libc.so.6。musl 必须用-musl.tar.gz变体,拿 glibc 包覆盖会把运行时打废,而 arm64 没有 musl 包。 - busybox 的 tar 不支持 xz,报
tar: invalid option -- J,或者静默退 1。Alpine 里换.tar.gz变体,不要为了解压去apk add xz污染镜像。
构建完立刻回读镜像里的版本,不看 Dockerfile 里写了什么:
docker run --rm --entrypoint node local/<服务>-overlay:node22.23.4 -p 'process.version+" "+process.versions.openssl'三、关掉自动更新是方案的一部分 #
补丁镜像的 tag 是本地的,没有 registry。如果让 watchtower 继续管这些容器,最好的情况是每天拉不到、刷错误日志;更糟的情况是有人把补丁打在上游同名 tag 上,下一次扫描会把容器静默换回带漏洞的镜像。所以打补丁的容器一律加:
labels:
- com.centurylinklabs.watchtower.enable=false代价是这些容器不再自动跟随上游应用更新。这个代价必须当场补上检测,否则关掉自动更新只是制造了一块没人看的盲区。
有个例外。平台本体那类升级链路不能断的容器(每天自己 pull 上游镜像)不适合 overlay,改用只读挂载覆盖二进制:image: 保持官方 tag,只把下载好的 node 挂到 /usr/local/bin/node。这样 docker compose pull/up 与 watchtower 都照旧,挂载在容器重建时会保留。挂之前先验一遍这个二进制能在镜像里跑:
docker run --rm -v <bin路径>:/usr/local/bin/node:ro <镜像> node -v四、看门狗盯两件事 #
关掉自动更新之后,没人会知道上游是否已经修好,也看不出容器被谁换回了旧镜像。看门狗每轮做两件事:
- 回读每个容器内的
process.version,与基线不符就告警「疑似被回退」。 - 逐个比对上游 tag 的 digest。digest 变了就把镜像真拉下来读里面的 node,达到修复版就输出退役指令,白名单内的容器直接自动退役(下一节);没达到就推进基线,静默。
第一条的坑是探针位置。镜像内的 node 必须在容器里读:
docker run --rm --entrypoint node <上游镜像> -p process.version我一开始写成在宿主机上循环,宿主机根本没有 node,于是那张伪造 digest 的测试永远只报「读不到镜像内 node」,看起来像是上游坏了。
第二条的坑是连接数。每台机器只开一次 ssh,把该机所有镜像的 digest 在一个连接里探完,镜像之间并发,全程两次连接。串行开十几个 ssh 会把跳板机惹毛,之后一段时间连它自己都回 Permission denied (publickey)。那是限流伪装成鉴权失败,不是密钥坏了,正确处理是停手等几分钟,再发一次探测。
形态按静默看门狗做:健康时零字节输出、不投递;异常推一条消息。远端探针连续失败只落盘留痕,不让网络抖动变成刷屏。监听器自身的未捕获异常必须出声,因为「崩溃等于永远静默」是这套机制里最危险的失败形态。
五、自动退役 #
白名单只放低风险容器:可随时重建、无在线协同、无 DB 迁移的那几个。TLS 前门、平台本体、在线协作文档、需要跑迁移的服务一律只告警,退役由人按手册执行。让 cron 无人值守地重建前门,炸的是一条线上的所有站点。
触发条件是回读上游镜像内的 node 达到修复版。每次退役的动作固定四步:
- 备份 compose,只改目标服务的块:
image:换回上游 tag,删掉本工具加的 watchtower 标签行和它上方那行注释。 docker compose up -d --no-deps <服务>。不带--no-deps会把同项目的其它容器一起重建。- 回读镜像名、容器状态、容器内 node。
- 任何一步失败就用备份回滚并再
up -d,本轮不推进该镜像的 digest 基线,下一轮自动重试,失败要在告警里写明「已回滚」。
第 1 步是这套东西里唯一有算法的地方。只动目标服务块,不能全文替换:同一个 compose 文件里 db、surrealdb 那些别块常有同名标签,全局 sed 会把它们一起删掉。
改写程序(下发到宿主机执行,逐行原样)
import re, sys
f, old, up = sys.argv[1], sys.argv[2], sys.argv[3]
src = open(f, encoding='utf-8').read()
lines = src.splitlines(keepends=True)
def indent(s):
return len(s) - len(s.lstrip(' '))
def is_service_key(ln):
# 服务级键:缩进正好 2 且以 ':' 结尾(如 ' trilium:' / ' open_notebook:')
return bool(re.match(r'^ [A-Za-z0-9_.\-]+:\s*$', ln))
# 1) 定位 image 行
idx = [i for i, ln in enumerate(lines) if ln.strip() == f"image: {old}"]
if len(idx) != 1:
print(f"EXPECT_ONE_IMAGE_LINE got={len(idx)}", file=sys.stderr); sys.exit(3)
img_i = idx[0]
# 2) 服务块范围:往上找最近的服务键,往下到下一个服务键 / 任何顶层非空行
start = 0
for i in range(img_i, -1, -1):
if is_service_key(lines[i]):
start = i; break
end = len(lines)
for i in range(img_i + 1, len(lines)):
ln = lines[i]
if not ln.strip() or ln.lstrip().startswith('#'):
continue
if is_service_key(ln) or indent(ln) == 0:
end = i; break
block = lines[start:end]
# 3) 改 image
for i, ln in enumerate(block):
if ln.strip() == f"image: {old}":
block[i] = ln.replace(f"image: {old}", f"image: {up}")
# 4) 删本工具加的标签(仅本块):标签行 + 紧随其上的注释行
label_idxs = [i for i, ln in enumerate(block)
if ln.strip() == "- com.centurylinklabs.watchtower.enable=false"]
drop = set()
for i in label_idxs:
drop.add(i)
j = i - 1
while j >= 0 and block[j].strip() == "":
j -= 1
if j >= 0 and block[j].lstrip().startswith("#") and ("本地补丁镜像" in block[j] or "2026-10-22" in block[j]):
drop.add(j)
block = [ln for i, ln in enumerate(block) if i not in drop]
# 5) labels: 空了就整键删掉(只针对本块,且只删这一处 labels:)
li = [i for i, ln in enumerate(block) if ln.strip() == "labels:"]
for i in li:
if i + 1 >= len(block) or indent(block[i + 1]) <= indent(block[i]):
del block[i]
break
lines[start:end] = block
new = "".join(lines)
if new == src:
print("NOCHANGE", file=sys.stderr); sys.exit(4)
open(f, 'w', encoding='utf-8').write(new)
print("REWRITE_OK")两个 bug 都是在演练里抓出来的,读代码看不出来:
- 服务块边界原来只判「缩进为 0 的顶层行」,而服务键在缩进 2。结果是后一个服务被当成同一块,合成用例立刻失败。
if ! docker compose up -d --no-deps "$SVC" | tail -4; then的退出码永远来自tail。管道把失败变成了成功,回滚分支根本不会触发。脚本开头加set -o pipefail之后这条判断才成立。
执行部分是这样的:
退役脚本(生成后下发到宿主机,逐行原样)
#!/bin/bash
# retire-container.sh <dir> <compose_file> <service> <container> <old_tag> <upstream_image>
# 由上游编排的 Python 工具生成下发:把某个容器从本地补丁镜像退回官方镜像,失败即回滚。
set -u
set -o pipefail # 否则 `docker compose up | tail -4` 的退出码永远来自 tail,回滚分支永不触发
DIR="$1"; F="$2"; SVC="$3"; CNAME="$4"; OLD="$5"; UP="$6"
cd "$DIR" || { echo "CD_FAIL $DIR"; exit 2; }
BK="$F.bak-retire-$(date +%Y%m%d-%H%M%S)"
cp "$F" "$BK" || { echo "BACKUP_FAIL"; exit 2; }
echo "backup=$BK"
python3 - "$F" "$OLD" "$UP" <<'PY' || { echo "REWRITE_FAIL(未改动)"; exit 3; }
__TRANSFORM__
PY
grep -n "image:" "$F" | head -5
if ! docker compose -f "$F" up -d --no-deps "$SVC" 2>&1 | tail -4; then
echo "UP_FAIL -> 回滚"
cp "$BK" "$F"
docker compose -f "$F" up -d --no-deps "$SVC" 2>&1 | tail -3
exit 4
fi
# 重建后容器 IP 变了,反代里缓存的 upstream 需要重新解析,否则该站点会 502(反代实例名各机不同)
for npm in nginxproxymanager-app-1 npm_app; do
if docker ps --format '{{.Names}}' | grep -qx "$npm"; then
docker exec "$npm" nginx -s reload >/dev/null 2>&1 && echo "npm_reload=ok($npm)" || echo "npm_reload=fail($npm)"
fi
done
sleep 12
IMG=$(docker inspect "$CNAME" --format '{{.Config.Image}}' 2>/dev/null | tr -d '\r')
STATE=$(docker inspect "$CNAME" --format '{{.State.Status}}' 2>/dev/null | tr -d '\r')
NODE=$(docker exec "$CNAME" node -p process.version < /dev/null 2>/dev/null | tr -d '\r')
echo "verify: state=$STATE image=$IMG node=$NODE(期望 image=$UP)"
if [ "$IMG" != "$UP" ] || [ "$STATE" != "running" ]; then
echo "VERIFY_FAIL -> 回滚"
cp "$BK" "$F"
docker compose -f "$F" up -d --no-deps "$SVC" 2>&1 | tail -3
exit 5
fi
echo "RETIRE_OK $CNAME"演练在宿主上建一个一次性容器,配一张假的 compose,把「退役成功」和「故意把服务名写错,进入回滚」两条分支真跑一遍,跑完自清理。判定要看输出关键字:想要的必须是 UP_FAIL -> 回滚 或 VERIFY_FAIL,看到 REWRITE_FAIL 就说明第一步就把场景搭错了,什么都没证。第二条分支之前要先把前提复位,因为第一条分支跑完 compose 已经是上游 tag,直接再跑失败分支会在改写阶段报 EXPECT_ONE_IMAGE_LINE got=0 就退出。
演练「上游已自愈」这条用旋钮伪造:伪造 digest 配伪造的目标版本,再加一个 dry-run 开关只打印将执行的命令。不要为了测试真把生产容器的补丁撤掉,那等于把漏洞放回线上。
六、第一次真实事件 #
上线第二天,看门狗报了第一个真实的上游自愈:在线协作文档与平台本体的上游镜像重建后,镜像内的 node 已经是修复版。我没有直接信它的结论,重新读了两张镜像的构建时间和镜像内 node,modules 也对上,才动手。
两处都退了。协作文档把 image: 改回官方 tag、删掉 watchtower 标签,up -d --no-deps 之后容器内 node 是 v24.22.1,站点 200,watchtower 恢复管辖。平台本体删掉 compose 里那行只读挂载,两个容器都回到 v26.12.1,dashboard 与消息网关各自给出就绪日志。
中间踩到一个 502。容器重建后 IP 变了,反代缓存的 upstream 不会自己重解析,站点直接 502,docker exec <反代容器> nginx -s reload 立刻恢复。这条已经写进退役脚本,up -d 之后无条件 reload 反代。推广开来说,重建任何被反代挡着的容器之后都要 reload,不只退役这一条路径。
之后把监控范围缩到 9 个容器、6 个上游镜像,退役掉的两处不再盯。
七、顺带挖出的静默故障 #
排查中发现两个容器的 compose 里 memswap_limit 小于 mem_limit,768m 对 1024m、448m 对 512m。这在 Docker 里是非法配置,任何重建都会被直接拒绝,包括 watchtower 的每日自动更新。也就是说这两个容器早就冻在旧镜像上,因为没人手工重建过,所以一直没人发现。热修前先 docker compose config | grep mem 扫一遍,代价很低。
node 20 那三个服务没有补丁可打。其中一个是面板自带的 web terminal,公网可达、登录后就是 root shell,同时还是 EOL 运行时,处理方式是关掉而不是慢慢升线:WEB_TERMINAL=false 加 systemctl disable,再确认端口无监听。另外两个直接 stop,并把 compose 的服务块注释掉,因为它们被 watchtower 管着,只 stop 不够。
八、这套流程的边界 #
- 判据永远是回读版本。上游重建了、base 镜像更新了、tag 时间变了,都替代不了
docker run --entrypoint node <img> -p process.version。 - 自动化的边界按爆炸半径划。白名单内的容器自动退役,前门、平台本体、有状态服务只告警。
- 关掉自动更新必须同时补上检测,否则「安全了」只是没人再碰它。
- 现在仍有 9 个容器处在补丁态。上游自愈之后,5 个自动退回官方镜像,4 个等人确认。
两天里真正花时间的不是打镜像(十几秒一张),而是判定链:哪些位置受影响、为什么 pull 修不了、哪条路径能自愈,以及怎么让自动化在出错时退回去。前两件事靠读,后两件必须真跑一遍才算数。