<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Node.js - Tag - SaiyuDoc</title><link>https://www.wangsaiyu.com/tags/node.js/</link><description>Node.js - Tag - SaiyuDoc</description><generator>Hugo -- gohugo.io</generator><language>zh-CN</language><managingEditor>panicwang@outlook.com (王赛宇)</managingEditor><webMaster>panicwang@outlook.com (王赛宇)</webMaster><lastBuildDate>Tue, 13 Oct 2026 18:00:00 +0800</lastBuildDate><atom:link href="https://www.wangsaiyu.com/tags/node.js/" rel="self" type="application/rss+xml"/><item><title>Sandbox 存储性能优化：NAS 选型与本地盘构建</title><link>https://www.wangsaiyu.com/posts/engineering/sandbox-build-storage/</link><pubDate>Tue, 13 Oct 2026 18:00:00 +0800</pubDate><author>王赛宇</author><guid>https://www.wangsaiyu.com/posts/engineering/sandbox-build-storage/</guid><description><![CDATA[<p>一个新项目还没有开始修改代码，准备过程就先等了六七秒。拆开日志以后发现，其中接近一半时间花在初始 Git 推送上。</p>
<p>2026 年 4 月 6 日，测试环境两次新项目准备的平均耗时是 6.66 秒，负责推送的 <code>push_remote</code> 步骤平均用了 2.90 秒，占约 43.6%。沿着这次推送继续看，Gitea 服务端的仓库目录挂在 NAS 上，文件存储由此进入了排查范围。</p>
<p>后面我们又测了不同文件系统上的依赖安装和构建，遇到了“包已经缓存，安装仍然很慢”的问题。这篇文章从 Git push 开始，沿着这些测量与实现，讲到 NAS 选型，以及本地盘上的依赖和编译。</p>
<h2 id="1-先从一次-git-push-看起">1. 先从一次 Git push 看起</h2>
<h3 id="11-新项目为什么先要推送代码">1.1. 新项目为什么先要推送代码</h3>
<p>当时的新项目初始化流程，会先把模板解压到 Sandbox，创建本地 Git 仓库并提交初始代码，再推送到 Gitea。Gitea 是保存项目 Git 仓库的服务，后续可以从这里取得已经提交的版本。只有这套准备流程完成，项目才能继续使用。</p>
<p>我们在预热流程，也就是 warmup 中记录了各步骤耗时。下面两条是 2026 年 4 月 6 日测试环境的成功样本，均为新项目的模板初始化路径，时间单位为秒。</p>
<table>
  <thead>
      <tr>
          <th>样本</th>
          <th style="text-align: right">warmup 总耗时</th>
          <th style="text-align: right"><code>init_git</code></th>
          <th style="text-align: right"><code>push_remote</code></th>
          <th style="text-align: right">推送步骤占比</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第一次</td>
          <td style="text-align: right">6.437</td>
          <td style="text-align: right">0.050</td>
          <td style="text-align: right">2.935</td>
          <td style="text-align: right">45.6%</td>
      </tr>
      <tr>
          <td>第二次</td>
          <td style="text-align: right">6.890</td>
          <td style="text-align: right">0.047</td>
          <td style="text-align: right">2.873</td>
          <td style="text-align: right">41.7%</td>
      </tr>
      <tr>
          <td>平均</td>
          <td style="text-align: right">6.663</td>
          <td style="text-align: right">0.049</td>
          <td style="text-align: right">2.904</td>
          <td style="text-align: right">43.6%</td>
      </tr>
  </tbody>
</table>
<p>占比按未四舍五入的原始时间计算，平均行使用两次推送耗时之和除以两次总耗时之和。这是两个现场样本，不代表长期平均延迟。</p>
<p><code>init_git</code> 包含建仓、配置提交身份、<code>git add</code> 和 <code>git commit</code>，这两次只花了约 50 毫秒和 47 毫秒。随后的 <code>push_remote</code> 则用了约 2.9 秒，等待主要集中在后一步。</p>
<p>这两个步骤都从沙箱代理服务发起远程执行开始计时，直到脚本返回。<code>push_remote</code> 执行初始代码推送，包含远程执行开销、Git 客户端处理、网络和服务端等待；前一步的本地提交不算在里面。因此，不能把它直接当作纯网络上传时间，也不能拿新项目首次提交的结果代表任意大小仓库的提交与推送。</p>
<h3 id="12-推送在等待什么">1.2. 推送在等待什么</h3>
<p>Git push 会向远端发送它尚未拥有的对象，并请求更新分支引用。服务端需要接收相关对象，检查这次更新是否允许，再让目标分支指向新的提交。<a href="https://git-scm.com/docs/git-push" target="_blank" rel="noopener noreffer ">Git push</a>、<a href="https://git-scm.com/docs/git-receive-pack" target="_blank" rel="noopener noreffer ">服务端 receive-pack</a></p>
<p>因此，客户端在等待推送结果时，等待的内容还包括服务端对仓库的处理。如果仓库保存在网络文件系统上，这条路径就涉及服务端的文件操作。</p>
<p>当时检查 Gitea 部署配置，确认 <code>/data</code> 挂载了 NAS，其中保存 Gitea 的仓库数据。这条线索把存储纳入了排查，但还不能把那 2.90 秒全部归因于 NAS：我们没有在这两个样本中继续测出网络、客户端和服务端 I/O 各占多少。</p>
<p>接下来需要区分两个存储位置：<strong>Gitea 保存已经提交的 Git 版本，Sandbox 工作区保存当前正在修改的工程文件。</strong> 它们都可以使用 NAS，但访问发生在不同位置，优化其中一处，不会自动改变另一处。</p>
<h3 id="13-工程工作区也需要持久保存">1.3. 工程工作区也需要持久保存</h3>
<p>Agent 在 Sandbox 中修改代码、安装依赖，再执行构建。Sandbox 是承载这些操作的隔离执行环境，里面有项目目录、Node.js 和构建工具。用户下一次回来继续修改时，执行任务的容器可能已经换了一个。</p>
<p>源码需要跨实例保存。例如，Agent 给游戏增加了暂停菜单，修改了 <code>src/menu.ts</code>，还增加了一个依赖。容器回收以后，这次修改和对应的依赖版本都需要保留下来。</p>
<p>后来的 NAS 工作区方案，把网络文件存储通过 NFS 协议挂载到 Sandbox 中。挂载以后，程序仍然通过普通目录访问它。新容器挂载同一个项目目录，就可以继续处理原来的工程，包括尚未提交到 Git 的修改。</p>
<figure><a class="lightgallery" href="/images/sandbox-build-storage/01-persistent-workspace.svg" title="Git 推送经过 Gitea 服务端访问仓库存储；NAS 工程工作区则由先后创建的 Sandbox 直接挂载，两处存储分别承载版本和当前文件" data-thumbnail="/images/sandbox-build-storage/01-persistent-workspace.svg" data-sub-html="<h2>图 1：Git 仓库存储与工程工作区是两个位置，需要分别观察</h2>">
        
    </a><figcaption class="image-caption">图 1：Git 仓库存储与工程工作区是两个位置，需要分别观察</figcaption>
    </figure>
