<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>架构设计 - Tag - SaiyuDoc</title><link>https://www.wangsaiyu.com/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/</link><description>架构设计 - 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 19:40:00 +0800</lastBuildDate><atom:link href="https://www.wangsaiyu.com/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/" rel="self" type="application/rss+xml"/><item><title>Sandbox 环境持久化：持久卷、磁盘保留与快照恢复</title><link>https://www.wangsaiyu.com/posts/engineering/sandbox-environment-persistence/</link><pubDate>Tue, 13 Oct 2026 19:40:00 +0800</pubDate><author>王赛宇</author><guid>https://www.wangsaiyu.com/posts/engineering/sandbox-environment-persistence/</guid><description><![CDATA[<p>用户让 Agent 安装了一个工具，安装成功，任务也执行完了。过一段时间回来继续用，项目文件都在，再执行那个命令却变成了 <code>command not found</code>。</p>
<p>我们遇到的就是这类问题。用户可以在 Sandbox 里安装软件，而实例回收、重新创建以后，安装过的东西没有跟着回来。</p>
<p><a href="/posts/engineering/sandbox-build-storage/" rel="">上一篇文章</a>讨论了如何把源码留在 NAS，把依赖安装和构建放到本地盘。这次要继续看工作区之外的内容：软件到底装在哪里，怎样让这些安装结果跨实例保存，以及三种持久化方案各自需要解决什么问题。</p>
<h2 id="1-文件还在软件为什么没了">1. 文件还在，软件为什么没了</h2>
<h3 id="11-先分清重启做了什么">1.1. 先分清“重启”做了什么</h3>
<p>先用一个简化场景说明。假设基础镜像没有 Graphviz，用户在 Debian 系的 Sandbox 中执行 <code>apt-get install graphviz</code>，随后用它的 <code>dot</code> 命令把项目里的关系图导出为图片。</p>
<p>这时有两份新内容：工作区里生成的图片，以及系统里新增的 Graphviz。两者虽然都能从同一个终端访问，实际可能写在完全不同的存储上。</p>
<p>容器启动时，根据基础镜像准备文件系统。镜像提供初始的系统文件，运行中新增和修改的内容进入这个实例的可写层。与此同时，平台把 NAS 上的项目目录挂进容器，作为工作区。这里把工作区简写为 <code>/workspace</code>。</p>
<figure><a class="lightgallery" href="/images/sandbox-environment-persistence/01-recreated-filesystem.svg" title="旧 Sandbox 的软件安装写入临时可写层，项目文件写入 NAS；新 Sandbox 从原基础镜像创建并重新挂载 NAS，因此只找回了项目文件" data-thumbnail="/images/sandbox-environment-persistence/01-recreated-filesystem.svg" data-sub-html="<h2>图 1：从原镜像重建实例时，工作区与系统环境的恢复路径不同</h2>">
        
    </a><figcaption class="image-caption">图 1：从原镜像重建实例时，工作区与系统环境的恢复路径不同</figcaption>
    </figure>
<p>如果只重启 Agent 进程，文件系统没有换掉，已经安装的文件通常还在。如果停止容器后继续启动原容器，能否保留文件，要看平台是否保留它的可写层。</p>
<p>我们遇到的另一条路径是：<strong>旧实例被销毁，再从基础镜像创建新实例。</strong> 新实例重新挂载原来的 NAS，所以代码和图片还在；它的系统文件重新来自基础镜像，后来安装的 Graphviz 就没有了。Docker 文档也明确区分了容器可写层和独立 Volume：删除容器会删除可写层，Volume 则有独立的生命周期。<a href="https://docs.docker.com/engine/storage/volumes/" target="_blank" rel="noopener noreffer ">Docker Volume</a></p>
<p>因此，排查时需要看实际发生的是进程重启、原实例恢复，还是销毁后重建。业务层沿用同一个 Sandbox ID，也不能证明底下仍然是同一份文件系统。</p>
<h3 id="12-一次安装会修改哪些地方">1.2. 一次安装会修改哪些地方</h3>
<p><code>apt</code>、<code>apk</code> 是系统包管理器，分别常见于 Debian 系和 Alpine 系发行版。它们安装软件时，不会把所有内容都放进当前工程目录。</p>
<p>下面列的是系统安装可能涉及的位置，不是 Graphviz 某个版本的完整文件清单。</p>
<table>
  <thead>
      <tr>
          <th>内容</th>
          <th>常见位置</th>
          <th>恢复时为什么需要它</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>可执行文件、共享库</td>
          <td><code>/usr/bin</code>、<code>/usr/lib</code> 等</td>
          <td>命令本身和运行时依赖</td>
      </tr>
      <tr>
          <td>系统配置</td>
          <td><code>/etc</code></td>
          <td>软件运行所需的配置</td>
      </tr>
      <tr>
          <td>包管理器记录</td>
          <td>Debian 的 <code>/var/lib/dpkg</code> 等</td>
          <td>记录已装的包、版本与安装状态</td>
      </tr>
      <tr>
          <td>安装脚本产生的修改</td>
          <td>用户、组、链接，以及其他目录</td>
          <td>让软件处于可用状态</td>
      </tr>
  </tbody>
</table>
<p>例如，只复制一个可执行文件，可能因为缺共享库而无法运行；只保存包管理器的数据库，则可能出现“记录显示已经安装，文件却不存在”的情况。Debian 的安装流程还会执行包中的维护脚本，处理配置等工作，安装结果不只是一组解压出来的文件。<a href="https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html" target="_blank" rel="noopener noreffer ">Debian 包安装过程</a></p>
<p>这里需要保存的是一套相互匹配的文件与元数据。后面提到的<strong>根文件系统（RootFS）</strong>，指的就是 Sandbox 从 <code>/</code> 开始看到的系统文件体系。独立挂载进去的 NAS、临时内存文件系统等，需要按各自的存储规则处理，不能默认算进一份 RootFS 备份里。</p>
<p>我们当时讨论的三条路，也就对应三个不同的保存范围：保留指定目录、保留原来的运行磁盘、把某一时刻的系统环境保存成快照。</p>
<h2 id="2-方案一把需要的目录挂到持久卷">2. 方案一：把需要的目录挂到持久卷</h2>
<h3 id="21-新实例重新挂载原目录">2.1. 新实例重新挂载原目录</h3>
<p>这是最容易从现有工作区方案继续扩展的一条路。</p>
<p>平台为项目准备一份独立存储，在每个新实例中挂到固定路径。写入这个目录的内容留在存储里，实例删除后，下一个实例再挂载同一份数据。NAS 可以承载这种目录；自建容器平台也可以通过 Volume 或 Kubernetes PVC 声明需要的存储。</p>
<p>但 Volume、PVC 说明的是挂载与管理方式，具体数据存在哪里，仍要看后端。Docker 的本地 Volume 默认在宿主机上；需要换节点恢复时，还必须有相应的共享存储、磁盘迁移或数据恢复能力。<a href="https://docs.docker.com/engine/storage/volumes/" target="_blank" rel="noopener noreffer ">Docker Volume 的存储与共享</a></p>
<figure><a class="lightgallery" href="/images/sandbox-environment-persistence/02-persistent-directories.svg" title="新旧实例都把 workspace 和用户工具目录挂到独立持久存储；系统包写入的 usr、etc 和包数据库仍在挂载范围之外" data-thumbnail="/images/sandbox-environment-persistence/02-persistent-directories.svg" data-sub-html="<h2>图 2：目录持久化覆盖哪些内容，由挂载位置和安装位置共同决定</h2>">
        
    </a><figcaption class="image-caption">图 2：目录持久化覆盖哪些内容，由挂载位置和安装位置共同决定</figcaption>
    </figure>
<p>平台至少需要记录项目对应的卷标识或远端目录、挂载路径、访问身份。创建新实例时，先完成挂载，再启动依赖这些目录的工具。挂错项目目录、改变安装路径，或者恢复后使用不同的用户权限，都可能让文件还在，却无法正常使用。</p>
<h3 id="22-适合能控制安装位置的工具">2.2. 适合能控制安装位置的工具</h3>
<p>例如，一个工具允许把自身及依赖安装到 <code>/home/user/tools</code>，我们可以把这个目录持久保存，启动时再配置好 <code>PATH</code>。对于能明确管理目录的用户级工具，这样做简单，也不需要备份整个系统。</p>
<p>语言依赖需要再看一层。保存 Python 虚拟环境的目录，并不保证换一个基础镜像后仍能运行：它可能依赖原来的解释器路径、系统库或本地编译扩展。恢复时应保持兼容的基础环境，或者根据依赖声明重新安装。</p>
<p>上一篇文章中的 <code>node_modules</code> 就采用了后者：保存源码、包声明和锁文件，在新的本地盘上重新准备依赖。这里主动保留的是重建输入，本地依赖目录可以丢弃。这有助于兼顾持久性和小文件访问性能。</p>
<h3 id="23-系统包为什么不能照搬">2.3. 系统包为什么不能照搬</h3>
<p>对于任意 <code>apt install</code> 或 <code>apk add</code>，平台无法事先假定所有修改都落在一个用户目录。多挂一个 <code>/home</code>，仍然覆盖不到 <code>/usr</code>、<code>/etc</code> 和系统包数据库。</p>
<p>把这些系统目录逐个挂出去，还会引出新的问题。挂载到已有目录上的外部文件系统，会遮住那个位置原有的内容；它不会自动把旧系统与新镜像正确合并。基础镜像升级后，外部目录里的旧库、旧配置和新系统之间，也需要处理兼容关系。<a href="https://docs.docker.com/engine/storage/volumes/#mounting-a-volume-over-existing-data" target="_blank" rel="noopener noreffer ">Docker 挂载覆盖行为</a></p>
<p>我们的 FC 环境还有平台限制：NAS 挂载文档要求使用适合的数据目录，不能把通用系统目录及其子目录作为挂载点，例如 <code>/bin</code>、<code>/opt</code>、<code>/var</code>、<code>/dev</code>。<a href="https://help.aliyun.com/zh/functioncompute/configure-a-nas-file-system-for-fc" target="_blank" rel="noopener noreffer ">FC 配置 NAS</a></p>
<p>所以，这个方案适合<strong>保存边界明确的目录</strong>。要支持用户自由安装系统软件，就需要把保存范围扩大到整套系统环境。</p>
<h2 id="3-方案二停止计算保留运行磁盘">3. 方案二：停止计算，保留运行磁盘</h2>
<h3 id="31-把磁盘的生命周期单独管理">3.1. 把磁盘的生命周期单独管理</h3>
<p>第二种方案直接保留已经安装好的那份文件系统。</p>
<p>对于虚拟机，可以是保留系统盘；对于容器，可以由运行平台保留根文件系统的可写部分，并继续使用匹配的基础镜像。用户安装软件后，文件和包管理器记录都在这份环境里。停止计算时保留它，恢复时继续使用它。</p>
<figure><a class="lightgallery" href="/images/sandbox-environment-persistence/03-retained-root-disk.svg" title="计算实例停止后保留项目的根磁盘或可写层，恢复时继续使用；磁盘删除由单独的生命周期操作控制" data-thumbnail="/images/sandbox-environment-persistence/03-retained-root-disk.svg" data-sub-html="<h2>图 3：计算可以停止，环境磁盘仍由项目持有</h2>">
        
    </a><figcaption class="image-caption">图 3：计算可以停止，环境磁盘仍由项目持有</figcaption>
    </figure>
