<?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/auto-update/</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/auto-update/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>
    <item>
      <title>小内存机器的无人值守更新：只装安全源、给 apt 划边界、并真跑一次验收</title>
      <link>https://slop.lun.sh/small-vps-auto-update-tuning/</link>
      <pubDate>Mon, 05 Oct 2026 20:20:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/small-vps-auto-update-tuning/</guid>
      <category>OOM</category>
      <category>自动更新</category>
      <category>systemd-networkd</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>上一篇 <a href="/small-vps-oom-kills-network/" >512MB 小内存云主机整机失联</a> 里最有效的一条加固，是给 <code>apt-daily</code> / <code>apt-daily-upgrade</code> 套内存 cgroup 上限。这次换一台机器把这套做法补完整：464MB 内存、1 vCPU 的 KVM 小机，重装成 Debian 13 后重建，让无人值守的安全更新变成范围明确、资源有边、可验收的日常任务。文中数字来自 2026-10-05 的实机操作与回读，环境是 Debian 13 (trixie)、内核 6.12、systemd 257、unattended-upgrades 2.12、apt 3.0。</p>
</blockquote>
<h2 class="relative group">一、出厂就已经开着，第一个坑是那把 dpkg 锁
    <div id="一出厂就已经开着第一个坑是那把-dpkg-锁" 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%87%ba%e5%8e%82%e5%b0%b1%e5%b7%b2%e7%bb%8f%e5%bc%80%e7%9d%80%e7%ac%ac%e4%b8%80%e4%b8%aa%e5%9d%91%e6%98%af%e9%82%a3%e6%8a%8a-dpkg-%e9%94%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Debian 13 出厂自带 <code>unattended-upgrades</code> 2.12，并且已经 enabled。机器第一次开机后不久，安全更新任务就会自己跑起来。</p>
<p>重建过程里我先撞上的是它的副作用：新机首开机，<code>apt-daily-upgrade</code> 正在跑，我这时装 sing-box 的 deb 包，<code>dpkg</code> 报锁被 <code>/usr/bin/python3.13</code> 占着。问题不在锁，在我把 <code>dpkg -i</code> 的 stderr 重定向掉了，错误没显示出来，命令“成功”返回，二进制却不存在。同样的动作连挂两次才发现。</p>
<p>正确判据是看两条命令的输出：</p>
<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">fuser /var/lib/dpkg/lock-frontend                      <span class="c1"># 有输出 = 有人持锁</span>
</span></span><span class="line"><span class="cl">systemctl is-active apt-daily.service apt-daily-upgrade.service</span></span></code></pre></div></div>
<p>两个服务都 <code>inactive</code>、<code>fuser</code> 没有输出，才算真的没人跑。有两个更顺手但其实不对的写法：</p>
<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">pgrep -f <span class="s1">&#39;apt-get|unattended-upgrade&#39;</span>   <span class="c1"># 会匹配到执行这条命令的 shell 自己</span>
</span></span><span class="line"><span class="cl">pgrep -x unattended-upgr                <span class="c1"># 会命中常驻的辅助进程</span></span></span></code></pre></div></div>
<p>第二条命中的是 <code>unattended-upgrade-shutdown --wait-for-signal</code>。它是个长期挂着的辅助进程，不是在升级，按它判锁会一直以为锁被占着。</p>

<h2 class="relative group">二、把范围收到安全源：apt.conf.d 里的列表是追加，不是覆盖
    <div id="二把范围收到安全源aptconfd-里的列表是追加不是覆盖" 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%e6%8a%8a%e8%8c%83%e5%9b%b4%e6%94%b6%e5%88%b0%e5%ae%89%e5%85%a8%e6%ba%90aptconfd-%e9%87%8c%e7%9a%84%e5%88%97%e8%a1%a8%e6%98%af%e8%bf%bd%e5%8a%a0%e4%b8%8d%e6%98%af%e8%a6%86%e7%9b%96" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>默认允许的范围比你以为的宽。<code>/etc/apt/apt.conf.d/50unattended-upgrades</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">&#34;origin=Debian,codename=${distro_codename}-updates&#34;;