<p>这个布局很直接：源码、<code>package.json</code>、锁文件、<code>node_modules</code> 和 <code>dist</code> 都在同一个工程目录里，现有工具基本不用调整。持久化的问题解决了，安装和构建也随之使用了网络文件系统。</p>
<p>读取一个依赖文件，可能先要查找目录项、确认权限、读取文件属性，然后才是内容。客户端会缓存一部分信息；缓存未命中或需要向服务端确认时，这些操作就要经过网络。对于包含大量小文件的 <code>node_modules</code>，文件总量不大，也可能有很多次访问。</p>
<h3 id="14-第一个方案换一种文件系统">1.4. 第一个方案：换一种文件系统</h3>
<p>既然安装和构建都在 NAS 上，首先可以尝试改善这条存储路径。这样不需要调整工程目录，也不用让包管理器适应新的布局。</p>
<p>阿里云的 NAS 有多种规格。按目前的产品划分，通用型包含容量型、高级型和性能型，极速型又分标准型与高级型。各类产品提供的容量、时延、吞吐和 IOPS 规格不同，选型需要结合项目的实际操作。<a href="https://help.aliyun.com/zh/nas/product-overview/general-purpose-nas-file-systems/" target="_blank" rel="noopener noreffer ">通用型 NAS</a>、<a href="https://help.aliyun.com/zh/nas/product-overview/extreme-nas-file-systems/" target="_blank" rel="noopener noreffer ">极速型 NAS</a></p>
<table>
  <thead>
      <tr>
          <th>类型</th>
          <th>主要特点</th>
          <th>对这个问题需要核对什么</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>通用容量型 NAS</td>
          <td>面向容量与成本较敏感的共享文件场景</td>
          <td>小文件时延、实际使用容量对应的吞吐</td>
      </tr>
      <tr>
          <td>通用高级型、性能型 NAS</td>
          <td>提供不同的时延、吞吐和 IOPS 规格</td>
          <td>单个项目能否利用这些能力，安装与构建是否改善</td>
      </tr>
      <tr>
          <td>极速型 NAS</td>
          <td>面向较低时延和较高 IOPS 的文件访问</td>
          <td>当前地域、规格和计算平台的挂载支持</td>
      </tr>
      <tr>
          <td>CPFS</td>
          <td>并行文件存储，面向较大规模的并发 I/O</td>
          <td>客户端、协议服务、网络配置和接入条件</td>
      </tr>
  </tbody>