<p>这样，前面的 Graphviz 无须重新安装。恢复时取得原来的磁盘，软件文件、配置和包记录就一起回来了。进程则通常要重新启动，磁盘持久化本身不包含进程内存。</p>
<p>Daytona 的普通 Container Sandbox 提供了这类使用语义：stop/start 保留文件与安装包，停止时清除内存；删除 Sandbox 则是另一种操作。具体选型仍需核对 Sandbox 类型和自动删除配置，不能只看一个“支持持久化”的介绍。<a href="https://www.daytona.io/docs/en/persistence/" target="_blank" rel="noopener noreffer ">Daytona 持久化说明</a></p>
<h3 id="32-平台需要记录什么">3.2. 平台需要记录什么</h3>
<p>业务层不能只保存“项目正在使用哪个实例”。还需要保存项目的环境磁盘标识、匹配的基础镜像版本，以及当前由哪个实例使用。</p>
<p>正常停止时，平台应让安装和写入结束，再按存储系统的要求完成落盘，最后停止计算、保留磁盘。恢复时找到这份磁盘，确认旧实例已经停止写入，再交给新的运行实例。删除项目时，才根据保留策略删除它。</p>
<p>这里有一个容易忽略的情况：旧实例网络断开，平台认为它失联，于是又启动了一个新实例。旧实例其实还活着，两个实例就可能同时修改包数据库。管理服务中的一把锁还不够，平台需要通过磁盘挂载约束或存储侧的写入控制，阻止已经失去所有权的旧实例继续写。</p>
<h3 id="33-磁盘保留在哪决定了能恢复到哪里">3.3. 磁盘保留在哪，决定了能恢复到哪里</h3>
<p>如果只是让容器停在原机器上，并保留那台机器的本地可写层，原地启动确实方便。但宿主机永久故障以后，能否恢复就取决于有没有其他持久副本。</p>
<p>要允许换节点恢复，需要磁盘本身能脱离原计算节点，例如使用可重新挂载的持久块存储，并满足可用区、文件系统和运行时的约束。异常断电后还可能面对文件系统恢复、包安装中断等状态，保存磁盘不会自动把安装过程变成一个事务。</p>
<p>这条路的主要成本是长期保留每个项目的环境空间，以及磁盘挂载、迁移和故障处理。计算停止后，存储仍然占用资源。它适合经常回来继续工作的开发环境；若有大量长期不活跃的项目，还需要归档和回收机制。</p>
<h2 id="4-方案三把环境保存成文件系统快照">4. 方案三：把环境保存成文件系统快照</h2>
<h3 id="41-新实例从保存的环境启动">4.1. 新实例从保存的环境启动</h3>
<p>第三种方案允许原实例彻底退出。退出之前，先把某个时刻的根文件系统保存为一个可恢复版本；下次根据这个版本创建新实例。</p>
<p>快照可以是完整磁盘副本，也可以只记录相对基础镜像的变化。后者能够复用公共镜像，项目只保存自己新增、修改和删除的内容。这里讨论的是文件系统快照；要继续执行原来的进程，还涉及另外的内存与设备状态恢复。</p>
<figure><a class="lightgallery" href="/images/sandbox-environment-persistence/04-snapshot-restore.svg" title="旧实例安装软件后生成环境快照，基础镜像和快照共同准备新实例的系统文件；NAS 工作区通过独立挂载恢复" data-thumbnail="/images/sandbox-environment-persistence/04-snapshot-restore.svg" data-sub-html="<h2>图 4：快照保存一个确定的环境版本，新实例从这个版本恢复</h2>">
        
    </a><figcaption class="image-caption">图 4：快照保存一个确定的环境版本，新实例从这个版本恢复</figcaption>
    </figure>
<p>例如，Modal 的文件系统快照记录 RootFS 相对基础镜像的差异，生成的 Image 可以创建新 Sandbox。它的文档也明确说明，挂载的 Volume 不包含在这份快照里；快照还需要配置保留期限。<a href="https://modal.com/docs/guide/sandbox-snapshots" target="_blank" rel="noopener noreffer ">Modal 文件系统快照</a></p>
<p>恢复 Graphviz 时，平台不再重新执行安装命令，而是从快照取得已经安装好的文件与状态。快照生成以后，即使原实例被删除，只要快照及其依赖仍在，便可以据此准备另一个实例。</p>
<p>保存差异时，删除操作也必须被记录。假设用户删除了基础镜像中的一个配置文件，只备份“当前新增的文件”，恢复后那个旧配置又会出现。以 OverlayFS 为例，它会用 whiteout 等元数据表达对下层文件的删除；这类信息不能在复制过程中丢失。<a href="https://docs.kernel.org/filesystems/overlayfs.html" target="_blank" rel="noopener noreffer ">Linux OverlayFS</a></p>
<h3 id="42-在什么时刻保存">4.2. 在什么时刻保存</h3>
<p>最直接的做法是正常关闭前保存一次。但节点故障、强制回收等情况，不保证有机会执行关闭脚本。要减少丢失，就要在环境修改后建立保存点。</p>
<p>用户安装 Graphviz 后，当前实例里已经有新软件，但持久存储中还是上一次保存的环境。只有新快照生成、上传并确认可恢复之后，才能把它设为项目下次启动时使用的环境。</p>
<figure><a class="lightgallery" href="/images/sandbox-environment-persistence/05-environment-commit.svg" title="环境修改完成后创建快照，保存并检查成功才更新项目的当前环境；中途失败继续使用上次保存的环境" data-thumbnail="/images/sandbox-environment-persistence/05-environment-commit.svg" data-sub-html="<h2>图 5：安装完成与环境保存完成，是两个不同的时刻；流程为方案设计</h2>">
        
    </a><figcaption class="image-caption">图 5：安装完成与环境保存完成，是两个不同的时刻；流程为方案设计</figcaption>
    </figure>
<p>一种可采用的保存流程是：</p>
<ol>
<li>暂停同一环境上的其他修改，确认安装进程及其后续配置已经完成。</li>
<li>按快照实现的要求，完成写入刷新，并取得一致的文件系统视图。</li>
<li>生成新快照，保存其数据与元信息，检查它已经能够被恢复。</li>
<li>把新版本标记为可用，再原子地切换项目的当前版本。</li>
</ol>
<p>单独调用一次 <code>sync</code> 不会阻止其他进程继续写，也不保证跨多个存储的一致性。如果软件还在修改文件，需要先让相关写入停下来，或使用平台提供的协调机制。快照内部完成的原子性范围，也应由具体实现说明。</p>
<p>如果第三步失败，下次启动仍使用上一次保存成功的环境。这样不会把半份快照交给下一次启动，但尚未保存的新安装也不能保证回来。因此，产品需要区分“本次安装可用了”和“环境已保存，可以重建恢复”。如果承诺每次安装成功后都能恢复，就必须把保存完成纳入这次操作的成功条件；允许任意终端命令时，则还需要明确的环境保存点或受控修改入口。</p>
<h3 id="43-快照记录不能只有一个文件地址">4.3. 快照记录不能只有一个文件地址</h3>
<p>管理服务需要在实例之外保存项目与环境版本的关系。可以在数据库中记录当前使用哪一版环境，每个版本关联以下信息。</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>快照位置</td>
          <td>找到已保存的文件系统数据及依赖</td>
      </tr>
      <tr>
          <td>保存状态</td>
          <td>区分正在保存、可恢复和保存失败</td>
      </tr>
      <tr>
          <td>恢复条件</td>
          <td>记录架构、运行时等兼容要求</td>
      </tr>
      <tr>
          <td>工作区关联</td>
          <td>找到独立保存的项目工作区</td>
      </tr>
  </tbody>