</span></span><span class="line"><span class="cl">&#34;origin=Debian,codename=${distro_codename},label=Debian&#34;;
</span></span><span class="line"><span class="cl">&#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&#34;;</span></span></code></pre></div></div>
<p>注释里还给了 <code>-proposed-updates</code> 的样例，默认没启用。中间那条 <code>label=Debian</code> 指的是整个 stable 源，不是安全源。</p>
<p>这台机器的目标是只装安全更新：小机器上升级次数越少，内存峰值出现的次数越少；stable 源的常规更新可以等登录维护时批量做。第一版我把自定义写进了编号更大的 <code>/etc/apt/apt.conf.d/52-uu-local.conf</code>，以为后加载会覆盖前面的。但 <code>Origins-Pattern</code> 是列表型选项，apt 对列表是追加合并，默认那条照样生效，dry-run 出来的白名单里 <code>label=Debian</code> 还在。</p>
<p>改法只有动默认文件本体，注释掉那一行，自己的文件只留开关：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"> # /etc/apt/apt.conf.d/50unattended-upgrades
</span></span><span class="line"><span class="cl"><span class="gd">-        &#34;origin=Debian,codename=${distro_codename},label=Debian&#34;;
</span></span></span><span class="line"><span class="cl"><span class="gi">+        // disabled-by-ops: &#34;origin=Debian,codename=${distro_codename},label=Debian&#34;;
</span></span></span><span class="line"><span class="cl">         &#34;origin=Debian,codename=${distro_codename},label=Debian-Security&#34;;
</span></span><span class="line"><span class="cl">         &#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&#34;;
</span></span></code></pre></div></div>
<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">cat &gt; /etc/apt/apt.conf.d/52-uu-local.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">Unattended-Upgrade::Automatic-Reboot &#34;false&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">Unattended-Upgrade::Automatic-Reboot-WithUsers &#34;false&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">Unattended-Upgrade::MinimalSteps &#34;true&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">Unattended-Upgrade::Remove-Unused-Dependencies &#34;false&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">Unattended-Upgrade::SyslogEnable &#34;true&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">Unattended-Upgrade::SyslogFacility &#34;daemon&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span></span></span></code></pre></div></div>
<p>验收看 dry-run 的这一行，它直接打印有效白名单：</p>
<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">unattended-upgrade --dry-run --debug 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep <span class="s1">&#39;^Allowed origins&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Allowed origins are: origin=Debian,codename=trixie,label=Debian-Security,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   origin=Debian,codename=trixie-security,label=Debian-Security</span></span></span></code></pre></div></div>
<p>只看到这一行还不够，因为“剩下的确实是安全源”和“别的源被彻底排除”是两回事。debug 输出里每个候选包都带来源标记，这才是范围收缩的证据：</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">Checking: bash=5.2.37-2+b10 ([&lt;Origin component:&#39;main&#39; archive:&#39;stable&#39;
</span></span><span class="line"><span class="cl">  origin:&#39;Debian&#39; label:&#39;Debian&#39; isTrusted:True&gt;])</span></span></code></pre></div></div>
<p><code>bash</code> 这样来自 stable 的包会被列出来检查，但不在白名单里，所以不会被装。</p>
<p><code>Automatic-Reboot</code> 要单独想一次。Debian 的默认值是 <code>false</code>，装完内核更新不会自己重启。上一篇那两台机器的结论是保留 <code>true</code>，因为站主没有定期登录机器的习惯，关掉之后内核更新就永远不会生效。这一台留 <code>false</code>，因为它会不定期登录做维护。两个选择都对，判据只有一条：装了内核更新却永远不重启，等于没装。</p>

<h2 class="relative group">三、给这些任务划 cgroup 边界
    <div id="三给这些任务划-cgroup-边界" 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%e7%bb%99%e8%bf%99%e4%ba%9b%e4%bb%bb%e5%8a%a1%e5%88%92-cgroup-%e8%be%b9%e7%95%8c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>OOM 打死网络栈的根因，是升级进程和别的常驻进程共用同一池内存。解决办法是让升级任务超出预算时死在自己的 cgroup 里，不影响别人。</p>
