<?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/posts/</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>Fri, 18 Sep 2026 20:15:00 +0800</lastBuildDate>
    <atom:link href="https://slop.lun.sh/posts/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>开源仓库历史里泄漏了本机路径：改写历史之后，还差删库重建这一步</title>
      <link>https://slop.lun.sh/git-history-leak-purge/</link>
      <pubDate>Fri, 18 Sep 2026 20:15:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/git-history-leak-purge/</guid>
      <category>git</category>
      <category>隐私门禁</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一个准备开源的小工具仓库，历史提交里的一行注释残留着开发机绝对路径（形如 <code>D:\某目录\...</code>）。当前文件早已清干净，直到有人把某个历史 commit 的链接发来，才发现<strong>历史里 5 个 commit 仍带着它</strong>——改文件根本改不到历史。本文记录 <code>git filter-repo</code> 全历史改写、逐提交重签 GPG、强推，以及最终删库重建的完整过程。结论先说：<strong>改写历史不等于清除历史，公开平台会缓存不可达对象</strong>；防再犯要靠提交前的自动门禁。适用环境 Windows 11 + Git for Windows（bash）+ Python 3.11，处理时间 2026-09-13。</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%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>仓库公开后不久（2026-09-13），有人把某个历史提交的链接发给站主，问了一个问题：这个提交的修改记录里暴露了本机路径（可能之前的提交也有），有什么好的处理方法吗。</p>