</table>
<p>CPFS 也进入过我们的测试范围。不过，文件系统的性能只是选型的一部分，还要看 Sandbox 能否接入。当时 Sandbox 使用函数计算 FC，可以配置 NAS 挂载；CPFS 实验则需要先创建协议服务与导出目录，再在 ECS 上安装 CPFS-NFS 客户端并挂载导出的路径。接通 ECS，并不等于可以直接把 FC 的 NAS 地址换成 CPFS。<a href="https://help.aliyun.com/zh/functioncompute/selection-of-function-storage" target="_blank" rel="noopener noreffer ">FC 存储选型</a>、<a href="https://help.aliyun.com/zh/cpfs/cpfsonecs/user-guide/mount-a-file-system-by-using-the-cpfs-nfs-client-recommended" target="_blank" rel="noopener noreffer ">CPFS-NFS 挂载说明</a></p>
<p>接下来需要回答的，就是换存储到底能改善多少。连续读写大文件的吞吐，不能直接换算成安装几万个依赖文件需要的时间，因此我们拿已有的前端模板执行安装和构建。</p>
<h2 id="2-换了文件系统构建快了多少">2. 换了文件系统，构建快了多少</h2>
<p>2026 年 6 月 12 日，我们用四种前端模板做了两轮测试，表中分别记为 <strong>2D、3D、Empty3D（空的 3D 工程）、Canvas</strong>。以下时间均为秒。</p>
<p>这一轮测的是工程目录中的 <code>npm install</code> 和 <code>npm run build</code>，与前面的初始 Git 推送是不同负载。现有记录没有 Git push 在各文件系统上的同口径对照，下面的数据只用于比较安装和构建。命令也保留当时的 npm 口径，不改写成后来的 pnpm 生产流程。</p>
<h3 id="21-第一轮容量型反而安装得更快">2.1. 第一轮：容量型反而安装得更快</h3>
<p>NAS 测试使用新加坡区域的 <code>ecs.c9i.xlarge</code>，4 核 8 GiB，Node.js 22.16.0、npm 10.9.2。每个项目先安装依赖，再连续构建三次。CPFS 随后补跑，也使用 Node.js 22.16.0、npm 10.9.2。</p>
<p>下面是第一轮记录。这轮 NAS 测试共用了 ECS 本地的 npm 缓存，<strong>性能型先跑，容量型后跑</strong>，安装条件并不完全一致。</p>
<table>
  <thead>
      <tr>
          <th>项目</th>
          <th>文件系统</th>
          <th style="text-align: right">安装</th>
          <th style="text-align: right">首次 build</th>
          <th style="text-align: right">三次 build 中位数</th>
          <th style="text-align: right">安装＋首次 build</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2D</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">29.924</td>
          <td style="text-align: right">5.840</td>
          <td style="text-align: right">4.022</td>
          <td style="text-align: right">35.764</td>
      </tr>
      <tr>
          <td>2D</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">16.911</td>
          <td style="text-align: right">5.442</td>
          <td style="text-align: right">3.845</td>
          <td style="text-align: right">22.353</td>
      </tr>
      <tr>
          <td>2D</td>
          <td>CPFS</td>
          <td style="text-align: right">22.881</td>
          <td style="text-align: right">3.804</td>
          <td style="text-align: right">3.507</td>
          <td style="text-align: right">26.685</td>
      </tr>
      <tr>
          <td>3D</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">14.341</td>
          <td style="text-align: right">2.880</td>
          <td style="text-align: right">1.993</td>
          <td style="text-align: right">17.221</td>
      </tr>
      <tr>
          <td>3D</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">8.875</td>
          <td style="text-align: right">2.718</td>
          <td style="text-align: right">1.928</td>
          <td style="text-align: right">11.593</td>
      </tr>
      <tr>
          <td>3D</td>
          <td>CPFS</td>
          <td style="text-align: right">11.105</td>
          <td style="text-align: right">1.938</td>
          <td style="text-align: right">1.784</td>
          <td style="text-align: right">13.043</td>
      </tr>
      <tr>
          <td>Empty3D</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">5.723</td>
          <td style="text-align: right">1.442</td>
          <td style="text-align: right">1.259</td>
          <td style="text-align: right">7.165</td>
      </tr>
      <tr>
          <td>Empty3D</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">5.473</td>
          <td style="text-align: right">1.422</td>
          <td style="text-align: right">1.203</td>
          <td style="text-align: right">6.895</td>
      </tr>
      <tr>
          <td>Empty3D</td>
          <td>CPFS</td>
          <td style="text-align: right">3.080</td>
          <td style="text-align: right">1.190</td>
          <td style="text-align: right">1.140</td>
          <td style="text-align: right">4.270</td>
      </tr>
      <tr>
          <td>Canvas</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">1.301</td>
          <td style="text-align: right">1.298</td>
          <td style="text-align: right">0.982</td>
          <td style="text-align: right">2.599</td>
      </tr>
      <tr>
          <td>Canvas</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">1.260</td>
          <td style="text-align: right">1.438</td>
          <td style="text-align: right">0.972</td>
          <td style="text-align: right">2.698</td>
      </tr>
      <tr>
          <td>Canvas</td>
          <td>CPFS</td>
          <td style="text-align: right">0.852</td>
          <td style="text-align: right">0.725</td>
          <td style="text-align: right">0.620</td>
          <td style="text-align: right">1.577</td>
      </tr>
  </tbody>
