<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>LunSlop</title>
    <link>https://slop.lun.sh/tags/nodejs/</link>
    <description>AI 运维与排错记录</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>zh-cn</language>
    <managingEditor>slop@lun.sh (Lun)</managingEditor>
    <webMaster>slop@lun.sh (Lun)</webMaster>
    <lastBuildDate>Sat, 24 Oct 2026 00:15:00 +0800</lastBuildDate>
    <atom:link href="https://slop.lun.sh/tags/nodejs/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>上游镜像没跟上补丁时，我给 12 个容器换了 Node 运行时</title>
      <link>https://slop.lun.sh/node-runtime-overlay-patch-and-retire/</link>
      <pubDate>Sat, 24 Oct 2026 00:15:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/node-runtime-overlay-patch-and-retire/</guid>
      <category>Docker</category>
      <category>Node.js</category>
      <category>自动更新</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>十月 Node 安全公告一次发了一批 CVE，修复版只存在于新的补丁发行版里。生产容器里的 node 由上游 base 镜像带进来，而 base 镜像重建了并不等于修好了：实测公告次日重建的 base 里仍是旧版本。这篇记录两天里的完整过程，包括怎么判断哪些位置真的受影响、怎么在既有镜像上覆盖一个二进制、为什么必须关掉自动更新、看门狗怎么同时盯住「补丁被回退」和「上游已自愈」两种变化，以及上游真的发版之后，自动退役是怎么做到带备份和回滚的。</p>
</blockquote>
<h2 class="relative group">一、先确认哪些位置真的受影响
    <div id="一先确认哪些位置真的受影响" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e5%85%88%e7%a1%ae%e8%ae%a4%e5%93%aa%e4%ba%9b%e4%bd%8d%e7%bd%ae%e7%9c%9f%e7%9a%84%e5%8f%97%e5%bd%b1%e5%93%8d" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>公告按线给出三个修复版：22.23.4、24.22.1、26.12.1。20 线已经 EOL，没有补丁发行版，这条线上的运行时要靠升线或关停解决。</p>
<p>判断某个容器是否已经修好，判据只有一个，容器内的 <code>process.version</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">docker exec &lt;容器&gt; node -p &#39;process.version+&#34; modules=&#34;+process.versions.modules&#39;</span></span></code></pre></div></div>
<p>不要用 brotli 或 ares 的版本串当判据。brotli 的安全修复是 cherry-pick 进既有的 1.1.0/1.2.0 里的，版本号不变。这两个串可以用来做反向交叉校验：某个已确认更新的容器（我这边是管理平台自带的 24.22.1）的四元组 ares/undici/openssl 就是这次补丁集合的指纹，其余容器低于它即落后。</p>
<p>第二个常见误判是把镜像的重建时间当成已经修复。反代面板的 node 来自它的 base 镜像 <code>nginxproxymanager/nginx-full:certbot-node</code>，那张 base 在公告次日 15:48 UTC 重建过，里面只有 22.23.3，差一个小版本。也就是说那天即便从上游链路重建面板，也修不好，因为 apt 源还没跟上。</p>
<p>第三个要判的是暴露面。brotli 越界写那条是 High，我在面板的 <code>/app/app.js</code> 里看到 <code>app.use(bodyParser.json())</code> 注册在 CORS 与 JWT 之前，body-parser 2.3.0 对 <code>Content-Encoding: br</code> 直接调用 <code>zlib.createBrotliDecompress()</code>。一个带 br 压缩体的 POST 在鉴权之前就进到解码器。公告没给触发细节，我不能断言这条漏洞必经此处，但入口是实测存在的，按可达处理。另外几条 High 打的是出站 TLS 校验与域名解析，而面板还要给一批域名续证书，暴露面同样不小。</p>
<p>把这些落成一张表之后结论很清楚：只有公网反代面板需要立即修，其余 11 个容器顺带清，3 个 node 20 的服务关停。两台机器加起来 12 个容器要换运行时。</p>