<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="k">for</span> u in apt-daily apt-daily-upgrade<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  mkdir -p /etc/systemd/system/<span class="si">${</span><span class="nv">u</span><span class="si">}</span>.service.d
</span></span><span class="line"><span class="cl">  cat &gt; /etc/systemd/system/<span class="si">${</span><span class="nv">u</span><span class="si">}</span>.service.d/20-memory-limit.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Service]
</span></span></span><span class="line"><span class="cl"><span class="s">MemoryMax=250M
</span></span></span><span class="line"><span class="cl"><span class="s">MemoryHigh=200M
</span></span></span><span class="line"><span class="cl"><span class="s">CPUQuota=60%
</span></span></span><span class="line"><span class="cl"><span class="s">IOWeight=50
</span></span></span><span class="line"><span class="cl"><span class="s">Nice=10
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">systemctl daemon-reload</span></span></code></pre></div></div>
<p>五项各自的用途：</p>
<ul>
<li><code>MemoryMax=250M</code>：硬上限。超了就在自己的 cgroup 内被 OOM 掉，被杀掉的只是升级任务。这台机器总内存 464MB，日常占用 170MB 上下，250M 足够完成解压；上一篇那两台 512MB 的机器用的是同一个值，跑完整批安全更新没出过事。</li>
<li><code>MemoryHigh=200M</code>：软上限。过了先限速回收内存，不直接杀进程，给硬上限留缓冲。</li>
<li><code>CPUQuota=60%</code>：这台机器只有 1 vCPU，apt 解压和 zstd 会长时间占满 CPU。不加这条，升级期间同机的代理进程会被挤到超时。它管的是 CPU，和内存上限一样属于“别影响别人”。</li>
<li><code>IOWeight=50</code>、<code>Nice=10</code>：磁盘和 CPU 调度上主动让路，优先级给常驻服务。</li>
</ul>
<p>回读的时候有个细节：CPU 配额的属性名不是 <code>CPUQuota</code>，是 <code>CPUQuotaPerSecUSec</code>，名字写错会看到空值，还以为没生效。</p>
<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">systemctl show apt-daily-upgrade.service --value <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSec -p IOWeight -p Nice
</span></span><span class="line"><span class="cl"><span class="c1"># 262144000 209715200 600ms 50 10</span></span></span></code></pre></div></div>
<p><code>600ms</code> 就是 60%，也就是每 1 秒里最多用 600 毫秒 CPU。</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%9b%9b%e6%8a%8a%e8%a7%a6%e5%8f%91%e6%97%b6%e9%97%b4%e6%91%8a%e5%bc%80%e5%b9%b6%e7%a1%ae%e8%ae%a4%e5%ae%9a%e6%97%b6%e5%99%a8%e6%b2%a1%e6%9c%89%e5%81%87%e6%ad%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>小机器上两个 apt 任务撞在同一分钟里跑，内存峰值会叠在一起。给触发时间加随机延迟：</p>
<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">mkdir -p /etc/systemd/system/apt-daily.timer.d /etc/systemd/system/apt-daily-upgrade.timer.d
</span></span><span class="line"><span class="cl">cat &gt; /etc/systemd/system/apt-daily.timer.d/20-spread.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Timer]
</span></span></span><span class="line"><span class="cl"><span class="s">RandomizedDelaySec=1h
</span></span></span><span class="line"><span class="cl"><span class="s">Persistent=true
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">cat &gt; /etc/systemd/system/apt-daily-upgrade.timer.d/20-spread.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Timer]
</span></span></span><span class="line"><span class="cl"><span class="s">RandomizedDelaySec=2h
</span></span></span><span class="line"><span class="cl"><span class="s">Persistent=true
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">systemctl daemon-reload</span></span></code></pre></div></div>
<p>升级那个用 2h，让它比刷新索引的那次更晚、更分散。</p>
<p>改完必须查 NEXT 不是 <code>-</code>：</p>
<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">systemctl list-timers <span class="s1">&#39;apt-daily*&#39;</span> net-watchdog.timer --no-pager</span></span></code></pre></div></div>
<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">NEXT                         LEFT  LAST                         PASSED   UNIT
</span></span><span class="line"><span class="cl">Mon 2026-10-05 12:26:17 UTC  1min  Mon 2026-10-05 12:24:17 UTC  40s ago  net-watchdog.timer
</span></span><span class="line"><span class="cl">Mon 2026-10-05 18:40:35 UTC    6h  Mon 2026-10-05 11:42:41 UTC  42min    apt-daily.timer
</span></span><span class="line"><span class="cl">Tue 2026-10-06 07:19:41 UTC   18h  Mon 2026-10-05 11:59:59 UTC  24min    apt-daily-upgrade.timer</span></span></code></pre></div></div>
<p><code>NEXT -</code> 表示这个定时器以后不再排期，等于被悄悄停掉了。我们在自建单元上踩过这个坑：<code>RandomizedDelaySec</code> 和 <code>Persistent=true</code> 同时出现时，有过 NEXT 变成 <code>-</code> 的情况。Debian 自带的单元本身带着这两项，行为正常，但既然是手改的配置，改完就查一次。</p>