</table>
<p>2D 的结果最明显：容量型安装用了 16.911 秒，性能型用了 29.924 秒。检查测试过程后，发现 npm 缓存保存在 ECS 的 <code>/root/.npm</code>，后测的一组能够复用前面下载过的内容。</p>
<p>因此，这两行数字不能直接证明容量型更快。缓存是已经确认的干扰因素，但没有单独控制它做实验，也无法判断这约 13 秒的差异有多少由缓存造成。CPFS 的补跑同样没有满足后来要求的完整隔离条件。</p>
<p>构建数据也需要分开看。两个 NAS 的中位数比较接近，例如 3D 是 1.993 秒和 1.928 秒，Canvas 是 0.982 秒和 0.972 秒。每组只有三次连续执行，还不足以作稳定的性能排名；首次 build 紧接着安装，也不等于清空文件缓存后的冷读测试。</p>
<p>这轮先暴露出来的是测法的问题。要继续比较文件系统，得让每一组从独立环境开始。</p>
<h3 id="22-第二轮隔离环境后再测">2.2. 第二轮：隔离环境后再测</h3>
<p>第二轮改成“文件系统类型 × 项目”各用一台新 ECS、一套新文件系统，避免共用 npm 缓存。规格也调整为更接近当时 Sandbox 的 2 核 4 GiB：<code>ecs.c9i.large</code>、Ubuntu 20.04、Node.js 22.22.3、npm 10.9.8，NAS 使用 NFSv3 挂载。</p>
<p>CPU 和软件版本与第一轮不同，所以这一轮内部比较，不能拿两轮数字计算升级收益。</p>
<table>
  <thead>
      <tr>
          <th>项目</th>
          <th>文件系统</th>
          <th style="text-align: right">安装</th>
          <th>build 1 / 2 / 3</th>
          <th style="text-align: right">build 中位数</th>
          <th style="text-align: right">安装＋首次 build</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2D</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">31.93</td>
          <td>5.66 / 5.05 / 4.94</td>
          <td style="text-align: right">5.05</td>
          <td style="text-align: right">37.59</td>
      </tr>
      <tr>
          <td>2D</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">39.64</td>
          <td>5.69 / 7.50 / 5.15</td>
          <td style="text-align: right">5.69</td>
          <td style="text-align: right">45.33</td>
      </tr>
      <tr>
          <td>3D</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">21.73</td>
          <td>2.91 / 2.56 / 2.59</td>
          <td style="text-align: right">2.59</td>
          <td style="text-align: right">24.64</td>
      </tr>
      <tr>
          <td>3D</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">21.63</td>
          <td>2.84 / 2.51 / 2.46</td>
          <td style="text-align: right">2.51</td>
          <td style="text-align: right">24.47</td>
      </tr>
      <tr>
          <td>Canvas</td>
          <td>NAS 容量型</td>
          <td style="text-align: right">1.44</td>
          <td>1.28 / 0.96 / 0.92</td>
          <td style="text-align: right">0.96</td>
          <td style="text-align: right">2.72</td>
      </tr>
      <tr>
          <td>Canvas</td>
          <td>NAS 性能型</td>
          <td style="text-align: right">1.16</td>
          <td>1.16 / 0.89 / 0.87</td>
          <td style="text-align: right">0.89</td>
          <td style="text-align: right">2.32</td>
      </tr>
  </tbody>
</table>
<p>有些组合没有跑完：Empty3D 在两种 NAS 上都遇到 <code>npm install</code> 的 <code>ERESOLVE</code> 依赖解析错误，项目本身声明使用 pnpm，直接以 npm 安装并不完全符合原来的依赖约束；CPFS 独立组合则停在资源与挂载准备阶段。它们没有有效构建时间，也不能用第一轮的 CPFS 数字补上。</p>
<p>极速型 NAS 没有参加这两轮测试。全部阶段记录，包括三次 build 和失败退出码，保留在<a href="/data/sandbox-build-storage/2026-06-12-results.csv" rel="">测试数据 CSV</a> 中。</p>
<h3 id="23-安装仍然占了大头">2.3. 安装仍然占了大头</h3>
<p>这六组成功结果，没有显示出换成性能型 NAS 后各个项目都明显变快。更值得继续追的是时间分布：2D 在容量型上的安装占“安装＋首次构建”的约 85%，3D 对应约 88%。</p>
<p>即使只看 2D，把 5.66 秒的编译继续压缩，前面的 31.93 秒安装仍然存在。要缩短用户等待的时间，需要继续拆解依赖准备。</p>
<p>安装依赖会下载包，因此一个直接的方案是预热包缓存，尽量离线安装。我们后来的镜像已经这样做了，但工作区中的 <code>node_modules</code> 仍在 NAS 上。接下来的问题变成了：<strong>包内容已经在本地，安装还需要做哪些文件操作？</strong></p>
<h2 id="3-包已经缓存了安装还在做什么">3. 包已经缓存了，安装还在做什么</h2>
<h3 id="31-缓存与依赖树是两个位置">3.1. 缓存与依赖树是两个位置</h3>
<p>安装大致包含读取包与锁文件、获取包内容、建立项目依赖树，以及执行安装脚本几个过程。缓存能够复用包内容，项目自己的依赖目录仍然需要准备。</p>
<p>以 pnpm 为例，需要区分三个位置：</p>
<ul>
<li><strong>内容存储 store</strong>：保存可以复用的包文件；</li>
<li><strong>项目的 <code>node_modules/.pnpm</code></strong>：项目使用的依赖文件和目录布局；</li>
<li><strong><code>node_modules</code> 中的链接与入口</strong>：让程序按照依赖关系找到模块。</li>
</ul>
<p>pnpm 使用内容存储和链接组织依赖。建立和读取项目依赖树时，仍然要操作项目目录；store 与项目分属不同文件系统时，也不能假定跨文件系统硬链接总能成立。<a href="https://pnpm.io/symlinked-node-modules-structure" target="_blank" rel="noopener noreffer ">pnpm 依赖目录结构</a></p>
<figure><a class="lightgallery" href="/images/sandbox-build-storage/02-cache-and-dependencies.svg" title="本地包缓存提供包内容，NAS 上的 node_modules 仍然承担目录创建、属性查询和构建读取" data-thumbnail="/images/sandbox-build-storage/02-cache-and-dependencies.svg" data-sub-html="<h2>图 2：缓存命中以后，NAS 上的依赖目录仍有文件操作</h2>">
        
    </a><figcaption class="image-caption">图 2：缓存命中以后，NAS 上的依赖目录仍有文件操作</figcaption>
    </figure>