<p>这个提交本身是一条「chore: 移除注释中的本机路径（脱敏）」。也就是说，最早发现泄漏后已经修过一轮：仓库公开前做最终审计时，在构建脚本 <code>web/build_web.py</code> 第 6 行抓到了泄漏——那行注释里直接写了开发机绝对路径（形如 <code>D:\某目录\某仓库</code>）。当时的处理是把注释改成「项目根目录」，提交（就是那条「脱敏」提交）、重打 tag、重建 Release——<strong>当前内容已经干净</strong>。但问题恰恰出在这次&quot;修复&quot;本身：它的 diff 显示的是被删除的那一行，而那一行里就是完整路径。任何人打开这个提交的页面都能看到路径；更糟的是，之前的提交里文件还带着它。</p>
<p>用一条命令把全历史每个提交的 tree 都扫一遍（关键词换成你自己的泄露特征词）：</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> c in <span class="k">$(</span>git rev-list --all<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">hits</span><span class="o">=</span><span class="k">$(</span>git grep -l -I -E <span class="s2">&#34;本地路径特征词|AppData|C:\\Users&#34;</span> <span class="nv">$c</span> -- 2&gt;/dev/null<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$hits</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2"> </span><span class="k">$(</span>git log -1 --format<span class="o">=</span>%s <span class="nv">$c</span><span class="k">)</span><span class="s2">: </span><span class="nv">$hits</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div></div>
<p>结果很干净利落：<strong>泄漏只在一行注释里，且只出现在 5 个历史 commit</strong>（全部指向同一个文件 <code>web/build_web.py</code>）；提交信息本身没有任何痕迹。当时的提交顺序也印证了泄漏路径：最初的 feat 提交新增这一行，其后 4 个中间提交一直带着它，直到「脱敏」提交删除——删除只是最后一步，前 5 个版本里它一直在。全仓库当时共 7 个 commit、2 个 tag。</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%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Git 的提交是一个内容寻址链表：commit 对象指向 tree，tree 再指向每个文件的 blob。改掉当前文件再提交，只会在新 commit 里换上新 blob；<strong>历史上每一个旧 commit 仍指向自己那份旧内容</strong>。所以&quot;文件已清理&quot;和&quot;历史已清理&quot;是两回事。</p>
<p>第二个坑更隐蔽：&ldquo;脱敏&quot;提交本身把路径搬进了 diff。git 的提交记录是公开面的直接表达，任何一次提交的增删行都会永久展示；即使那条提交的目的是删除，被删除的原文也完整留在页面上。</p>
<p>还有一个教训：首轮修复时宣称&quot;公开前做了最终 grep 复查&rdquo;，但那次复查本质只扫了工作区/当前内容，没有遍历 <code>git rev-list --all</code> 的每一个提交。<strong>用 git grep 检查当前文件，是发现不了历史泄漏的。</strong></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%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">1. 改写前先备份
    <div id="1-改写前先备份" 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="#1-%e6%94%b9%e5%86%99%e5%89%8d%e5%85%88%e5%a4%87%e4%bb%bd" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>用 <code>git bundle</code> 把全部 refs 打包成一份备份（改动前的历史以此为准），再装 <code>git-filter-repo</code>（pip 安装）。</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">git bundle create &lt;备份路径&gt; --all
</span></span><span class="line"><span class="cl">python -m pip install git-filter-repo</span></span></code></pre></div></div>
<p>注意：这份备份打包的是<strong>旧的、含路径的历史</strong>，它本身绝不能推出去，确认一切稳定后可删。</p>

<h3 class="relative group">2. filter-repo 全历史替换
    <div id="2-filter-repo-全历史替换" 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="#2-filter-repo-%e5%85%a8%e5%8e%86%e5%8f%b2%e6%9b%bf%e6%8d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>准备替换表，<strong>长串优先</strong>（避免短串先命中、长串匹配不到），把路径特征替换成无信息的占位注释：</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">D:\某目录\某仓库==&gt;项目根目录
</span></span><span class="line"><span class="cl">D:\某目录==&gt;项目根目录</span></span></code></pre></div></div>
<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">python -m git_filter_repo --replace-text 替换表.txt --force</span></span></code></pre></div></div>
<p>实际输出：<code>Parsed 7 commits</code>，<code>New history written in 0.31 seconds; now repacking/cleaning...</code>，<code>Completely finished after 0.72 seconds</code>。对小型仓库，改写本身是秒级操作。替换表里的规则一长一短，正是为了先命中长串：若短串先匹配，长串会被截成残片，替换结果不干净。</p>
<p>一个值得注意的副产物：那条「移除注释中的本机路径」的提交，唯一改动就是删除路径行；替换后它的内容与父提交一致、diff 为空，<strong>被 git-filter-repo 自动当空提交清理</strong>（7→6 个提交）。&ldquo;清路径&quot;的提交从历史上消失了——这反而是好事，因为它不再作为&quot;把路径亮出来&quot;的页面存在。改写后全历史重扫应为空，两个 tag 按内容自动重指到改写后的对应提交。</p>

<h3 class="relative group">3. 逐提交重新 GPG 签名
    <div id="3-逐提交重新-gpg-签名" 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="#3-%e9%80%90%e6%8f%90%e4%ba%a4%e9%87%8d%e6%96%b0-gpg-%e7%ad%be%e5%90%8d" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>历史改写必然使所有提交哈希变化，GPG 签名（基于提交内容）全部失效，<code>git log</code> 里签名状态显示 <code>N</code>。用 <code>rebase --root</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">git rebase --root --exec <span class="s1">&#39;git commit --amend --no-edit -S&#39;</span></span></span></code></pre></div></div>
<p>（gpg 不在 PATH 时，命令要加 <code>-c gpg.program=&lt;gpg 可执行文件路径&gt;</code>。）重签后全部提交显示 <code>G</code>（Verified）；两个 tag 用 <code>-a -s</code> 重新打签名，<code>git tag -v</code> 校验输出显示签名算法为 EDDSA，随后强推。</p>

<h3 class="relative group">4. 上防再犯门禁：check_privacy.py + CI + pre-commit
    <div id="4-上防再犯门禁check_privacypy--ci--pre-commit" 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="#4-%e4%b8%8a%e9%98%b2%e5%86%8d%e7%8a%af%e9%97%a8%e7%a6%81check_privacypy--ci--pre-commit" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>趁历史改写完、还没推上去的窗口，写了一个不到 70 行的隐私检查脚本 <code>scripts/check_privacy.py</code>：遍历 git 跟踪文件（或只查暂存区），命中即退出码 1。规则如下：</p>
<table>
	<thead>
			<tr>
					<th>规则（正则）</th>
					<th>识别目标</th>
					<th>示例命中</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>[A-Za-z]:[\\/]</code>（带负向断言，避免误伤 <code>https://</code> 等）</td>
					<td>Windows 盘符绝对路径</td>
					<td><code>C:\</code>、<code>F:/</code></td>
			</tr>
			<tr>
					<td><code>AppData[\\/](Local|Roaming)</code></td>
					<td>Windows 用户配置目录</td>
					<td><code>AppData\Local</code></td>
			</tr>
			<tr>
					<td><code>C:[\\/]Users[\\/]</code></td>
					<td>Windows 用户目录</td>
					<td><code>C:\Users\...</code></td>
			</tr>
			<tr>
					<td><code>/home/[A-Za-z0-9_.-]+/</code></td>
					<td>Linux 家目录</td>
					<td><code>/home/user/...</code></td>
			</tr>
			<tr>
					<td><code>/Users/[A-Za-z0-9_.-]+/</code></td>
					<td>macOS 家目录</td>
					<td><code>/Users/user/...</code></td>
			</tr>
			<tr>
					<td><code>%LOCALAPPDATA%|%APPDATA%|%USERPROFILE%</code></td>
					<td>本机环境变量路径</td>
					<td><code>%USERPROFILE%</code></td>
			</tr>
	</tbody>
</table>
<p>脚本跳过二进制与图片后缀（.exe/.zip/.dll/.pyc/.png/.ico/.jpg 等）和自身；<code>--staged</code> 模式只扫暂存区（给 pre-commit 钩子用）。</p>
<p>全文如下，连同启用它的本地钩子一起（点开即可复制；只用 Python 标准库，无第三方依赖）：</p>





<div
  id="accordion-b96870f15e9de74552d633591a18b9e9"
  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>scripts/check_privacy.py（69 行）</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="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="c1"># -*- coding: utf-8 -*-</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;隐私检查：扫描仓库跟踪的文件中是否残留本机绝对路径等痕迹。
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">CI（每次 push/PR）与本地 pre-commit 钩子共用；命中即退出码 1。
</span></span></span><span class="line"><span class="cl"><span class="s2">用法:
</span></span></span><span class="line"><span class="cl"><span class="s2">    python scripts/check_privacy.py            # 扫描 git 跟踪的全部文件
</span></span></span><span class="line"><span class="cl"><span class="s2">    python scripts/check_privacy.py --staged   # 只扫描暂存区（pre-commit 用）
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">_s</span> <span class="ow">in</span> <span class="p">(</span><span class="n">sys</span><span class="o">.</span><span class="n">stdout</span><span class="p">,</span> <span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_s</span><span class="o">.</span><span class="n">reconfigure</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">,</span> <span class="n">errors</span><span class="o">=</span><span class="s2">&#34;replace&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 每条：正则、说明。注意 (?!...) 负向断言避免误伤 https:// 这类正常文本。</span>
</span></span><span class="line"><span class="cl"><span class="n">PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;(?&lt;![A-Za-z0-9])[A-Za-z]:[</span><span class="se">\\</span><span class="s2">/]&#34;</span><span class="p">),</span> <span class="s2">&#34;Windows 绝对路径（如 C:</span><span class="se">\\</span><span class="s2"> 或 F:/）&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;AppData[</span><span class="se">\\</span><span class="s2">/](?:Local|Roaming)&#34;</span><span class="p">),</span> <span class="s2">&#34;Windows 用户配置目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;C:[</span><span class="se">\\</span><span class="s2">/]Users[</span><span class="se">\\</span><span class="s2">/]&#34;</span><span class="p">),</span> <span class="s2">&#34;Windows 用户目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;/home/[A-Za-z0-9_.-]+/&#34;</span><span class="p">),</span> <span class="s2">&#34;Linux 家目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;/Users/[A-Za-z0-9_.-]+/&#34;</span><span class="p">),</span> <span class="s2">&#34;macOS 家目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;%LOCALAPPDATA%|%APPDATA%|%USERPROFILE%&#34;</span><span class="p">),</span> <span class="s2">&#34;本机环境变量路径&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SKIP_FILES</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;scripts/check_privacy.py&#34;</span><span class="p">}</span>          <span class="c1"># 本文件含规则字面量，跳过自身</span>
</span></span><span class="line"><span class="cl"><span class="n">SKIP_SUFFIX</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;.ico&#34;</span><span class="p">,</span> <span class="s2">&#34;.png&#34;</span><span class="p">,</span> <span class="s2">&#34;.jpg&#34;</span><span class="p">,</span> <span class="s2">&#34;.zip&#34;</span><span class="p">,</span> <span class="s2">&#34;.exe&#34;</span><span class="p">,</span> <span class="s2">&#34;.dll&#34;</span><span class="p">,</span> <span class="s2">&#34;.pyc&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">tracked_files</span><span class="p">(</span><span class="n">staged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">staged</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">out</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;diff&#34;</span><span class="p">,</span> <span class="s2">&#34;--cached&#34;</span><span class="p">,</span> <span class="s2">&#34;--name-only&#34;</span><span class="p">,</span> <span class="s2">&#34;--diff-filter=ACM&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">                             <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">out</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;ls-files&#34;</span><span class="p">],</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">out</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">staged</span> <span class="o">=</span> <span class="s2">&#34;--staged&#34;</span> <span class="ow">in</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></span><span class="line"><span class="cl">    <span class="n">hits</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="n">tracked_files</span><span class="p">(</span><span class="n">staged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">path</span> <span class="ow">in</span> <span class="n">SKIP_FILES</span> <span class="ow">or</span> <span class="n">path</span><span class="o">.</span><span class="n">endswith</span><span class="p">(</span><span class="n">SKIP_SUFFIX</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">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">,</span> <span class="n">errors</span><span class="o">=</span><span class="s2">&#34;ignore&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">for</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">line</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                    <span class="k">for</span> <span class="n">rx</span><span class="p">,</span> <span class="n">desc</span> <span class="ow">in</span> <span class="n">PATTERNS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                        <span class="n">m</span> <span class="o">=</span> <span class="n">rx</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">line</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                        <span class="k">if</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                            <span class="n">hits</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">desc</span><span class="p">,</span> <span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">0</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="p">(</span><span class="ne">OSError</span><span class="p">,</span> <span class="ne">UnicodeError</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">hits</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;✘ 检测到可能的隐私痕迹（本机路径），请脱敏后再提交：&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">path</span><span class="p">,</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">desc</span><span class="p">,</span> <span class="n">frag</span> <span class="ow">in</span> <span class="n">hits</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;  </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">lineno</span><span class="si">}</span><span class="s2">  [</span><span class="si">{</span><span class="n">desc</span><span class="si">}</span><span class="s2">] 命中：</span><span class="si">{</span><span class="n">frag</span><span class="si">}</span><span class="s2">&#34;</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;</span><span class="se">\n</span><span class="s2">提示：改用相对路径或占位符（如『项目根目录』『示例目录A』）。&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;✓ 隐私检查通过：未发现本机绝对路径痕迹&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span></span></span></code></pre></div></div>

  </div>
</details>




  










<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>scripts/hooks/pre-commit（本地拦截，仓库根目录执行一次 git config core.hooksPath scripts/hooks 即生效）</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-sh" data-lang="sh"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="c1"># 本地 pre-commit 钩子：提交前扫描暂存区是否含本机路径等隐私痕迹。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 启用方式（仓库根目录执行一次）：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   git config core.hooksPath scripts/hooks</span>
</span></span><span class="line"><span class="cl">python scripts/check_privacy.py --staged <span class="o">||</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;提交被拦截：请先处理上述隐私痕迹（或临时用 git commit --no-verify 跳过）。&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

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

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

<script>
  (() => {
    const root = document.getElementById("accordion-b96870f15e9de74552d633591a18b9e9");
    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>上线第一天它就立了功：本地首跑立即命中 <code>README.md:67</code>——README 里演示命令行用法写的示例路径（形如 <code>D:\某目录\...</code>）被判为 Windows 绝对路径。演示路径虽是占位用途，但规则不留模糊地带：把示例改成无盘符的「目录A...」占位，提交通过。</p>
<p>门禁分两处挂上：</p>
<ul>
<li><strong>CI</strong>：workflow 里在语法检查之后、内核一致性验收之前加一步 <code>python scripts/check_privacy.py</code>，每次 push 都跑，命中即失败；</li>
<li><strong>本地</strong>：<code>scripts/hooks/pre-commit</code> 里跑 <code>--staged</code> 模式；在仓库根目录执行一次 <code>git config core.hooksPath scripts/hooks</code> 即生效。</li>
</ul>

<h3 class="relative group">5. 弯路：强推之后，旧 commit 依然可达
    <div id="5-弯路强推之后旧-commit-依然可达" 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="#5-%e5%bc%af%e8%b7%af%e5%bc%ba%e6%8e%a8%e4%b9%8b%e5%90%8e%e6%97%a7-commit-%e4%be%9d%e7%84%b6%e5%8f%af%e8%be%be" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一切就绪后 <code>git push --force origin main</code> 与 <code>git push --force origin --tags</code> 强推。GitHub 侧确认远端 main 已换成干净历史（最新提交即门禁提交，状态 G），Release 完好。然后探测三个旧 SHA，结果如下：</p>
<table>
	<thead>
			<tr>
					<th>探测对象</th>
					<th>强推之后</th>
					<th>删库重建之后</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>旧提交 SHA-1（含路径）</td>
					<td>返回完整 SHA，<strong>仍可访问</strong></td>
					<td>HTTP 422 <code>No commit found</code></td>
			</tr>
			<tr>
					<td>旧提交 SHA-2</td>
					<td>仍可访问</td>
					<td>HTTP 422</td>
			</tr>
			<tr>
					<td>旧提交 SHA-3</td>
					<td>仍可访问</td>
					<td>HTTP 422</td>
			</tr>
	</tbody>
</table>
<p>这是本篇最值钱的一点：<strong>改写历史 ≠ 清除历史</strong>。force push 让旧对象在引用层面&quot;不可达&rdquo;，但公开平台会保留不可达对象一段时间（作为旧链接缓存），不会立即物理删除；被分享出去的旧 commit 链接，打开仍能看到那行路径和完整 diff。</p>

<h3 class="relative group">6. 终局：删库重建
    <div id="6-终局删库重建" 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="#6-%e7%bb%88%e5%b1%80%e5%88%a0%e5%ba%93%e9%87%8d%e5%bb%ba" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>接受现状不符合&quot;彻底清除&quot;的目标（旧链接还挂着路径），于是选了最彻底也最省心的方案：<strong>删除仓库，同名重建</strong>。</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">gh repo delete &lt;账号&gt;/&lt;仓库&gt; --yes                <span class="c1"># 删除</span>
</span></span><span class="line"><span class="cl">gh api repos/&lt;账号&gt;/&lt;仓库&gt;                        <span class="c1"># 确认 404</span>
</span></span><span class="line"><span class="cl">gh repo create &lt;账号&gt;/&lt;仓库&gt; --public --source . --remote origin --push
</span></span><span class="line"><span class="cl">git push origin --tags                            <span class="c1"># 触发 tag 构建</span></span></span></code></pre></div></div>
<p>执行要点：删除后先用 API 确认返回 404 再重建（同名）；重建后推 main 与两个 tag，同一刻三个 CI run（main 提交 + 两个 tag）排队构建，Release 由 tag 触发自动重建；顺手补上仓库描述与 9 个 topics。</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%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>判定&quot;解决&quot;的标准是<strong>旧链接彻底失效</strong>：</p>
<ul>
<li>删库重建后，三个旧 SHA 探测全部返回 HTTP 422 <code>No commit found for SHA: ...</code>（GitHub REST 对不存在的提交返回 422 而非 404）；当年被分享出去的那个链接也失效了；</li>
<li>新仓库为 PUBLIC，描述与 9 个 topics 就位；</li>
<li>3 个 CI run（main + 两个 tag）全部 success；两个 Release 由 tag 触发重建完毕，资产齐全（可执行程序、GUI 压缩包、网页版、SHA256SUMS 校验文件）；</li>
<li>远端 <code>web/build_web.py</code> 第 6 行确认为「项目根目录」，全历史零泄漏；</li>
<li>门禁提交在远端日志显示 <code>G</code>（Verified）。</li>
</ul>
<p>怎么判断&quot;没解决&quot;：复制旧 commit 链接直接打开，还能看到 diff 和那行路径——说明不可达对象还活着。</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%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>
<table>
	<thead>
			<tr>
					<th>时机</th>
					<th>动作</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>提交前（成本最低）</td>
					<td>仓库放 <code>check_privacy.py</code>；CI 加一步扫描；本地 <code>git config core.hooksPath scripts/hooks</code> 启用 pre-commit；示例路径一律用无盘符占位</td>
			</tr>
			<tr>
					<td>提交前（另一视角）</td>
					<td>记住&quot;历史即公开面&quot;：任何一次提交的增删行都会永久展示，删除一行泄漏 = 把泄漏写进 diff</td>
			</tr>
			<tr>
					<td>发现历史泄漏后</td>
					<td>先 <code>git bundle create --all</code> 备份全历史（备份内仍含旧路径，勿外推）</td>
			</tr>
			<tr>
					<td>清理时</td>
					<td>filter-repo <code>--replace-text</code>（替换表长串优先）→ 空提交自动清 → 全历史重扫 → <code>rebase --root</code> 重签全部提交与 tag</td>
			</tr>
			<tr>
					<td>强推之后</td>
					<td>用 <code>gh api repos/&lt;账号&gt;/&lt;仓库&gt;/commits/&lt;旧SHA&gt;</code> 探测：仍返回 SHA 就说明缓存还活着</td>
			</tr>
			<tr>
					<td>要彻底清除</td>
					<td>删库重建（同名）：删除 → 404 确认 → 重建 → 推 main/tags → CI 重建 Release → 旧 SHA 全部 422</td>
			</tr>
	</tbody>
</table>

<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%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>事后清理这一趟，成本不在改写本身（秒级），而在连锁动作：重签全部提交、重建 tag、删库重建、等 CI 重打 Release、一个个探测旧 SHA。而这一切的起因，只是提交时多写的一行注释，加一次只扫工作区、没扫历史的&quot;复查&quot;。</p>
<p>对开源仓库来说，历史就是公开面，任何一次提交都可能在未来某个时刻被人翻开。比事后清理便宜得多的做法，是让机器在提交发生前拦一道：一条正则、一个 CI 步骤、一个 pre-commit 钩子。最后再强调一遍备份的事：改写前的备份里还躺着那行旧路径，别把备份当成普通仓库推上去，确认干净稳定后记得删掉。</p>
]]></content:encoded>
    </item>
    <item>
      <title>512MB 小内存云主机整机失联：自动更新触发 OOM，打死了网络栈</title>
      <link>https://slop.lun.sh/small-vps-oom-kills-network/</link>
      <pubDate>Fri, 18 Sep 2026 20:10:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/small-vps-oom-kills-network/</guid>
      <category>OOM</category>
      <category>systemd-networkd</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一台 512MB 的 Ubuntu 云主机（跑 sing-box 代理）某天突然&quot;整机失联&quot;：云控制台显示实例正在运行、静态 IP 没变，SSH、代理端口、云厂商的浏览器终端却全都不通，只有重启才恢复。一天之内，两台同配置的机器先后中招。根因不是网卡、不是运营商、也不是云厂商，而是无人值守自动更新（unattended-upgrades）把内存打满，全局 OOM 连带把负责网络配置的 systemd-networkd 拖进 Failed，默认路由与 DNS 随后消失——机器&quot;活着但没网&quot;。适用环境：Ubuntu 22.04 LTS（内核 6.8.0-1063-aws）、systemd-networkd、512MB 云主机，时间 2026-09-09 至 09-10。</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%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两台机在不到一天里先后病倒。本文主角（下称 A 机）于 2026-09-10 早晨 08:00（CST）前后失联；另一台同配置机器（下称 B 机）在约 19 小时前——09-09 06:18 UTC——以同样的链条先倒下，失联约 18 小时才被发现。</p>
<p>A 机失联时的观测数据：</p>
<ul>
<li>云控制台：实例状态&quot;正在运行&quot;，静态 IP 未变；实例历史无任何停止/回收动作 → 云侧没动过它。</li>
<li>外部探测：SSH（非标准端口）与代理入站端口全部 TCP 超时、无 SYN-ACK（不是拒绝），ICMP 不通。</li>
<li>浏览器终端：报 <code>UPSTREAM_ERROR [515]</code>——云厂商会话管理 agent（SSM）连不上元数据服务 <code>169.254.169.254</code>，控制台通道本身也断了。</li>
<li>自建监控 agent 主动出站上报心跳，最后一次心跳 23:55:24 UTC。入站与出站同时中断 → 故障在整机/网络层，不在 sing-box 进程层。</li>
<li>对照：其余地域节点全部正常，云厂商状态页无对应事件 → 排除全局网络故障与区域事故。</li>
</ul>
<p>排查期间还踩了个坑：本机经代理/TUN 测端口会&quot;乐观握手&quot;——本地应用立刻拿到 SYN-ACK，实际转发到上游才算成功，第一轮误报&quot;已恢复&quot;。改用两台异地干净观测点直连复测，才确认 09:13 控制台重启后 sing-box 09:13:52 自动拉起、监控 09:13:55 恢复上报，失联时长约 74 分钟。</p>
<p>重启后从前一次启动的 journal 里挖出完整的故障链日志：</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="c1"># A 机（最后一段崩溃现场）</span>
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:07:33 systemd-networkd<span class="o">[</span>342<span class="o">]</span>: ens5: Could not <span class="nb">set</span> route: Connection timed out
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:07:33 systemd-networkd<span class="o">[</span>342<span class="o">]</span>: ens5: Failed
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:08:13 kernel: oom-kill: ... global_oom, <span class="nv">task_memcg</span><span class="o">=</span>/system.slice/apt-daily-upgrade.service, <span class="nv">task</span><span class="o">=</span>unattended-upgr
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:08:13 kernel: Out of memory: Killed process <span class="m">14467</span> <span class="o">(</span>unattended-upgr<span class="o">)</span> total-vm:469680kB, anon-rss:150328kB ... oom_score_adj:0
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:08:38 kernel: Out of memory: Killed process <span class="m">14650</span> <span class="o">(</span>unattended-upgr<span class="o">)</span> anon-rss:163744kB
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:12:15 kernel: agent invoked oom-killer ... Out of memory: Killed process <span class="o">(</span>unattended-upgr<span class="o">)</span>
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 08:00:09 sing-box: ERROR router: missing default interface
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 08:00:23 agent: lookup &lt;监控域名&gt; on 127.0.0.53:53: server misbehaving
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 08:04:51 ssm-agent: 169.254.169.254 connect: network is unreachable
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 09:13:36 systemd<span class="o">[</span>1<span class="o">]</span>: systemd-networkd.service: Deactivated successfully.  ← 重启</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"><span class="c1"># B 机（早 19 小时的同一套日志）</span>
</span></span><span class="line"><span class="cl">Sep <span class="m">09</span> 06:18:11 systemd-networkd<span class="o">[</span>352<span class="o">]</span>: eth0: Could not <span class="nb">set</span> route: Connection timed out
</span></span><span class="line"><span class="cl">Sep <span class="m">09</span> 06:18:11 systemd-networkd<span class="o">[</span>352<span class="o">]</span>: eth0: Failed
</span></span><span class="line"><span class="cl">Sep <span class="m">09</span> 06:18:11 kernel: Out of memory: Killed process <span class="m">51149</span> <span class="o">(</span>localedef<span class="o">)</span> total-vm:127980kB, anon-rss:115752kB</span></span></code></pre></div></div>
<p>两机同源：都是 512MB 内存、<code>Swap: 0</code>、日常 <code>apt-daily-upgrade</code>（unattended-upgrades）触发全局 OOM。B 机先倒、无人察觉；A 机随后复现，才把问题翻出来。</p>
<table>
	<thead>
			<tr>
					<th>时间（UTC）</th>
					<th>日志/事件</th>
					<th>含义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>06:52</td>
					<td><code>apt-daily-upgrade.timer</code> 启动</td>
					<td>每日升级开始了</td>
			</tr>
			<tr>
					<td>07:07:33</td>
					<td>networkd <code>Could not set route: Connection timed out</code> → <code>Failed</code></td>
					<td>内存耗尽、内核极端迟滞，设置 DHCP 路由超时，网络配置服务死亡</td>
			</tr>
			<tr>
					<td>07:08:13 / 07:08:38 / 07:12:15</td>
					<td><code>Out of memory: Killed process (unattended-upgr)</code></td>
					<td>全局 OOM，三次杀掉升级进程；apt 被杀后又被拉起、再次被杀</td>
			</tr>
			<tr>
					<td>08:00:09</td>
					<td>sing-box <code>missing default interface</code></td>
					<td>networkd 死后无人续租约，默认路由消失</td>
			</tr>
			<tr>
					<td>08:00:23</td>
					<td>监控 agent DNS 查询失败</td>
					<td>DNS 失效</td>
			</tr>
			<tr>
					<td>08:04:51</td>
					<td>SSM agent 连元数据服务失败</td>
					<td>控制台终端 515 的直接原因</td>
			</tr>
			<tr>
					<td>09:13</td>
					<td>控制台重启</td>
					<td>sing-box 09:13:52 拉起、监控 09:13:55 恢复上报</td>
			</tr>
	</tbody>
</table>

<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%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>链条是这样走的：</p>
<ol>
<li><strong>内存被打满</strong>。512MB 实例、无 swap，日常占用已接近一半（journald 约 28MB、multipathd 约 27MB、sing-box 约 25MB、snapd 约 21MB，加 buff/cache 可用只剩约 200MB）。<code>unattended-upgrades</code> 升级时再吃 150MB+，内存耗尽。</li>
<li><strong>负责网络的服务先被拖死</strong>。内存耗尽后内核迟滞，<code>systemd-networkd</code> 执行设置路由的操作超时，报 <code>Could not set route: Connection timed out</code> 后进入 <code>Failed</code> 状态——它一死，DHCP 租约不再续期。</li>
<li><strong>OOM killer 动手，但为时已晚</strong>。全局 OOM 触发，被杀的是 <code>unattended-upgr</code>（150MB）和 <code>localedef</code>（115MB）这类升级进程；此时 networkd 已死，路由/DNS 只是时间问题。约 53 分钟后默认路由消失（A 机 08:00:09），DNS 随后失效。</li>
<li><strong>机器&quot;活着但没网&quot;</strong>。内核还在跑、进程还在、云面板显示 running，但没有任何网络路径：出站不行、入站更不行，SSH、代理、控制台终端全部不可达。没有 panic、没有 oops、连 hung task 都没有——journal 里除了内核那几行 OOM 和 networkd 两行 <code>Failed</code>，再无痕迹。常规搜 <code>panic/error</code> 什么也扫不出来。</li>
</ol>
<p>两个值得注意的点。其一，OOM killer 实际杀掉的是升级进程，networkd 是&quot;超时自杀&quot;；但加固前它的 <code>OOMScoreAdjust=0</code>、<code>Restart=on-failure</code>，在全局 OOM 里一样是零保护的候选，这次没被挑中纯属运气——这决定了下面的加固方向。其二，这种故障不留明显崩溃痕迹，B 机失联 18 小时没有任何人发现，正是因为面板显示正常、日志&quot;看起来很干净&quot;。</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%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>弯路一：本机测端口误报恢复。</strong> 本机经 TUN/代理测目标端口，本地应用立刻拿到 SYN-ACK，第一轮三个端口全部&quot;OPEN&quot;；干净外部源直连才暴露真相：全部超时。结论必须以非本机的观测点为准。</p>
<p><strong>弯路二：自动重启被误关又恢复。</strong> 处置中一度把 <code>50unattended-upgrades</code> 的 <code>Automatic-Reboot</code> 从 <code>true</code> 改成 <code>false</code>，理由&quot;去掉多余的重启&quot;。随后确认一个名实问题：机器每周日凌晨的重启<strong>不是 cron</strong>——手动重启 cron 早在几天前加固时已删除（末次触发 09-06）；真正在做凌晨自动重启的是 unattended-upgrades 的 <code>Automatic-Reboot-Time &quot;02:00&quot;</code>（另一台机 08-20、08-22 两次 02:00 重启即它所致）。而站主平时不会定期登录机器，若关掉这个开关，装了内核更新也永不生效——最终改回 <code>true</code> 并以 <code>apt-config dump</code> 验证生效。这里想对读者说明取舍：关掉 <code>Automatic-Reboot</code> 的代价，就是内核安全更新要等下次人工重启才生效；无定期登录习惯的机器不建议关。</p>
<p><strong>上机复核：另一套并行加固已生效。</strong> 出手前先复核现状，发现当天早些时候已经有另一批加固配置落在两台机器上（不是本次操作加的）：apt 服务的内存 cgroup 上限（<code>MemoryMax=250M</code> / <code>MemoryHigh=200M</code>）、networkd 的 <code>OOMScoreAdjust=-1000</code> + <code>Restart=always</code>、<code>multipathd</code> 停用、以及一个 <code>net-watchdog</code> 路由看门狗。逐项验证确实生效，且与本次新增的 swap 不冲突——fstab 里 <code>/swapfile</code> 只有一条、无重复挂载，<code>findmnt --verify</code> 通过。这套思路比&quot;只加 swap&quot;更对：<strong>卡住 OOM 源头</strong>（apt 内存升到 250M 就在自己的 cgroup 内被杀，整机不受影响），而不是等它打死网络栈再救。</p>
<p><strong>本次补做的处置</strong>：</p>
<ol>
<li><strong>2GB swapfile</strong>：<code>fallocate -l 2G</code> → <code>mkswap</code> → <code>swapon</code>，写进 <code>/etc/fstab</code>（<code>/swapfile none swap sw 0 0</code>）。作用：吸收突发内存压力，让 OOM 尽量不发生。注意 swap 只是缓冲不是解药——OOM 一旦真的发生，netfilter 之外的任何进程都可能被随机挑中，网络栈仍可能中招。</li>
<li><strong>清理纯代理机用不上的定时器</strong>：禁用 <code>motd-news.timer</code>、<code>update-notifier-download.timer</code>、<code>update-notifier-motd.timer</code>（每日拉资讯/弹更新通知，代理机上无用途，省网络与内存）；保留 <code>apt-daily</code>、<code>apt-daily-upgrade</code>（安全更新）、<code>fstrim</code>、<code>e2scrub_all</code>、<code>logrotate</code>、<code>dpkg-db-backup</code>、<code>net-watchdog</code>。顺手停用 <code>multipathd</code>（无多路径存储，白占约 27MB）。</li>
<li><strong>补上离线告警</strong>：自建监控的离线告警此前未开，B 机失联 18 小时无人知晓。开启后同类失联约 3 分钟（grace 180 秒）内推送到消息通道。</li>
<li><strong>规格决策</strong>：站主否决升配（加内存每月约 +2 美元，对纯代理机不划算），以&quot;swap + 内存 cgroup 上限 + OOM 保护 + 路由看门狗&quot;组合替代。</li>
</ol>
<p>看门狗逻辑（每 2 分钟，<code>OnBootSec=3min</code>、<code>OnUnitActiveSec=2min</code>、<code>AccuracySec=15s</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"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nv">LOG</span><span class="o">=</span>/var/log/net-watchdog.log
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! ip -4 route show default <span class="p">|</span> grep -q .<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;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> NO-DEFAULT-ROUTE -&gt; restarting systemd-networkd&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  systemctl restart systemd-networkd
</span></span><span class="line"><span class="cl">  sleep <span class="m">10</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ip -4 route show default <span class="p">|</span> grep -q .<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;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> recovered&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> STILL-DOWN&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$LOG</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">fi</span></span></span></code></pre></div></div>
<p>它只治网络栈这一层，机器彻底卡死时它自己也跑不起来——所以它是兜底，不是主力；主力仍是掐住 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%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>加固后用可观测指标逐项复核（两台机同批）；另外注意 <code>OOMScoreAdjust</code> 对已运行的进程要下次启动才生效，核实取值即可，不必为此强行重启 networkd。</p>
<table>
	<thead>
			<tr>
					<th>检查项</th>
					<th>方法</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>内存上限生效</td>
					<td><code>systemctl show apt-daily-upgrade.service -p MemoryMax</code></td>
					<td>262144000（250M）✅</td>
			</tr>
			<tr>
					<td>networkd 保护</td>
					<td><code>systemctl show systemd-networkd -p OOMScoreAdjust -p Restart</code></td>
					<td><code>-1000</code> / <code>always</code> ✅</td>
			</tr>
			<tr>
					<td>看门狗运行且未误触发</td>
					<td><code>systemctl list-timers net-watchdog.timer</code>；<code>/var/log/net-watchdog.log</code></td>
					<td>enabled + active，每 2 分钟一次；日志为空 = 默认路由从未丢过 ✅</td>
			</tr>
			<tr>
					<td>swap 持久化</td>
					<td><code>swapon --show</code>、<code>grep swap /etc/fstab</code>、<code>findmnt --verify</code></td>
					<td><code>/swapfile</code> 2G 生效，fstab 单条目无重复，校验通过；已在吸收压力（一台 8MB、一台 35MB 在用）✅</td>
			</tr>
			<tr>
					<td>服务端口</td>
					<td>异地干净源 TCP 探测 + <code>openssl s_client</code> REALITY 握手</td>
					<td>全通；TLS1.3、<code>Verify return code: 0 (ok)</code> ✅</td>
			</tr>
			<tr>
					<td>监控心跳</td>
					<td>监控面板各节点最后上报</td>
					<td>全部 5 分钟内（含故障过的两台）✅</td>
			</tr>
			<tr>
					<td>自动重启配置</td>
					<td><code>apt-config dump | grep Automatic-Reboot</code></td>
					<td><code>true</code>，与站主决策一致 ✅</td>
			</tr>
			<tr>
					<td>保留的定时器</td>
					<td><code>systemctl list-timers</code></td>
					<td><code>apt-daily*</code>/<code>fstrim</code>/<code>logrotate</code>/<code>dpkg-db-backup</code>/<code>net-watchdog</code> 全部 enabled ✅</td>
			</tr>
	</tbody>
</table>
<p>实机后的观察：swap 建立后已开始承接压力（一台 8MB、一台 35MB），说明 512MB 上日常内存就是紧的。遗留的边界：apt 被限制在 250M 后，个别大升级可能因内存不足<strong>失败但机器没事</strong>——失败目前没有独立告警（监控只报在线/离线），需要的话可加一个&quot;升级任务失败&quot;探针。</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%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><strong>第一步永远是看内核日志里有没有 OOM</strong>：<code>journalctl -k --since &quot;1 day ago&quot; | grep -iE &quot;Out of memory|Killed process&quot;</code>。整机&quot;活着但没网&quot;时，这是最快的定位动作。</li>
<li>面板 Running + 静态 IP 未变 + 入站出站同时断（监控 agent 的主动上报也停）= 先怀疑整机资源/网络栈层，别急着找运营商。</li>
<li>小内存机器给 apt 体系套内存 cgroup 上限：<code>apt-daily</code> 与 <code>apt-daily-upgrade</code> 各建 drop-in，<code>MemoryMax=250M</code>、<code>MemoryHigh=200M</code>。</li>
<li>给网络服务保命：<code>systemd-networkd</code> drop-in 设 <code>OOMScoreAdjust=-1000</code>、<code>Restart=always</code>、<code>RestartSec=10s</code>。</li>
<li>路由看门狗（上文的 shell 脚本）作为兜底；记住它只治网络栈，机器彻底卡死时无效。</li>
<li>加 swap 只解决&quot;尽量不发生 OOM&quot;，不解决&quot;OOM 发生时谁被挑中&quot;；两者定位不同，别混为一谈。</li>
<li>自动重启开关的取舍：无定期登录习惯就保留 <code>Automatic-Reboot &quot;true&quot;</code>，否则内核更新永不生效。</li>
<li>本机经代理/TUN 测端口会乐观握手，端口存活判断一律用异地干净源。</li>
<li>这类故障不留崩溃痕迹，离线告警必须开——监控不告警等于没监控。</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="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>B 机先倒下 19 小时无人发现，A 机再倒才被重视——两台的差别只是先后，不是运气。回头想，&ldquo;机器活着但没网&quot;大概是运维里最容易被误判为运营商/云厂商问题的一类故障：面板一切正常，探测全部超时，谁都会先怀疑链路。实际上只要先花十秒钟看一眼内核日志里有没有 <code>Out of memory</code>，这整场排查就结束了。小内存机器上，自动更新这种&quot;每天都在跑、从来不看结果&quot;的任务，往往是隐藏的定时炸弹；给它套上 cgroup 上限、给网络服务戴上护具，再留一个看门狗兜底，这套组合拳对 512MB 级别的云主机都适用。</p>
]]></content:encoded>
    </item>
    <item>
      <title>反复掉线一分钟的 WordPress 站：CDN 其实从未缓存过 HTML</title>
      <link>https://slop.lun.sh/cdn-html-cache-wordpress-outage/</link>
      <pubDate>Fri, 18 Sep 2026 20:05:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/cdn-html-cache-wordpress-outage/</guid>
      <category>WordPress</category>
      <category>CDN</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一个服务端渲染的 WordPress 站每隔几天就出现一次约 1 分钟不可用：访客和监控探针同时超时或 504，随后自行恢复，发生频率与外部抓取器、扫描器的活动正相关。本文记录这次排查。适用环境：WordPress + Hestia 控制面板（nginx→Apache 反向代理栈）+ php-fpm 8.3 + 前置 Bunny CDN，2026-09 实测。结论先行：根因不是网络、不是重启，而是这条链路上没有任何一层 HTML 缓存——CDN 的 Smart Cache 设计上永不缓存 <code>text/html</code>，源站又对每个页面发 <code>no-cache</code>，于是每次匿名访问都是完整回源跑一遍 PHP。</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%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>监控服务在最近 5 天报告了 3 起约 1 分钟的掉线（表 1）。每次都没有明确的错误码：多数探测点是超时，个别探到源站 504；站点没有重启、没有 OOM、没有网络变更，掉线是&quot;自己好的&quot;。频率上与外部请求活动强相关。</p>