</table>
<p>并发保存时，还需要检查当前版本和写入所有权。保存任务开始时记下项目使用的环境版本，更新时确认它没有被其他任务替换，且自己仍持有有效写入权，再切换到新快照。检查与切换需要原子完成，避免超时后继续执行的旧任务覆盖已经发布的新环境。</p>
<p>NAS 工作区不随 RootFS 快照一起回退。如果还希望把代码与环境一起回到过去，就要额外保存代码提交或工作区版本，并协调两者的对应关系。只记录工作区标识，找到的仍然是同一个目录，并不代表这个目录在历史时刻的内容。</p>
<h2 id="5-三种方案放在一起看">5. 三种方案放在一起看</h2>
<p>三种方案都能保存文件，区别在于保存范围和恢复方式。</p>
<div style="overflow-x: auto;">
<div style="min-width: 42rem;">
<table>
  <thead>
      <tr>
          <th>比较项</th>
          <th>指定目录持久化</th>
          <th>保留运行磁盘</th>
          <th>文件系统快照</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>保存范围</td>
          <td>选定的挂载目录</td>
          <td>原来的系统磁盘或完整可写部分</td>
          <td>某个保存点的根文件系统</td>
      </tr>
      <tr>
          <td>任意系统包安装</td>
          <td>仅目录方案无法完整覆盖</td>
          <td>在保留范围内可以覆盖</td>
          <td>在快照范围内可以覆盖</td>
      </tr>
      <tr>
          <td>恢复方式</td>
          <td>新实例挂载原目录</td>
          <td>继续使用原环境磁盘</td>
          <td>从已保存版本创建实例</td>
      </tr>
      <tr>
          <td>原节点永久故障</td>
          <td>取决于卷的存储后端</td>
          <td>取决于磁盘是否独立持久</td>
          <td>取决于快照与依赖是否独立持久</td>
      </tr>
      <tr>
          <td>回到历史环境</td>
          <td>需要另做目录版本</td>
          <td>需要另做磁盘快照</td>
          <td>可选择仍保留的快照版本</td>
      </tr>
      <tr>
          <td>主要额外工作</td>
          <td>目录边界、路径与权限</td>
          <td>磁盘所有权、迁移与回收</td>
          <td>保存时机、版本提交与垃圾回收</td>
      </tr>
  </tbody>
</table>
</div>
</div>
<p><em>手机上可左右滑动查看完整对比。</em></p>
<p>表里的“可以覆盖”有前提：保存的文件范围完整，元数据正确，并使用兼容的恢复环境。外部服务状态、进程内存、单独挂载的数据卷，不会因为保留 RootFS 就全部跟着保存。</p>
<p>性能也需要按用户真正等待的路径来测。目录方案要看挂载后访问文件的成本；保留磁盘要看重新挂载与启动的时间；快照方案则要看生成、传输、恢复，以及恢复后第一次真正使用软件的时间。延迟加载可以把一部分恢复工作推迟到首次读文件，单看“实例启动完成”容易漏掉这部分等待。</p>
<p>这次回顾没有三种方案在同一负载下的耗时和费用实测，因此不能给出谁快多少的排名。更适合先按需求缩小范围：固定工具链与可控目录优先考虑持久卷；需要频繁继续使用同一套环境，可以评估保留磁盘；需要释放原实例、回滚或从同一环境分出多个副本，快照提供的版本能力更直接。</p>
<p>三者也可以组合。例如，活跃项目保留磁盘，长期不活跃时归档为快照；源码继续放 NAS，构建缓存留在可丢弃的本地盘。</p>
<h2 id="6-放回我们的-fc-架构怎样接入">6. 放回我们的 FC 架构，怎样接入</h2>
<h3 id="61-现有实现保存到了哪一层">6.1. 现有实现保存到了哪一层</h3>
<p>现有实现把 NAS 挂到项目工作区；恢复时，根据保存的记录选择镜像和运行配置，再创建实例。部分配置变化还会触发旧实例删除与重新创建。</p>
<p>这条创建、恢复链路没有携带每项目的 RootFS 快照或持久系统盘标识。基础镜像预装的工具会随镜像重新出现，用户在旧实例中临时安装的软件则没有相应的恢复步骤。FC 文档也说明，自定义容器的可写层随容器销毁而删除。<a href="https://www.alibabacloud.com/help/en/functioncompute/custom-container/" target="_blank" rel="noopener noreffer ">FC 自定义容器</a></p>
<p>目前能从代码中确认的，是公共工具预装、NAS 工作区，以及本地项目依赖的重新准备。项目级环境磁盘、RootFS 快照和下面的派生镜像流程，仍属于这里讨论的设计，不能据此声称已经上线。</p>
<h3 id="62-继续使用-fc可以先约束可保存的安装">6.2. 继续使用 FC，可以先约束可保存的安装</h3>
<p>如果暂时继续使用当前的 FC 运行方式，又无法取得和恢复容器的完整可写层，可以把可持久化的安装收敛为声明式配置：记录项目需要哪些系统包、版本和来源，再构建一份项目专用镜像。</p>
<p>例如，用户通过受控入口增加 Graphviz，平台记录基础镜像 digest、发行版与软件源、包名和版本。构建任务在隔离环境中生成派生镜像，验证后推送到镜像仓库，再把项目的环境镜像指针切换过去。下次重建时，创建逻辑使用这个 digest。</p>
<p>软件源也要纳入设计。只记一个包版本，如果上游已经不再提供对应包，仍可能构建失败。长期重建需要可保留的包来源、仓库快照或构建产物。已经构建成功的镜像可以复用，不必在每次启动时重新下载和安装。</p>
<p>这种方式保存的是<strong>明确声明并成功构建的环境</strong>。用户随手修改 <code>/etc</code>，或者运行一个会改动多个位置的安装脚本，不能仅靠记录命令就完整恢复。允许的包管理入口、额外配置文件和安装脚本范围，需要作为产品能力明确下来。</p>
<p>它是现有平台约束下的一条接入路径，与前面的 RootFS 快照有不同的保存语义。构建失败时也不能先更新项目镜像；应该保留上一版可用环境，并说明这次修改尚未持久保存。</p>
<h3 id="63-完整环境持久化需要运行平台配合">6.3. 完整环境持久化需要运行平台配合</h3>
<p>如果产品要让用户自由安装系统软件，并在实例重建后继续使用，就应选择能保存完整环境的运行平台，或者自行提供磁盘与快照能力。这已经涉及 Sandbox 创建和回收的接口，工作区里的脚本无法单独完成。</p>
<figure><a class="lightgallery" href="/images/sandbox-environment-persistence/06-layered-environment.svg" title="目标布局由公共基础镜像、每项目环境版本、NAS 工作区和本地缓存组成；管理服务分别保存这些资源的标识并在创建实例时恢复" data-thumbnail="/images/sandbox-environment-persistence/06-layered-environment.svg" data-sub-html="<h2>图 6：环境与工作区分别持久保存，本地缓存按需重建；这是目标布局</h2>">
        
    </a><figcaption class="image-caption">图 6：环境与工作区分别持久保存，本地缓存按需重建；这是目标布局</figcaption>
    </figure>
<p>创建接口需要接受环境版本，保存接口需要返回可恢复的磁盘或快照标识，删除接口则要区分“释放这次计算”和“删除项目环境”。恢复完成后，确认软件真正可用，再让 Agent 继续执行任务。</p>
<p>对应的回收也要分开：本地缓存可以随实例删除；项目当前环境不能被普通实例清理顺手删掉；旧快照只有在不再被项目、恢复任务和派生版本引用时，才能回收。还要设置容量配额，并在写入时返回明确错误，避免用户安装了很大的软件以后，环境迟迟无法保存。</p>
<p>环境快照也不宜包含运行时注入的临时凭证。凭证应放在独立的临时挂载或注入机制中，恢复时重新取得，而不是把一个可能过期的访问令牌随环境版本长期保存。</p>
<h2 id="7-怎样确认软件确实保住了">7. 怎样确认软件确实保住了</h2>
<p>验收不能只看“Sandbox 又启动了”。对于开头的例子，应在新实例中执行 <code>dot</code>，实际生成一张图，同时检查包管理器记录、安装配置和工作区文件。</p>
<p>下面是选型时应执行的验证场景，并非本次已经完成的测试结果。</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>原计算节点不可用</td>
          <td>独立持久数据仍可取得；无法恢复时明确报错</td>
      </tr>
      <tr>
          <td>安装中断或保存失败</td>
          <td>不发布半成品环境，恢复点与用户看到的状态一致</td>
      </tr>
      <tr>
          <td>两次修改或恢复并发</td>
          <td>旧实例、旧保存任务不能覆盖新的环境</td>
      </tr>
      <tr>
          <td>删除软件后再恢复</td>
          <td>被删除的文件不会从基础镜像中重新出现</td>
      </tr>
      <tr>
          <td>工作区独立变化</td>
          <td>明确代码与环境分别恢复到哪一版</td>
      </tr>
  </tbody>
</table>
<p>性能测试则应固定基础镜像、软件集合和修改量，分别记录保存耗时、保存大小、冷恢复时间，以及第一个真实命令的完成时间。只有这些数据齐全，才能把方案能力、用户等待和存储费用放在同一张表里比较。</p>
<h2 id="8-环境留下来了基础镜像怎样升级">8. 环境留下来了，基础镜像怎样升级</h2>
<p>持久化解决了安装结果丢失，也带来了下一个问题：公共基础镜像升级以后，用户保存的旧环境要怎样跟上。</p>
<p>一个项目可能在原基础镜像上安装了很多包。直接换成新版基础镜像，再覆盖旧环境的差异，可能混入两版系统库，甚至把已经修复的旧文件重新盖回去。因此，环境版本需要绑定基础镜像，升级应当是一次可验证的迁移。</p>
<p>声明式环境可以尝试在新镜像上重新构建；保存了任意文件修改的环境，则需要更谨慎地迁移和验证。可以先创建一份新环境，运行项目的真实任务，成功后再切换，并保留旧版本作为回退点。</p>
<p>到了这一步，平台管理的已经不只是一个会启动和销毁的 Sandbox，还包括用户逐渐积累起来的开发环境。它需要明确的保存点、版本和升级过程。这也是持久化方案选定以后，下一步要继续解决的事情。</p>
<p><em>文中实现状态截至 2026 年 10 月 13 日；项目级环境持久化流程为方案设计。</em></p>]]></description></item><item><title>Agent 部署方式：中心服务与 Sandbox</title><link>https://www.wangsaiyu.com/posts/ai/agent-deployment-architecture/</link><pubDate>Tue, 13 Oct 2026 17:00:00 +0800</pubDate><author>王赛宇</author><guid>https://www.wangsaiyu.com/posts/ai/agent-deployment-architecture/</guid><description><![CDATA[<p>Agent 接收到任务后，会多次调用模型和工具，直到任务结束。组织这个过程的程序称为运行时，也就是 Runtime。</p>
<p>本文讨论两种部署方式：由中心服务运行 Agent，通过远程接口操作工程；或者把 Codex 放进 Sandbox，由沙箱内的引擎直接操作工程。下面分别介绍它们的组成、数据存储、执行过程，以及中断后的恢复方法。</p>
<h2 id="1-总体结构">1. 总体结构</h2>
<p>先说明本文中几个组件的含义：</p>
<ul>
<li><code>Runtime</code>：组织模型调用、工具执行和上下文更新的程序；</li>
<li><code>Sandbox</code>：隔离的执行环境，包含工程目录，以及读写文件、运行命令所需的程序；</li>
<li><code>模型 API</code>：接收上下文，返回文本或工具调用请求；</li>
<li><code>平台服务</code>：接收用户需求，管理项目、任务、权限、预算和交付结果。</li>
</ul>
<p>这几个组件可以有不同的部署关系，如下图所示。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/01-runtime-location.svg" title="中心 Runtime 与 Sandbox 内 Codex 的部署关系" data-thumbnail="/images/agent-deployment/01-runtime-location.svg" data-sub-html="<h2>图 1：两种部署方式</h2>">
        
    </a><figcaption class="image-caption">图 1：两种部署方式</figcaption>
    </figure>