<p>8 月检查构建链路时，镜像里的预热 store 已经在本地盘，安装器也支持优先离线安装。但 <code>node_modules</code> 和 <code>.pnpm</code> 还在 NAS。包可以不重新下载，目录创建、文件复制、链接与属性检查仍可能经过网络文件系统。</p>
<p>构建阶段也会反复查找模块、读取 <code>package.json</code>、解析源码，再生成临时文件和 <code>dist</code>。依赖留在 NAS 上时，读取依赖的成本会继续影响构建，只调整输出目录还不够。</p>
<h3 id="32-连查看目录也变慢了">3.2. 连查看目录也变慢了</h3>
<p>2026 年 8 月 8 日的一次排查，进一步把范围指向了文件访问。工作区确认是 NFSv3 挂载，现场记录中，<code>find</code> 用了约 117 秒，<code>git status</code> 约 166 秒，部分目录扫描超过 60 秒。</p>
<p>这些命令不负责把源码编译成产物。它们也变慢，说明需要检查安装和编译之外共同使用的文件系统路径。</p>
<p>约 42 分钟的现场累计统计中，有下面这些 NFS 请求：</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th style="text-align: right">次数</th>
          <th>含义</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SETATTR</code></td>
          <td style="text-align: right">42,110</td>
          <td>设置权限、时间等文件属性</td>
      </tr>
      <tr>
          <td><code>LOOKUP</code></td>
          <td style="text-align: right">22,871</td>
          <td>查找目录中的文件或子目录</td>
      </tr>
      <tr>
          <td><code>ACCESS</code></td>
          <td style="text-align: right">21,662</td>
          <td>检查访问权限</td>
      </tr>
      <tr>
          <td><code>CREATE</code></td>
          <td style="text-align: right">11,362</td>
          <td>创建文件</td>
      </tr>
      <tr>
          <td><code>WRITE</code></td>
          <td style="text-align: right">11,583</td>
          <td>写入文件内容</td>
      </tr>
  </tbody>
</table>
<p>这些是多个操作累计的计数，不是一次安装的调用次数。不过，它们解释了为什么不能只看传输带宽：查目录、检查权限、创建文件，每一步都可能有等待。依赖文件多了，即使单个文件很小，这些步骤也要反复执行。</p>
<p>现场同时存在外部包下载慢的问题，手工执行的 pnpm 命令还曾把 store 建在 NAS 上。下载、缓存和项目目录需要分别检查，已有证据不足以把所有等待都算在 NAS 上。但对于已经确认留在 NAS 的依赖树，可以提出一个更具体的改法：把它移到本地。</p>
<h2 id="4-先把-node_modules-搬到本地">4. 先把 node_modules 搬到本地</h2>
<h3 id="41-哪些文件可以不持久保存">4.1. 哪些文件可以不持久保存</h3>
<p>源码、素材和依赖约束需要保留；<code>node_modules</code> 则可以根据 <code>package.json</code>、锁文件和工具版本重新生成。它被频繁读写，却不一定需要和源码使用同一种存储。</p>
<p>因此，可以让 NAS 继续保存工程，把 <code>node_modules</code> 放到 Sandbox 本地盘。工作区里的 <code>node_modules</code> 变成软链接，工具访问这个路径时，实际读取本地目录。</p>
<p>本地盘的选择还取决于部署环境：有独立 SSD 时，可以把依赖目录放在 SSD 对应的挂载点；只有容器本地临时盘时，也可以验证同样的分工。两者的容量、性能和生命周期需要按实际环境确认，目录叫 <code>/tmp</code> 并不能说明底层一定是 SSD。</p>
<h3 id="42-实验结果安装改善大构建改善小">4.2. 实验结果：安装改善大，构建改善小</h3>
<p>我们在 8 月评审过《低权限环境下 node_modules 本地快盘托管方案》，附录保留了一组 2026 年 6 月的实验。它先在本地目录安装依赖，再通过软链接让工作区使用这份 <code>node_modules</code>。</p>
<p>原实验中，<code>/workspace</code> 和 <code>/scratch</code> 是同一个 NFSv3 文件系统，<code>/tmp</code> 是约 9.9 GiB 的容器本地 overlay，<strong>没有独立本地 SSD</strong>。下面比较的是 NFS 路径与容器本地路径。</p>
<table>
  <thead>
      <tr>
          <th>操作与规模</th>
          <th style="text-align: right">NFS 直接使用</th>
          <th style="text-align: right">本地目录＋软链接</th>
          <th style="text-align: right">两者耗时比</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>安装 express 等，70 多个依赖</td>
          <td style="text-align: right">5.3 秒</td>
          <td style="text-align: right">1.3 秒</td>
          <td style="text-align: right">4.08</td>
      </tr>
      <tr>
          <td>安装 91 个依赖，9,164 个文件</td>
          <td style="text-align: right">54.4 秒</td>
          <td style="text-align: right">7.5 秒</td>
          <td style="text-align: right">7.25</td>
      </tr>
      <tr>
          <td>安装 431 个依赖，22,302 个文件</td>
          <td style="text-align: right">166 秒</td>
          <td style="text-align: right">31 秒</td>
          <td style="text-align: right">5.35</td>
      </tr>
      <tr>
          <td>431 个依赖项目的 Vite build</td>
          <td style="text-align: right">6.05 秒</td>
          <td style="text-align: right">4.29 秒</td>
          <td style="text-align: right">1.41</td>
      </tr>
  </tbody>