<table>
	<thead>
			<tr>
					<th>掉线时刻（UTC）</th>
					<th>源站现场</th>
					<th>触发者</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>09-14 10:53</td>
					<td>25 s 内 250 个请求（峰值 18/s）扫不存在的主题 CSS；每个命中都返回 46.6 KB 的主题 404 页（= 一次完整 PHP 渲染）</td>
					<td>伪造官方优化器 UA 的扫描器</td>
			</tr>
			<tr>
					<td>09-17 02:09</td>
					<td>探针收到 10 条 503（约 3 KB 错误页）；插件目录与升级备份目录落盘时间吻合，伴随更新回环自检与 wp-cron 并发</td>
					<td>WordPress 插件自动更新（维护模式）</td>
			</tr>
			<tr>
					<td>09-17 18:02</td>
					<td>100 s 内 357 个请求、单秒 61 个到达；同一篇文章被重复抓 88 次；源站出现 2 条 504 与 Apache <code>AH01075 ... (polling)</code>（FCGI 派发超时，即请求已排队超过 30 s）</td>
					<td>一个家宽 IP 的抓取器（UA 自报）</td>
			</tr>
	</tbody>
</table>
<p>判据来自监控探针的请求计数：探针在源站日志里的基数是每分钟 3–4 次，掉线分钟翻到 9–12 次——监控判定 down 后会对每个探测地点重试，请求量放大就是掉线指纹。用这一条从源站日志反推出全部掉线时刻，与监控 CSV 完全对齐，还为历史补齐了一串同型记录（每隔 2–4 天一次的整点后 :09 分钟 503 风暴）。</p>
<p>三个现场都指向同一个事实：<strong>php-fpm 池被突发请求打满</strong>。16 个 worker 的池子被 61 并发、250 个 404、或插件更新风暴任一撑爆，与站点共用同一池的监控探针立即跟着失败。</p>
<p>但&quot;被打满&quot;只是最后一环，真正要回答的是：<strong>为什么区区几十个并发就能打满整站？</strong> 对响应头做一次&quot;判缓存真伪&quot;的检查，答案立刻浮出来。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-http" data-lang="http"><span class="line"><span class="cl"><span class="err"># 改造前，公网连打 5 次匿名首页/文章，每次都一样
</span></span></span><span class="line"><span class="cl"><span class="kr">HTTP</span><span class="o">/</span><span class="m">2</span> <span class="m">200</span>
</span></span><span class="line"><span class="cl"><span class="n">content-type</span><span class="o">:</span> <span class="l">text/html; charset=UTF-8</span>
</span></span><span class="line"><span class="cl"><span class="n">cache-control</span><span class="o">:</span> <span class="l">no-cache</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-cache</span><span class="o">:</span> <span class="l">MISS</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-cachedat</span><span class="o">:</span> <span class="l">09/17/2026 23:43:10   ← 等于请求当刻</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-requestpullsuccess</span><span class="o">:</span> <span class="l">True</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-requestpullcode</span><span class="o">:</span> <span class="l">200</span></span></span></code></pre></div></div>
<p><code>cdn-cache: MISS</code> + <code>cdn-cachedat</code> 等于请求当刻 + 每次都 <code>cdn-requestpullsuccess: True</code>，这是一个&quot;每次都现拉&quot;的指纹。对照验证：同一批请求同步出现在源站 Apache 访问日志里——CDN 每收到一次页面请求，就回源运行一整次 PHP。</p>
<p>同一时刻的静态资源是完全不同的待遇：</p>
<table>
	<thead>
			<tr>
					<th>请求类型</th>
					<th>改造前 <code>cdn-cache</code></th>
					<th>源站行为</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>首页 / 文章（text/html）</td>
					<td>全部 MISS</td>
					<td>每次都回源，完整 PHP 渲染</td>
			</tr>
			<tr>
					<td>css / js / 图片</td>
					<td>HIT</td>
					<td>边缘直接返回，源站零请求</td>
			</tr>
	</tbody>
</table>
<p>所以准确的说法不是&quot;CDN 没工作&quot;——静态资源它是干活的；是 <strong>HTML 流量从没被缓存过，一直是原样转给源站的</strong>。</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%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把链路一层层看过去，结论是：<strong>三层都没有 HTML 缓存</strong>。</p>
<table>
	<thead>
			<tr>
					<th>层</th>
					<th>改造前实际状态</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>WordPress 页面缓存</td>
					<td><code>WP_CACHE=true</code> 但 <code>wp-content/advanced-cache.php</code> 不存在——旧缓存插件卸载后常量空转，页面缓存根本没生效</td>
			</tr>
			<tr>
					<td>源站 nginx</td>
					<td>反向代理用的是默认模板，vhost 里没有 <code>fastcgi_cache</code>，microcache 未按域开启</td>
			</tr>
			<tr>
					<td>CDN 边缘</td>
					<td>Smart Cache 开着；Bunny 文档明确：<code>text/html</code>、<code>application/json</code>、<code>application/xml</code> 三种 MIME <strong>永不缓存，无论扩展名</strong></td>
			</tr>
	</tbody>
</table>
<p>第三层的机制值得展开。Bunny 的 Smart Cache 默认只缓存白名单里的静态扩展名（图片/字体/文档/代码），把 HTML 这类当作&quot;动态内容&quot;无条件放行回源，其设计理由是&quot;防止把敏感的个性化内容误缓存给别人&quot;。文档给出的绕过方式只有一条：写 Edge Rule，用 Override Cache Time 动作覆盖。</p>
<p>而源站这边，WordPress 生态里有插件在给每个页面发 <code>Cache-Control: no-cache</code>（卸载插件留下的持久化行为）。于是双重叠加：</p>
<ul>
<li>Smart Cache 说：<code>text/html</code> 永不缓存；</li>
<li>源站说：这个响应不许缓存；</li>
<li>连&quot;用 Edge Rule 强制缓存 N 分钟&quot;也被第二条否决——Bunny 不为标了 <code>no-cache</code> 的响应建缓存。</li>
</ul>
<p>结果就是：CDN 提供了一整套&quot;带宽能力&quot;（TLS/HTTP2、DDoS 吸收、静态资源缓存、图片优化），<strong>唯独对 HTML 是纯中转</strong>。这个站的每一次页面浏览，都等价于有人直接访问源站。</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%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这一节比答案更值钱：Bunny 面板上的 HTML 缓存开关和 Edge Rule 强制缓存，我们实测都走不通，而且有一个开关比不开更糟。</p>