<p>中心服务方案中，Runtime 调用模型，解析模型提出的工具请求，再通过远程接口让 Sandbox 执行。模型拿到工具结果后，继续决定下一步操作。Runtime 可以部署多个实例，分别执行不同任务。</p>
<p>沙箱内 Codex 方案中，平台把任务交给 Codex，由它完成这段循环。文件和命令就在 Codex 所在的环境中，可以直接访问。这里放进沙箱的是执行程序，模型推理仍然可以使用远端 API。</p>
<p>两种方案都需要平台，也都可以扩容。它们主要区别在于：模型调用与工具执行之间的循环，由中心进程维护，还是由沙箱内进程维护。</p>
<p>运行时是否自研，是另一个选择。中心服务可以接入现成引擎，沙箱里也可以运行自研 Agent。本文用我们现有的中心 Runtime 说明第一种方式，用沙箱内 Codex 说明第二种方式；后者按参考架构讨论。</p>
<h2 id="2-中心服务">2. 中心服务</h2>
<h3 id="21-接收任务与分配执行">2.1. 接收任务与分配执行</h3>
<p>假设用户提交一个需求：“给游戏增加暂停菜单”。接入服务需要先确定用户、项目和工作区，检查权限与预算，再创建运行记录。</p>
<p>我们将一次用户需求对应的运行称为 <code>Run</code>。Runtime 的工作实例称为 <code>Worker</code>。接入服务把 <code>run_id</code> 交给任务队列，空闲 Worker 领取任务后，从数据库读取运行信息，开始执行。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/02-central-runtime.svg" title="任务从接入服务经队列交给 Runtime，并访问数据库和工具" data-thumbnail="/images/agent-deployment/02-central-runtime.svg" data-sub-html="<h2>图 2：中心服务的组成与调用关系</h2>">
        
    </a><figcaption class="image-caption">图 2：中心服务的组成与调用关系</figcaption>
    </figure>
<p>数据库写入和队列派发之间，存在一个需要处理的间隙：任务已经保存，但进程在发送队列消息前退出，任务就可能一直无人执行。</p>
<p>一种实现方法是在同一事务中保存 <code>Run</code> 和待派发记录，再由后台程序将待派发记录送进队列，这就是 <code>Outbox</code>。发送失败时重新发送；Worker 收到重复通知时，通过数据库条件更新领取任务，避免多个实例同时取得执行权。</p>
<p>用户重复点击提交，也需要单独处理。接入请求可以携带稳定的请求编号，相同请求再次到达时返回原 <code>Run</code>。这个编号表示用户的一次提交，与 Worker 后续重试的编号不同。</p>
<h3 id="22-运行记录">2.2. 运行记录</h3>
<p>中心服务首先要区分三种对象：</p>
<ul>
<li><code>Thread</code>：会话，关联多轮对话及其工程；</li>
<li><code>Run</code>：一次运行，对应本轮用户需求；</li>
<li><code>Attempt</code>：一次执行尝试，记录这次由哪个 Worker 执行，以及如何结束。</li>
</ul>
<p>例如，同一个会话里“增加暂停菜单”和“修改菜单颜色”是两个 <code>Run</code>。第一个 Run 执行到一半，Worker 重启后由另一实例接管，则会产生新的 <code>Attempt</code>，但仍属于原 Run。</p>
<p>我们的中心运行时使用 <code>threads</code>、<code>agent_runs</code>、<code>agent_run_attempts</code> 和 <code>messages</code> 保存相应记录。运行配置和恢复所需变量也随 Run 保存，恢复时使用原任务的输入。</p>
<p>围绕一次运行，持久存储至少需要覆盖以下内容。这里按用途列出逻辑记录，实际实现可以合并或拆分表。</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>执行尝试</td>
          <td>领取或接管任务时登记执行者，执行中更新心跳，结束时记录退出原因。</td>
      </tr>
      <tr>
          <td>消息</td>
          <td>按顺序追加完整的用户输入、模型输出、工具调用及工具结果。</td>
      </tr>
      <tr>
          <td>工具操作</td>
          <td>调用前保存参数和操作编号，返回后保存状态、远端任务或命令会话编号。</td>
      </tr>
      <tr>
          <td>检查点</td>
          <td>上下文压缩完成后，保存压缩结果及覆盖到哪条消息。</td>
      </tr>
      <tr>
          <td>执行事件</td>
          <td>保存需要补发给客户端的进度、事件顺序和所属执行尝试。</td>
      </tr>
      <tr>
          <td>交付记录</td>
          <td>保存代码或文件检查点、产物地址、运行版本和验证结果。</td>
      </tr>
  </tbody>
</table>
<p>这些记录可以使用关系数据库保存。编号、状态、归属和时间使用固定字段；模型消息、工具参数等可变内容使用 JSON 字段。按会话、Run 和消息顺序建立索引，便于查询和恢复。</p>
<p>工程文件放在独立的工作区，通过持久数据盘或文件快照保存。工作区包含未提交到 Git 的修改，所以仅保存 Git 提交号并不够。图片、截图、完整日志和构建产物一般进入对象存储，数据库保存引用和校验信息。</p>
<p>Worker 内存中仍然会缓存当前上下文和执行状态，但进程重启后，继续任务所需的数据应当能从这些存储中取回。</p>
<h3 id="23-消息与上下文">2.3. 消息与上下文</h3>
<p>消息历史和模型上下文有不同的用途。</p>
<p>消息历史记录已经发生的对话和操作，用于展示、排查与恢复；上下文是下一次实际发给模型的输入，除了消息，还包括本轮指令、工程规则和工具定义。</p>
<p>工具定义描述名称、用途和参数格式。例如命令工具需要命令与工作目录，文件工具需要路径。模型根据这些定义提出调用，Runtime 再交给对应的执行代码。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/03-context-storage.svg" title="消息字段、历史顺序、上下文检查点及模型输入" data-thumbnail="/images/agent-deployment/03-context-storage.svg" data-sub-html="<h2>图 3：消息与上下文的逻辑结构，字段为示意</h2>">
        
    </a><figcaption class="image-caption">图 3：消息与上下文的逻辑结构，字段为示意</figcaption>
    </figure>
<p>图中的消息包含以下信息：</p>
<ul>
<li><code>message_id</code>：消息编号；</li>
<li><code>run_id</code>：所属运行；</li>
<li><code>role</code>：用户、模型或工具等消息角色；</li>
<li><code>tool_call_id</code>：工具调用与结果的关联编号，普通消息可以没有；</li>
<li><code>content</code>：文本或结构化内容。</li>
</ul>
<p>上下文长度有限，不能无限追加全部历史。任务较长时，可以把较早的历史整理成摘要，保留继续执行所需的原文，再追加新的消息。压缩完成后保存的记录，称为上下文检查点。</p>
<p>图中较早的消息已整理进检查点，后续新消息仍单独保存。恢复时先加载检查点，再按顺序追加它尚未覆盖的消息。因而检查点既要保存整理后的历史，也要保存覆盖位置；只留下摘要，无法确定哪些消息已经包含在内。</p>
<p>工具调用和结果还需要符合所用模型的消息协议。压缩时保留必要的关联，尚未确认完成的操作也要保留真实状态，避免后续把“已提交”理解成“已完成”。</p>
<p>我们的实现中，<code>prompt_checkpoints</code> 保存压缩后的历史及覆盖边界，<code>runtime_context_snapshots</code> 按会话和 Agent 保存最新的模型可见上下文。前者用于接续，后者便于检查当前输入。</p>
<p>两者分开还有存储上的原因。假设每一步都向历史表追加一整份上下文，前面的内容就会反复写入。原始消息顺序追加、压缩时保存检查点、最新快照覆盖更新，可以减少这种重复。</p>
<h3 id="24-工具调用过程">2.4. 工具调用过程</h3>
<p>Runtime 收到模型输出后，需要区分普通文本和工具调用。以运行测试为例，一次工具调用的过程如下：</p>
<ol>
<li>接收完整的工具名称和参数；</li>
<li>检查工具是否可用、参数是否合法，以及任务是否有操作权限；</li>
<li>保存本次工具操作，分配可查询的调用编号；</li>
<li>调用 Sandbox 的执行接口；</li>
<li>保存执行结果，与原工具调用关联；</li>
<li>将结果加入上下文，发起下一次模型请求。</li>
</ol>
<p>工具参数可能通过流式响应逐段到达。执行层需要等完整参数形成后再调用，不能把半段命令提前交给沙箱。</p>
<p>工具也有两种常见去向。读文件、改代码、运行命令进入 Sandbox；素材生成、扣费、版本发布进入各自的业务服务。后者的操作状态由业务服务保存，Runtime 留下查询它们所需的编号。</p>
<h3 id="25-命令执行会话">2.5. 命令执行会话</h3>
<p>读取文件通常很快完成，命令则可能持续很久。测试运行几十秒，开发服务器还可能一直不退出。工具接口等待一段时间后先返回时，需要同时说明命令是否仍在执行。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/04-command-session.svg" title="命令执行会话的启动、继续读取及结束" data-thumbnail="/images/agent-deployment/04-command-session.svg" data-sub-html="<h2>图 4：通过同一执行会话访问长时间运行的命令</h2>">
        
    </a><figcaption class="image-caption">图 4：通过同一执行会话访问长时间运行的命令</figcaption>
    </figure>