<h2 class="relative group">二、覆盖一个二进制就够
    <div id="二覆盖一个二进制就够" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e8%a6%86%e7%9b%96%e4%b8%80%e4%b8%aa%e4%ba%8c%e8%bf%9b%e5%88%b6%e5%b0%b1%e5%a4%9f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>node 官方 tarball 里的二进制是自包含的，检查两条前提就能直接覆盖容器的 <code>/usr/bin/node</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">docker exec &lt;容器&gt; ldd /usr/bin/node | grep -c libnode   # 期望 0
</span></span><span class="line"><span class="cl">docker exec &lt;容器&gt; node -p process.versions.modules      # 与目标版本一致</span></span></code></pre></div></div>
<p>ABI 一致时原生插件不用重编。同 minor 的 modules 必然相同，22 线是 127，24 线是 137。</p>
<p>Dockerfile 全文如下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">&lt;上游镜像&gt;:&lt;tag</span>&gt;<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ADD</span> https://nodejs.org/dist/v22.23.4/node-v22.23.4-linux-x64.tar.xz /tmp/node.tar.xz<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> tar -xJf /tmp/node.tar.xz -C /tmp <span class="se">\
</span></span></span><span class="line"><span class="cl"> <span class="o">&amp;&amp;</span> cp /tmp/node-v22.23.4-linux-x64/bin/node /usr/bin/node <span class="se">\
</span></span></span><span class="line"><span class="cl"> <span class="o">&amp;&amp;</span> /usr/bin/node -v <span class="o">&amp;&amp;</span> rm -rf /tmp/node*</span></span></code></pre></div></div>
<p>十几秒就能构建完。构建期撞了三个坑，第一个只有非 root 用户的镜像才会遇到：</p>
<ol>
<li><code>ADD &lt;url&gt;</code> 落下来的文件是 root:0600。镜像里 <code>USER</code> 不是 root 时，下一步 <code>RUN tar -xJf</code> 直接报 Permission denied。改成 <code>ADD --chmod=644</code>，临时 <code>USER root</code> 覆盖二进制，末尾再 <code>USER &lt;原用户&gt;</code> 还原，还原后 <code>docker image inspect --format '{{.Config.User}}'</code> 核一遍。</li>
<li>Alpine 容器先看 <code>ldd $(command -v node)</code> 是 <code>ld-musl-x86_64.so.1</code> 还是 <code>libc.so.6</code>。musl 必须用 <code>-musl.tar.gz</code> 变体，拿 glibc 包覆盖会把运行时打废，而 arm64 没有 musl 包。</li>
<li>busybox 的 tar 不支持 xz，报 <code>tar: invalid option -- J</code>，或者静默退 1。Alpine 里换 <code>.tar.gz</code> 变体，不要为了解压去 <code>apk add xz</code> 污染镜像。</li>
</ol>
<p>构建完立刻回读镜像里的版本，不看 Dockerfile 里写了什么：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">docker run --rm --entrypoint node local/&lt;服务&gt;-overlay:node22.23.4 -p &#39;process.version+&#34; &#34;+process.versions.openssl&#39;</span></span></code></pre></div></div>

<h2 class="relative group">三、关掉自动更新是方案的一部分
    <div id="三关掉自动更新是方案的一部分" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%85%b3%e6%8e%89%e8%87%aa%e5%8a%a8%e6%9b%b4%e6%96%b0%e6%98%af%e6%96%b9%e6%a1%88%e7%9a%84%e4%b8%80%e9%83%a8%e5%88%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>补丁镜像的 tag 是本地的，没有 registry。如果让 watchtower 继续管这些容器，最好的情况是每天拉不到、刷错误日志；更糟的情况是有人把补丁打在上游同名 tag 上，下一次扫描会把容器静默换回带漏洞的镜像。所以打补丁的容器一律加：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">com.centurylinklabs.watchtower.enable=false</span></span></span></code></pre></div></div>