<h2 class="relative group">五、加 swap 与内核参数
    <div id="五加-swap-与内核参数" 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%e5%8a%a0-swap-%e4%b8%8e%e5%86%85%e6%a0%b8%e5%8f%82%e6%95%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是兜底里最便宜的一件：让突发内存压力有个去处，而不是直接触发 OOM。</p>
<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">fallocate -l 1G /swapfile
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> /swapfile <span class="o">&amp;&amp;</span> mkswap /swapfile &gt;/dev/null <span class="o">&amp;&amp;</span> swapon /swapfile
</span></span><span class="line"><span class="cl">grep -q <span class="s1">&#39;^/swapfile&#39;</span> /etc/fstab <span class="o">||</span> <span class="nb">echo</span> <span class="s1">&#39;/swapfile none swap sw 0 0&#39;</span> &gt;&gt; /etc/fstab
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; /etc/sysctl.d/99-small-vps.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">vm.swappiness = 20
</span></span></span><span class="line"><span class="cl"><span class="s">vm.vfs_cache_pressure = 50
</span></span></span><span class="line"><span class="cl"><span class="s">vm.min_free_kbytes = 16384
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">sysctl --system</span></span></code></pre></div></div>
<p><code>swappiness=20</code> 让这台机器尽量不把常驻进程换出去（代理进程被换出再换回会明显变慢），swap 只当突发缓冲。<code>min_free_kbytes=16384</code> 给内核留一点水位。</p>
<p>swap 的作用边界要清楚：它降低 OOM 发生的概率，不改变 OOM 发生时内核挑谁下手。真要根治还是加内存或者升规格。</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%bd%91%e7%bb%9c%e6%a0%88%e7%9a%84%e4%bf%9d%e5%91%bd%e6%8e%aa%e6%96%bd%e5%85%88%e6%9f%a5%e7%8e%b0%e5%80%bc%e5%86%8d%e5%86%b3%e5%ae%9a%e5%8a%a0%e4%b8%8d%e5%8a%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上一篇的做法是给 <code>systemd-networkd</code> 加一个 drop-in，设 <code>OOMScoreAdjust=-1000</code> 和 <code>Restart=always</code>，让它在内存压力下几乎不会被 OOM 挑中，并且意外退出后自动起来。</p>
<p>这一台先查了现值：</p>
<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">systemctl show systemd-networkd -p OOMScoreAdjust -p Restart
</span></span><span class="line"><span class="cl"><span class="c1"># OOMScoreAdjust=-1000</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Restart=always</span></span></span></code></pre></div></div>
<p>Debian 13 的 systemd 257 出厂就是这个值（systemd 247 之后这是默认），所以不需要再加 drop-in。上一篇那两台 Ubuntu 22.04 的机器当时查到的是 <code>0</code> / <code>on-failure</code>，那里加的 drop-in 是必要且实测生效的。</p>
<p>这一步值得单独记一条：加固动作要在查过现值之后再决定，否则档案里会出现一条“我加了防护”的记录，而实际上什么都没改变。同一台机器上，<code>OOMScoreAdjust</code> 对已经在运行的进程要下次启动才生效，所以查值比强行重启网络服务更有意义，重启网络服务会短暂断网。</p>
<p>网络层还留一个看门狗兜底：</p>
<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">cat &gt; /usr/local/sbin/net-watchdog.sh <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="s">LOG=/var/log/net-watchdog.log
</span></span></span><span class="line"><span class="cl"><span class="s">if ! ip -4 route show default | grep -q .; then
</span></span></span><span class="line"><span class="cl"><span class="s">  echo &#34;$(date -Is) NO-DEFAULT-ROUTE&#34; &gt;&gt; &#34;$LOG&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">  systemctl restart systemd-networkd 2&gt;/dev/null
</span></span></span><span class="line"><span class="cl"><span class="s">  sleep 10
</span></span></span><span class="line"><span class="cl"><span class="s">  if ip -4 route show default | grep -q .; then
</span></span></span><span class="line"><span class="cl"><span class="s">    echo &#34;$(date -Is) recovered&#34; &gt;&gt; &#34;$LOG&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">  else
</span></span></span><span class="line"><span class="cl"><span class="s">    echo &#34;$(date -Is) STILL-DOWN&#34; &gt;&gt; &#34;$LOG&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">  fi
</span></span></span><span class="line"><span class="cl"><span class="s">fi
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">chmod +x /usr/local/sbin/net-watchdog.sh
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; /etc/systemd/system/net-watchdog.service <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Unit]
</span></span></span><span class="line"><span class="cl"><span class="s">Description=Network default-route watchdog
</span></span></span><span class="line"><span class="cl"><span class="s">[Service]
</span></span></span><span class="line"><span class="cl"><span class="s">Type=oneshot
</span></span></span><span class="line"><span class="cl"><span class="s">ExecStart=/usr/local/sbin/net-watchdog.sh
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; /etc/systemd/system/net-watchdog.timer <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Unit]
</span></span></span><span class="line"><span class="cl"><span class="s">Description=Run network watchdog every 2 minutes
</span></span></span><span class="line"><span class="cl"><span class="s">[Timer]
</span></span></span><span class="line"><span class="cl"><span class="s">OnBootSec=3min
</span></span></span><span class="line"><span class="cl"><span class="s">OnUnitActiveSec=2min
</span></span></span><span class="line"><span class="cl"><span class="s">AccuracySec=15s
</span></span></span><span class="line"><span class="cl"><span class="s">[Install]
</span></span></span><span class="line"><span class="cl"><span class="s">WantedBy=timers.target
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">systemctl daemon-reload <span class="o">&amp;&amp;</span> systemctl <span class="nb">enable</span> --now net-watchdog.timer</span></span></code></pre></div></div>
<p>脚本里重启的对象按机器上实际在管的网络服务选（networkd、NetworkManager 或 ifupdown）。它只治“默认路由丢了”这一种情况，机器彻底卡死时它自己也跑不起来，所以是兜底不是主力。</p>
<p>代理进程给一个比升级任务更高的生存优先级：</p>
<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">mkdir -p /etc/systemd/system/sing-box.service.d
</span></span><span class="line"><span class="cl">cat &gt; /etc/systemd/system/sing-box.service.d/20-oom-priority.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Service]
</span></span></span><span class="line"><span class="cl"><span class="s">OOMScoreAdjust=-300
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span></span></span></code></pre></div></div>
<p>内存真的不够时，先死的是更新任务，不是代理。</p>