<p>命令启动后，执行服务返回会话标识，通过它找到对应的沙箱和进程。后续请求使用同一标识，继续读取新增输出、发送输入，或者中断进程。进程结束时再返回退出码。</p>
<p>我们现有的 <code>exec_command</code> 由中心 Runtime 调用 SandboxProxy，再由沙箱中的执行服务启动命令。后续通过 <code>write_stdin</code> 等接口访问原会话。它们采用 Codex 风格的工具接口，这条链路中的 Agent 主循环仍在中心 Runtime。</p>
<p>输出量也需要控制。短输出可以直接进入工具结果；长日志分段返回，并注明是否截断，必要时附上完整日志引用。否则一次构建就可能把后续模型输入占满。</p>
<h3 id="26-中断与恢复">2.6. 中断与恢复</h3>
<p>Worker 重启时，Sandbox 中的命令可能还在运行。新 Worker 读取运行、消息和工具记录后，应当先查询原执行会话：</p>
<ul>
<li>进程仍在运行：继续读取输出，等待结果；</li>
<li>进程已经结束：取回退出码和剩余输出；</li>
<li>会话或沙箱已经丢失：根据操作性质决定是否重新执行；</li>
<li>状态暂时无法确认：保留未知状态，继续查询或安排核对。</li>
</ul>
<p>测试一般可以重跑，发布和付费操作则需要查询原操作、确认幂等条件，不能统一按失败重试。</p>
<p>接管还需要处理旧 Worker 恢复连接的情况。任务可以使用带有效期的租约表示执行权，由 Worker 定期更新心跳。接管时生成新的执行编号，后续数据库写入校验这个编号，拒绝旧执行者的更新。</p>
<p>但数据库的校验不会自动停止旧进程修改工程。同一个工作区需要单独保证写入者唯一，或者为不同执行尝试使用独立副本。交给新执行者之前，要确认旧进程已经停止或失去写入权限。</p>
<p>至此，中心运行时需要维护的主要部分就包括：运行记录、模型上下文、工具协议、远程进程，以及这些状态的恢复。</p>
<h2 id="3-sandbox-内运行-codex">3. Sandbox 内运行 Codex</h2>
<h3 id="31-接入结构">3.1. 接入结构</h3>
<p>把 Codex 放进 Sandbox 后，平台仍然接收用户需求、创建 Run，并保存项目、权限和预算。执行时由平台分配沙箱，再将任务交给其中的 Codex。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/05-sandbox-runtime.svg" title="平台调度多个包含 Runner 和 Codex 的 Sandbox" data-thumbnail="/images/agent-deployment/05-sandbox-runtime.svg" data-sub-html="<h2>图 5：平台与沙箱内执行程序的关系</h2>">
        
    </a><figcaption class="image-caption">图 5：平台与沙箱内执行程序的关系</figcaption>
    </figure>
<p>平台与引擎之间通常还需要一段接入程序。这里称为 <code>Runner</code>，主要负责：</p>
<ul>
<li>启动引擎，传入任务与运行配置；</li>
<li>继续原会话，传入用户的补充输入；</li>
<li>接收引擎事件，回传进度和结果；</li>
<li>处理中断，管理引擎及相关命令进程。</li>
</ul>
<p>Codex 提供 SDK 和命令行入口，可以启动、继续或恢复会话，也可以输出结构化执行事件。具体采用哪种方式，需要与选定版本一起验证。<a href="https://learn.chatgpt.com/docs/codex-sdk" target="_blank" rel="noopener noreffer ">Codex SDK</a>、<a href="https://learn.chatgpt.com/docs/developer-commands#codex-exec" target="_blank" rel="noopener noreffer ">codex exec</a>。</p>
<p>普通文件读写和命令执行在沙箱内部完成。平台原来围绕模型维护的上下文组织、工具调用循环，转由引擎处理；平台与执行侧的主要接口变为任务、补充输入、事件和控制指令。</p>
<h3 id="32-数据保存">3.2. 数据保存</h3>
<p>执行程序移入沙箱后，数据也会分布在不同位置。</p>
<table>
  <thead>
      <tr>
          <th>保存位置</th>
          <th>保存内容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>平台数据库</td>
          <td>Run、Attempt、用户与项目、预算、沙箱和会话映射、已确认事件、交付引用。</td>
      </tr>
      <tr>
          <td>Codex 状态目录</td>
          <td>固定版本恢复会话所需的文件和索引，以及对应的版本和运行配置。</td>
      </tr>
      <tr>
          <td>工程持久卷或快照</td>
          <td>源码、未提交修改、必要配置和文件检查点。</td>
      </tr>
      <tr>
          <td>业务服务</td>
          <td>已提交操作的编号、状态、费用和结果。</td>
      </tr>
      <tr>
          <td>对象存储</td>
          <td>需要留存的日志、截图、图片和构建产物。</td>
      </tr>
      <tr>
          <td>沙箱临时目录</td>
          <td>可重新安装的依赖、缓存和临时构建文件。</td>
      </tr>
  </tbody>
</table>
<p>平台需要把 <code>run_id</code>、<code>attempt_id</code>、<code>sandbox_id</code>、工作区编号和 Codex 会话编号关联起来。用户继续修改时，据此找到原工程与会话；沙箱替换时，据此确定恢复哪份状态。</p>
<p>Codex 状态目录需要按照固定版本的实际要求保存。聊天文本导出未必包含恢复所需的全部索引和执行状态。不同项目还应使用隔离的状态目录与工作区，避免引擎找到另一项目的文件或会话。</p>
<h3 id="33-任务执行与事件回传">3.3. 任务执行与事件回传</h3>
<p>一项任务进入执行阶段时，平台先创建或复用沙箱，挂载工作区与引擎状态，准备依赖和范围受限的访问权限。Runner 接收启动指令后，创建新会话或继续原会话。</p>
<p>这里需要分别处理启动指令和执行事件的重试。</p>
<p>平台发送启动指令后没有收到确认，Runner 可能已经启动了引擎。启动指令因此需要稳定编号，Runner 保存已接受的指令，平台重连后查询原执行。否则一次网络超时就可能启动两个 Codex 进程。</p>
<p>事件回传也可能断线。Runner 可以按 Run 和 Attempt 为事件编号，暂存尚未确认的事件；平台保存后返回确认，重连后从最后确认位置继续传递。平台按照事件编号去重，客户端也可以从已接收位置补读。</p>
<p>这里重传的是执行记录，接收方不会因此再执行一遍工具。沙箱本地暂存只能应对短暂断线；如果连磁盘一起丢失，未同步出去的事件仍然会丢失，关键恢复记录需要及时保存到外部存储。</p>
<h3 id="34-沙箱回收与恢复">3.4. 沙箱回收与恢复</h3>
<p>沙箱重新创建后，文件、会话和进程需要分别处理。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/06-sandbox-recovery.svg" title="利用文件快照、引擎状态和业务记录恢复沙箱任务" data-thumbnail="/images/agent-deployment/06-sandbox-recovery.svg" data-sub-html="<h2>图 6：沙箱重建后的恢复过程</h2>">
        
    </a><figcaption class="image-caption">图 6：沙箱重建后的恢复过程</figcaption>
    </figure>
<p>继续用暂停菜单的例子：Codex 修改了源码并启动测试，随后沙箱丢失。恢复时先准备新环境，取回源码和未提交修改，再加载原会话。原测试进程通常已经不存在，确认可以安全重跑后，重新启动测试。</p>
<p>文件与会话的保存位置不同，保存时间也可能不同。会话已记录“修改完成”，文件快照却来自修改前，后续执行就会基于错误的工程状态。可以在相关工具动作结束后，为工程快照、会话状态和事件确认位置保存关联检查点；无法保证一致时，恢复流程需要检查实际差异。</p>
<p>开发服务器、终端和网络连接属于运行中的进程状态。即使会话与文件都恢复了，也需要重新检查这些进程是否存在，并按需启动。</p>
<p>我们的沙箱代码中，有一套可选的本地文件读写方案：依赖与构建尽量使用本地磁盘，源码保存在持久工作区，最终产物再写回。该方案默认关闭，需要配置启用。这里区分的正是可重建数据与需要保留的任务成果。</p>
<p>沙箱分配方式也影响恢复成本。按项目保留环境，可以复用依赖和预览进程，同时需要处理空闲资源、残留进程和并发写入；按任务新建环境，清理比较直接，但每次要准备依赖并恢复工作区。</p>
<p>引擎与镜像版本同样要固定并记录。升级前验证原会话能否继续、工具行为是否变化。重新分配执行者时，仍需处理旧租约、旧进程及工作区写入权限。</p>
<h2 id="4-业务工具">4. 业务工具</h2>
<h3 id="41-请求已执行但响应丢失">4.1. 请求已执行，但响应丢失</h3>
<p>两种部署方式都会调用素材、费用和发布等业务服务。这类调用存在一种情况：服务已经接受请求，Agent 却没有收到响应。</p>
<p>例如素材服务创建了背景图任务并登记费用，网络在返回结果时断开。Agent 恢复后若新建一次生成请求，就可能产生第二份任务和费用。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/07-lost-response.svg" title="通过稳定操作编号查询或重试同一次素材任务" data-thumbnail="/images/agent-deployment/07-lost-response.svg" data-sub-html="<h2>图 7：业务操作的查询与幂等重试</h2>">
        
    </a><figcaption class="image-caption">图 7：业务操作的查询与幂等重试</figcaption>
    </figure>