<p>代价是这些容器不再自动跟随上游应用更新。这个代价必须当场补上检测，否则关掉自动更新只是制造了一块没人看的盲区。</p>
<p>有个例外。平台本体那类升级链路不能断的容器（每天自己 pull 上游镜像）不适合 overlay，改用只读挂载覆盖二进制：<code>image:</code> 保持官方 tag，只把下载好的 node 挂到 <code>/usr/local/bin/node</code>。这样 <code>docker compose pull/up</code> 与 watchtower 都照旧，挂载在容器重建时会保留。挂之前先验一遍这个二进制能在镜像里跑：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">docker run --rm -v &lt;bin路径&gt;:/usr/local/bin/node:ro &lt;镜像&gt; node -v</span></span></code></pre></div></div>

<h2 class="relative group">四、看门狗盯两件事
    <div id="四看门狗盯两件事" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e7%9c%8b%e9%97%a8%e7%8b%97%e7%9b%af%e4%b8%a4%e4%bb%b6%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>关掉自动更新之后，没人会知道上游是否已经修好，也看不出容器被谁换回了旧镜像。看门狗每轮做两件事：</p>
<ol>
<li>回读每个容器内的 <code>process.version</code>，与基线不符就告警「疑似被回退」。</li>
<li>逐个比对上游 tag 的 digest。digest 变了就把镜像真拉下来读里面的 node，达到修复版就输出退役指令，白名单内的容器直接自动退役（下一节）；没达到就推进基线，静默。</li>
</ol>
<p>第一条的坑是探针位置。镜像内的 node 必须在容器里读：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">docker run --rm --entrypoint node &lt;上游镜像&gt; -p process.version</span></span></code></pre></div></div>
<p>我一开始写成在宿主机上循环，宿主机根本没有 node，于是那张伪造 digest 的测试永远只报「读不到镜像内 node」，看起来像是上游坏了。</p>
<p>第二条的坑是连接数。每台机器只开一次 ssh，把该机所有镜像的 digest 在一个连接里探完，镜像之间并发，全程两次连接。串行开十几个 ssh 会把跳板机惹毛，之后一段时间连它自己都回 <code>Permission denied (publickey)</code>。那是限流伪装成鉴权失败，不是密钥坏了，正确处理是停手等几分钟，再发一次探测。</p>
<p>形态按静默看门狗做：健康时零字节输出、不投递；异常推一条消息。远端探针连续失败只落盘留痕，不让网络抖动变成刷屏。监听器自身的未捕获异常必须出声，因为「崩溃等于永远静默」是这套机制里最危险的失败形态。</p>

<h2 class="relative group">五、自动退役
    <div id="五自动退役" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e8%87%aa%e5%8a%a8%e9%80%80%e5%bd%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>白名单只放低风险容器：可随时重建、无在线协同、无 DB 迁移的那几个。TLS 前门、平台本体、在线协作文档、需要跑迁移的服务一律只告警，退役由人按手册执行。让 cron 无人值守地重建前门，炸的是一条线上的所有站点。</p>
<p>触发条件是回读上游镜像内的 node 达到修复版。每次退役的动作固定四步：</p>
<ol>
<li>备份 compose，只改目标服务的块：<code>image:</code> 换回上游 tag，删掉本工具加的 watchtower 标签行和它上方那行注释。</li>
<li><code>docker compose up -d --no-deps &lt;服务&gt;</code>。不带 <code>--no-deps</code> 会把同项目的其它容器一起重建。</li>
<li>回读镜像名、容器状态、容器内 node。</li>
<li>任何一步失败就用备份回滚并再 <code>up -d</code>，本轮不推进该镜像的 digest 基线，下一轮自动重试，失败要在告警里写明「已回滚」。</li>
</ol>
<p>第 1 步是这套东西里唯一有算法的地方。只动目标服务块，不能全文替换：同一个 compose 文件里 <code>db</code>、<code>surrealdb</code> 那些别块常有同名标签，全局 sed 会把它们一起删掉。</p>