<h3 class="relative group">弯路 1：面板的&quot;静态 HTML&quot;开关把全站变成 BYPASS
    <div id="弯路-1面板的静态-html开关把全站变成-bypass" 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%bc%af%e8%b7%af-1%e9%9d%a2%e6%9d%bf%e7%9a%84%e9%9d%99%e6%80%81-html%e5%bc%80%e5%85%b3%e6%8a%8a%e5%85%a8%e7%ab%99%e5%8f%98%e6%88%90-bypass" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先试 Bunny 官方路径的第一条：开 Optimizer 的&quot;静态 HTML&quot;开关。开启的瞬间，面板自动在拉区生成两条 Edge Rule：一条 action=OverrideCacheTime 且参数为 0（即永不缓存）、带 cookie 触发器，另一条是 BypassPermaCache，两条的 URL 触发器都是全站通配符。</p>
<p>实测（开启后连打匿名页面、带 cookie 请求、静态资源）：</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">edge try1   200 0.549s   cdn-cache: BYPASS   ← 不是 HIT，是显式旁路
</span></span><span class="line"><span class="cl">edge try2   200 0.080s   cdn-cache: BYPASS
</span></span><span class="line"><span class="cl">css try1    200 0.055s   cdn-cache: BYPASS</span></span></code></pre></div></div>
<p>连静态资源也从 HIT 掉成 BYPASS——<strong>这个开关把它自动生成的规则当作唯一裁定，全站绕开缓存，比不开更糟</strong>。回退后边缘静态资源恢复 HIT。</p>

<h3 class="relative group">弯路 2：Edge Rule 强制缓存时间仍然无效
    <div id="弯路-2edge-rule-强制缓存时间仍然无效" 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%bc%af%e8%b7%af-2edge-rule-%e5%bc%ba%e5%88%b6%e7%bc%93%e5%ad%98%e6%97%b6%e9%97%b4%e4%bb%8d%e7%84%b6%e6%97%a0%e6%95%88" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第二条路按文档来：自己写 Edge Rule，按响应 <code>content-type: text/html</code> + <code>status 200</code> 覆盖缓存时间 600 s。先后试了两种动作（OverrideCacheTime 与 OverrideCacheTimePublic），再不断收窄触发器（全站 → 单篇文章 → 去掉可疑触发器），结果一致：</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">article try1   200 0.595s   cdn-cache: MISS    origin: EXPIRED
</span></span><span class="line"><span class="cl">article try2   200 0.076s   cdn-cache: MISS    origin: HIT
</span></span><span class="line"><span class="cl">article try3   200 0.078s   cdn-cache: MISS    origin: HIT</span></span></code></pre></div></div>
<p>边缘依旧 MISS，只是源站层自己命中（说明这时已有一层 nginx 代理缓存在工作——它属于第二层，与边缘无关）。排除触发器问题后结论收敛：<strong>卡点是源站显式发的 <code>Cache-Control: no-cache</code>——Bunny 拒绝为这类响应建缓存，什么 Edge Rule 都覆盖不了</strong>。官方两条路径在我们这里失效的原因由此明确：它们的前提都是源站先把头发对。</p>

<h3 class="relative group">弯路 3（侦查层面）：防火墙日志里的大量 BLOCK 不是攻击
    <div id="弯路-3侦查层面防火墙日志里的大量-block-不是攻击" 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%bc%af%e8%b7%af-3%e4%be%a6%e6%9f%a5%e5%b1%82%e9%9d%a2%e9%98%b2%e7%81%ab%e5%a2%99%e6%97%a5%e5%bf%97%e9%87%8c%e7%9a%84%e5%a4%a7%e9%87%8f-block-%e4%b8%8d%e6%98%af%e6%94%bb%e5%87%bb" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>排查早期（另一个掉线窗口）宿主防火墙日志持续刷着数千条 BLOCK 记录，第一反应很像被扫描或攻击。逐一核对策略后确认：那是防火墙默认拒绝策略对容器 DNS 查询的常规拦截——<strong>软件默认行为，不是攻击信号</strong>。它确实造成容器内 DNS 不可用等一系列副作用（另案修复），但与掉线窗口无关，窗口内源站全程 200。排障时先分清&quot;策略性拦截噪音&quot;和&quot;真正的 5xx/断流&quot;，否则会在默认行为上浪费整个调查阶段。</p>