<p>Agent 在第一次调用前保存业务操作标识，素材服务接单时，将这个标识与创建的素材任务关联。这样，即使回复丢失，双方仍然能够找到同一次操作。</p>
<p>同一操作编号和相同参数再次提交时，服务返回原任务，不再执行第二次业务动作，这就是幂等。相同编号携带不同参数则应拒绝或报冲突。编号的去重范围还要结合用户、项目等归属确定。</p>
<p>恢复时按原操作标识查询，可以确认原任务仍在执行、已经结束，还是没有成功提交。不支持查询、也不支持幂等的接口，在断线后可能暂时无法确定结果，应保留未知状态，再按操作性质处理。</p>
<h3 id="42-取消权限与预算">4.2. 取消、权限与预算</h3>
<p>用户取消任务时，中心 Runtime 或 Runner 可以停止后续模型调用，并中断当前命令。已经交给素材或发布服务的工作，需要另外发送取消请求或确认最终结果。因此，“收到取消指令”和“执行已经停止”需要分别记录。</p>
<p>权限与预算由平台和业务服务校验。平台向执行环境提供有有效期、范围受限的访问权限，下游服务校验项目、操作和额度。恢复任务可以使用原运行配置，但已经撤销的权限仍以当前业务校验为准。</p>
<p>沙箱内程序可访问的文件、网络与凭证，由运行环境限制。长期、跨项目的高权限凭证进入沙箱后，生成的代码也可能读取到。Codex 自身的命令限制和审批策略需要与外层沙箱共同配置。<a href="https://learn.chatgpt.com/docs/agent-approvals-security" target="_blank" rel="noopener noreffer ">Codex 安全与审批说明</a>。</p>
<h2 id="5-方案比较">5. 方案比较</h2>
<table>
  <thead>
      <tr>
          <th>中心 Runtime</th>
          <th>Sandbox 内 Codex</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>中心运行时维护模型循环与上下文，也可以接入现成引擎。</td>
          <td>沙箱中的 Codex 维护模型循环与上下文。</td>
      </tr>
      <tr>
          <td>文件和命令经过远程工具协议执行，需要维护执行会话。</td>
          <td>基础工程操作在本地执行，平台维护引擎托管接口。</td>
      </tr>
      <tr>
          <td>恢复中心记录后，查询远端工具和业务操作。</td>
          <td>恢复平台映射、引擎状态与工作区，再核对外部操作。</td>
      </tr>
      <tr>
          <td>管理 Worker，以及实际执行代码的沙箱。</td>
          <td>管理沙箱内的引擎版本、会话、依赖和进程。</td>
      </tr>
      <tr>
          <td>项目、权限、费用和交付由业务系统管理。</td>
          <td>项目、权限、费用和交付仍由业务系统管理。</td>
      </tr>
  </tbody>
</table>
<p>任务以调用业务 API 为主、流程比较明确时，中心服务便于集中组织。任务需要长时间在工程中修改、构建和调试时，沙箱内 Codex 可以复用已有的编码执行能力，减少基础工具适配。</p>
<p>这只是按工作形态做出的判断。比较具体方案时，还需要固定任务、模型和工具条件，检查正常完成率，以及 Worker 重启、沙箱丢失、响应超时和用户取消时的恢复情况。等待时间、重复操作与有效任务成本，应当一起记录。</p>
<p>任务结束时，还需要明确交付了什么。下面是暂停菜单任务的一份简化结果结构。</p>
<figure><a class="lightgallery" href="/images/agent-deployment/08-platform-responsibility.svg" title="运行结果关联源码、构建、预览和功能验证记录" data-thumbnail="/images/agent-deployment/08-platform-responsibility.svg" data-sub-html="<h2>图 8：运行结果与交付记录的逻辑关系</h2>">
        
    </a><figcaption class="image-caption">图 8：运行结果与交付记录的逻辑关系</figcaption>
    </figure>
<p>源码记录说明改了哪些文件；构建记录说明构建是否通过、使用哪份产物；预览记录说明哪个版本正在运行；验证记录说明暂停与继续功能是否符合要求。</p>
<p>我们的一次游戏实验中，部署和健康检查通过以后，多人玩法仍然需要用多个客户端验证。执行器的结束、部署的完成和需求的实现，有各自对应的记录。无论采用哪种部署方式，平台都需要把这些结果关联到原任务。</p>]]></description></item><item><title>后端架构演进：服务拆分、环境选路与研发平台建设</title><link>https://www.wangsaiyu.com/posts/engineering/rd-platform-evolution/</link><pubDate>Mon, 21 Sep 2026 11:09:07 +0000</pubDate><author>王赛宇</author><guid>https://www.wangsaiyu.com/posts/engineering/rd-platform-evolution/</guid><description><![CDATA[<p>生成链路最初由两个核心服务组成：Gateway 接收请求并处理项目接口，Runtime 运行 Agent，调用模型和工具，返回执行过程与结果。</p>
<p>随着业务增加，项目管理、调度和不同客户端的接入逐步拆开。服务之间的调用、环境选择、发布与排障，也需要相应调整。本文按这些问题的出现顺序，介绍各阶段的结构和实现方法。</p>
<h2 id="1-服务拆分">1. 服务拆分</h2>
<h3 id="11-初始结构">1.1. 初始结构</h3>
<p>早期 Gateway 通过 Kitex RPC 调用 Runtime。Runtime 已经维护任务状态与事件，也采集运行、工具调用和错误指标。项目、素材、用户及其他外部依赖，围绕这两个核心服务接入。</p>
<p>当时服务地址和发布目标比较固定。个人 Gateway 对应个人 Runtime，共享开发环境使用另一组应用。CI 根据提交来源选择目标，构建镜像，再更新相应云应用。</p>
<p>服务较少时，这些对应关系容易维护。调试人员知道请求经过哪两个应用，流水线也只需要更新少量固定目标。</p>
<h3 id="12-项目与调度的拆分">1.2. 项目与调度的拆分</h3>
<p>Gateway 中的逻辑增加后，一次改动可能同时涉及客户端协议、项目状态和任务执行。我们把项目管理与调度抽出，让它们分别维护自己的状态。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/17-evolution-map.svg" title="生成链路从 Gateway 与 Runtime，演进为项目、调度和执行服务" data-thumbnail="/images/rd-platform-evolution/17-evolution-map.svg" data-sub-html="<h2>图 1：生成链路的主要服务变化</h2>">
        
    </a><figcaption class="image-caption">图 1：生成链路的主要服务变化</figcaption>
    </figure>
<p>拆分后的主要职责如下：</p>
<table>
  <thead>
      <tr>
          <th>组件</th>
          <th>负责的内容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gateway</td>
          <td>客户端协议、请求校验和必要的业务编排。</td>
      </tr>
      <tr>
          <td>项目服务</td>
          <td>项目、版本等业务对象及其状态。</td>
      </tr>
      <tr>
          <td>Dispatcher</td>
          <td>任务提交、排队与分发，并组织项目创建和首轮执行。</td>
      </tr>
      <tr>
          <td>Runtime</td>
          <td>Agent 执行、模型与工具调用、执行状态和结果。</td>
      </tr>
  </tbody>
</table>
<p>入口校验请求后，通过 RPC 调用相应服务。Dispatcher 组织项目创建和首轮执行，需要异步处理的事件与后续工作进入队列。项目对象、执行任务和客户端连接由此具有独立的生命周期。</p>
<p>这使不同入口能够复用同一份项目状态，调度可以单独处理积压，Runtime 也可以围绕执行能力演进。相应的代价是调用次数和版本组合增加，一次请求开始需要跨服务追踪。</p>
<p>图中按职责归纳阶段，各阶段都有其他依赖，迁移期间也存在新旧实现并行的情况。</p>
<h2 id="2-环境选路">2. 环境选路</h2>
<p>这里的灰度主要指按环境标记定向验证，以及预发布环境选择。目标是让一次请求经过一组匹配的服务版本；按百分比放大正式流量属于另一种发布策略。</p>
<p>按照目标地址的确定方式，这部分可以分为四轮调整。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/18-gray-four-designs.svg" title="固定地址、命名空间、服务名前缀与请求上下文四种选路方式" data-thumbnail="/images/rd-platform-evolution/18-gray-four-designs.svg" data-sub-html="<h2>图 2：四轮环境选路方式</h2>">
        
    </a><figcaption class="image-caption">图 2：四轮环境选路方式</figcaption>
    </figure>
<h3 id="21-固定地址映射">2.1. 固定地址映射</h3>
<p>第一轮给开发人员分配固定应用。CI 将代码部署到这些应用，请求携带环境标记，Nginx 根据标记选择对应的上游地址或端口。Gateway 再根据配置连接 Runtime。</p>
<p>这种方式要维护三组关系：人员与应用、环境标记与地址、Gateway 与 Runtime。每增加一套环境，都要同步更新这些配置。</p>
<p>这个阶段 Nginx 选择的上游中包含 NLB 地址。后续基础设施迁移改变了 NLB 与 Nginx 的连接关系，早期选路方式需要按当时的拓扑理解。</p>
<h3 id="22-命名空间与服务发现">2.2. 命名空间与服务发现</h3>
<p>第二轮通过服务名寻找目标。调用方先在当前命名空间查询同名服务，明确不存在时，再根据规则查找共享环境。</p>
<p>实例地址变化后，由服务发现和 DNS 更新，调用方继续使用稳定服务名。允许共享回退的依赖，也不必在每个个人环境中部署一份。</p>
<p>限制在于，环境身份与进程所在命名空间绑定。若一个命名空间内需要同时运行多组实例，只根据命名空间已无法区分它们。</p>
<h3 id="23-服务名前缀">2.3. 服务名前缀</h3>
<p>第三轮把环境标记加入服务名。同组个人实例使用相同前缀，调用方优先查询带前缀的目标，再按策略选择共享版本。Nginx 也可以根据环境标记推导服务名，减少逐个维护地址映射的工作。</p>
<p>CI 在部署时把环境身份写入实例配置；调度队列也逐步从命名空间维度转为环境身份维度。</p>
<p>但共享进程可以接收不同环境的请求。例如共享 Gateway 收到个人请求，如果后续服务发现只使用 Gateway 自己的启动配置，仍然会调用共享下游。因此，还需要将进程身份与本次请求选择的环境分开。</p>
<h3 id="24-请求上下文">2.4. 请求上下文</h3>
<p>第四轮把环境选择放入请求上下文。HTTP 入口识别环境，标准 RPC 客户端将其传给下游；发送消息时写入消息属性，消费时再恢复为上下文。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/19-routing-context.svg" title="环境信息在 HTTP、RPC 和消息之间传递，驱动每一跳的服务选择" data-thumbnail="/images/rd-platform-evolution/19-routing-context.svg" data-sub-html="<h2>图 3：请求环境在调用链中的传递</h2>">
        
    </a><figcaption class="image-caption">图 3：请求环境在调用链中的传递</figcaption>
    </figure>