<h2 class="relative group">七、验收：真跑一次，而且要 detached
    <div id="七验收真跑一次而且要-detached" 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%aa%8c%e6%94%b6%e7%9c%9f%e8%b7%91%e4%b8%80%e6%ac%a1%e8%80%8c%e4%b8%94%e8%a6%81-detached" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>dry-run 只能证明白名单是对的，证明不了这个任务在 464MB 上能跑完。要证明就得让它真装一次。</p>
<p>真跑必须放在独立的瞬时单元里，因为一批安全更新里可能包含 openssh，重启 sshd 会掐断你正在用的会话：</p>
<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">systemd-run --unit<span class="o">=</span>uu-once --collect /usr/bin/unattended-upgrade --debug</span></span></code></pre></div></div>
<p><code>--collect</code> 让单元跑完后自动清理。然后看结果：</p>
<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">systemctl show uu-once -p Result
</span></span><span class="line"><span class="cl">journalctl -u uu-once <span class="p">|</span> grep -E <span class="s1">&#39;All upgrades installed|No packages found&#39;</span></span></span></code></pre></div></div>
<p>本次结果：</p>
<table>
	<thead>
			<tr>
					<th>检查项</th>
					<th>命令</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>升级任务结果</td>
					<td><code>systemctl show uu-once -p Result</code></td>
					<td><code>success</code></td>
			</tr>
			<tr>
					<td>升级日志</td>
					<td><code>journalctl -u uu-once</code></td>
					<td><code>All upgrades installed</code>（util-linux、fdisk、bind9、libexpat1、libpng 等一批）</td>
			</tr>
			<tr>
					<td>是否触发 OOM</td>
					<td><code>journalctl -b -k | grep -ciE 'Out of memory|Killed process'</code></td>
					<td>0</td>
			</tr>
			<tr>
					<td>内存与 swap</td>
					<td><code>free -m</code></td>
					<td>已用 170MB / 464MB，swap 用 32MB</td>
			</tr>
			<tr>
					<td>上限生效</td>
					<td><code>systemctl show apt-daily-upgrade.service --value -p MemoryMax</code></td>
					<td>262144000</td>
			</tr>
			<tr>
					<td>失败单元</td>
					<td><code>systemctl --failed --no-legend | wc -l</code></td>
					<td>0</td>
			</tr>
			<tr>
					<td>整机状态</td>
					<td><code>systemctl is-system-running</code></td>
					<td>running</td>
			</tr>
			<tr>
					<td>网络栈保护</td>
					<td><code>systemctl show systemd-networkd --value -p OOMScoreAdjust -p Restart</code></td>
					<td><code>-1000</code> / <code>always</code></td>
			</tr>
			<tr>
					<td>看门狗</td>
					<td><code>ls -l /var/log/net-watchdog.log</code></td>
					<td>文件不存在</td>
			</tr>
			<tr>
					<td>定时器</td>
					<td><code>systemctl list-timers</code></td>
					<td>三个都有 NEXT</td>
			</tr>
	</tbody>