<h3 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="#%e6%9c%80%e7%bb%88%e6%96%b9%e6%a1%88%e4%b8%a4%e5%b1%82%e7%bc%93%e5%ad%98--%e5%8f%91%e5%b8%83%e5%8d%b3%e6%b8%85%e9%99%a4" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>教训汇总成一句话：<strong>要让 CDN 缓存 HTML，得先让源站对&quot;值得缓存的请求&quot;发出可缓存的头</strong>。落地四步：</p>
<ol>
<li><strong>源站头正确化</strong>（WordPress mu-plugin，在 <code>send_headers</code> 钩子里按最终优先级覆盖）：
<ul>
<li>匿名 + 无查询串 + 非后台/API/Feed 的 HTML：<code>Cache-Control: public, max-age=0, s-maxage=600</code>（边缘缓存 10 分钟，浏览器每次验证）；</li>
<li>登录/评论者/密码页 cookie、wp-admin、AJAX、REST、查询串、预览、404：<code>Cache-Control: no-cache, no-store, must-revalidate, private</code>。</li>
</ul>
</li>
<li><strong>边缘层</strong>：关闭 Smart Cache（<code>EnableSmartCache=false</code>），让 Bunny 完全按源站头决定；只留一条 Edge Rule 做旁路——cookie 匹配 <code>wordpress_logged_in</code>/<code>wordpress_sec</code>/<code>wp-postpass</code>/<code>comment_author</code> 等，或路径匹配 <code>/wp-admin/*</code>、<code>/wp-login.php</code>、<code>/wp-json/*</code>、<code>/xmlrpc.php</code>、<code>/wp-cron.php</code>，一律缓存时间 0。</li>
<li><strong>源站层</strong>：控制面板的反向代理模板切成 caching（nginx <code>proxy_cache</code>：200 缓存 5 分钟，301/302 与 404 缓存 10 分钟）；cookie 与 POST 自动置 <code>$no_cache=1</code> 旁路；<code>X-Update</code> 请求头既绕过读取又写回新副本。</li>
<li><strong>发布即清除</strong>：<code>bunnycdn</code> 官方插件只有后台手动清除按钮、没有发布钩子，所以由同一个 mu-plugin 在发布/更新/评论事件里触发：CDN 全区 purge + 对受影响 URL 发带 <code>X-Update</code> 的回源请求刷新源站层。注意回源刷新<strong>必须请求干净 URL</strong>——nginx 缓存键包含查询串，带缓存破坏参数会写进另一个键，等于没刷新。</li>
</ol>
<p>另有一处与缓存无关的独立优化：给不存在的静态资源直接返 404。扫描器打的 <code>/wp-content/themes/xxx/style.css</code> 原本会被 <code>try_files → fallback</code> 交给 WordPress，渲染出 46.6 KB 的主题 404 页（又是一次完整 PHP 引导）。改为在 nginx 层对 <code>/wp-content/</code>、<code>/wp-includes/</code> 下带静态扩展名且文件不存在的请求直接返回 404（2.9 KB）。实现要点：<strong>必须用 <code>location ^~</code> 前缀写法</strong>——普通 <code>location ~</code> 会被控制面板 vhost 内嵌的正则位置抢先，写了不生效（同样是实测踩坑）。</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%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最终结构与实测数字（公网路径，全部经过 CDN）：</p>
<table>
	<thead>
			<tr>
					<th>请求类型</th>
					<th>走到哪</th>
					<th>耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>匿名页面（已缓存）</td>
					<td>CDN 边缘命中</td>
					<td>约 55 ms，源站零请求</td>
			</tr>
			<tr>
					<td>匿名页面（新页 / 清除后首次）</td>
					<td>源站 nginx 缓存命中</td>
					<td>50–80 ms，仍不进 PHP</td>
			</tr>
			<tr>
					<td>登录 / 评论者、后台、API、POST、查询串</td>
					<td>直穿到 PHP</td>
					<td>正常渲染（设计如此）</td>
			</tr>
	</tbody>
</table>
<p>改造前同路径匿名页回源约 0.5 s；改造后匿名命中只要零头，而且<strong>源站请求量归零</strong>。</p>
<p><strong>洪峰复现（关键验收）</strong>：拿 09-17 的真实洪峰形态（60 个请求、并发 20、混打真实页面）重放：</p>
<ul>
<li>全部 200，平均 67 ms、最大 95 ms，没有一个超过 1 s；</li>
<li>源站 Apache 日志只多了 9 条（绝大多数请求没碰 PHP）；</li>
<li>源站负载从 0.13 升到 0.22，探针请求全程 50–70 ms 正常。</li>
</ul>
<p>对照改造前同类洪峰：504 + 30 秒级排队。那个&quot;1 分钟掉线&quot;的机制被从根上拿掉。</p>
<p><strong>发布即清除验证</strong>：手动执行一次 purge 路径后，边缘立即转 MISS，下一次请求自动回温 HIT；源站缓存条目由 <code>X-Update</code> 请求刷新而非等过期。</p>
<p><strong>怎么判断没解决（反向验收）</strong>：匿名页 <code>cdn-cache</code> 仍是 MISS，且源站访问日志与访客请求同步增长——两者出现任何一个，说明 HTML 又回到&quot;每次现拉&quot;状态（常见诱因：源站头又被某个插件改回去、Smart Cache 被重新打开、旁路规则误伤匿名流量）。</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%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>
<ol>
<li><strong>先判缓存真伪，再谈缓存配置</strong>。三个响应头一次到位：
<ul>
<li><code>cdn-cache: MISS</code> 且 <code>cdn-cachedat</code> 等于请求当刻 → 每次都回源，没吃到边缘缓存；</li>
<li>同一批请求同步出现在源站日志 → 每次都跑了完整渲染；</li>
<li>对照静态资源是否 HIT → 区分&quot;CDN 坏了&quot;与&quot;HTML 没缓存&quot;。</li>
</ul>
</li>
<li><strong>查&quot;链路上有没有一层 HTML 缓存&quot;，三个看着有其实没有</strong>：<code>WP_CACHE=true</code> 但 <code>advanced-cache.php</code> 不存在（常量空转）；控制面板 microcache 需按域开启、vhost 没有对应指令等于没开；CDN 拉区若延续源站 <code>no-cache</code> 头，边缘也不会缓存 HTML。</li>
<li><strong>Bunny HTML 缓存配方（本文实测可跑通）</strong>：Smart Cache 关闭 → 源站 mu-plugin 只对&quot;匿名 + 无查询串&quot;的 HTML 发 <code>public, max-age=0, s-maxage=600</code>，其余一律 <code>no-cache, private</code> → 一条 Edge Rule 做 cookie/后台路径旁路 → 边缘与源站各配一层缓存 → 发布/评论时双层清除（全区 purge + <code>X-Update</code> 干净 URL 回源）。</li>
<li><strong>旁路清单别漏</strong>：登录/评论者/密码页 cookie、<code>wp-admin</code>、<code>wp-json</code>、<code>xmlrpc.php</code>、<code>wp-cron.php</code>、带查询串、POST、预览、404。漏掉任何一个，轻则缓存错页面，重则把登录后的页面发给别人。</li>
<li><strong>用 API 改边缘规则注意部分更新语义</strong>：未提交的字段会被重置为默认值，改完拉全量配置核对漂移。回滚路径各一步：删 mu-plugin 文件、模板切回 default、删旁路规则、Smart Cache 重新打开。</li>
</ol>

<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%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把这次调查串起来的其实是最后一条因果链：<strong>这套架构里，16 个 worker 的 php-fpm 动态处理池就是整站吞吐上限</strong>。CDN 不缓存 HTML，&ldquo;读一个页面&quot;和&quot;跑一次完整渲染&quot;就变成同一件事；池子被打满（抓取器、插件自动更新、扫描器 404），新请求全部排队直至超时——而监控探针探的就是贴着你首页的 PHP 渲染，和访客挤在同一池子里。池满那一刻，监控和访客看到完全相同的&quot;1 分钟黑洞&rdquo;。</p>
<p>之前把池子从 8 扩到 16、把图片交给 CDN 优化，都只是把黑洞往后推了推。真正让它消失的，是承认 CDN 的 HTML 缓存坑、把源站头发对、再把&quot;页面浏览&quot;从 PHP 渲染里剥离出来。给博客做缓存的价值也在这里：它不只是面板上的命中率数字，更是让&quot;监控探针&quot;和&quot;真实访客&quot;两种流量不再挤同一个池子。以后若再见同型症状，先看两个响应头，再决定要不要碰防火墙。</p>
]]></content:encoded>
    </item>
    <item>
      <title>把自建博客从 Ghost 6 换成 Hugo &#43; Blowfish 静态站：实测数据与发布管线实录</title>
      <link>https://slop.lun.sh/ghost-to-hugo-static-site/</link>
      <pubDate>Fri, 18 Sep 2026 20:00:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/ghost-to-hugo-static-site/</guid>
      <category>Hugo</category>
      <category>静态站</category>
      <category>CDN</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>本站原先跑在 Ghost 6 上（Node/Express + SQLite，单容器）。这篇记录把它整体换成 Hugo + Blowfish 静态站的全过程：迁移前实测的对照数据、内容与 URL 的搬家方式、版本化部署与回滚，以及过程中踩到的十个坑。适用环境：Ghost 6.64 / Hugo 0.166.0 extended + Blowfish 主题 / nginx:alpine 容器 + Nginx Proxy Manager + systemd / 前置 Bunny CDN，2026-09-18 实测。结论先说：<strong>值得迁，但&quot;省内存、省带宽&quot;不是理由</strong>——客户端那一千 KB 里最肥的一块与换不换平台无关；真正的理由是内容变成 git 里的 Markdown、发布链路可验证、回滚变成一条命令。</p>
</blockquote>
<h2 class="relative group">一、迁移前先把&quot;更轻便&quot;量出来
    <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%e8%bf%81%e7%a7%bb%e5%89%8d%e5%85%88%e6%8a%8a%e6%9b%b4%e8%bd%bb%e4%be%bf%e9%87%8f%e5%87%ba%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>迁移前本站的规模：4 篇文章、5 个标签、3 个附件脚本、一共 12 条 URL。全部实测数据如下（不是文档推断，是在真实环境里量出来的）。</p>
<p>服务端：</p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>迁移前（Ghost 6.64）</th>
					<th>迁移后（Hugo + Blowfish）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>运行形态</td>
					<td>Node/Express 服务端渲染 + SQLite</td>
					<td>纯静态文件，nginx:alpine</td>
			</tr>
			<tr>
					<td>镜像体积</td>
					<td>639 MB</td>
					<td>62.9 MB</td>
			</tr>
			<tr>
					<td>常驻内存</td>
					<td>117.7 MiB</td>
					<td>4.9 MiB</td>
			</tr>
			<tr>
					<td>数据库 / 后台</td>
					<td>SQLite + Admin API + 登录 + 两步验证</td>
					<td>无</td>
			</tr>
			<tr>
					<td>发布通路</td>
					<td>Admin API（签名 JWT + 一堆必填参数）</td>
					<td>写 <code>.md</code> → 构建 → 同步</td>
			</tr>
			<tr>
					<td>渲染开销</td>
					<td>每个请求现渲染</td>
					<td>构建一次 148–190 ms（30 页）</td>
			</tr>
			<tr>
					<td>内容体积</td>
					<td>数据目录 21 MB（其中主题 15 MB、日志 2.7 MB、数据库 2.1 MB）</td>
					<td>仓库 + 构建产物 861 KB</td>
			</tr>
	</tbody>
</table>
<p>客户端（页面全资源，含 CSS/JS/字体/图标）：</p>
<table>
	<thead>
			<tr>
					<th>页面</th>
					<th>迁移前</th>
					<th>迁移后</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>首页</td>
					<td>1008 KB（9 个子资源）</td>
					<td>218 KB 原始 / 61 KB gzip</td>
			</tr>
			<tr>
					<td>文章页</td>
					<td>1030 KB</td>
					<td>232 KB 原始 / 68 KB gzip</td>
			</tr>
			<tr>
					<td>体积大头</td>
					<td><code>mermaid.min.js</code> 764.8 KB <strong>每页无条件加载</strong>、搜索 82.5 KB、KaTeX 74.1 KB、代码高亮 59.5 KB</td>
					<td>主样式 128.7 KB（gzip 21 KB）、主脚本 33.2 KB（gzip 11.4 KB）、缩放 8.6 KB</td>
			</tr>
			<tr>
					<td>mermaid</td>
					<td>每页强载</td>
					<td>构建产物里 0 字节（没有页面用就不发）</td>
			</tr>
	</tbody>
</table>
<p>上线后实测：首页联网传输 <strong>63.7 KB</strong>（迁移前 1008 KB，大约 1/16）。</p>
<p><strong>但客户端这份收益不该记在迁移头上。</strong> 那 764.8 KB 的 mermaid 是旧主题里一行无条件加载造成的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">defer</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;{{asset &#39;js/mermaid.min.js&#39;}}&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>全站没有任何一张 mermaid 图，把这一行删掉或改成按需判断，留在原平台也能拿到同样的页面瘦身。同理静态资源缓存、图片优化这些，都是 CDN 与主题层面的事，与换不换引擎无关。</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%e4%b8%ba%e4%bb%80%e4%b9%88%e8%bf%81%e7%bb%b4%e6%8a%a4%e9%9d%a2%e4%b8%8d%e6%98%af%e5%86%85%e5%ad%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把迁移前的账摊开看，真正的过度工程在 CMS 这一侧：4 篇纯文本内容，养着一套完整的 Node 运行时 + SQLite + Admin API + 登录 + 两步验证 + 主题模板手术，外加一条相当脆弱的发布路径——签名用的是十六进制密钥（必须先解码再作 HMAC key，用错就 401）、更新内容必须带特定参数否则静默不生效、必须有乐观锁字段、某些接口直接抛 501 只能绕过 API 改数据库、换新设备登录会被强制邮箱验证码拦下。这些都不是能力，是维护面。</p>
<p>换成静态站之后，值得写在纸上的收益只有三条：</p>
<ol>
<li><strong>内容主权</strong>。内容从数据库行变成 git 仓库里的 Markdown 文件，版本、差异、审阅、回滚、增量全是现成的。</li>
<li><strong>发布链路可验证</strong>。写 <code>.md</code> → 构建 → 同步，构建可以在本地跑通再上线；运行态的故障面（Node 运行时、数据库、后台登录）收缩成一个&quot;构建管线&quot;，而它失败时线上可以不受影响。</li>
<li><strong>现在迁最便宜</strong>。4 篇内容、无图片、无会员、无评论、无注册，URL 一共 12 条。内容越多、外链越多，迁移只会更贵。</li>
</ol>
<p>反过来说，<strong>不建议为了省内存而迁</strong>：宿主机的内存本来就不缺这一百多 MiB；而迁移会把&quot;跟着 CMS 镜像升级&quot;的维护精力，换成&quot;Hugo 版本窗口 + 主题兼容&quot;的维护精力——故障面是换了种类，不是消失了。</p>
<p>唯一不可逆的东西是 <strong>URL 与订阅（RSS 的 <code>&lt;guid&gt;</code>）</strong>，所以整套方案按&quot;先保 URL 与订阅、再谈别的&quot;的顺序设计。</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%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">1. 内容搬家：从数据库的 HTML 列，而不是导出 JSON
    <div id="1-内容搬家从数据库的-html-列而不是导出-json" 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="#1-%e5%86%85%e5%ae%b9%e6%90%ac%e5%ae%b6%e4%bb%8e%e6%95%b0%e6%8d%ae%e5%ba%93%e7%9a%84-html-%e5%88%97%e8%80%8c%e4%b8%8d%e6%98%af%e5%af%bc%e5%87%ba-json" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>内容源直接取 CMS 数据库的 <code>html</code> 列（避免二手导出数据），用一次性脚本转成 Markdown。保真度实测：表格（6 张）、围栏代码块（含语言标注）、引用块全部保留；把两边归一化后做 token 级比对，差异只剩 Markdown 记号本身（转义下划线、分隔线、列表序号）。</p>
<p>过程中有一个必须知道的坑：<strong>CMS 在数据库里把站点地址存成了占位符</strong>（形如 <code>__GHOST_URL__</code>），渲染时才替换成真实地址。直接迁移会得到一堆 <code>__GHOST_URL__/content/files/...</code> 的死链，脚本里必须把它替换成站内相对路径。附件（3 个 PowerShell 脚本）落到 <code>static/content/files/&lt;年&gt;/&lt;月&gt;/</code>，URL 与迁移前逐字节一致，构建即发布。</p>
<p>清洗分两步：先用扫描脚本列出所有反斜杠转义（这个平台的转换器会给词内下划线加转义），确认哪些是 Markdown 记号、哪些是 Windows 路径里的真反斜杠，<strong>围栏代码块一律不动</strong>，再动手还原。</p>

<h3 class="relative group">2. URL 保平：12 条旧地址一条不漏
    <div id="2-url-保平12-条旧地址一条不漏" 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="#2-url-%e4%bf%9d%e5%b9%b312-%e6%9d%a1%e6%97%a7%e5%9c%b0%e5%9d%80%e4%b8%80%e6%9d%a1%e4%b8%8d%e6%bc%8f" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>文章 URL 形态用配置项固定住（<code>[permalinks] posts = &quot;/:slug/&quot;</code>），所以三篇文章的地址原样不变。需要额外处理的是其余地址：</p>
<table>
	<thead>
			<tr>
					<th>旧地址（CMS）</th>
					<th>新地址</th>
					<th>处理方式</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>3 篇文章 + 首页 + 关于页</td>
					<td>同名</td>
					<td>配置对齐，无需处理</td>
			</tr>
			<tr>
					<td><code>/tag/&lt;slug&gt;/</code>（5 个标签）</td>
					<td><code>/tags/&lt;slug&gt;/</code></td>
					<td>一条 nginx 正则 301 全量转过去</td>
			</tr>
			<tr>
					<td><code>/author/&lt;名字&gt;/</code></td>
					<td><code>/about/</code></td>
					<td>nginx 301</td>
			</tr>
			<tr>
					<td><code>/rss/</code>、<code>/rss</code></td>
					<td><code>/index.xml</code></td>
					<td>nginx 301（两个都实测跳转成功）</td>
			</tr>
			<tr>
					<td>占位帖 <code>/coming-soon/</code></td>
					<td><code>/</code></td>
					<td>nginx 301（内容本身不迁移：它是订阅引导页，本站没有会员，迁过去只会带出死链）</td>
			</tr>
			<tr>
					<td>一条历史改名的旧 slug</td>
					<td>现文章地址</td>
					<td>nginx 301</td>
			</tr>
	</tbody>
</table>
<p>标签页有个静态站特有的坑：<strong>新引擎里给标签写 <code>slug:</code> 不生效（实测 0.166 版本），必须写 <code>url:</code></strong>，否则标签页地址会变成中文名的拼音或原文，与旧地址对不上。写法是给每个标签建一个索引页，front matter 里写 <code>url: &quot;/tags/&lt;旧 slug&gt;/&quot;</code>。</p>
<p>还有一个只有反代环境才会遇到的坑：<strong>301 的目标必须写死 <code>https://</code></strong>。源站在反向代理后面，看到的请求协议是 <code>http</code>，如果 301 用相对路径或 <code>$scheme</code>，访问者会被降级跳回 <code>http</code> 再被跳一次。正确写法是显式 <code>return 301 https://$host/&lt;新路径&gt;;</code>。</p>

<h3 class="relative group">3. RSS 订阅连续性：<code>&lt;guid&gt;</code> 必须是旧的那一串
    <div id="3-rss-订阅连续性guid-必须是旧的那一串" 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="#3-rss-%e8%ae%a2%e9%98%85%e8%bf%9e%e7%bb%ad%e6%80%a7guid-%e5%bf%85%e9%a1%bb%e6%98%af%e6%97%a7%e7%9a%84%e9%82%a3%e4%b8%80%e4%b8%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这是整个迁移里最容易翻车、又最容易被忽略的一处。RSS 条目的 <code>&lt;guid&gt;</code> 是不透明字符串，迁移前的引擎用的是数据库内部的文章 id；而 Hugo 默认拿永久链接当 <code>&lt;guid&gt;</code>。两者一换，<strong>所有老订阅者会在切换当天把已有的旧文章当成新文章重收一遍</strong>。</p>
<p>处理方式：从旧站的 <code>/rss/</code> 里把每篇文章的 <code>&lt;guid&gt;</code> 取回来写进 front matter，再用一个自定义的 <code>layouts/rss.xml</code> 覆盖默认模板——front matter 里有 <code>guid</code> 就用它，没有才退回永久链接；同时用 <code>mainSections = [&quot;posts&quot;]</code> 把独立页面（关于页等）踢出 feed。</p>
<p>模板里另有一个语法坑：XML 声明必须走模板函数输出，字面写会被转义成 <code>&amp;lt;?xml</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="cl"><span class="cp">{{</span><span class="w"> </span><span class="k">printf</span><span class="w"> </span><span class="s">&#34;&lt;?xml version=\&#34;1.0\&#34; encoding=\&#34;utf-8\&#34; standalone=\&#34;yes\&#34;?&gt;&#34;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">safeHTML</span><span class="w"> </span><span class="cp">}}</span></span></span></code></pre></div></div>
<p>验证方式是逐条比对：feed 条数、每条 <code>&lt;guid&gt;</code> 与线上旧 feed 逐个一致、XML 可解析。</p>

<h3 class="relative group">4. 部署：版本化目录 + 符号链接原子切换
    <div id="4-部署版本化目录--符号链接原子切换" 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="#4-%e9%83%a8%e7%bd%b2%e7%89%88%e6%9c%ac%e5%8c%96%e7%9b%ae%e5%bd%95--%e7%ac%a6%e5%8f%b7%e9%93%be%e6%8e%a5%e5%8e%9f%e5%ad%90%e5%88%87%e6%8d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>落地结构：</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">/srv/site/
</span></span><span class="line"><span class="cl">├── src/        内容仓库的工作副本（构建前 fetch + reset）
</span></span><span class="line"><span class="cl">├── releases/   每次构建一个带时间戳的目录
</span></span><span class="line"><span class="cl">├── bin/        hugo 二进制（锁定版本，不跟随包管理器）
</span></span><span class="line"><span class="cl">├── scripts/    构建与部署脚本
</span></span><span class="line"><span class="cl">├── deploy/     systemd 单元等部署配置
</span></span><span class="line"><span class="cl">├── logs/       部署与自动部署日志
</span></span><span class="line"><span class="cl">└── current -&gt; releases/&lt;时间戳&gt;   符号链接，网站根目录指向它</span></span></code></pre></div></div>
<p>部署脚本的语义固定为六步：拉取内容 → 构建到新的 <code>releases/&lt;时间戳&gt;</code> → 校验关键产物非空 → <strong>原子替换 <code>current</code> 符号链接</strong> → 配置有变化则同步并让容器 reload → 只保留最近 5 份。</p>
<p>这样做的意义是&quot;发不出去&quot;不等于&quot;站挂了&quot;：构建失败或校验不通过时 <code>current</code> 不动，线上继续跑上一版。</p>
<p>服务容器是 <code>nginx:alpine</code>，只接内网（不映射公网端口），由反向代理转发，前面挂 CDN。回滚有两档：</p>
<ul>
<li>回上一版产物：把 <code>current</code> 指回上一个 release 目录（nginx 每个请求都会重新解析符号链接，不需要重启）。</li>
<li>回旧平台：把反向代理的转发目标改回 CMS 容器，再把它启动起来（容器与数据一直保留，切换前的数据库行与生成的配置文件都有备份）。</li>
</ul>
<p><strong>Hugo 的版本是硬约束</strong>：主题声明了兼容窗口（<code>extended 0.163–0.166</code>），所以本地二进制、服务器上的二进制、主题三者必须一起动，不追新。</p>
<p>自动部署用 systemd timer 每 5 分钟检查一次远端仓库：有新提交就跑部署脚本，没有就静默退出（不写日志，避免日志噪音），失败时发一条即时通知。两个实测细节：<strong>timer 的多时刻要拆成多行 <code>OnCalendar=</code> 写</strong>（单行逗号串或 <code>*:0/5</code> 这类重复语法会解析失败或算不出下一次排期）；验收要看服务单元的启动时间戳是否往前推进，只看 <code>list-timers</code> 的&quot;下次触发时间&quot;不算数。</p>

<h3 class="relative group">5. 十个坑（全部实测）
    <div id="5-十个坑全部实测" 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="#5-%e5%8d%81%e4%b8%aa%e5%9d%91%e5%85%a8%e9%83%a8%e5%ae%9e%e6%b5%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>现象</th>
					<th>机制</th>
					<th>处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>容器里所有页面 403，服务器上直接 curl 却正常</td>
					<td>站点目录放在 <code>/root</code> 下，而 <code>/root</code> 是 0700，容器内的 nginx worker（非 root）穿不进去；301 之所以正常，是因为它在 <code>return</code> 阶段就返回了，根本不读文件</td>
					<td>站点目录移到 <code>/srv</code> 下</td>
			</tr>
			<tr>
					<td>2</td>
					<td>切换版本后容器还在发旧内容</td>
					<td>bind mount 在容器创建时解析一次符号链接，之后换链接容器仍指旧目录</td>
					<td>容器挂载<strong>父目录</strong>，nginx 里 <code>root</code> 指向 <code>.../current</code></td>
			</tr>
			<tr>
					<td>3</td>
					<td>服务器上 <code>Permission denied</code>，脚本跑不起来</td>
					<td>Windows 提交时丢了可执行位（100755 → 100644）</td>
					<td><code>git update-index --chmod=+x &lt;脚本&gt;</code> 后重新提交</td>
			</tr>
			<tr>
					<td>4</td>
					<td>从本机 ssh 推私有仓超时</td>
					<td>自建 Git 服务的 SSH 端口从公网不可达（CDN 与反代只放 443）；本机代理的&quot;端口探测&quot;会假报开放</td>
					<td>推送走 HTTPS + 访问令牌（令牌存系统凭据管理器，不进日志）</td>
			</tr>
			<tr>
					<td>5</td>
					<td>改了反向代理的转发目标，流量却没切过去</td>
					<td>反向代理的转发目标同时存在于数据库和生成的 vhost 文件里，改数据库不会重写文件</td>
					<td>两处一起改，<code>nginx -t</code> 通过后 reload；改前先备份</td>
			</tr>
			<tr>
					<td>6</td>
					<td>CDN 的缓存清除 API 报 401</td>
					<td>清除用的密钥不在证书配置的 JSON 字段里，而是嵌在一段说明文本中</td>
					<td>从文本里取出后单独保存；清缓存是<strong>逐 URL</strong> 调用，主机名级别不可用（返回 500）</td>
			</tr>
			<tr>
					<td>7</td>
					<td>改了主题/升了引擎版本的某天构建突然崩</td>
					<td>主题声明的 Hugo 兼容窗口是滚动的，越界即失败</td>
					<td>三处（本地、服务器、主题）一起动，并锁定具体版本</td>
			</tr>
			<tr>
					<td>8</td>
					<td>一篇 2300 汉字的文章显示&quot;302 字 · 2 分钟&quot;</td>
					<td>引擎默认按空格切词统计字数与阅读时间，中文没有空格</td>
					<td>打开 CJK 语言开关后实测变为&quot;3817 字 · 8 分钟&quot;（含代码与表格字符）</td>
			</tr>
			<tr>
					<td>9</td>
					<td>站点模板里写的新样式不生效</td>
					<td>主题的样式表是作者预编译后提交的，只包含主题自己用过的类，新类名根本不存在于产物里</td>
					<td>新样式写进站点自己的 CSS 文件（主题会自动加载），用自定义类名</td>
			</tr>
			<tr>
					<td>10</td>
					<td>301 跳转把访客降级回 http</td>
					<td>源站在反向代理后面，看到的协议是 http</td>
					<td>301 目标显式写 <code>https://$host/...</code></td>
			</tr>
	</tbody>
</table>

<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%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上线后的核对清单（全部实测）：</p>
<table>
	<thead>
			<tr>
					<th>检查项</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>三篇文章 + 首页 + 关于页</td>
					<td>全 200</td>
			</tr>
			<tr>
					<td>附件直链</td>
					<td>200，路径与迁移前逐字节一致</td>
			</tr>
			<tr>
					<td>旧地址（<code>/rss/</code>、<code>/tag/*</code>、<code>/author/*</code>、占位帖、历史 slug）</td>
					<td>全部 301 且指向 https</td>
			</tr>
			<tr>
					<td>线上 RSS 的 <code>&lt;guid&gt;</code></td>
					<td>与迁移前逐条一致</td>
			</tr>
			<tr>
					<td>首页是否残留旧引擎痕迹（关键字扫描）</td>
					<td>0 处</td>
			</tr>
			<tr>
					<td>容器状态</td>
					<td>静态站容器运行中，4.9 MiB；旧容器已停但保留（回滚用）</td>
			</tr>
			<tr>
					<td>首页联网传输</td>
					<td>63.7 KB（迁移前 1008 KB）</td>
			</tr>
	</tbody>
</table>
<p>两件必须做的事：</p>
<p><strong>回读断言。</strong> &ldquo;推送成功&quot;不等于&quot;已上线&rdquo;。构建静默崩溃、缓存没清、权限位丢了，都能让推送成功而页面不变；部署脚本的退出码看不出这些。所以每次发布后都要回读一次目标 URL，确认新内容真的可见——这是静态站最典型的失败模式。</p>
<p><strong>CDN 缓存清除。</strong> 前置 CDN 会缓存 HTML，改版后不清缓存读者还会看到旧页。清除接口按 URL 逐个调用，切换当天把那十几条地址（含已废弃的旧地址）全部清了一遍。</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%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>迁移前先量数据：镜像、内存、页面全资源体积都实测再拍板；动机锚定&quot;内容主权 + 发布链路&quot;，别为省内存带宽而迁。</li>
<li>内容源取数据库的 HTML 列，别用导出 JSON；先查有没有站点地址占位符。</li>
<li>URL 保平做成清单：文章用永久链接配置对齐、标签页写 <code>url:</code> 而不是 <code>slug:</code>、其余用 nginx 301 收口，目标写死 https。</li>
<li>RSS 的 <code>&lt;guid&gt;</code> 沿用旧值，否则老订阅者重收旧文；XML 声明用模板函数输出。</li>
<li>部署用&quot;版本化目录 + 符号链接原子切换 + 产物校验&quot;，构建失败不动线上；只留最近几份。</li>
<li>站点目录别放 <code>/root</code>（0700 会让容器里的 worker 全站 403）；容器别直接挂符号链接，挂父目录。</li>
<li>附件进版本库前先算体积：几个小脚本可以忽略；将来若要放二进制包（压缩包/可执行文件），考虑对象存储或另建仓库，别让 git 历史无限长。</li>
<li>Windows 下提交任何脚本前，先确认执行位没有丢。</li>
<li>发布闭环固定为：构建 → 部署 → 清缓存 → <strong>回读断言</strong>，缺一步就可能误报成功。</li>
<li>动手前先把回滚路径写下来（旧容器与数据留几周），改反向代理前备份数据库行与生成的配置文件。</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="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这次迁移之所以能在一天内做完，是因为站点足够小：4 篇文章、12 条 URL。规模再大一点，任何&quot;迁移&quot;都会变成一场项目。过程中真正花时间的不是搬家本身，而是那十个坑——它们几乎都跟&quot;两层抽象叠在一起&quot;有关：容器里看权限、容器外看符号链接、反代后面看协议、CDN 前面看缓存、主题里面看样式表产物。排错时先把这条链拆开，逐层确认身份，比猜配置快得多。</p>
<p>另一件值得记住的事：旧平台上那行无条件加载 764 KB 脚本的代码，算下来比换成静态站带来的服务端收益还大。<strong>主题里一行代码的技术债，可能比平台选择本身更值钱</strong>——迁移前后都值得先把这行找出来。</p>
]]></content:encoded>
    </item>
    <item>
      <title>LTSC 精简版大坑与排错全记录</title>
      <link>https://slop.lun.sh/ltsc-lite-pitfalls-reinstall/</link>
      <pubDate>Wed, 02 Sep 2026 15:34:36 +0800</pubDate>
      <guid isPermaLink="false">6a98420c4eeac90001a903be</guid>
      <category>Windows</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一台&quot;看起来一切正常&quot;的 Windows 11 IoT 企业版 LTSC 2024（24H2）机器，从一次普通的 Windows 更新失败开始，走过七轮覆盖安装、数十条命令、三次多模型会诊，最后不得不格盘重装。这篇回顾把整个历程的病症、误判、实验和最终结论，用平实的语言讲清楚。</p>
</blockquote><hr>

<h2 class="relative group">一、缘起：一个&quot;普通&quot;的更新失败
    <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%e7%bc%98%e8%b5%b7%e4%b8%80%e4%b8%aa%e6%99%ae%e9%80%9a%e7%9a%84%e6%9b%b4%e6%96%b0%e5%a4%b1%e8%b4%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>某天系统提示：</p>
<blockquote><p>2026-08 安全更新 (KB5121003) 安装错误 - 0x800f0991，并要求持续安装 Windows 以修复系统文件和组件。</p>
</blockquote><p>点&quot;修复&quot;按钮，没用。重启，没用。错误码永远挂在 Windows 更新页面上。</p>
<p>机器是 <strong>Windows 11 IoT 企业版 LTSC 2024（Build 26100.6972）</strong>——注意，这个系统是&quot;精简/优化版&quot;LTSC 镜像装出来的，不是微软官方原版。当时谁也没想到，这个出身会在半年后酿成一场七连败的战役。</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%e7%97%85%e7%81%b6%e7%9a%84%e7%9c%9f%e7%9b%b8%e7%bb%84%e4%bb%b6%e5%ba%93%e4%b8%8e%e7%a3%81%e7%9b%98%e6%96%87%e4%bb%b6%e8%84%b1%e8%8a%82" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>第一轮诊断就发现了诡异之处：<strong>系统声称装了 7900/8457/8972/9156 几个时代的组件，但关键系统文件（ntoskrnl、ntdll、DismCore）各自停留在不同版本</strong>，混装了四个时代的东西。实证结果是：UBR 停留在 6972，说明 2025 年 10 月之后再没有一次更新真正完整落地过。</p>
<p>也就是说：这台机器从装好的那天起，就在&quot;带病运行&quot;。能开机、能上网、能打游戏，但<strong>系统内部的账本（组件库记录）和仓库（磁盘文件）早就对不上了</strong>。</p>
<p>这是精简版 LTSC 的第一个大坑：<strong>&ldquo;能正常用&quot;不等于&quot;系统健康&rdquo;</strong>。精简优化镜像为了体积删掉了大量组件，删的时候如果动到了组件库的元数据（而非仅仅卸载应用），系统不会当场报错，而是把病根埋在半年后你第一次想打补丁的那一天。</p>

<h2 class="relative group">三、常规手段全灭（Stage 1：患者尝试自救）
    <div id="三常规手段全灭stage-1患者尝试自救" 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%b8%b8%e8%a7%84%e6%89%8b%e6%ae%b5%e5%85%a8%e7%81%adstage-1%e6%82%a3%e8%80%85%e5%b0%9d%e8%af%95%e8%87%aa%e6%95%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我们按教科书顺序试了一遍，全部失败：</p>
<table>
	<thead>
			<tr>
					<th>手段</th>
					<th>结果</th>
					<th>说明了什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>清空 WinSxS 缓存、强制重下 7.2GB 全新载荷</td>
					<td>安装仍 0x800f0991（PSFX_E_MISSING_PAYLOAD_FILE）</td>
					<td>增量载荷结构不完整，WinSxS 载荷子树缺文件</td>
			</tr>
			<tr>
					<td>微软官方目录下载基线包+增量包（共 5.6GB）手动装</td>
					<td>基线包秒挂 0x80070228（=552）</td>
					<td>本机服务栈解析不了 MSWIM/PSF 新包格式——<strong>DismCore 太老了</strong></td>
			</tr>
			<tr>
					<td><code>DISM /RestoreHealth</code></td>
					<td>&ldquo;在任何位置都找不到修复内容&rdquo;</td>
					<td>更新源对这台机器的结构残缺，扫不出可修项</td>
			</tr>
			<tr>
					<td>设置→恢复→&ldquo;使用 Windows 更新修复问题&rdquo;</td>
					<td>&ldquo;找不到 Windows 的修复版本，请稍后重试&rdquo;</td>
					<td>系统自带的就地修复也识别不了自己的状态</td>
			</tr>
			<tr>
					<td><code>sfc /scannow</code></td>
					<td>扫描到 26% 中断，CBS 无任何记录</td>
					<td>连完整性校验器都跑不完，服务子系统病入膏肓</td>
			</tr>
	</tbody>
</table>
<p><strong>这一阶段的关键结论</strong>：这不是一次&quot;更新失败&quot;，而是<strong>服务子系统整体坏死</strong>——它不认识新格式的包、读不懂自己的账本、连修复工具都跑不完。传统偏方（清缓存、DISM、sfc、疑难解答）对这种病全部无效。</p>

<h2 class="relative group">四、外科手术之路：slipstream 为什么数学上不成立（Stage 2）
    <div id="四外科手术之路slipstream-为什么数学上不成立stage-2" 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%e5%a4%96%e7%a7%91%e6%89%8b%e6%9c%af%e4%b9%8b%e8%b7%afslipstream-%e4%b8%ba%e4%bb%80%e4%b9%88%e6%95%b0%e5%ad%a6%e4%b8%8a%e4%b8%8d%e6%88%90%e7%ab%8bstage-2" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>既然本机服务栈是坏的，那就绕开它——自己做一个新镜像来覆盖安装？这条路我们走得最远，也最有教育意义：</p>
<ol>
<li><strong>uupdump 没有 LTSC</strong>：这个著名的镜像生成站只有零售 SKU，微软把 LTSC 从 UUP 公开通道里排除了，做不了。</li>
<li><strong>官方评估 ISO 比你机器还旧</strong>：微软官方 LTSC 2024 评估镜像还是 26100.1742（GA 版本），比机器上的 6972 还老，不能用于升级。</li>
<li><strong>WIM 挂载注入失败</strong>（0xc1510111）：本机挂载驱动也是坏的。</li>
<li><strong>解包→注入→重新打包</strong>（apply/add-package/capture）：解包成功，但注入基线包依然 552——宿主的 DismCore（老）不认新格式。</li>
<li><strong>装上 ADK 现代 DISM 再注入</strong>：能解析 MSWIM 了，甚至成功装入了 SSU（服务栈更新），但 SSU+LCU 必须处于<strong>同一个服务会话</strong>，否则报 1726（RPC）/ schema.dat 拒绝访问。</li>
<li><strong>最后一击：检查点（Checkpoint）机制</strong>。2024 年后的累积更新是&quot;检查点基线 + 增量&quot;结构，9168 的增量基于 8972 基线，而对 GA（1742）镜像意味着要<strong>连锁安装 8 个月的检查点更新</strong>——离线注入在数学上不成立。</li>
</ol>
<p><strong>结论</strong>：手工集成镜像这条路，被微软的更新架构（MSWIM/PSF 格式 + 检查点模型 + 服务会话绑定）彻底封死。这也是普通用户&quot;做个新镜像升上去&quot;的普遍误区——<strong>在 2024 年之后的系统上，这条路已经走不通了</strong>。</p>

<h2 class="relative group">五、覆盖安装七连败（Stage 3：换镜像强攻）
    <div id="五覆盖安装七连败stage-3换镜像强攻" 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%a6%86%e7%9b%96%e5%ae%89%e8%a3%85%e4%b8%83%e8%bf%9e%e8%b4%a5stage-3%e6%8d%a2%e9%95%9c%e5%83%8f%e5%bc%ba%e6%94%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>拿到第三方集成镜像（一个号称 26100.9168 的 25H2 镜像，实则 26200），开始覆盖安装。七次，七次失败，全部同一个面错误：</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">无法安装 Windows 11
</span></span><span class="line"><span class="cl">0xC1900101 - 0x20017
</span></span><span class="line"><span class="cl">在启动(SAFE_OS)操作阶段安装失败</span></span></code></pre></div></div>
<p><strong>0xC1900101 是著名的&quot;回滚错误码&quot;</strong>，屏幕上写&quot;驱动错误&quot;，但 minidump 里全是半年前的旧文件——<strong>SAFE_OS 阶段根本没有真实内核崩溃，屏幕提示是回滚状态机的误报</strong>。真正的死因一次比一次挖得深：</p>
<ul>
<li><strong>第 1 次</strong>：卡巴斯基。迁移阶段写注册表 <code>HKCU\Software\KasperskyLab</code> 被拒（0x00000005）——杀软的自保护驱动拦截了系统迁移。卸载卡巴后，死因变了。</li>
<li><strong>第 2 次</strong>：PITR.dll 缺失。收尾阶段移除回滚快照时找不到 <code>C:\Windows\System32\OOBE\PITR.dll</code>（组件库里有，System32 副本丢了——精简版后遗症）。从 WinSxS 补回后，死因又变了。</li>
<li><strong>第 3~7 次</strong>：<code>SPDeserializeOperations: saw unrecognized object header '0x0'</code>。<strong>操作队列反序列化崩溃</strong>——这是核心死因，也是最终判决书。</li>
</ul>
<p>为了排除嫌疑，我们做了这些干净利落的实验：</p>
<table>
	<thead>
			<tr>
					<th>实验</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>清空 <code>$WINDOWS.~BT</code> 残留 + VSS 快照后重试</td>
					<td>仍 0x0（排除残留污染）</td>
			</tr>
			<tr>
					<td>补回 UnBCL.dll（序列化运行时，System32 缺失）</td>
					<td>仍 0x0（排除缺文件）</td>
			</tr>
			<tr>
					<td>换同分支 26100.9168 介质（24H2→24H2）</td>
					<td>仍 0x0（排除跨分支协议）</td>
			</tr>
			<tr>
					<td>拔掉全部外接硬盘重试</td>
					<td>operations.dat 内容变了（哈希不同）但<strong>失败点纹丝不动</strong>（排除外接介质）</td>
			</tr>
			<tr>
					<td>检查队列文件本体</td>
					<td>225KB、头部标准、无截断——<strong>文件是好的，是解析器认不出里面的对象类型</strong></td>
			</tr>
	</tbody>
</table>
<p><strong>最终归因</strong>：旧系统<strong>注册表层的 setup 组件类型注册元数据</strong>已经坏死——序列化端写出的对象，反序列化端不认。这不是缺 DLL、不是残留、不是介质问题，是&quot;账本本身烂了&quot;，任何文件层面的修复都无效。</p>

<h2 class="relative group">六、7 次失败之后的决策：为什么只能格盘
    <div id="六7-次失败之后的决策为什么只能格盘" 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%ad7-%e6%ac%a1%e5%a4%b1%e8%b4%a5%e4%b9%8b%e5%90%8e%e7%9a%84%e5%86%b3%e7%ad%96%e4%b8%ba%e4%bb%80%e4%b9%88%e5%8f%aa%e8%83%bd%e6%a0%bc%e7%9b%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把决策过程说清楚，这件事才算讲完：</p>
<ol>
<li><strong>非破坏手段穷尽</strong>：清理、补文件、换介质、拔设备、多模型会诊（GPT/Kimi/Gemini/DeepSeek/Claude 三轮回诊全部指向同一个结论）。剩余方案胜算全部 &lt;5%。</li>
<li><strong>止损线</strong>：同一错误点出现三次以上且每次实验变量被严格排除，就必须停止&quot;再试一次&quot;的冲动。第七次拔盘实验是<strong>最后一个被允许的实验</strong>（因为它从未被验证过），失败后即无条件转重装。</li>
<li><strong>&ldquo;安全网&quot;是幻觉</strong>：覆盖安装的所谓&quot;保留系统回退&quot;在这个场景里毫无意义——旧系统本身就是病，回退回去等于回到病里。</li>
<li><strong>重装成本其实很低</strong>：备份全部个人数据（robocopy 三小时）→ 格式化 C 盘 → 全新安装 26100.9168（45 分钟）→ 数据归位。干净系统激活自动延续（OEM 数字授权），Windows 更新第一晚就自动装上了新补丁。</li>
</ol>
<p><strong>关键认知</strong>：7 次失败换来的不是&quot;再试一次&rdquo;，而是&quot;确信重装是唯一解&quot;。<strong>排错的价值在于把不确定性清零，而不是把可能性耗尽。</strong></p>

<h2 class="relative group">七、LTSC 精简版能离谱到什么程度（教训清单）
    <div id="七ltsc-精简版能离谱到什么程度教训清单" 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%83ltsc-%e7%b2%be%e7%ae%80%e7%89%88%e8%83%bd%e7%a6%bb%e8%b0%b1%e5%88%b0%e4%bb%80%e4%b9%88%e7%a8%8b%e5%ba%a6%e6%95%99%e8%ae%ad%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这台机器的病历拿出来，每一行都是一条反例：</p>
<ul>
<li><strong>组件库记录与磁盘文件脱节</strong>：声称装了 4 个时代的组件，实测系统文件混装 4 个时代的版本，而<strong>没有任何报错</strong>。</li>
<li><strong>System32 文件黑洞</strong>：PITR.dll、UnBCL.dll 说没就没——精简工具删除组件时连核心运行时都敢动。</li>
<li><strong>DismCore 老到不认新包</strong>：缩写为 552 的怪错误，本质是旧服务栈解析不了 2024 年后新格式的更新包。</li>
<li><strong>升级时的连环爆</strong>：卡巴拦截（外部因素）→ PITR 缺文件（文件被删）→ 序列化坏死（元数据损坏），一次比一次深，像剥洋葱。</li>
<li><strong>第三方镜像的坑</strong>：&ldquo;原版集成&quot;镜像把 26100.9168 集成成了 26200（跨分支！）；wiminfo 的版本元数据被抹掉；号称&quot;完整+适量精简&quot;的镜像里<strong>没有 IoT 版索引</strong>（只有 EnterpriseS，装不上你机器的 SKU）；7.6GB 的&quot;无驱版&quot;连版本号都查不到。</li>
</ul>
<p><strong>给后来者的三句话</strong>： 1. <strong>LTSC/精简镜像装完先打一次更新验证</strong>：机器能开机能上网，不代表服务栈健康。装好系统第一件事是打满更新，如果更新失败，趁数据少赶紧重装。 2. <strong>升级失败看日志，不看屏幕</strong>：0xC1900101 的屏幕文案是&quot;驱动错误&rdquo;，真因全在 <code>C:\$WINDOWS.~BT\Sources\Panther\setuperr.log</code> 和 SetupDiag 里。 3. <strong>错误码是一个阶梯</strong>：0x800f0991（载荷缺失）、0x80070228/552（服务栈不认包格式）、0xC1900401（检查点基线缺失）、SPDeserializeOperations 0x0（元数据/序列化坏死）——越靠后的错误越接近&quot;没救了&quot;的判决。</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%e6%9c%80%e7%bb%88%e7%9a%84%e6%9c%80%e7%bb%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>机器现在是：<strong>Windows 11 IoT 企业版 LTSC 2024，26100.9168，已激活，更新畅通</strong>。</li>
<li>数据：桌面/文档/音乐/邮件/浏览器/微信文件，<strong>100% 回归</strong>（连浏览器打开的标签页都救了回来，<a href="/brave-data-recovery/" >见第二篇</a>）。</li>
<li>教训：<strong>&ldquo;能用&quot;和&quot;健康&quot;是两回事。精简版 LTSC 用它的方式为你省了空间，而你终将在某一天用一次格盘重装偿还。</strong></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Windows 重装后 SID/所有权批量修复工具包</title>
      <link>https://slop.lun.sh/windows-sid-fix-toolkit/</link>
      <pubDate>Wed, 02 Sep 2026 15:34:35 +0800</pubDate>
      <guid isPermaLink="false">6a98420a4eeac90001a903b4</guid>
      <category>Windows</category>
      <category>工具包</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>适用场景：重装系统后（即使还用同一个用户名），文件/文件夹属性里出现&quot;未知账户&quot;， 或者旧硬盘（包括移动硬盘）上的文件提示&quot;拒绝访问&quot;。 原理：Windows 重装会生成新的机器 SID，旧文件的所有者和权限条目里记录的是旧 SID。</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="#%e9%9c%80%e8%a6%81%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>Windows 10/11，<strong>PowerShell 7 (pwsh)</strong>（没有的话管理员运行：<code>winget install Microsoft.PowerShell</code>）</li>
<li>管理员权限（运行时会弹 UAC，点&quot;是&quot;）</li>
<li>要修复的硬盘全部接好</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%bd%bf%e7%94%a8%e6%ad%a5%e9%aa%a4" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">1. 修复（改所有权 + 替换所有旧 SID）
    <div id="1-修复改所有权--替换所有旧-sid" 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="#1-%e4%bf%ae%e5%a4%8d%e6%94%b9%e6%89%80%e6%9c%89%e6%9d%83--%e6%9b%bf%e6%8d%a2%e6%89%80%e6%9c%89%e6%97%a7-sid" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>管理员 PowerShell 7 中运行：</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">pwsh -NoProfile -ExecutionPolicy Bypass -File fix_sids.ps1 -Roots &#34;D:;E:;F:;G:;H:;I:&#34;</span></span></code></pre></div></div>
<ul>
<li><code>-Roots</code> 换成你自己的盘符列表，分号分隔；<strong>不要包含系统盘 C:</strong>；不填则自动选择除 C: 外所有卷</li>
<li>脚本会先做一次<strong>自检</strong>（在临时目录造测试数据验证修复逻辑），通过后才开始处理真实数据</li>
<li>每块盘的处理顺序：<code>takeown /r</code> 批量改所有者 → 逐个对象替换旧 SID（所有者/组/权限条目，含从父目录继承的）</li>
<li>进度写入 <code>%TEMP%\hermes_acl\fix_sids.log</code>；跑完看日志末尾：<code>err=0 fails=0</code> 即完美</li>
</ul>

<h3 class="relative group">2. 恢复盘符（可选，重装后 Windows 会重新分配盘符）
    <div id="2-恢复盘符可选重装后-windows-会重新分配盘符" 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="#2-%e6%81%a2%e5%a4%8d%e7%9b%98%e7%ac%a6%e5%8f%af%e9%80%89%e9%87%8d%e8%a3%85%e5%90%8e-windows-%e4%bc%9a%e9%87%8d%e6%96%b0%e5%88%86%e9%85%8d%e7%9b%98%e7%ac%a6" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先确认重装前各盘是什么字母（旧笔记/习惯），再编辑 <code>restore_letters.ps1</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">pwsh -NoProfile -ExecutionPolicy Bypass -File restore_letters.ps1</span></span></code></pre></div></div>

<h3 class="relative group">3. 验证（确认&quot;未知账户&quot;清零）
    <div id="3-验证确认未知账户清零" 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="#3-%e9%aa%8c%e8%af%81%e7%a1%ae%e8%ae%a4%e6%9c%aa%e7%9f%a5%e8%b4%a6%e6%88%b7%e6%b8%85%e9%9b%b6" aria-label="锚点">#</a>
    </span>
    
</h3>
<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">pwsh -NoProfile -ExecutionPolicy Bypass -File acl_scan.ps1 -Roots &#34;D:;E:;F:;G:;H:;I:;C:\Users&#34;</span></span></code></pre></div></div>
<p>结果文件在 <code>%TEMP%\hermes_acl\acl_scan_result.txt</code>：<strong>bad = 0</strong> 即全部修复。 （<code>C:\Users\Public</code> 下出现 <code>S-1-5-3</code>/<code>S-1-5-4</code> 是 Windows 系统默认 SID，属正常，不是问题。）</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="#%e6%b3%a8%e6%84%8f%e4%ba%8b%e9%a1%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>修复期间<strong>不要关机、不要拔盘</strong>；被其它程序占用中的文件可能被跳过（日志里 <code>SET_ERR</code>），关掉程序后重跑一次即可</li>
<li>跳过 <code>$RECYCLE.BIN</code>、<code>System Volume Information</code>、<code>Recovery</code>（系统自己会重建，不用管）</li>
<li>权限级别（完全控制/修改/只读等）<strong>原样保留</strong>，只是把旧身份换成新身份；不会破坏自定义权限</li>
<li>修复结果只对&quot;这台机器&quot;有效：把盘插到另一台电脑上，那台电脑仍会显示未知（NTFS 权限按机器记）。多机共用建议额外加 <code>Authenticated Users</code> 权限</li>
<li>中途断掉也没关系：脚本幂等，重跑会继续处理剩下的</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="#%e5%b8%b8%e8%a7%81%e9%97%ae%e9%a2%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>现象</th>
					<th>处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>日志 <code>NOT_ELEVATED_ABORT</code></td>
					<td>没管理员权限，用管理员终端重跑</td>
			</tr>
			<tr>
					<td>自检 <code>SELFTEST_FAIL</code></td>
					<td>环境异常，先解决权限/磁盘问题，不要强行跳过</td>
			</tr>
			<tr>
					<td><code>err</code> 或 <code>fails</code> 不为 0</td>
					<td>看日志对应行；多数是被占用文件，关程序重跑</td>
			</tr>
			<tr>
					<td>没有 pwsh</td>
					<td><code>winget install Microsoft.PowerShell</code> 后重开终端</td>
			</tr>
	</tbody>
</table>

<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%b7%a5%e5%85%b7%e5%8c%85%e4%b8%8b%e8%bd%bd" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>三个脚本（管理员 PowerShell 7 运行，均为明文、程序员友好）：</p>
<ul>
<li><a href="/content/files/2026/09/acl_scan.ps1" >acl_scan.ps1</a> — 扫描验证（bad = 0 即修复完成）</li>
<li><a href="/content/files/2026/09/fix_sids.ps1" >fix_sids.ps1</a> — 一键修复（自检 + takeown + SID 替换）</li>
<li><a href="/content/files/2026/09/restore_letters.ps1" >restore_letters.ps1</a> — 恢复盘符映射</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Brave 数据复活记：重装系统后如何不丢标签页与扩展数据</title>
      <link>https://slop.lun.sh/brave-data-recovery/</link>
      <pubDate>Mon, 31 Aug 2026 17:35:28 +0800</pubDate>
      <guid isPermaLink="false">6a95bb60f0de240001bca9b2</guid>
      <category>Brave</category>
      <category>浏览器恢复</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>重装 Windows 后，把浏览器数据从备份里拷回去，打开 Brave 却发现&quot;因安全原因&quot;丢了打开的标签页、固定标签页和扩展程序数据。本文基于 2026-08-26 与 2026-08-31 两轮实战（Default 完整恢复 + 另一个 Profile 扩展全丢后完整救回），讲清楚 Chrome/Brave 的数据机制、正确的备份/恢复姿势，以及所有试错后验证出的<strong>唯一正道</strong>。</p>
</blockquote><hr>

<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%e6%90%9e%e6%87%82%e5%9b%9b%e4%b8%aa%e6%9c%ba%e5%88%b6%e4%bd%a0%e5%b0%b1%e6%88%90%e5%8a%9f%e4%ba%86%e4%b8%80%e5%8d%8a" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">机制 1：会话文件分两套，只有一套能救你
    <div id="机制-1会话文件分两套只有一套能救你" 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="#%e6%9c%ba%e5%88%b6-1%e4%bc%9a%e8%af%9d%e6%96%87%e4%bb%b6%e5%88%86%e4%b8%a4%e5%a5%97%e5%8f%aa%e6%9c%89%e4%b8%80%e5%a5%97%e8%83%bd%e6%95%91%e4%bd%a0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Chrome 系浏览器的标签页恢复依赖两类文件：</p>
<p><strong>① 活动会话文件（会无辜失踪的那套）</strong> - <code>User Data\Default\Current Session</code> / <code>Current Tabs</code> - <code>User Data\Default\Last Session</code> / <code>Last Tabs</code></p>
<p>这是&quot;进行中会话&quot;的实时快照。<strong>浏览器正常关闭时，会主动删掉它们</strong>——所以当你看到&quot;备份里没有 Session 文件&quot;，不代表没救，而是浏览器最后一次是干净退出的（这恰恰是好事）。</p>
<p><strong>② 会话恢复快照（真正救你命的那套）</strong> - <code>User Data\Default\Sessions\Tabs_1389xxxxx</code>（标签页状态，十几 MB） - <code>User Data\Default\Sessions\Session_1389xxxxx</code>（每个标签的数据） - <code>User Data\Default\Session Storage\</code>（表单、页面局部状态）</p>
<p>这套文件由&quot;会话恢复系统&quot;<strong>持续落盘</strong>，浏览器退出时不会删除，专门用于崩溃恢复和&quot;继续上次会话&quot;。<strong>备份时只要这套文件在，标签页就能救回来</strong>（含最后写盘时刻的状态）。多 profile 同理：<code>Profile N\Sessions\</code> 也在。</p>

<h3 class="relative group">机制 2：扩展注册在哪——Secure Preferences，不是 Preferences（实战修正）
    <div id="机制-2扩展注册在哪secure-preferences不是-preferences实战修正" 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="#%e6%9c%ba%e5%88%b6-2%e6%89%a9%e5%b1%95%e6%b3%a8%e5%86%8c%e5%9c%a8%e5%93%aasecure-preferences%e4%b8%8d%e6%98%af-preferences%e5%ae%9e%e6%88%98%e4%bf%ae%e6%ad%a3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><strong>扩展的注册（启用的扩展清单、安装时间、来源）在 <code>Secure Preferences</code> 文件的 <code>extensions.settings</code> 里，不在 <code>Preferences</code> 里。</strong> 这是最容易踩的认知误区：</p>
<table>
	<thead>
			<tr>
					<th>文件</th>
					<th>受保护？</th>
					<th>扩展注册</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Preferences</code></td>
					<td>❌ 明文、可手改</td>
					<td>只有 <code>extensions.install_signature.ids</code>（扩展 ID 的签名记录，不受 MAC 保护，<strong>可作恢复依据</strong>）</td>
			</tr>
			<tr>
					<td><code>Secure Preferences</code></td>
					<td>✅ 有 MAC 防篡改（protection.super_mac + 字段级 macs）</td>
					<td><strong><code>extensions.settings</code> —— 扩展注册真正所在</strong></td>
			</tr>
	</tbody>
</table>
<p><code>Secure Preferences</code> 的 MAC 用 <code>Local State</code> 里的 os_crypt 密钥计算。<strong>手改 Secure Preferences 任何一个受保护字段 → MAC 失效 → 浏览器丢弃整个受保护区（扩展注册清空，只剩内置扩展）→ 写回一份&quot;干净&quot;的文件。</strong></p>

<h3 class="relative group">机制 3：&ldquo;安全原因&quot;重置的真实触发条件（实战修正）
    <div id="机制-3安全原因重置的真实触发条件实战修正" 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="#%e6%9c%ba%e5%88%b6-3%e5%ae%89%e5%85%a8%e5%8e%9f%e5%9b%a0%e9%87%8d%e7%bd%ae%e7%9a%84%e7%9c%9f%e5%ae%9e%e8%a7%a6%e5%8f%91%e6%9d%a1%e4%bb%b6%e5%ae%9e%e6%88%98%e4%bf%ae%e6%ad%a3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>旧认知：&ldquo;外部修改了 Preferences 文件 → 重置&rdquo;。<strong>实测真正的触发条件是：<code>Extensions\</code> 目录与 Secure Preferences 里的注册不一致</strong>：</p>
<ul>
<li><strong>目录里有&quot;未注册&quot;的扩展本体</strong> = 疑似外部注入 → 整体重置（清空注册 + <strong>删除整个 Extensions 目录内容</strong>）</li>
<li><strong>注册了但本体目录缺失</strong> = 疑似被破坏 → 同上</li>
</ul>
<p>也就是说：<strong>把扩展本体拷进 <code>Extensions\</code> 目录这个&quot;标准操作&rdquo;，在注册已被清空的状态下反而会触发重置并删掉你刚拷进去的东西。</strong> 这是本次目标 Profile 恢复中反复被重置删目录的根因。</p>
<p><strong>它重置什么、保留什么：</strong></p>
<table>
	<thead>
			<tr>
					<th>被重置/删除</th>
					<th>被保留</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>extensions.settings</code> 注册（只剩内置 PDF/Brave/应用商店）</td>
					<td><strong>书签、历史、密码、Cookie</strong>（数据库文件，不受影响）</td>
			</tr>
			<tr>
					<td><code>Extensions\</code> 目录全部内容（扩展本体）</td>
					<td><strong><code>Local Extension Settings\</code></strong>（多数情况保留）</td>
			</tr>
			<tr>
					<td>部分扩展的设置项</td>
					<td><strong><code>IndexedDB\</code></strong>（实测数据完好）</td>
			</tr>
			<tr>
					<td></td>
					<td><code>Sessions\</code> / <code>Session Storage\</code>（标签页快照）</td>
			</tr>
	</tbody>
</table>
<blockquote><p>⚠️ 注意：<strong>扩展被&quot;卸载&quot;时（比如从 chrome://extensions 移除，或强制安装策略被移除）会连带删除 <code>Local Extension Settings</code> 和 <code>IndexedDB</code> 里该扩展的数据</strong>——这与&quot;安全重置&quot;不同，重置通常保留数据目录，卸载会删。所以<strong>卸载类操作前务必先备份数据目录</strong>。</p>
</blockquote>
<h3 class="relative group">机制 4：加密链——Local State 的密钥，重装后就换掉了
    <div id="机制-4加密链local-state-的密钥重装后就换掉了" 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="#%e6%9c%ba%e5%88%b6-4%e5%8a%a0%e5%af%86%e9%93%belocal-state-%e7%9a%84%e5%af%86%e9%92%a5%e9%87%8d%e8%a3%85%e5%90%8e%e5%b0%b1%e6%8d%a2%e6%8e%89%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><code>User Data\Local State</code> 里保存着加密主密钥（<code>os_crypt.encrypted_key</code>，DPAPI 加密）。它保护： - <code>Secure Preferences</code> 的 MAC（防篡改校验） - 密码库 <code>Login Data</code>、Cookie 的解密</p>
<p><strong>重装 Windows 后</strong>：DPAPI 上下文（用户 master key）随旧系统消失 → 备份里的 Local State 的 encrypted_key <strong>解不开</strong> → 浏览器静默生成新密钥并重写 Local State。后果：</p>
<ul>
<li>备份里所有 <code>Secure Preferences</code>（旧密钥 MAC）→ <strong>全部校验失败</strong> → 首次启动各 profile 的扩展注册被逐 profile 重置（哪个 profile 先被打开就先重置哪个）</li>
<li>备份里的密码库/Cookie（旧密钥加密）→ 无法解密，视为空库（新密钥下重新登录）</li>
</ul>
<p><strong>所以：重装后&quot;从备份整体回拷 User Data&quot;必然经历一次&quot;安全原因&quot;重置，扩展注册必丢。这不是操作失误，是机制使然。</strong> 书签/历史/标签页（不加密的 SQLite/快照）不受影响。</p>
<hr>

<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%e5%a4%87%e4%bb%bd%e7%9a%84%e6%ad%a3%e7%a1%ae%e5%a7%bf%e5%8a%bf%e4%ba%8b%e5%89%8d%e6%9c%80%e9%87%8d%e8%a6%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<ol>
<li><strong>完全退出浏览器</strong>（关闭所有窗口 + 托盘图标退出，确认 <code>brave.exe</code> 无进程）。运行中备份会漏掉锁定的文件。</li>
<li><strong>整体拷贝 <code>User Data</code> 目录</strong>（9~10GB），绝不只拷 <code>Default</code>： <code>%LOCALAPPDATA%\BraveSoftware\Brave-Browser\User Data\ ← 整个目录</code> 里面包含：<code>Local State</code>、<code>Default\</code> 及所有 <code>Profile N\</code>、Sessions 快照、扩展本体与数据。</li>
<li><strong>校验</strong>：拷贝后对比文件数与总大小（差 5% 以内正常，差 20% 以上有问题）。</li>
<li><strong>不要修改任何文件</strong>，也<strong>不要做&quot;精简&quot;</strong>（例如裁剪备份时把 Brave 目录删掉——本次事故的伏笔就是裁剪备份时以为&quot;已恢复&quot;而删了 Brave 目录，事后只剩 原始备份包 里的副本）。</li>
</ol>
<blockquote><p>一句话：<strong>关浏览器、全量拷 User Data、一个字节都不要改、备份别裁剪。</strong></p>
</blockquote><hr>

<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%e6%81%a2%e5%a4%8d%e7%9a%84%e6%ad%a3%e7%a1%ae%e5%a7%bf%e5%8a%bf%e4%ba%8b%e5%90%8e%e5%ae%9e%e6%88%98%e9%aa%8c%e8%af%81%e7%89%88" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">第 0 步：认清现实
    <div id="第-0-步认清现实" 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="#%e7%ac%ac-0-%e6%ad%a5%e8%ae%a4%e6%b8%85%e7%8e%b0%e5%ae%9e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>重装系统后从备份回拷，<strong>扩展注册必丢</strong>（机制 4）。目标不是&quot;整体回拷免重置&quot;，而是：</p>
<ol>
<li><strong>恢复数据</strong>（LES / IndexedDB / Local Storage / Sessions——都不受 MAC 保护，可从备份任意合并）</li>
<li><strong>重新安装扩展本体</strong>（唯一正道：Chrome 商店，见下）</li>
</ol>

<h3 class="relative group">第 1 步：恢复数据目录（任意合并，安全）
    <div id="第-1-步恢复数据目录任意合并安全" 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="#%e7%ac%ac-1-%e6%ad%a5%e6%81%a2%e5%a4%8d%e6%95%b0%e6%8d%ae%e7%9b%ae%e5%bd%95%e4%bb%bb%e6%84%8f%e5%90%88%e5%b9%b6%e5%ae%89%e5%85%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>关闭浏览器后，从备份把缺失的数据目录拷回（robocopy 合并，只补不删）：</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">Local Extension Settings\   ← 扩展设置/chrome.storage（重置会删部分，卸载会删全部）
</span></span><span class="line"><span class="cl">IndexedDB\                  ← 扩展数据库（站点/脚本数据等）
</span></span><span class="line"><span class="cl">Local Storage\              ← 站点本地存储
</span></span><span class="line"><span class="cl">Sessions\ + Session Storage\ ← 标签页快照（若当前已恢复会话可跳过）</span></span></code></pre></div></div>
<p><strong>不要碰</strong> <code>Secure Preferences</code>（MAC 保护区）和 <code>Extensions\</code>（放未注册本体 = 触发重置）。</p>

<h3 class="relative group">第 2 步：商店重装扩展（唯一正道）
    <div id="第-2-步商店重装扩展唯一正道" 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="#%e7%ac%ac-2-%e6%ad%a5%e5%95%86%e5%ba%97%e9%87%8d%e8%a3%85%e6%89%a9%e5%b1%95%e5%94%af%e4%b8%80%e6%ad%a3%e9%81%93" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><strong>扩展 ID 由 manifest 里的 key（公钥）派生</strong>（SHA256(key) 前 16 字节，每字节高低 nibble 映射 a-p，共 32 字符）。商店扩展的 ID 是稳定公开的 → <strong>从商店重新安装，ID 必然与原来相同 → 已恢复的 <code>Local Extension Settings</code> / <code>IndexedDB</code> 数据自动挂载</strong>（Chrome 按 ID 找数据目录）。</p>
<p>操作（每个扩展）： 1. 打开商店直链 <code>https://chromewebstore.google.com/detail/&lt;扩展ID&gt;</code> 2. 点「添加到 Brave」（或&quot;添加至 Chrome&quot;） 3. 点 Brave 弹出的「添加扩展程序」确认框 ← <strong>这一步只能手动点，任何自动化都点不了浏览器 UI 弹窗</strong> 4. 验证：打开扩展面板/设置页，<strong>没有欢迎页 = 数据完整挂载</strong></p>
<blockquote><p>商店安装 = 正常扩展（自动更新、无特殊状态）。ID 相同的商店版会直接挂载旧数据目录里的数据。</p>
</blockquote>
<h3 class="relative group">第 3 步：验证
    <div id="第-3-步验证" 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="#%e7%ac%ac-3-%e6%ad%a5%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<ul>
<li><code>chrome://extensions</code> 列表里扩展齐全、无&quot;已损坏&quot;标记</li>
<li>打开每个扩展的设置页：配置/脚本/数据在（无欢迎页、无空白）</li>
<li>固定标签页：手动重新固定（防篡改保护区内，不值得硬碰）</li>
</ul>
<hr>

<h2 class="relative group">四、本次实战的完整踩坑记录（目标 Profile 恢复全历程）
    <div id="四本次实战的完整踩坑记录目标-profile-恢复全历程" 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%9c%ac%e6%ac%a1%e5%ae%9e%e6%88%98%e7%9a%84%e5%ae%8c%e6%95%b4%e8%b8%a9%e5%9d%91%e8%ae%b0%e5%bd%95%e7%9b%ae%e6%a0%87-profile-%e6%81%a2%e5%a4%8d%e5%85%a8%e5%8e%86%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>背景</strong>：8/28 恢复时 其余三个 profile 的扩展本体拷回了，目标 Profile 的 <code>Extensions\</code> 漏拷（空目录）。8/31 第一次打开目标 Profile → 注册 10 条 vs 本体 0 个 → 触发重置 → 注册清空（只剩 2 个内置）+ 删空目录。用户看到&quot;扩展记录消失&quot;。</p>
<p><strong>以下方案全部实测失败，勿再尝试：</strong></p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>失败原因</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>把本体拷回 <code>Extensions\</code> 目录</td>
					<td>目录里有未注册扩展 = 注入嫌疑 → <strong>整体重置并删掉刚拷入的目录</strong></td>
			</tr>
			<tr>
					<td>从备份直接覆盖 <code>Secure Preferences</code>（含完整注册）</td>
					<td>备份是旧系统密钥签的 MAC → 校验失败 → 注册被清（连内置之外全丢）</td>
			</tr>
			<tr>
					<td>手改 <code>Secure Preferences</code>（删掉本体缺失的注册条目再写回）</td>
					<td>同 MAC 问题：手改 → MAC 失效 → 被丢弃</td>
			</tr>
			<tr>
					<td><code>--load-extension</code> 命令行加载</td>
					<td><strong>会话级</strong>：写入的注册 state 字段缺失（loc=8 但 state=None），重启后运行时即失效，扩展全部消失</td>
			</tr>
			<tr>
					<td>chrome://extensions 里&quot;加载已解压的扩展程序&quot;</td>
					<td>可行但：① 文件选择器是原生对话框，CDP 无法自动化；② UNPACKED 状态带&quot;开发者模式&quot;标签、不自动更新；③ 商店版才是正常状态</td>
			</tr>
			<tr>
					<td><code>ExtensionInstallForcelist</code> 注册表策略静默安装</td>
					<td><strong>装上了，但移除策略 = 卸载扩展 + 连带删除该扩展的 LES/IndexedDB 数据目录</strong>（数据删了才发现）</td>
			</tr>
	</tbody>
</table>
<p><strong>最终成功路径</strong>：数据目录从备份恢复（LES + IndexedDB）→ 用户手动商店安装 5 个扩展 → 数据自动挂载（扩展数据完整回归）。<strong>全程唯一需要用户动手的就是点确认弹窗。</strong></p>
<p><strong>其他实战细节：</strong> - 固实/非固实压缩包 提取单文件用 <code>UnRAR.exe e -y -idq &lt;rar&gt; &lt;精确路径&gt; &lt;dest&gt;\</code>，python subprocess 传参避免 shell 转义地狱；路径前缀匹配必须单反斜杠 - 验证运行时扩展状态用 CDP：<code>brave.exe --profile-directory=&quot;Profile N&quot; --remote-debugging-port=9333 --remote-allow-origins=*</code>，然后在 <code>chrome://extensions</code> 页面上下文执行 <code>chrome.management.getAll()</code>（<strong>商店页面等普通页面没有该权限，返回空不代表没装</strong>） - <code>install_signature.ids</code>（Preferences 里，不受保护）是扩展 ID 的可靠参照，可用于核对应恢复哪些扩展</p>
<hr>

<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%e5%b8%b8%e8%a7%81%e7%9a%84%e9%94%99%e8%af%af%e5%81%9a%e6%b3%95%e9%81%bf%e9%9b%b7%e8%a1%a8%e6%89%a9%e5%85%85%e7%89%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>错误</th>
					<th>后果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>只拷 <code>Default</code> 不拷 <code>Local State</code></td>
					<td>加密链断裂 → 密码/扩展数据全军覆没</td>
			</tr>
			<tr>
					<td>浏览器开着时备份/恢复</td>
					<td>锁定的文件没拷全 → 恢复后档案残缺</td>
			</tr>
			<tr>
					<td>手改 <code>Preferences</code> 想&quot;开启恢复&quot;</td>
					<td>Preferences 本身可改，但改动不会生效在受保护区；真正禁区是 Secure Preferences</td>
			</tr>
			<tr>
					<td>手改 <code>Secure Preferences</code></td>
					<td>MAC 失效 → 注册被清（只剩内置）</td>
			</tr>
			<tr>
					<td>把扩展本体拷进 <code>Extensions\</code>（注册已丢时）</td>
					<td>触发&quot;注入&quot;重置 → 目录被删</td>
			</tr>
			<tr>
					<td>用 <code>--load-extension</code> 长期恢复</td>
					<td>重启即失效</td>
			</tr>
			<tr>
					<td>用 Forcelist 策略装完就移除</td>
					<td>扩展 + 数据目录一起被卸载删除</td>
			</tr>
			<tr>
					<td>恢复后立即删除备份</td>
					<td>万一重置/卸载删了数据，没有后悔药（<strong>先确认扩展无欢迎页再删</strong>）</td>
			</tr>
			<tr>
					<td>备份裁剪时删掉浏览器目录</td>
					<td>事后想恢复没有&quot;原始快照&quot;可用（保留原始 RAR）</td>
			</tr>
	</tbody>
</table>
<hr>

<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%e4%b8%80%e5%9b%be%e6%b5%81%e9%80%9f%e6%9f%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<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">备份：关浏览器 → 整体拷 User Data → 校验大小文件数 → 存档（别裁剪）
</span></span><span class="line"><span class="cl">重装后恢复：
</span></span><span class="line"><span class="cl">  1) 关浏览器 → 合并拷回数据目录（LES/IndexedDB/Local Storage/Sessions）
</span></span><span class="line"><span class="cl">     ⚠️ 不要碰 Secure Preferences 和 Extensions\
</span></span><span class="line"><span class="cl">  2) 打开浏览器（扩展注册已被重置，只剩内置 —— 正常现象）
</span></span><span class="line"><span class="cl">  3) 逐个商店重装扩展（https://chromewebstore.google.com/detail/&lt;扩展ID&gt;）
</span></span><span class="line"><span class="cl">     → 点「添加到 Brave」→ 点确认弹窗「添加扩展程序」（必须手动）
</span></span><span class="line"><span class="cl">  4) 打开扩展设置页：无欢迎页、数据在 = 完成
</span></span><span class="line"><span class="cl">确认一切正常 → 才删除备份</span></span></code></pre></div></div>
<hr>

<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%e6%9c%80%e5%90%8e%e7%9a%84%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>浏览器数据是最珍贵的数字资产：书签是索引，密码是钥匙，标签页是&quot;我还没读完的世界&quot;，扩展是生产力。重装系统前花十分钟按上面的姿势备份，重装后就不会经历&quot;安全原因&quot;四个字带来的心跳骤停。</p>
<p><strong>记住四件事：Local State 必须一起拷；Secure Preferences 一个字节都别改；扩展注册必丢是机制使然，数据目录才是要救的；扩展本体用商店重装（ID 不变 → 数据自动挂载），确认弹窗手动点。</strong></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