<p>这时，共享 Gateway 可以接收个人请求，再调用对应的个人 Dispatcher 和 Runtime。是否存在个人入口，与后续任务应由哪组实例执行，可以分别决定。</p>
<p>服务发现缓存也需要包含环境维度。若缓存只按逻辑服务名保存，环境 A 查询到的地址就可能被环境 B 复用。</p>
<p>环境标记只用于选路，用户身份与操作权限仍然独立校验。共享 Gateway 的进程环境也不会因为一条个人请求而改变。</p>
<h3 id="25-回退条件">2.5. 回退条件</h3>
<p>不同依赖采用不同选路策略：</p>
<ul>
<li>优先个人：存在个人版本时使用个人版本，满足回退条件时使用共享版本；</li>
<li>必须个人：必须命中目标环境，缺失时返回错误；</li>
<li>仅共享：始终使用共享实例。</li>
</ul>
<p>关键调度和执行链要求命中同一组实例。普通依赖是否可以共享，由对应策略确定。</p>
<p>允许回退时，也要区分“未部署”和“运行出错”。明确的 DNS 不存在可以表示目标未部署；DNS 超时、连接失败或应用错误则不能直接当作未部署。否则新版本出错后转用共享版本，测试结果就无法反映新版本的实际行为。</p>
<h2 id="3-分支环境的发布与回收">3. 分支环境的发布与回收</h2>
<h3 id="31-构建范围与运行依赖">3.1. 构建范围与运行依赖</h3>
<p>严格选路之后，目标环境必须包含必要的服务。</p>
<p>例如只修改 Gateway，CI 按代码差异可能只生成 Gateway 的构建与发布任务。但新环境中的 Dispatcher 和 Runtime 尚不存在，请求就无法继续执行。</p>
<p>我们因此为关键链路增加发布组。命中相关改动时，除代码依赖决定的构建范围外，还要补齐这组服务的运行依赖。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/01-overview.svg" title="代码变更决定构建范围，发布组补齐环境所需服务" data-thumbnail="/images/rd-platform-evolution/01-overview.svg" data-sub-html="<h2>图 4：构建范围与发布顺序</h2>">
        
    </a><figcaption class="image-caption">图 4：构建范围与发布顺序</figcaption>
    </figure>
<p>构建可以并行进行；部署按依赖顺序，先 Runtime，再 Dispatcher，最后入口。发布组保证按约定准备链路，不提供跨服务事务，也不意味着数据库和全部外部资源都被复制。</p>
<p>只发布改动服务适合已有完整、兼容依赖的环境。创建新环境时，还需要检查那些没有代码变化、但执行链必需的服务。</p>
<h3 id="32-环境生命周期">3.2. 环境生命周期</h3>
<p>个人环境后来逐步改为按功能分支创建。分支打开时准备环境，更新代码时继续发布，合并、关闭或到期后回收。这样，同一个人可以同时维护多套改动，每套环境都有明确的代码来源和寿命。</p>
<p>临时环境使用内部 Service，并按策略复用共享依赖。共享入口的 NLB 绑定不能直接复制给个人实例，否则个人实例可能接收到共享流量。数据库和外部资源的隔离范围，也需要单独定义。</p>
<p>回收时还要处理资源名称复用的问题。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/10-lifecycle.svg" title="环境名称可以复用，回收操作通过创建记录和资源身份区分旧环境与新环境" data-thumbnail="/images/rd-platform-evolution/10-lifecycle.svg" data-sub-html="<h2>图 5：同名环境重建后，旧回收任务只能处理原来的资源</h2>">
        
    </a><figcaption class="image-caption">图 5：同名环境重建后，旧回收任务只能处理原来的资源</figcaption>
    </figure>
<p>同名环境关闭后重新创建，已经是另一代资源。旧回收任务如果延迟执行，不能仅根据名称删除新环境。</p>
<p>因此，回收先关闭当前代次，阻止新发布，等待正在执行的操作退出，再按保存的资源 UID 等精确身份删除。新建环境使用新的代次和资源身份，迟到的旧任务据此失效。</p>
<p>选路负责确定请求去哪一组实例，生命周期管理负责确定这组实例何时存在，两者使用同一份环境身份关联。</p>
<h2 id="4-接入层与移动端">4. 接入层与移动端</h2>
<h3 id="41-按业务拆分-gateway">4.1. 按业务拆分 Gateway</h3>
<p>项目和执行服务拆出以后，大 Gateway 中仍有不同领域的客户端接口。后续按业务逐步划分接入服务，通过 Nginx 路由让新旧入口并存。</p>
<p>外部调用方继续使用稳定入口，Nginx 按路径选择对应 Gateway。一个领域迁移完成后，再移除旧路由和旧实现。</p>
<p>迁移中的错误处理需要明确。新 Gateway 返回错误时，自动转到旧实现可能掩盖问题；写操作重试到另一上游，也需要考虑原请求是否已经执行。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/15-ingress-chain.svg" title="NLB、Nginx、鉴权服务、Gateway 和 WAP 的接入关系" data-thumbnail="/images/rd-platform-evolution/15-ingress-chain.svg" data-sub-html="<h2>图 6：业务 API 的接入结构</h2>">
        
    </a><figcaption class="image-caption">图 6：业务 API 的接入结构</figcaption>
    </figure>
<p>图从业务 API 的 NLB 展开，各层分别负责：</p>
<ul>
<li><code>NLB</code>：将连接交给入口实例；</li>
<li><code>Nginx</code>：HTTP 路由、协议升级，以及通过子请求衔接鉴权；</li>
<li><code>Gateway</code>：对应业务的客户端协议与必要编排；</li>
<li><code>WAP</code>：面向移动端的创建、会话和流式交互协议；</li>
<li><code>内部服务</code>：通过 RPC 提供项目、版本、调度和执行能力。</li>
</ul>
<p>域名前的代理与 TLS 终止配置，按具体入口确定，不在图中统一假设。</p>
<p>迁移到 Kubernetes 时，我们保留外部入口地址与监听，将后端接到由集群控制器维护的 Pod 集合。原托管平台管理的服务器组，需要重新确认管理归属，不能假设新控制器会直接接管。</p>
<h3 id="42-httpwebsocket-与-rpc-流">4.2. HTTP、WebSocket 与 RPC 流</h3>
<p>移动端需要更集中、稳定的创建和对话协议，因此增加了与普通 Gateway 并列的 WAP。它复用已有的项目、版本、文件、调度与执行能力。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/16-client-rpc-lifecycle.svg" title="HTTP 创建项目，WebSocket 连接交互，WAP 桥接内部 RPC 流" data-thumbnail="/images/rd-platform-evolution/16-client-rpc-lifecycle.svg" data-sub-html="<h2>图 7：客户端连接与执行任务的生命周期</h2>">
        
    </a><figcaption class="image-caption">图 7：客户端连接与执行任务的生命周期</figcaption>
    </figure>
<p>一次创建请求通过 HTTP 返回项目对象。客户端随后通过 WebSocket 连接项目，WAP 校验访问权限，再桥接内部双向 RPC 流，将客户端命令和执行事件进行转换。</p>
<p>关闭页面结束的是观察连接。若用户要求停止生成，需要发送指向当前轮次的终止命令；已经存在的项目和任务不会因为连接关闭而自动删除。</p>
<p>按业务拆分 Gateway 和增加移动端入口，解决的是两种接口变化，但业务状态仍由原服务维护，避免不同入口各存一份项目数据。</p>
<h2 id="5-指标采集与查询">5. 指标采集与查询</h2>
<h3 id="51-统一采集方式">5.1. 统一采集方式</h3>
<p>Runtime 和 Gateway 原来已有各自的指标。服务增加后，主要问题是同一个概念可能使用不同的标签、分母和聚合方式，面板之间难以比较。</p>
<p>公共组件逐步接管重复的采集逻辑：Kitex 中间件记录 RPC 调用量、错误、耗时和并发；Hertz 入口统一采集 HTTP 指标；队列与业务代码补充任务相关指标。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/20-metrics-collection.svg" title="公共组件采集指标，经 PushGateway、Prometheus 和 Grafana 查询展示" data-thumbnail="/images/rd-platform-evolution/20-metrics-collection.svg" data-sub-html="<h2>图 8：指标的采集、存储与查询</h2>">
        
    </a><figcaption class="image-caption">图 8：指标的采集、存储与查询</figcaption>
    </figure>
<p>服务定期将指标发送到托管 Prometheus 的 PushGateway，再由 Prometheus 保存和查询，Grafana 展示并计算规则。</p>
<p>服务清单声明能力与部署目标，对应的监控模板生成面板和规则。平台根据实际目标确定查询范围。服务代码仍需产生正确样本，声明某项能力本身不会生成数据；采集未接入时，应当反映为缺少数据。</p>
<h3 id="52-成功与失败的统计范围">5.2. 成功与失败的统计范围</h3>
<p>一次请求可能在入口成功接收后继续排队，也可能在多次工具调用后失败。因此，不同层的成功与失败要分别统计。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/04-outcomes.svg" title="入口接收、RPC 调用、队列等待和任务结束分别记录状态" data-thumbnail="/images/rd-platform-evolution/04-outcomes.svg" data-sub-html="<h2>图 9：不同层的统计对象</h2>">
        
    </a><figcaption class="image-caption">图 9：不同层的统计对象</figcaption>
    </figure>