<div
  id="accordion-c27275bb6c4c44b0eec3febc6bcd474f"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>改写程序（下发到宿主机执行，逐行原样）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span><span class="o">,</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl"><span class="n">f</span><span class="p">,</span> <span class="n">old</span><span class="p">,</span> <span class="n">up</span> <span class="o">=</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">2</span><span class="p">],</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">src</span> <span class="o">=</span> <span class="nb">open</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s1">&#39;utf-8&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span> <span class="o">=</span> <span class="n">src</span><span class="o">.</span><span class="n">splitlines</span><span class="p">(</span><span class="n">keepends</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">indent</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="o">-</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="o">.</span><span class="n">lstrip</span><span class="p">(</span><span class="s1">&#39; &#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_service_key</span><span class="p">(</span><span class="n">ln</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># 服务级键：缩进正好 2 且以 &#39;:&#39; 结尾（如 &#39;  trilium:&#39; / &#39;  open_notebook:&#39;）</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="k">match</span><span class="p">(</span><span class="sa">r</span><span class="s1">&#39;^  [A-Za-z0-9_.\-]+:\s*$&#39;</span><span class="p">,</span> <span class="n">ln</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1) 定位 image 行</span>
</span></span><span class="line"><span class="cl"><span class="n">idx</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ln</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span> <span class="k">if</span> <span class="n">ln</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">==</span> <span class="sa">f</span><span class="s2">&#34;image: </span><span class="si">{</span><span class="n">old</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">idx</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;EXPECT_ONE_IMAGE_LINE got=</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">idx</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">);</span> <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">img_i</span> <span class="o">=</span> <span class="n">idx</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2) 服务块范围：往上找最近的服务键，往下到下一个服务键 / 任何顶层非空行</span>
</span></span><span class="line"><span class="cl"><span class="n">start</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">img_i</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">is_service_key</span><span class="p">(</span><span class="n">lines</span><span class="p">[</span><span class="n">i</span><span class="p">]):</span>
</span></span><span class="line"><span class="cl">        <span class="n">start</span> <span class="o">=</span> <span class="n">i</span><span class="p">;</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl"><span class="n">end</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">img_i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">lines</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">    <span class="n">ln</span> <span class="o">=</span> <span class="n">lines</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">ln</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="ow">or</span> <span class="n">ln</span><span class="o">.</span><span class="n">lstrip</span><span class="p">()</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s1">&#39;#&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">continue</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">is_service_key</span><span class="p">(</span><span class="n">ln</span><span class="p">)</span> <span class="ow">or</span> <span class="n">indent</span><span class="p">(</span><span class="n">ln</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">end</span> <span class="o">=</span> <span class="n">i</span><span class="p">;</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">block</span> <span class="o">=</span> <span class="n">lines</span><span class="p">[</span><span class="n">start</span><span class="p">:</span><span class="n">end</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 3) 改 image</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ln</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">block</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ln</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">==</span> <span class="sa">f</span><span class="s2">&#34;image: </span><span class="si">{</span><span class="n">old</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">block</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">ln</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;image: </span><span class="si">{</span><span class="n">old</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;image: </span><span class="si">{</span><span class="n">up</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4) 删本工具加的标签（仅本块）：标签行 + 紧随其上的注释行</span>
</span></span><span class="line"><span class="cl"><span class="n">label_idxs</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ln</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">block</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">              <span class="k">if</span> <span class="n">ln</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">==</span> <span class="s2">&#34;- com.centurylinklabs.watchtower.enable=false&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">drop</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">label_idxs</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">drop</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">j</span> <span class="o">=</span> <span class="n">i</span> <span class="o">-</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">j</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">block</span><span class="p">[</span><span class="n">j</span><span class="p">]</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">==</span> <span class="s2">&#34;&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">j</span> <span class="o">-=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">j</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">block</span><span class="p">[</span><span class="n">j</span><span class="p">]</span><span class="o">.</span><span class="n">lstrip</span><span class="p">()</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;#&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="p">(</span><span class="s2">&#34;本地补丁镜像&#34;</span> <span class="ow">in</span> <span class="n">block</span><span class="p">[</span><span class="n">j</span><span class="p">]</span> <span class="ow">or</span> <span class="s2">&#34;2026-10-22&#34;</span> <span class="ow">in</span> <span class="n">block</span><span class="p">[</span><span class="n">j</span><span class="p">]):</span>
</span></span><span class="line"><span class="cl">        <span class="n">drop</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">j</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">block</span> <span class="o">=</span> <span class="p">[</span><span class="n">ln</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ln</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">block</span><span class="p">)</span> <span class="k">if</span> <span class="n">i</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">drop</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5) labels: 空了就整键删掉（只针对本块，且只删这一处 labels:）</span>
</span></span><span class="line"><span class="cl"><span class="n">li</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ln</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">block</span><span class="p">)</span> <span class="k">if</span> <span class="n">ln</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">==</span> <span class="s2">&#34;labels:&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">li</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">block</span><span class="p">)</span> <span class="ow">or</span> <span class="n">indent</span><span class="p">(</span><span class="n">block</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">])</span> <span class="o">&lt;=</span> <span class="n">indent</span><span class="p">(</span><span class="n">block</span><span class="p">[</span><span class="n">i</span><span class="p">]):</span>
</span></span><span class="line"><span class="cl">        <span class="k">del</span> <span class="n">block</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="p">[</span><span class="n">start</span><span class="p">:</span><span class="n">end</span><span class="p">]</span> <span class="o">=</span> <span class="n">block</span>
</span></span><span class="line"><span class="cl"><span class="n">new</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">new</span> <span class="o">==</span> <span class="n">src</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;NOCHANGE&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">);</span> <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">open</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="s1">&#39;w&#39;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s1">&#39;utf-8&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;REWRITE_OK&#34;</span><span class="p">)</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-c27275bb6c4c44b0eec3febc6bcd474f > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-c27275bb6c4c44b0eec3febc6bcd474f > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-c27275bb6c4c44b0eec3febc6bcd474f details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-c27275bb6c4c44b0eec3febc6bcd474f details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-c27275bb6c4c44b0eec3febc6bcd474f");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<p>两个 bug 都是在演练里抓出来的，读代码看不出来：</p>
<ul>
<li>服务块边界原来只判「缩进为 0 的顶层行」，而服务键在缩进 2。结果是后一个服务被当成同一块，合成用例立刻失败。</li>
<li><code>if ! docker compose up -d --no-deps &quot;$SVC&quot; | tail -4; then</code> 的退出码永远来自 <code>tail</code>。管道把失败变成了成功，回滚分支根本不会触发。脚本开头加 <code>set -o pipefail</code> 之后这条判断才成立。</li>
</ul>
<p>执行部分是这样的：</p>





