<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>shiv on Chaney&#39;s MoonBook</title>
    <link>https://chaneyzorn.github.io/tags/shiv/</link>
    <description>Recent content in shiv on Chaney&#39;s MoonBook</description>
    <follow_challenge>
        <feedId>94402801171278848</feedId>
        <userId>60932538427591680</userId>
    </follow_challenge>
    <image>
      <title>Chaney&#39;s MoonBook</title>
      <url>https://chaneyzorn.github.io/cm/chaney-cover.jpg</url>
      <link>https://chaneyzorn.github.io/cm/chaney-cover.jpg</link>
    </image>
    <generator>Hugo(0.160.1) -- gohugo.io</generator>
    <language>zh</language>
    <managingEditor>chaneyzorn#gmail#com (ChaneyZorn)</managingEditor>
    <webMaster>chaneyzorn#gmail#com (ChaneyZorn)</webMaster>
    <copyright>Copyright © ChaneyZorn | CC BY-NC-ND 4.0 |</copyright>
    <lastBuildDate>Sun, 06 Sep 2026 21:19:33 +0800</lastBuildDate>
    <atom:link href="https://chaneyzorn.github.io/tags/shiv/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>检查工具的自包含分发</title>
      <link>https://chaneyzorn.github.io/codes/self-contained-dist-tools/</link>
      <pubDate>Sun, 06 Sep 2026 21:19:33 +0800</pubDate><author>chaneyzorn#gmail#com (ChaneyZorn)</author>
      <guid>https://chaneyzorn.github.io/codes/self-contained-dist-tools/</guid>
      <description><![CDATA[<p>最近做了一个检查工具，对服务器基础环境进行检查，提前暴露可能影响后续部署和上线的问题。目标环境仅安装 Ubuntu 22.04/24.04 和必要的硬件驱动，不包含其他可利用的组件（比如 Docker），且通常不与外网连通，无法在线安装所需的工具。</p>
<p>目标环境没有可用的依赖，因此一个核心考量是：让执行检查所需的代码、依赖库、三方工具都自包含在检查工具内，同时不触及系统自身的包管理和标准路径，保持环境纯净。在多节点上执行任务，一个主流选择是 <a href="https://github.com/ansible/ansible">Ansible</a>；Ubuntu 内置了 Python 3.10+，这一部分可以直接利用，除此之外的部分都由检查工具自包含。下文把运行检查工具、发起检查的机器称为控制节点，被检查的服务器称为目标节点。具体来说包含这些部分：</p>
<ul>
<li>使用 Python 开发的检查工具主体代码，以及所需的依赖；</li>
<li>Ansible 命令工具；</li>
<li>执行检查所需的三方工具，比如 <a href="https://github.com/linux-nvme/nvme-cli">nvme-cli</a> 和 <a href="https://github.com/NVIDIA/nccl-tests">nccl-tests</a> 等；</li>
<li>检查报告所需的 HTML 单文件模板；</li>
</ul>
<p>最终交付物只有一个压缩包，用户解压后只需要执行：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dist/bare-metal-check.pyz run <span class="se">\
</span></span></span><span class="line"><span class="cl">  --nodes &lt;node1&gt;,&lt;node2&gt;,&lt;node3&gt;
</span></span></code></pre></td></tr></table>
</div>
</div><p>检查完毕后会启动一个 HTTP 服务并打开浏览器查看报告，报告支持下载和离线查看，整个过程不需要安装任何依赖。整体的分发与执行流程如下：</p>

<div class="mermaid-block">
    <pre class="mermaid">flowchart TD
    subgraph ctrl[控制节点]
        CLI["bare-metal-check.pyz<br/>默认入口：检查 CLI"]
        PB["bare-metal-check.pyz<br/>入口切换为 ansible-playbook"]
        TAR["预构建 tar 归档包<br/>nvme、nccl-tests 等"]
        CLI -->|SHIV_CONSOLE_SCRIPT 切换入口| PB
    end
    subgraph nodes[目标节点]
        N1["node1"]
        N2["node2"]
        N3["nodeN"]
    end
    PB -->|ssh 下发检查任务| N1 & N2 & N3
    TAR -.->|按需分发，解压到 /opt/ 下| N1 & N2 & N3
    N1 & N2 & N3 -->|检查结果 JSON| RPT["单文件 HTML 报告"]
    RPT -->|"file:// 离线查看，或临时 HTTP 服务在线查看"| BR["浏览器"]</pre>
    <p class="mermaid-hint">Mermaid 图表需要浏览器启用 JavaScript 并从 CDN 加载 mermaid 库才能渲染；某些网络环境或移动端可能加载失败，此时下方显示的是图表源码。</p>
</div>
<p>下面说明这几个部分如何自包含在检查工具内，以及如何在执行过程中分发到目标环境。</p>
<h2 id="1-将-python-程序打包为单体文件">1. 将 Python 程序打包为单体文件</h2>
<p>Python 程序由若干 <code>py</code> 文件和三方依赖组成。检查工具是一个临时使用的工具，不希望将这些文件和依赖放到全局标准路径下；除了把文件和依赖隔离在独立环境中，另一个目标是让用户完全不需要 Python 相关的背景知识，把工具当作一个普通可执行文件，上手即用。</p>
<h3 id="11-主流方案比较">1.1 主流方案比较</h3>
<p>将 Python 程序打包为可执行产物的常见方案如下：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>产物形态</th>
          <th>Python 解释器</th>
          <th>典型体积</th>
          <th>适合场景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://pyinstaller.org/">PyInstaller</a></td>
          <td>单可执行文件</td>
          <td>内置</td>
          <td>大</td>
          <td>GUI/桌面应用、Windows 分发</td>
      </tr>
      <tr>
          <td><a href="https://github.com/marcelotduarte/cx_Freeze">cx_Freeze</a></td>
          <td>目录或单文件</td>
          <td>内置/外部</td>
          <td>较大</td>
          <td>跨平台桌面程序</td>
      </tr>
      <tr>
          <td><a href="https://nuitka.net/">Nuitka</a></td>
          <td>编译后的二进制</td>
          <td>内置</td>
          <td>较大</td>
          <td>性能敏感、需要保护源码</td>
      </tr>
      <tr>
          <td><a href="https://github.com/pex-tool/pex">pex</a></td>
          <td>单 <code>.pex</code> 文件</td>
          <td>外部</td>
          <td>中等</td>
          <td>多解释器环境、服务端脚本</td>
      </tr>
      <tr>
          <td><a href="https://docs.python.org/zh-cn/3/library/zipapp.html">zipapp</a></td>
          <td>单 <code>.pyz</code> 文件</td>
          <td>外部</td>
          <td>小</td>
          <td>依赖系统 Python 的 CLI 工具</td>
      </tr>
      <tr>
          <td><a href="https://docs.python.org/zh-cn/3/library/venv.html">venv</a></td>
          <td>目录</td>
          <td>外部</td>
          <td>小</td>
          <td>可联网、可写安装脚本的环境</td>
      </tr>
  </tbody>
</table>
<p>这些方案的关键差异在于是否把 Python 解释器本身也打包进去：PyInstaller、Nuitka 把解释器一起分发，产物独立但体积大；zipapp、pex 假设目标环境已有兼容的 Python，只打包代码和依赖，产物小但需要外部解释器。</p>
<h3 id="12-zipapp--shiv">1.2 zipapp + shiv</h3>
<p>目标环境已经确定有 Python 3.10+，再打包一个解释器进去既浪费空间也增加复杂度。</p>
<p><a href="https://docs.python.org/zh-cn/3/library/zipapp.html">zipapp</a> 从 Python 3.5 起内置于标准库，支持创建包含 Python 代码的压缩文件，直接由 Python 解释器执行，可以理解为带 shebang 的 zip 文件。</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">python -m zipapp myapp -m <span class="s2">&#34;myapp:main&#34;</span>
</span></span><span class="line"><span class="cl">python myapp.pyz
</span></span><span class="line"><span class="cl">&lt;output from myapp&gt;
</span></span></code></pre></td></tr></table>
</div>
</div><p><a href="https://github.com/linkedin/shiv">shiv</a> 在 zipapp 的基础上，把第三方依赖一起装进 zip。检查工具的依赖里包含 <code>ansible-core</code>，它会拉入 <code>cryptography</code>、<code>cffi</code>、<code>PyYAML</code>、<code>MarkupSafe</code> 等带原生扩展的间接依赖。只要这些库提供 <a href="https://github.com/pypa/manylinux">manylinux</a> wheel，shiv 就可以把 wheel 原样放进 zip，不需要在构建时本地编译；代价是构建产物与平台和架构绑定。例如在 x86_64 Linux 上构建，产物中使用的就是对应的 x86_64 Linux wheel；如果需要 arm64 或 macOS 产物，则要在对应平台上重新构建。</p>
<p>打包方式如下：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">shiv <span class="se">\
</span></span></span><span class="line"><span class="cl">  --output-file bare-metal-check.pyz <span class="se">\
</span></span></span><span class="line"><span class="cl">  --entry-point bare_metal_check.cli:main <span class="se">\
</span></span></span><span class="line"><span class="cl">  --python <span class="s1">&#39;/usr/bin/env python3&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  .
</span></span></code></pre></td></tr></table>
</div>
</div><p>shiv 默认会把构建时本机解释器的绝对路径写进 zip 首行的 shebang，这个路径在目标环境中可能不存在；<code>--python '/usr/bin/env python3'</code> 把 shebang 改为从 PATH 查找解释器，避免这个问题。<code>--entry-point</code> 指定启动时执行的函数。可以配合 <a href="https://docs.astral.sh/uv/">uv</a> 的 <code>uv.lock</code> 锁定依赖版本，构建时用 <code>uv run --locked shiv ...</code> 调用，保证每次构建的依赖一致。</p>
<p>这种方式的产物体积只有几 MiB，构建流程也简单。对于「目标环境只有系统 Python、不能联网使用 pip」的场景，是贴合的选择。</p>
<h2 id="2-让-ansible-复用同一个-pyz">2. 让 Ansible 复用同一个 pyz</h2>
<p>检查工具打包进 pyz 后，接下来要处理的问题是：Ansible 由哪里提供、如何调用？有以下几种选择：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>是否自包含</th>
          <th>与 pyz 统一构建打包</th>
          <th>说明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>依赖环境中预装的 Ansible</td>
          <td>否</td>
          <td>差</td>
          <td>现场需单独安装，会污染环境</td>
      </tr>
      <tr>
          <td>在交付包里额外放一个 ansible 二进制</td>
          <td>部分</td>
          <td>中</td>
          <td>多一个需要单独维护的文件</td>
      </tr>
      <tr>
          <td>同一个 pyz 通过环境变量切换入口</td>
          <td>是</td>
          <td>高</td>
          <td>构建和打包与 pyz 完全一致</td>
      </tr>
  </tbody>
</table>
<p>第三种思路最符合「单文件、单 Python 环境、零额外依赖」的目标。</p>
<h3 id="21-shiv_console_script-机制">2.1 SHIV_CONSOLE_SCRIPT 机制</h3>
<p>shiv 在打包时会执行一次 pip 安装，<code>ansible-core</code> 提供的 <code>ansible-playbook</code>、<code>ansible-inventory</code> 等可执行入口都会被放进 zip 内的 <code>site-packages/bin/</code> 目录。shiv 启动时如果发现 <code>SHIV_CONSOLE_SCRIPT</code> 环境变量，就不执行默认的 <code>--entry-point</code>，而是执行 zip 里同名的入口脚本。</p>
<p>也就是说，同一个 <code>.pyz</code> 文件有两种启动方式：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># 默认入口：运行 CLI</span>
</span></span><span class="line"><span class="cl">python3 bare-metal-check.pyz run --nodes ...
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 切换入口：运行 ansible-playbook</span>
</span></span><span class="line"><span class="cl"><span class="nv">SHIV_CONSOLE_SCRIPT</span><span class="o">=</span>ansible-playbook python3 bare-metal-check.pyz site.yml
</span></span></code></pre></td></tr></table>
</div>
</div><p>实际代码中，调用方只需设置好环境变量，再用同一个 pyz 路径启动子进程即可：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">env</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">env</span><span class="p">[</span><span class="s2">&#34;SHIV_CONSOLE_SCRIPT&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;ansible-playbook&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="n">sys</span><span class="o">.</span><span class="n">executable</span><span class="p">,</span> <span class="n">pyz_path</span><span class="p">,</span> <span class="n">playbook</span><span class="p">,</span> <span class="o">...</span><span class="p">],</span> <span class="n">env</span><span class="o">=</span><span class="n">env</span><span class="p">)</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="22-限定从-pyz-启动">2.2 限定从 pyz 启动</h3>
<p>Python 程序可以有很多种构建和调用方式，而检查工具希望限定为只以 zipapp 方式执行，<code>SHIV_CONSOLE_SCRIPT</code> 也只有在文件确实是 zipapp 时才有效。因此检查工具在启动时会检查 <code>sys.argv[0]</code> 是预期的 <code>.pyz</code> 文件，并使用同一个文件作为调用 Ansible 的入口；否则直接退出，提醒用户从 <code>.pyz</code> 启动。</p>
<p>这样 CLI 和 Ansible 使用的是同一个 zip 里的库，版本必然一致，也避免了在现场再装一份 Ansible。</p>
<h2 id="3-离线工具的构建与分发">3. 离线工具的构建与分发</h2>
<p>CLI 和 Ansible 已经能随 pyz 一起分发，还剩下目标节点上执行检查所需的工具。例如 <code>dmidecode</code>、<code>smartctl</code>、<code>lspci</code>、<code>nvme</code> 等命令和 NCCL 测试程序，目标环境里不一定有，也无法通过 apt 在线安装。</p>
<h3 id="31-可选思路">3.1 可选思路</h3>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>是否自包含</th>
          <th>对环境的影响</th>
          <th>说明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>目标节点 apt 在线安装</td>
          <td>否</td>
          <td>介入系统包管理</td>
          <td>现场无外网，且会污染被检环境</td>
      </tr>
      <tr>
          <td>通过 Docker 镜像运行检查</td>
          <td>否</td>
          <td>需要容器运行时</td>
          <td>目标节点不一定有 Docker，镜像守护进程本身会改变环境</td>
      </tr>
      <tr>
          <td>控制节点分发预构建的 tar 归档包</td>
          <td>是</td>
          <td>仅写入 <code>/opt/</code> 下的独立目录</td>
          <td>不依赖系统包管理，按需安装，可校验完整性，通过 PATH 环境变量控制调用</td>
      </tr>
  </tbody>
</table>
<p>由于目标环境的约束，这里选择第三种方案：由控制节点校验并向目标节点分发预构建的 tar 归档包。归档在目标节点上解压到 <code>/opt/</code> 下的独立目录，再通过 PATH 环境变量让检查命令优先使用归档内的工具，而不是系统路径里的版本。</p>
<h3 id="32-构建与解压位置无关的归档">3.2 构建与解压位置无关的归档</h3>
<p>离线归档包里包含命令二进制和它们依赖的动态库，核心问题是：如何让这些二进制在目标节点的 <code>/opt/...</code> 目录下找到这些库，而不是依赖系统路径？</p>
<p>Linux 可执行文件通过 <code>rpath</code>（运行时库搜索路径）决定从哪里加载动态库。rpath 通常写成绝对路径，比如 <code>/usr/lib/x86_64-linux-gnu</code>，这会导致二进制只能在该固定位置找到库。为了让一个 tar 包在任意解压位置都能工作，需要让二进制从它自己所在的位置出发去查找库。</p>
<p>Linux 动态链接器提供了一个标准机制来实现这一点：<code>$ORIGIN</code> 是 glibc <a href="https://www.man7.org/linux/man-pages/man8/ld.so.8.html"><code>ld.so</code></a> 支持的特殊标记，表示二进制文件自身所在的目录。借助这个标记，rpath 可以写成相对路径，例如 <code>$ORIGIN/../lib</code> 表示「从二进制所在的目录往上退一级，再进入 lib 目录」。</p>
<p><a href="https://github.com/NixOS/patchelf"><code>patchelf</code></a> 用于修改已有二进制的 rpath，例如：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">patchelf --set-rpath <span class="s1">&#39;$ORIGIN/../lib&#39;</span> bin/&lt;tool&gt;
</span></span></code></pre></td></tr></table>
</div>
</div><p>这样无论归档解压到 <code>/opt/bare-metal-check-tools</code> 还是其他目录，<code>bin/&lt;tool&gt;</code> 都会去 <code>../lib</code> 找自己的私有库。</p>
<p>不同类型的工具，构建方式不同。</p>
<p><strong><code>dmidecode</code>、<code>lspci</code>、<code>nvme</code> 这类命令</strong>来自 Ubuntu 软件包里的现成二进制，构建方式更接近「重打包」：在干净的 Ubuntu 22.04 容器里用 apt 安装所需软件包，把二进制拷出来，再用 <code>ldd</code> 扫描每个二进制依赖的共享库。动态链接器和 glibc 等基础库由 Ubuntu 系统本身提供，无需打包，其余私有库才拷贝进归档。选择在 Ubuntu 22.04 上构建这份归档，是因为其 glibc 版本较低，产物在 glibc 更新的 Ubuntu 24.04 上也能运行。</p>
<p><strong>NCCL 测试工具则必须从源码编译</strong>：它依赖特定版本的 NCCL、CUDA 和 GPU 架构，目标节点上这些组件的版本无法预期。构建时在一个带 CUDA toolkit（开发工具包）的容器里固定源码版本并编译，再连同 NCCL 库和 Open MPI 一起打包。它的二进制同样用 <code>patchelf</code> 改成相对路径的 rpath，让命令在解压目录内找到所有依赖。</p>
<p>无论哪一类归档，都不包含 NVIDIA 驱动、CUDA toolkit、OFED（InfiniBand 驱动栈）、固件、内核模块等与目标环境绑定的组件。这些属于目标节点的既有条件，缺失时应由检查项暴露，而不是由检查工具补齐。唯一的例外是 CUDA 运行时库（libcudart）：NCCL 测试运行需要它，因此随 NCCL 归档一起打包。</p>
<p>两类归档的共同点是：都用 <code>patchelf</code> 把绝对路径改成相对路径，使 tar 包可以在目标节点的任意位置解压运行。</p>
<h3 id="33-清单校验与按需分发">3.3 清单、校验与按需分发</h3>
<p>每个离线归档对应一份清单（manifest），记录适用节点角色、适用 OS 版本、安装目的地、归档内必须存在的路径等元信息。清单纳入版本管理，但<strong>不记录 size/sha256</strong>；完整性数据属于构建产物，由构建脚本生成单独的校验文件。打包交付时会强制检查清单声明的每个归档都存在，且重新计算的 sha256 与校验文件一致。</p>
<p>分发不是一开始就把所有归档推送到全部节点，而是按检查阶段和节点角色按需进行，整体流程如下：</p>

<div class="mermaid-block">
    <pre class="mermaid">flowchart LR
    M["清单 manifest"] --> S["控制节点校验<br/>按 OS、架构、角色筛选<br/>校验 sha256"]
    S --> D["按需分发<br/>通用工具全量下发<br/>主动测试工具条件下发"]
    D --> I["幂等安装<br/>摘要一致则跳过<br/>校验后解压到 /opt/ 下"]</pre>
    <p class="mermaid-hint">Mermaid 图表需要浏览器启用 JavaScript 并从 CDN 加载 mermaid 库才能渲染；某些网络环境或移动端可能加载失败，此时下方显示的是图表源码。</p>
</div>
<ol>
<li><strong>控制节点校验</strong>：根据目标节点的 OS 版本、架构和角色，从清单中筛选适用的归档，并校验其完整性。</li>
<li><strong>按需分发</strong>：通用命令工具在检查初期分发给所有节点；NCCL 测试这类会在节点上施加负载的主动测试（区别于只读检查），只在 GPU 节点通过驱动、CUDA 等前置资格检查并明确允许主动测试后才下发。</li>
<li><strong>幂等安装</strong>：目标节点上记录已安装归档的摘要，与待安装的归档一致则跳过传输；需要安装时先传到临时目录，校验后再解压到 <code>/opt/</code> 下的独立目录。</li>
</ol>
<h2 id="4-html-报告单文件自包含--数据独立">4. HTML 报告：单文件自包含 + 数据独立</h2>
<p>报告是检查工具的最终产出，而查看报告的环境同样可能离线、没有 Node.js、甚至没有 Python，因此报告本身也要自包含：一个文件、零外部依赖、双击就能打开。报告的实际页面如下：</p>
<p><img alt="检查报告的批次矩阵与节点详情" loading="lazy" src="/codes/self-contained-dist-tools/asserts/check-report.webp#center"></p>
<p>常见的报告形态有几种：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>离线可用</th>
          <th>是否需要本地服务</th>
          <th>说明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>静态 HTML + 外部 JSON</td>
          <td>否</td>
          <td>是</td>
          <td>需要 fetch，file:// 下受浏览器安全限制</td>
      </tr>
      <tr>
          <td>单文件 HTML，数据内联</td>
          <td>是</td>
          <td>否</td>
          <td>所有资源在文件内部，任意位置打开</td>
      </tr>
      <tr>
          <td>本地 HTTP 服务动态渲染</td>
          <td>是</td>
          <td>是</td>
          <td>需要服务进程，服务关闭后无法查看</td>
      </tr>
  </tbody>
</table>
<p>这里选择第二种作为主要形态：模板和数据分离，模板一次性构建，数据在每次检查结束后注入。第三种则作为检查完成后临时分享的便利手段。</p>
<h3 id="41-单文件模板--base64-数据岛">4.1 单文件模板 + base64 数据岛</h3>
<p>前端模板用 Vue + Vite 开发，构建时通过 <a href="https://github.com/richardtallent/vite-plugin-singlefile">vite-plugin-singlefile</a> 插件把 JS/CSS 全部内联，产出只有一个零外部请求的 HTML 文件。文件里预留一个数据占位符：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<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">type</span><span class="o">=</span><span class="s">&#34;application/json&#34;</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;report-data&#34;</span><span class="p">&gt;</span><span class="nx">__BASE64_REPORT_DATA__</span><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>检查结束后，pyz 内的 Python 代码把本次结果 JSON 做 base64 编码，替换这个占位符，生成最终的 HTML 报告。用 base64 有两个好处：一是避免 JSON 里的 <code>&lt;/script&gt;</code> 破坏 HTML 解析；二是 base64 字符集安全，即使现场只有 shell 也能手工完成注入。</p>
<p>前端读取时把 <code>&lt;script type=&quot;application/json&quot;&gt;</code> 当作纯数据容器，base64 解码后再按 UTF-8 还原文本，避免中文乱码。模板和数据各自演进、互不依赖，同一份模板可以生成任意多份报告。</p>
<h3 id="42-file-与-http-两种访问">4.2 file:// 与 http:// 两种访问</h3>
<p>因为所有资源都已内联、数据在 DOM 内部，报告文件可以直接用 <code>file://</code> 协议打开，浏览器双击即可查看。没有 fetch 请求，也就不存在跨域或 file:// 安全限制问题，文件可以随意拷贝、归档或通过邮件发送。</p>
<p>检查命令完成后，CLI 也会启动一个临时静态服务，打印局域网 URL，方便同事直接在浏览器中查看。报告检测到自己是通过 <code>http://</code> 协议访问时，会额外渲染一个下载按钮，方便直接把报告保存到本地，省去手工拷贝文件的步骤。</p>
<h2 id="5-重操旧业">5. 重操旧业</h2>
<p>这套方案的核心思路是把「自包含」贯彻到交付流程的每一层，从代码依赖、Ansible、三方工具，再到报告，整个流程对现场环境的要求降到了一个 Python 3 解释器和一个可选的浏览器。</p>
<p>实际上这并不是我第一次做巡检工具：差不多四五年前，我就做过一个类似的工具。回顾来看，我在方案的设定和技术偏好上发生了很多变化。</p>
<p>那时的我更看重组件的编码灵活性、接口的标准性，抵触做一个脚本小子。所以那个时候的我非常讨厌 ansible，在实现类似工具时，我尽可能用 http 接口来标准化各个环节，包括分发逻辑和文件传输。但其实并没有做得很好：</p>
<ul>
<li>组件自身的 bootstrap 仍然需要 ansible 来执行，毕竟 ssh 才是真正意义上的 linux native；没有做到自举是当时的一大遗憾；</li>
<li>http 接口虽然形式上标准，避免了脚本小子的作风，但是和 ansible 这种业界事实标准差距不小，无法借力已经存在的丰富生态；那时也缺乏插件的模块化经验，机制薄弱，代码还混乱；</li>
<li>当时为了满足报告可编辑的需求，采用了 <a href="https://github.com/elapouya/python-docx-template">docxtpl</a> 来渲染报告，它是一种组合 docx xml 和 jinja 语法的做法，虽然某种程度上也算做到了数据和模板分离，但远没有 html 生态灵活和易于维护。</li>
</ul>
<p>再次做类似的工具，我不再认为使用 ansible 就是脚本小子。我认为关键在于数据接口的标准化：这一次我没有使用 http 接口来定义标准结构，而是用 json schema 来约定每一个检查模块的输出内容。脚本小子的问题不在于写冗长的脚本，而是不会因地制宜。使用 ansible 后节省大量精力用于思考更关键的问题，这就值得。</p>
<p>我记得当时实现那个工具的初版时，我用了一个月；而现在有 AI 辅助之后，我只用了一周（当然后续的打磨还是花了不少时间），还附带了一份精美的 html 报告，真让人唏嘘不已。</p>
]]></description>
    </item>
  </channel>
</rss>