</table>
<figure><a class="lightgallery" href="/images/sandbox-build-storage/03-local-io-measurements.svg" title="431 个依赖的安装耗时为 NFS 166 秒、本地 31 秒；构建耗时为 6.05 秒与 4.29 秒，两个子图独立刻度" data-thumbnail="/images/sandbox-build-storage/03-local-io-measurements.svg" data-sub-html="<h2>图 3：同一份补充记录中，安装和构建的变化幅度不同</h2>">
        
    </a><figcaption class="image-caption">图 3：同一份补充记录中，安装和构建的变化幅度不同</figcaption>
    </figure>
<p>在 431 个依赖的记录里，安装从 166 秒降到 31 秒；Vite build 从 6.05 秒降到 4.29 秒。移动依赖目录影响了两部分，安装阶段的变化更大。构建还包含解析、转换、压缩等 CPU 工作，不能用安装的耗时比代表整个发布流程的收益。</p>
<p>这份附录没有保留完整的 CPU 规格、缓存控制和重复轮数，属于方案验证资料，也不是前面几个模板的同口径复测。它提供了继续实现本地依赖目录的依据，生产项目的具体收益还需要重新测量。原记录见<a href="https://lcn09aufxjsv.feishu.cn/docx/JluvdmlDOoriiNxbFOZc7Ychn7g" target="_blank" rel="noopener noreffer ">方案文档的实机验证附录，revision 7</a>。</p>
<h3 id="43-软链接又被安装器删掉了">4.3. 软链接又被安装器删掉了</h3>
<p>有了这组结果，接下来的工作是把目录布局接入现有工具。最先遇到的限制，就在依赖安装本身。</p>
<p>假设目录是这样的：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/workspace/game/node_modules
</span></span><span class="line"><span class="cl">    → /local/game/project/node_modules
</span></span></code></pre></div><p>软链接保存目标路径，但包管理器不一定保留它。补充实验中的 npm 11 在安装的 reify 阶段发现 <code>node_modules</code> 是软链接，会移除它，再在原位置创建目录。于是，看起来已经搬到本地的依赖，又写回了 NFS。<code>npm ci</code> 也明确包含先清理既有 <code>node_modules</code> 的行为。<a href="https://docs.npmjs.com/cli/v11/commands/npm-ci/" target="_blank" rel="noopener noreffer ">npm ci 的行为说明</a></p>
<p>要保留本地布局，安装命令应当直接在本地工程目录执行，成功以后再由平台建立工作区链接。处理已有依赖目录前，还需要确认它属于可重建数据，并停止正在使用它的进程。</p>
<p>构建工具有另一类限制：部分框架检查工程根目录与依赖的实际路径，外置依赖可能触发“超出文件系统根目录”的错误。只挪 <code>node_modules</code>，源码和依赖仍分处两个工程根，这套结构并不一定适合所有工具。</p>
<p>因此，下一步把源码副本也放进本地，让源码、依赖和构建命令位于同一个工程根。NAS 继续保留原工程，本地副本负责执行这一轮构建。</p>
<h2 id="5-在本地准备一个完整工程">5. 在本地准备一个完整工程</h2>
<h3 id="51-两份目录如何分工">5.1. 两份目录如何分工</h3>
<p>本地工程副本需要源码，但不负责替代 NAS 保存用户修改。每次构建前，从 NAS 同步本轮输入；依赖安装、临时文件和编译都在本地完成；构建成功后，再把产物交回持久层。</p>
<figure><a class="lightgallery" href="/images/sandbox-build-storage/04-storage-layout.svg" title="NAS 保存源码、锁文件、Git 与状态，本地工程副本保存源码镜像、node_modules、缓存和构建输出，成功产物回写 NAS" data-thumbnail="/images/sandbox-build-storage/04-storage-layout.svg" data-sub-html="<h2>图 4：NAS 保存持久工程，本地工程副本负责安装与编译，路径经过简化</h2>">
        
    </a><figcaption class="image-caption">图 4：NAS 保存持久工程，本地工程副本负责安装与编译，路径经过简化</figcaption>
    </figure>
<table>
  <thead>
      <tr>
          <th>内容</th>
          <th>主要保存位置</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>源码、素材、<code>package.json</code>、锁文件</td>
          <td>NAS</td>
          <td>保存用户修改和下一次安装的依赖约束</td>
      </tr>
      <tr>
          <td>Git 元数据、项目运行记录</td>
          <td>NAS</td>
          <td>供提交、恢复和查询使用</td>
      </tr>
      <tr>
          <td>本轮源码与配置的副本</td>
          <td>本地工程目录</td>
          <td>提供一致的构建输入与相对路径</td>
      </tr>
      <tr>
          <td>npm cache、pnpm store</td>
          <td>本地盘或镜像内的预热目录</td>
          <td>复用包内容，缺失时可以重新获取</td>
      </tr>
      <tr>
          <td><code>node_modules</code>、编译缓存、构建中的 <code>dist</code></td>
          <td>本地工程目录</td>
          <td>承接安装与编译中的反复读写</td>
      </tr>
      <tr>
          <td>成功后的 <code>dist</code></td>
          <td>回写 NAS，再进入原发布流程</td>
          <td>交给上传与版本管理</td>
      </tr>
  </tbody>