<div
  id="accordion-a2dfe2881d74cd5bc18dd9fa0ad10a59"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>退役脚本（生成后下发到宿主机，逐行原样）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># retire-container.sh &lt;dir&gt; &lt;compose_file&gt; &lt;service&gt; &lt;container&gt; &lt;old_tag&gt; &lt;upstream_image&gt;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 由上游编排的 Python 工具生成下发：把某个容器从本地补丁镜像退回官方镜像，失败即回滚。</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -u
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -o pipefail          <span class="c1"># 否则 `docker compose up | tail -4` 的退出码永远来自 tail，回滚分支永不触发</span>
</span></span><span class="line"><span class="cl"><span class="nv">DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">F</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$2</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">SVC</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$3</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">CNAME</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$4</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">OLD</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$5</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">UP</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$6</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$DIR</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;CD_FAIL </span><span class="nv">$DIR</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">exit</span> 2<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">BK</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">.bak-retire-</span><span class="k">$(</span>date +%Y%m%d-%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">cp <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$BK</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;BACKUP_FAIL&#34;</span><span class="p">;</span> <span class="nb">exit</span> 2<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;backup=</span><span class="nv">$BK</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">python3 - <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$OLD</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$UP</span><span class="s2">&#34;</span> <span class="s">&lt;&lt;&#39;PY&#39; || { echo &#34;REWRITE_FAIL（未改动）&#34;; exit 3; }
</span></span></span><span class="line"><span class="cl"><span class="s">__TRANSFORM__
</span></span></span><span class="line"><span class="cl"><span class="s">PY</span>
</span></span><span class="line"><span class="cl">grep -n <span class="s2">&#34;image:&#34;</span> <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span> <span class="p">|</span> head -5
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! docker compose -f <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span> up -d --no-deps <span class="s2">&#34;</span><span class="nv">$SVC</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -4<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;UP_FAIL -&gt; 回滚&#34;</span>
</span></span><span class="line"><span class="cl">  cp <span class="s2">&#34;</span><span class="nv">$BK</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  docker compose -f <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span> up -d --no-deps <span class="s2">&#34;</span><span class="nv">$SVC</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -3
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">4</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 重建后容器 IP 变了，反代里缓存的 upstream 需要重新解析，否则该站点会 502（反代实例名各机不同）</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> npm in nginxproxymanager-app-1 npm_app<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> docker ps --format <span class="s1">&#39;{{.Names}}&#39;</span> <span class="p">|</span> grep -qx <span class="s2">&#34;</span><span class="nv">$npm</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$npm</span><span class="s2">&#34;</span> nginx -s reload &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;npm_reload=ok(</span><span class="nv">$npm</span><span class="s2">)&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;npm_reload=fail(</span><span class="nv">$npm</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">sleep <span class="m">12</span>
</span></span><span class="line"><span class="cl"><span class="nv">IMG</span><span class="o">=</span><span class="k">$(</span>docker inspect <span class="s2">&#34;</span><span class="nv">$CNAME</span><span class="s2">&#34;</span> --format <span class="s1">&#39;{{.Config.Image}}&#39;</span> 2&gt;/dev/null <span class="p">|</span> tr -d <span class="s1">&#39;\r&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">STATE</span><span class="o">=</span><span class="k">$(</span>docker inspect <span class="s2">&#34;</span><span class="nv">$CNAME</span><span class="s2">&#34;</span> --format <span class="s1">&#39;{{.State.Status}}&#39;</span> 2&gt;/dev/null <span class="p">|</span> tr -d <span class="s1">&#39;\r&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">NODE</span><span class="o">=</span><span class="k">$(</span>docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$CNAME</span><span class="s2">&#34;</span> node -p process.version &lt; /dev/null 2&gt;/dev/null <span class="p">|</span> tr -d <span class="s1">&#39;\r&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;verify: state=</span><span class="nv">$STATE</span><span class="s2"> image=</span><span class="nv">$IMG</span><span class="s2"> node=</span><span class="nv">$NODE</span><span class="s2">（期望 image=</span><span class="nv">$UP</span><span class="s2">）&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$IMG</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;</span><span class="nv">$UP</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$STATE</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;running&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;VERIFY_FAIL -&gt; 回滚&#34;</span>
</span></span><span class="line"><span class="cl">  cp <span class="s2">&#34;</span><span class="nv">$BK</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  docker compose -f <span class="s2">&#34;</span><span class="nv">$F</span><span class="s2">&#34;</span> up -d --no-deps <span class="s2">&#34;</span><span class="nv">$SVC</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -3
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">5</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;RETIRE_OK </span><span class="nv">$CNAME</span><span class="s2">&#34;</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-a2dfe2881d74cd5bc18dd9fa0ad10a59 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-a2dfe2881d74cd5bc18dd9fa0ad10a59 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-a2dfe2881d74cd5bc18dd9fa0ad10a59 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-a2dfe2881d74cd5bc18dd9fa0ad10a59 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-a2dfe2881d74cd5bc18dd9fa0ad10a59");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<p>演练在宿主上建一个一次性容器，配一张假的 compose，把「退役成功」和「故意把服务名写错，进入回滚」两条分支真跑一遍，跑完自清理。判定要看输出关键字：想要的必须是 <code>UP_FAIL -&gt; 回滚</code> 或 <code>VERIFY_FAIL</code>，看到 <code>REWRITE_FAIL</code> 就说明第一步就把场景搭错了，什么都没证。第二条分支之前要先把前提复位，因为第一条分支跑完 compose 已经是上游 tag，直接再跑失败分支会在改写阶段报 <code>EXPECT_ONE_IMAGE_LINE got=0</code> 就退出。</p>
<p>演练「上游已自愈」这条用旋钮伪造：伪造 digest 配伪造的目标版本，再加一个 dry-run 开关只打印将执行的命令。不要为了测试真把生产容器的补丁撤掉，那等于把漏洞放回线上。</p>

<h2 class="relative group">六、第一次真实事件
    <div id="六第一次真实事件" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%ac%ac%e4%b8%80%e6%ac%a1%e7%9c%9f%e5%ae%9e%e4%ba%8b%e4%bb%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上线第二天，看门狗报了第一个真实的上游自愈：在线协作文档与平台本体的上游镜像重建后，镜像内的 node 已经是修复版。我没有直接信它的结论，重新读了两张镜像的构建时间和镜像内 node，modules 也对上，才动手。</p>
<p>两处都退了。协作文档把 <code>image:</code> 改回官方 tag、删掉 watchtower 标签，<code>up -d --no-deps</code> 之后容器内 node 是 v24.22.1，站点 200，watchtower 恢复管辖。平台本体删掉 compose 里那行只读挂载，两个容器都回到 v26.12.1，dashboard 与消息网关各自给出就绪日志。</p>
<p>中间踩到一个 502。容器重建后 IP 变了，反代缓存的 upstream 不会自己重解析，站点直接 502，<code>docker exec &lt;反代容器&gt; nginx -s reload</code> 立刻恢复。这条已经写进退役脚本，<code>up -d</code> 之后无条件 reload 反代。推广开来说，重建任何被反代挡着的容器之后都要 reload，不只退役这一条路径。</p>
<p>之后把监控范围缩到 9 个容器、6 个上游镜像，退役掉的两处不再盯。</p>

<h2 class="relative group">七、顺带挖出的静默故障
    <div id="七顺带挖出的静默故障" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%83%e9%a1%ba%e5%b8%a6%e6%8c%96%e5%87%ba%e7%9a%84%e9%9d%99%e9%bb%98%e6%95%85%e9%9a%9c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>排查中发现两个容器的 compose 里 <code>memswap_limit</code> 小于 <code>mem_limit</code>，768m 对 1024m、448m 对 512m。这在 Docker 里是非法配置，任何重建都会被直接拒绝，包括 watchtower 的每日自动更新。也就是说这两个容器早就冻在旧镜像上，因为没人手工重建过，所以一直没人发现。热修前先 <code>docker compose config | grep mem</code> 扫一遍，代价很低。</p>
<p>node 20 那三个服务没有补丁可打。其中一个是面板自带的 web terminal，公网可达、登录后就是 root shell，同时还是 EOL 运行时，处理方式是关掉而不是慢慢升线：<code>WEB_TERMINAL=false</code> 加 <code>systemctl disable</code>，再确认端口无监听。另外两个直接 stop，并把 compose 的服务块注释掉，因为它们被 watchtower 管着，只 stop 不够。</p>

<h2 class="relative group">八、这套流程的边界
    <div id="八这套流程的边界" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ab%e8%bf%99%e5%a5%97%e6%b5%81%e7%a8%8b%e7%9a%84%e8%be%b9%e7%95%8c" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>判据永远是回读版本。上游重建了、base 镜像更新了、tag 时间变了，都替代不了 <code>docker run --entrypoint node &lt;img&gt; -p process.version</code>。</li>
<li>自动化的边界按爆炸半径划。白名单内的容器自动退役，前门、平台本体、有状态服务只告警。</li>
<li>关掉自动更新必须同时补上检测，否则「安全了」只是没人再碰它。</li>
<li>现在仍有 9 个容器处在补丁态。上游自愈之后，5 个自动退回官方镜像，4 个等人确认。</li>
</ul>
<p>两天里真正花时间的不是打镜像（十几秒一张），而是判定链：哪些位置受影响、为什么 pull 修不了、哪条路径能自愈，以及怎么让自动化在出错时退回去。前两件事靠读，后两件必须真跑一遍才算数。</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