<p>HTTP 成功表示入口接收或处理了这次请求，RPC 成功表示该次调用完成。最终任务失败率则按任务的终态统计，一次任务中的多次重试放在调用指标中。</p>
<p>用户断开页面、主动取消任务和执行器异常，也要保留不同结束原因。否则一次用户请求中的三次调用失败，会被误解成三个用户任务失败；入口持续返回 200，又可能掩盖后续任务异常。</p>
<h3 id="53-依赖资源指标">5.3. 依赖资源指标</h3>
<p>数据库、Redis 等依赖同时需要观察应用调用和资源运行情况。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/05-infrastructure.svg" title="标准客户端采集调用指标，云监控采集资源指标，再按实例和时间关联" data-thumbnail="/images/rd-platform-evolution/05-infrastructure.svg" data-sub-html="<h2>图 10：应用调用与云资源指标</h2>">
        
    </a><figcaption class="image-caption">图 10：应用调用与云资源指标</figcaption>
    </figure>
<p>客户端插件或 Hook 记录调用方、耗时与错误；云监控提供实例 CPU、内存、连接数和 I/O。先对齐资源身份与时间窗口，才能判断延迟来自调用方、连接池还是资源本身。</p>
<p>查询目标也需要准确。预发布和正式环境可能使用同一份生产配置，单一环境标签未必足以区分，要继续约束实际部署实例。共享 Gateway 接收个人请求时，进程环境与请求环境不同，也不能把全部基础指标都改写为请求环境。</p>
<h3 id="54-告警处理">5.4. 告警处理</h3>
<p>Grafana 计算告警规则，平台维护事件状态、分级和通知节奏，既有消息服务负责发送。</p>
<p>调用错误、队列积压、最终任务失败和资源饱和分别设置条件。多个指标可能来自同一故障，通知时需要结合事件状态组织，而不是每层都无差别发送。</p>
<p>平台在这里主要维护服务、部署目标、实例与告警对象之间的关系，指标存储与查询仍然由 Prometheus 和 Grafana 完成。</p>
<h2 id="6-日志采集与查询">6. 日志采集与查询</h2>
<h3 id="61-两条采集通道">6.1. 两条采集通道</h3>
<p>跨服务排查需要将日志汇聚到一起，并保留请求、任务与运行实例的关联。业务服务和 Nginx 使用不同的采集通道。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/21-log-collection.svg" title="Go 服务通过公共日志库上报，Nginx 通过文件采集进程上报 SLS" data-thumbnail="/images/rd-platform-evolution/21-log-collection.svg" data-sub-html="<h2>图 11：业务日志与入口日志的采集过程</h2>">
        
    </a><figcaption class="image-caption">图 11：业务日志与入口日志的采集过程</figcaption>
    </figure>
<p>业务代码通过公共日志接口写入，公共库补充服务、运行环境和调用位置，保留上下文中的请求与任务字段，再由 SLS 后端异步批量发送。标准输出仍可保留，用于进程启动和现场排查。</p>
<p>Nginx 不直接调用 Go 日志库。它先写结构化访问日志和错误日志，由随入口镜像运行的采集进程读取文件，解析字段、脱敏并批量写入 SLS。</p>
<p>入口日志中的请求编号、上游、状态和耗时，说明请求经过了哪一跳；业务日志中的请求、任务和执行编号，用于继续查找后续处理。消息队列 SDK 等依赖库的日志，也需要接到公共通道，才能在同一时间窗口内排查。</p>
<h3 id="62-查询范围">6.2. 查询范围</h3>
<p>日志集中后，需要进一步限定查哪一组实例。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/06-logs.svg" title="发布目标推导服务与实例范围，结合固定时间窗口查询日志" data-thumbnail="/images/rd-platform-evolution/06-logs.svg" data-sub-html="<h2>图 12：由发布目标确定日志查询范围</h2>">
        
    </a><figcaption class="image-caption">图 12：由发布目标确定日志查询范围</figcaption>
    </figure>
<p>预发布与正式环境共用生产配置时，只筛选 <code>env=prod</code> 会混入两组实例。平台根据目标声明确定完整实例范围，加入固定时间窗口，再查询并核对返回记录的归属。</p>
<p>翻页也使用同一窗口，避免第一页和第二页覆盖不同时间范围。日志字段还需要正确建立索引，否则记录已经写入，查询仍然可能没有结果。</p>
<p>一条日志从写入到被查出，会经过应用输出、采集与发送、SLS 接收、字段索引和查询条件。排查时逐段确认；发布验证则使用同一个请求编号，检查入口和业务日志能否被找到。</p>
<p>异步发送还存在进程退出、发送失败和缓冲积压等情况，所以任务最终结果仍以业务持久记录为准。日志集中也不自动等同于完整的分布式调用追踪。</p>
<h2 id="7-发布制品与执行记录">7. 发布制品与执行记录</h2>
<h3 id="71-从固定目标到发布操作">7.1. 从固定目标到发布操作</h3>
<p>早期 CI 直接维护提交来源与应用目标的对应关系，完成构建、推镜像和更新应用。</p>
<p>服务拆分后，构建范围要展开共享代码影响，发布范围还要补齐运行依赖。页面、CLI 和自动化也开始发起发布，若各自拼接集群、应用和环境配置，就容易出现不同入口指向不同目标的情况。</p>
<p>后来，发布目标与执行过程收进研发平台。CI 提交可核验的构建结果，平台登记制品、解析目标、保存发布操作，再由持久 Worker 执行。各入口使用同一个操作编号查询进度。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/22-release-bindings.svg" title="构建结果、发布目标、操作记录与运行实例之间的关联" data-thumbnail="/images/rd-platform-evolution/22-release-bindings.svg" data-sub-html="<h2>图 13：发布所需的记录与关联，字段为逻辑示意</h2>">
        
    </a><figcaption class="image-caption">图 13：发布所需的记录与关联，字段为逻辑示意</figcaption>
    </figure>
<p>发布记录需要保存以下关系：</p>
<table>
  <thead>
      <tr>
          <th>关系</th>
          <th>需要保存的内容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>代码与制品</td>
          <td>提交、真实构建任务、镜像 digest 和构建来源。</td>
      </tr>
      <tr>
          <td>制品与目标</td>
          <td>逻辑服务、目标环境、部署对象和配置身份。</td>
      </tr>
      <tr>
          <td>目标与流量</td>
          <td>Service 选择器与实例标签；入口还涉及 NLB 监听及后端集合。</td>
      </tr>
      <tr>
          <td>实例与观测</td>
          <td>实际实例范围、日志字段、指标模板和查询条件。</td>
      </tr>
      <tr>
          <td>操作与结果</td>
          <td>发布进度、实际目标和制品，以及对应的功能验证结果。</td>
      </tr>
  </tbody>
</table>
<p>镜像 digest 为构建产物提供确定身份。对于声明了预发布的服务，晋级正式环境时需要核对同一制品和兼容配置；重新构建后得到的新镜像，需要重新确认。没有预发布声明的服务，使用自身声明的发布流程。</p>
<p>配置也要能够识别。有的配置随镜像封装，有的由外部提供，操作记录需要说明本次使用哪份配置。</p>
<p>实例启动以后，还要确认 Service 选择器是否命中它，NLB 后端是否由正确控制器管理，个人实例是否误带共享入口绑定。否则进程正常运行，也可能接不到目标请求。</p>
<h3 id="72-超时与恢复">7.2. 超时与恢复</h3>
<p>发布请求超时，并不能确定部署动作失败。调用方可能只丢失了响应，Worker 仍然在更新目标。</p>
<figure><a class="lightgallery" href="/images/rd-platform-evolution/09-unknown-result.svg" title="发布响应丢失后，使用原操作编号查询并继续跟踪" data-thumbnail="/images/rd-platform-evolution/09-unknown-result.svg" data-sub-html="<h2>图 14：发布操作的查询与恢复</h2>">
        
    </a><figcaption class="image-caption">图 14：发布操作的查询与恢复</figcaption>
    </figure>
<p>调用方先查询原操作，核对目标、制品和当前进度，再决定是否需要后续处理。若直接新建发布，两次操作可能同时修改同一个目标。</p>
<p>提交入口还需要保留原操作的查询凭证。例如在提交前生成请求编号，平台接受后保存它与操作编号的关联。首个响应丢失时，调用方也能据此找回同一次发布。</p>
<p>平台内部还需要互斥、租约和执行代次。Worker 接管时更新执行身份，迟到的旧结果不能覆盖新状态；外部部署动作则需要继续核对实际目标，不能仅靠数据库状态判断已经停止。</p>
<p>研发平台由此承担持久执行、恢复与排障的责任。它不在用户业务请求的转发路径上，但新的发布依赖它正常工作。</p>
<h2 id="8-整条链路的验证">8. 整条链路的验证</h2>
<p>上面的机制最终需要在同一个任务上验证。以一套分支环境为例，验证过程可以分为：</p>
<ol>
<li>确认分支环境及必要依赖已经准备，目标版本与制品一致；</li>
<li>发起生成请求，检查入口识别的环境与实际后端；</li>
<li>沿 RPC 和消息检查 Dispatcher、Runtime 是否保持同一环境选择；</li>
<li>检查任务结束原因、产物与用户得到的版本；</li>
<li>用同一请求或任务编号查到入口和业务日志；</li>
<li>确认监控查询的是本次部署的实例，并验证目标功能。</li>
</ol>
<p>流水线完成、Pod Ready、HTTP 成功、任务结束分别验证其中的一部分。保存它们对应的对象和结果，才能说明这次变更实际运行到了哪一步。</p>]]></description></item></channel></rss>