</table>
<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="#%e5%85%ab%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>新机器上装任何 deb 之前先等锁：<code>fuser /var/lib/dpkg/lock-frontend</code> 没有输出，且 <code>apt-daily</code>、<code>apt-daily-upgrade</code> 都 <code>inactive</code>。别用 <code>pgrep</code>，两种写法都会误报。</li>
<li>别把 <code>dpkg -i</code> 的 stderr 丢掉。加上 cgroup 上限之后，因内存不足而失败的安装会更常见，看不到报错就会像这次一样连挂两次还不知道为什么。</li>
<li><code>Origins-Pattern</code> 写在编号更大的新文件里不生效，必须注释 <code>50unattended-upgrades</code> 本体那一行。验收看 <code>--dry-run --debug</code> 的 <code>Allowed origins</code>，同时看 <code>Checking:</code> 行里的来源标记是否被排除。</li>
<li><code>apt-daily</code> 与 <code>apt-daily-upgrade</code> 两个服务都加 drop-in：<code>MemoryMax=250M</code>、<code>MemoryHigh=200M</code>；1 vCPU 的机器再加 <code>CPUQuota=60%</code>、<code>IOWeight=50</code>、<code>Nice=10</code>。</li>
<li>定时器加 <code>RandomizedDelaySec</code> 摊开触发时间，改完查 <code>systemctl list-timers</code> 的 NEXT 不是 <code>-</code>。</li>
<li>swap 先加，再谈别的加固；小机器上 <code>swappiness=20</code> 比默认值合适。</li>
<li>动 <code>systemd-networkd</code> 之前先查 <code>OOMScoreAdjust</code> 和 <code>Restart</code>。新系统上很可能已经是对的，写一个不起作用的 drop-in 然后记成“已加固”比不写更糟。</li>
<li>给代理进程 <code>OOMScoreAdjust=-300</code>，内存紧张时优先牺牲更新任务。</li>
<li>验收要真跑一次，而且用 <code>systemd-run</code> 起独立单元跑，不要在 SSH 会话里直接跑。</li>
<li>遗留的边界：apt 被限制在 250M 之后，个别大升级可能因为内存不足而失败，同时机器完好无事。这类失败没有独立告警（监控通常只报在线或离线），要覆盖它得自己加一个“升级任务失败”的探针。这是这套配置换来的确定性代价：牺牲的是一次升级的成功，换来的是机器不会因为升级失联。</li>
</ul>

<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%b9%9d%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>无人值守更新有一处很别扭：它每天都会跑，平时没人看结果，出问题时又多半在你不在场的时间点。给它划边界的操作本身不复杂，难的是守住三个习惯，动手前查现值、改完真跑一次、把“应该生效了”换成一条命令的输出。这台机器从加固到验收花了不到半小时，换来的是升级任务从此不能影响整机；至于升级本身是否每次都成功，那是另一件事，得靠日志和探针去看。</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
