↓ 跳过正文

上游镜像没跟上补丁时,我给 12 个容器换了 Node 运行时

·4805 字·10 分钟
目录

十月 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 用户的镜像才会遇到:

  1. ADD <url> 落下来的文件是 root:0600。镜像里 USER 不是 root 时,下一步 RUN tar -xJf 直接报 Permission denied。改成 ADD --chmod=644,临时 USER root 覆盖二进制,末尾再 USER <原用户> 还原,还原后 docker image inspect --format '{{.Config.User}}' 核一遍。
  2. Alpine 容器先看 ldd $(command -v node) 是 ld-musl-x86_64.so.1 还是 libc.so.6。musl 必须用 -musl.tar.gz 变体,拿 glibc 包覆盖会把运行时打废,而 arm64 没有 musl 包。
  3. 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

四、看门狗盯两件事
#

关掉自动更新之后,没人会知道上游是否已经修好,也看不出容器被谁换回了旧镜像。看门狗每轮做两件事:

  1. 回读每个容器内的 process.version,与基线不符就告警「疑似被回退」。
  2. 逐个比对上游 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 达到修复版。每次退役的动作固定四步:

  1. 备份 compose,只改目标服务的块:image: 换回上游 tag,删掉本工具加的 watchtower 标签行和它上方那行注释。
  2. docker compose up -d --no-deps <服务>。不带 --no-deps 会把同项目的其它容器一起重建。
  3. 回读镜像名、容器状态、容器内 node。
  4. 任何一步失败就用备份回滚并再 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 修不了、哪条路径能自愈,以及怎么让自动化在出错时退回去。前两件事靠读,后两件必须真跑一遍才算数。