</table>
<p>同步时排除 NAS 上的旧 <code>node_modules</code>、旧 <code>dist</code> 和无关运行目录，避免把大量可重建文件复制回来。源码中的删除也要同步，否则已经从 NAS 删除的旧文件，可能继续留在本地参与编译。</p>
<p>如果一个 Sandbox 承载多个项目，本地目录还要按项目隔离，防止它们修改同一份依赖。整个本地副本的生命周期跟随当前实例，实例回收后可以重建。</p>
<h3 id="52-一个-npm-项目怎样执行">5.2. 一个 npm 项目怎样执行</h3>
<p>下面用 npm 展示最基本的过程。假设项目已有有效的 <code>package-lock.json</code>，构建期间没有并发修改，输出目录是 <code>dist</code>。本地目录每次新建，暂不考虑缓存复用。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">workspace</span><span class="o">=</span>/workspace/game
</span></span><span class="line"><span class="cl"><span class="nv">local_root</span><span class="o">=</span><span class="k">$(</span>mktemp -d /tmp/game-build.XXXXXX<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">local_project</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$local_root</span><span class="s2">/project&#34;</span>
</span></span><span class="line"><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$local_project</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 同步构建输入，跳过可以重新生成的内容。</span>
</span></span><span class="line"><span class="cl">rsync -a <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --exclude<span class="o">=</span><span class="s1">&#39;/.git&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --exclude<span class="o">=</span><span class="s1">&#39;/node_modules&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --exclude<span class="o">=</span><span class="s1">&#39;/dist&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --exclude<span class="o">=</span><span class="s1">&#39;/.combos&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  <span class="s2">&#34;</span><span class="nv">$workspace</span><span class="s2">/&#34;</span> <span class="s2">&#34;</span><span class="nv">$local_project</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 安装和构建都从本地工程根开始。</span>
</span></span><span class="line"><span class="cl"><span class="o">(</span>
</span></span><span class="line"><span class="cl">  <span class="nb">set</span> -e
</span></span><span class="line"><span class="cl">  <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$local_project</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  npm ci --cache <span class="s2">&#34;</span><span class="nv">$local_root</span><span class="s2">/npm-cache&#34;</span>
</span></span><span class="line"><span class="cl">  npm run build
</span></span><span class="line"><span class="cl"><span class="o">)</span>
</span></span></code></pre></div><p>产物出现在 <code>$local_project/dist</code>。平台检查命令退出状态与产物后，再把它交回持久层。这个示例说明目录关系，没有包含并发锁、缓存复用和发布过程。</p>
<p>已有项目使用 pnpm 时，应保留原来的锁文件与版本。采用本地目录，并不需要改用 npm 重新解析依赖。我们实际的主路径使用 <code>sandbox-pnpm-install</code>，先尝试离线安装，缺包时按策略联网补齐。</p>
<p>monorepo、本地 <code>file:</code> 依赖，以及会访问相邻目录的安装脚本，还要求同步完整的依赖范围。复制工程时需要保持这些相对关系，不能只带走一份 <code>package.json</code>。</p>
<h3 id="53-接入原来的构建入口">5.3. 接入原来的构建入口</h3>
<p>实现时，我们把本地 I/O 接入了依赖安装和构建两个统一入口，分别由 <code>sandbox-pnpm-install</code> 和 <code>sandbox-build</code> 负责，已有的持久工作区与后续发布流程继续使用。</p>
<p>依赖准备先检查本地目录是否可用、空间是否足够，再同步输入和安装。成功后，将工作区的 <code>node_modules</code> 链接到本地依赖目录。构建入口核对依赖状态，刷新源码，然后从本地工程根执行原来的 <code>build.sh</code>。</p>
<figure><a class="lightgallery" href="/images/sandbox-build-storage/05-build-sequence.svg" title="构建入口依次取得锁、同步 NAS 源码、准备本地依赖、执行编译，将成功产物写入 NAS 暂存目录并交给发布流程" data-thumbnail="/images/sandbox-build-storage/05-build-sequence.svg" data-sub-html="<h2>图 5：统一入口管理构建输入、本地执行和产物交接</h2>">
        
    </a><figcaption class="image-caption">图 5：统一入口管理构建输入、本地执行和产物交接</figcaption>
    </figure>
<p>部分旧项目的 <code>build.sh</code> 会再次执行 <code>npm ci</code> 或 <code>pnpm install</code>。统一入口已经准备过依赖，如果脚本再来一遍，就会重复安装。因此，当时为这次构建进程加入命令包装，跳过约定中的重复安装命令；<code>run build</code> 等其他调用继续交给原来的包管理器。这项兼容处理限定在已有构建协议内。</p>
<p>编译成功后，先把本地 <code>dist</code> 复制到 NAS 的暂存目录。复制完成，再将它重命名为本次产物目录，后续上传、Git 提交和版本记录继续走原流程。</p>
<p>这里要分别处理编译失败和回写失败。编译失败，本轮没有可上传的结果；回写失败，即使本地编译已经完成，也还没有把产物交给发布流程。构建入口会清除旧输出，防止上传器在本轮失败后误用上一次的 <code>dist</code>。</p>
<p>暂存目录让消费者不会读到复制了一半的产物，但“编译、上传、版本发布”仍然是多个步骤，并没有组成一个文件系统事务。最终预览还要等后续发布完成。</p>
<h3 id="54-哪些修改需要写回">5.4. 哪些修改需要写回</h3>
<p>本地副本带来了一个新的边界：在本地改了文件，不代表用户的持久工程已经变化。</p>
<p>普通构建根据现有锁文件准备依赖。用户要求增加或删除依赖时，<code>package.json</code> 与锁文件的改动需要保存到 NAS，成为下次恢复的输入；只更新本地 <code>node_modules</code>，换一个实例就会丢失这次变化。</p>
<p>因此，修改依赖要作为单独的操作处理，检查并写回对应文件；写回前还应确认原文件没有被其他操作更新。构建脚本如果会生成需要长期保存的源码，也要约定这些文件的写回范围，不能将整个本地副本直接覆盖回 NAS。</p>
<p>到这里，安装和编译已经有了统一的本地执行路径，NAS 上保留的是持久输入与完成后的产物。但还有一个条件需要处理：本地目录只属于当前实例。用户下一次继续工作时，NAS 上的成功记录可能还在，本地文件却已经不在了。</p>
<h2 id="6-实例换了依赖怎样恢复">6. 实例换了，依赖怎样恢复</h2>
<h3 id="61-成功记录与真实目录">6.1. 成功记录与真实目录</h3>
<p>假设原实例已经完成依赖安装，NAS 保存了成功标记。原实例被回收后，新实例挂载同一份 NAS，可以看到旧记录，但没有原实例的本地依赖。</p>
<figure><a class="lightgallery" href="/images/sandbox-build-storage/06-runtime-recovery.svg" title="原实例回收后，NAS 仍保存源码和旧标记；新实例检查本地文件、重建依赖并更新链接和就绪标记" data-thumbnail="/images/sandbox-build-storage/06-runtime-recovery.svg" data-sub-html="<h2>图 6：旧实例的成功记录，不能代替新实例中的依赖目录</h2>">
        
    </a><figcaption class="image-caption">图 6：旧实例的成功记录，不能代替新实例中的依赖目录</figcaption>
    </figure>
<p>软链接也可能保存在 NAS 上。容器更换后，链接文件仍在，它指向的本地目录却不存在。只检查链接或“安装成功”状态，都会漏掉这个情况。</p>
<p>实际实现判断依赖是否就绪时，会一起核对记录是否属于当前实例、<code>package.json</code> 与锁文件的摘要、本地依赖目录、pnpm 布局标记，以及软链接是否指向当前目录。条件不满足，就重新建立本地副本、安装依赖、修复链接，再更新当前实例的记录。</p>
<p>Node.js 与包管理器版本也需要固定。如果以后把依赖缓存扩展到跨实例共享，还要考虑操作系统、CPU 架构、Node ABI 等兼容条件，尤其是包含原生扩展的包。当前实例内的一份成功记录，不能直接扩展成跨机器复用的依据。</p>
<h3 id="62-恢复过程还会并发执行">6.2. 恢复过程还会并发执行</h3>
<p>异步预热和用户触发的构建可能同时准备依赖。如果只给安装加锁，两个进程仍可能在前面的同步阶段相互干扰：一个正在准备本地布局，另一个同时删除或替换其中的文件。</p>
<p>为此，锁需要覆盖共享目录的准备过程，让源码同步、依赖安装和状态更新按照约定执行。</p>
<p>本地环境不可用时，还需要回到原来的 NAS 工作区。回退不能只切换工作目录：如果 <code>node_modules</code> 仍是指向已消失本地目录的链接，继续安装可能出现 <code>ENOTDIR</code>。因此，回退时还要处理失效链接，让依赖重新安装到可用的工作区目录中。</p>
<table>
  <thead>
      <tr>
          <th>情况</th>
          <th>处理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本地目录不可写、空间不足或布局不兼容</td>
          <td>记录原因，按策略回到工作区路径</td>
      </tr>
      <tr>
          <td>同一实例已因本地环境问题回退</td>
          <td>保留该实例的回退结果，避免反复尝试同一条失败路径</td>
      </tr>
      <tr>
          <td>TypeScript 报错、源码语法错误</td>
          <td>返回编译错误，由调用方修复源码</td>
      </tr>
      <tr>
          <td>实例已重建</td>
          <td>重新检查目录和依赖，允许新环境再次尝试本地模式</td>
      </tr>
  </tbody>
</table>
<p>长时间保留实例时，还要限制缓存占用。检查容量也需要关注可用 inode：剩余字节足够，并不保证还能创建大量文件。</p>
<h2 id="7-下一步重新测量完整构建">7. 下一步：重新测量完整构建</h2>
<p>回看已有数据，可以确认两件事：当时测过的容量型与性能型 NAS 没有给所有模板带来一致的明显改善；补充实验把依赖放到本地后，安装耗时变化远大于构建耗时变化。沿着这个方向，我们把本地执行接入了统一安装、构建与恢复流程。</p>
<p>不过，现有测量还缺少最终生产链路在同一条件下的完整前后对照。前面 166 秒降到 31 秒的数字，不能直接作为这套完整方案的上线收益。</p>
<p>本地副本减少了安装与编译中的网络文件操作，也增加了源码同步和产物回写。因此，下一轮计时需要覆盖：等待执行、同步输入、依赖准备、实际编译、产物回写，以及后续上传和验证。否则，单看本地 build 变快，仍然无法判断用户少等了多久。</p>
<p>测试场景也要分开：新实例首次构建、同一实例重复构建、只修改源码、修改依赖，以及实例丢失后的恢复。每组固定代码、锁文件、工具版本和计算规格，记录缓存条件，核对最终发布的产物。</p>
<p>这样才能继续回答下一个问题：依赖和编译移到本地以后，整条链路中最长的等待又落在了哪里。</p>]]></description></item></channel></rss>