Today for AI

Hacker News AI · 2026/10/7 18:10:27

Mainbrella 开源后端解析:分布式系统中幂等性重试与资源槽位独占机制

原标题:When the machine boots but the reply disappears
48AI 研判分
核心综述

Mainbrella 工程博客深入剖析其开源后端如何处理“机器启动但回复丢失”的分布式故障场景。文章重点阐述了在创建 Linux 实例时,如何通过严格的槽位独占(Slot Exclusivity)和幂等性设计来防止因网络超时导致的重复资源分配,确保重试操作不会引发成本激增或状态冲突。

报道全文原始报道全文

本文目录9 个章节

← 工程博客 2026 年 10 月 7 日 · Mainbrella 工程团队

你请求服务器创建一台 Linux 机器。机器启动了,但响应却消失了。从客户端的角度来看,这看起来就像是一个从未到达服务器的请求。重试是合理的。但如果启动第二台机器,恢复成本将高于故障本身带来的损失。

这是理解 Mainbrella 后端的一个有效切入点。深入追踪一台机器的生命周期,你会发现同样的问题反复出现:命令可能在执行后,调用方却看不到结果;私有请求的生命周期可能超过其所属的网络成员资格;磁盘快照可能成功完成,却没有留下我们恢复它所需的句柄。每一个边界都需要明确定义“重试”被允许做什么。

让我们跟踪一个示例任务,该任务运行 analysis.py,从另一个容器读取数据,并写入 metrics.csv。这台机器占用槽位 c17。文件名和槽位号仅为示例;以下机制源自我们的 开源后端。我们将从 Linux 启动之前开始讲起,因为所有权和预算正是在那里确定的。

由单一节点决定谁获得最后一个槽位

当某个账户仅剩一个槽位时,可能会同时收到两个创建请求。如果先读取计数器、启动机器,然后再更新计数器,这两个请求都有机会“获胜”。等到我们发现冲突时,昂贵的操作(启动机器)已经发生了。

我们为每个账户分配一个 Cloudflare Durable Object:这是一个拥有独立存储的持久化协调器。公共 API 解析经过认证的用户及其付费权限,然后将该协调器寻址为 account:<userId>。每个机器槽位都有一个独立的 Durable Object。对于 c17,其名称为 user:<userId>:slot:17。第一个槽位保留了旧名称 user:<userId> 和公共 ID small;该 ID 并不用于选择机器规格。

账户协调器对准入过程进行序列化。请求必须先预留其槽位、月度启动次数和计算配额,然后才能请求运行时启动机器。因此,即使第一台机器仍在启动过程中,下一个请求也会看到容量已被占用。账户控制器 通过 Promise 链显式实现了队列;在准入决策内部使用 await 可防止下一个决策插队。

持有该锁直到所有机器就绪,也会使它们的启动时间串行化。我们在完成持久化预留后释放该锁,并在锁外进行资源供给。由单个账户按顺序做出决策;其运行时可以并行启动。

Image 1: Three account reservations commit in sequence. Each commit releases a different runtime to boot, so the three boot intervals overlap. Readiness happens independently in each runtime.

短暂的共享决策阶段先于较长的独立工作阶段。图中的时间是示意性的:重叠的条形图用于说明并发特性,而非实测的启动延迟。

有一个特别有用的测试用例专门验证这一区别。它向一个拥有五个槽位的 Builder 账户发送六个并发请求,并将每个运行时就绪检查阻塞在一个门控(gate)之后。在门控打开之前,有五台机器完成了启动。第六个请求收到冲突响应,且账户记录了五次启动。该测试同时验证了设计的两个核心部分:独占准入和并行供给。

所有权在此协调过程开始之前就已确立。认证适配器接受 API 密钥或登录会话。即使伴随有效的浏览器 Cookie,显式无效的 Bearer 凭据也会导致认证失败。内部请求构建器自行注入账户身份和权限信息。如果允许调用方通过 x-mainbrella-user 头部指定用户,将会破坏我们刚刚建立的账户边界。

收据先于机器存在

对于我们的 analysis.py 任务,客户端在 POST /containers 请求中提供 Idempotency-Key。该密钥代表“创建机器的特定尝试”。账户记录属于该密钥的槽位和预留,以及所请求配置的指纹。如果在复用同一密钥时更改镜像、规格或其他参与指纹计算的选项,将产生冲突。

关键写入操作非常轻量。以下是 container-account-core.js 的相关部分,省略了周围的验证逻辑:

文本
const reservationId = ++state.nextReservationId;
state.reservations[slot] = reservationId;
const creation = {
  id: crypto.randomUUID(), slot, reservationId, fingerprint,
  expiresAt: this.now() + CREATION_RETENTION_MS,
};
await this.ctx.storage.put({
  [KEY]: state,
  [CREATION_PREFIX + idempotencyKey]: creation,
});

这种多键写入操作原子性地提交了已扣费的预留和创建回执。我们不希望出现针对从未预留过的槽位的回执,也不希望存在一个已扣费但无法通过键值重试找到的槽位。只有在此之后,控制器才会分发启动指令。

现在假设回复丢失。匹配的重试会在尝试新的准入之前先找到该回执。在预留处于待定状态期间,它可以返回 starting。待定预留有九十秒的对账窗口;此后,协调器会询问运行时实际存在的资源。如果它找到了正在运行的机器,它会返回该机器而不再收取另一次启动费用。重试绝不会第二次分发这个模糊的预留。

回执有效期为二十四小时。如果机器已停止或其槽位已被复用,相同的保留键将返回 creation_no_longer_running。它不会悄悄启动一个替代实例。需要替代实例的客户端必须使用新键发起新的创建请求。这也是为什么客户端应在发送前保存其键和请求:如果客户端忘记了需要查询的身份信息,服务端去重几乎毫无帮助。

什么确立了就绪状态?运行时控制器以 sleep infinity 作为入口点启动选定的镜像,然后执行 uname -a。该命令必须在六十秒内成功退出。这确立了客户机可以执行命令的事实。我们的 Python 分析仍然需要其自身的依赖项和检查;一个能响应的内核无法认证财务计算。

昨天的消息可能到达明天的机器

假设我们第一次启动的分发被延迟了。与此同时,账户取消了预留,释放了 c17,并将该槽位分配给一个替代实例。旧的分发最终到达了。其目标仍然是同一个运行时对象。按名称查找槽位无法告诉我们这条消息是否有权启动任何东西。

每个被接纳的启动请求都会获得一个递增的预留编号。在运行时,我们持久化两个高水位标记:最新的已接受启动和最新的取消操作。任何等于或低于这两个标记之一的启动请求都会被拒绝。即使没有正在运行的客户机(guest)需要销毁,取消操作也会写入其栅栏(fence),从而防止后续到达的请求复活已取消的工作。

Image 2: Time runs downward between the account and runtime c17. Boot 41 is delayed. Cancel 41 arrives first, then boot 42 starts a replacement. When boot 41 finally arrives it is rejected. A later cancel 41 is ignored and leaves machine 42 running.

沿着虚线对角线观察:第一个启动请求在替换实例之后才到达。取消操作的栅栏和已接受的预留编号使其“年龄”对运行时可见。

反向竞争同样重要。针对预留编号 41 的延迟清理操作绝不能停止来自预留编号 42 的机器。运行时的 DELETE 路径会推进取消标记,但如果该取消操作比已接受的启动更旧,则不会触碰客户机。回到账户层面,启动完成后的确认必须与当前的槽位预留匹配,才能清除待处理状态。旧的成功响应对于替换实例也没有权威效力。

预留编号保护内部的生命周期消息。公共命令和文件请求通过 { id, createdAt } 来标识一个正在运行的代际(generation)。槽位 ID 是可复用的,但代际不是。尽管 createdAt 看起来像时间戳,但它实际上是计算为 max(now, previousCreatedAt + 1)。即使在同一个毫秒内重建机器,或者将系统时钟向后调整,新创建的客户机仍将获得不同的身份标识。

请保留返回的运行中容器的这两个值。仅针对 c17 的清理请求无法表达你指的是哪一次生命周期。生命周期测试故意在释放旧的启动分发之前停止并复用槽位,包括在不推进时钟的情况下。这比另一次成功的 hello-world 启动更能揭示问题。

硬性截止日期是准入控制的一部分

我们的机器还需要权限来持续消耗算力。如果仅在启动时检查月度计数器,那么许多同时运行的机器可能会消耗掉相同的剩余配额。我们在任何机器启动之前,就预先保留它们可能使用的运行时资源。

机器规格在 plan-policy.js 中定义了权重:Lite 使用 1 个计算单元,Medium 使用 10 个,XL 使用 28 个。账户预留的是“单元-毫秒”(unit-milliseconds)。其租约会在以下四个边界中的最早者处结束:

文本
hard deadline = min(
  start time + plan session limit,
  paid access expiration,
  next UTC month boundary,
  start time + remaining unit-ms / machine weight
)

举个算术例子,预留一台 Medium 机器一小时会消耗 10 个计算单元小时。如果在五分钟后确认停止,则实际消耗量为 10 × 5 / 60,约为 0.833 计算单元小时;未使用的预留会被释放。若停止操作失败或运行时状态不可读,则保留其预留额度。如果将“无法联系到实例”视为“它必须是空闲的”,就会导致账户重复消费该额度。即使启动请求被接受但最终失败,仍会消耗当月的启动次数配额;运行时的结算则是独立的计算过程。

运行时会持久化这个硬性截止时间(hard deadline)和一个空闲截止时间(idle deadline)。真实的活动可以推移空闲截止时间,但不会超过硬性截止时间。状态轮询不会推移空闲截止时间。Durable Object 的报警机制会在客户端断开连接的情况下强制执行过期逻辑。套餐变更可能会缩短现有的生命周期,但不能延长其原始的硬性截止时间。

当计费查询不可用时,停止工作应当仍然可行。公开的 DELETE 路径在不要求重新解析计费信息的情况下验证所有权;协调器在清理时使用其保存的有效权限凭证。同样地,较新的未付费观察结果优先于较旧的已付费记录。否则,延迟的检查可能会重新授权我们此前已经撤销的机器。

断开的查看者不应拥有进程

在获得正在运行的生成版本后,我们可以启动 analysis.py。短的前台命令最长限制为六十秒。对于希望在断开连接后仍能找到结果的工作负载,我们使用托管执行(managed execution)。其标识符属于特定的容器生成版本,且创建时需要一个独立的幂等性键。

文本
// machine 是运行中 guest 返回的精确 { id, createdAt }。
// 在发送之前,请持久化 executionKey 和此请求。
const query = new URLSearchParams(machine);
const response = await fetch(`${apiOrigin}/containers/executions?${query}`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': executionKey,
  },
  body: JSON.stringify({
    argv: ['python3', '/workspace/analysis.py'],
    timeoutMs: 120_000,
  }),
});
if (!response.ok) throw new Error(`Execution HTTP ${response.status}`);
const execution = await response.json(); // 保存 execution.id。

argv 形式提供字面参数,而不是组装 shell 文本。在 executions.js 中,执行记录会在进程启动前保存。匹配的重试会找到该保留的身份标识。中止的创建请求或断开的事件流不会获得终止托管作业的权限;只有显式取消才会这样做。

输出事件具有递增的序列号,并与记录的更新游标一起提交。客户端使用其最后处理的序列作为 cursor 重新连接到事件端点。它读取存储的后缀,而不是依赖特定 socket 已看到每个字节。流本身限制为三十秒,因此重连是常规操作。进程最多可运行十五分钟,并进一步受其请求的超时时间和容器剩余硬租期的限制。

这些记录是有限的资源。每个运行时最多保留三十二个执行记录,从作业准入开始计算的一小时保留期限。托管作业与前台命令和文件传输共享一个包含四个并发操作的池。输出限制为一 MiB 和有限的事件数量。达到输出限制会终止作业并将其标记为截断;因此,仅在 stdout 中找到几行看似合理的内容是不够的。我们在信任结果之前,会检查终端状态、退出代码、超时和截断情况。

这里存在一个令人不适的边界:持久化记录并不能使进程句柄具备持久性。当运行时对象重启时,恢复机制会将未完成的执行标记为 interrupted(已中断)。如果这些执行属于其当前的 guest 代际(generation),系统会销毁该 guest,而不是让未被追踪的任务继续运行。系统绝不会静默地重新执行命令。对于我们的分析而言,这意味着中断可能会导致未保存的 CSV 文件丢失;而对于发送发票的命令来说,自动重放可能会造成更严重的后果。重启恢复机制被刻意设计得比重新连接查看器更具破坏性。

数据请求携带了 guest 无法选择的身份标识

假设 analysis.py 从 http://data.internal/shards/west 读取一个分片。我们将该代际注册为私有服务网络的一个成员。在同一个网络中,另一个代际拥有服务名称 data 和端口 8080。源端可能是一个仅限调用者(caller-only)的成员,没有监听端口。

guest 的请求既未指定账户,也未指定网络。private-services-runtime.js 为 *.internal 安装了一个出站 HTTP 拦截器。其中继器会移除保留的身份标头,并从运行时入口点配置的属性中提供受信任的账户、槽位(slot)和代际值。从 Python 发送伪造的所有权标头并不会选择另一个客户。

该账户的 私有服务注册表 会查找包含该确切源代际的网络,然后在该网络内解析 data。另一个账户——或同一账户中的另一个网络——可以复用该名称。目标端在其生命周期锁下重新检查其正在运行的代际及注册状态,然后才打开已注册的应用程序端口。解析名称仅仅是第一道权限检查。

为什么要再次检查?我们的数据服务可能在响应过程中被分离或替换。目标端会缓冲有界响应并再次检查其注册状态。账户会在释放响应之前重新检查源端的存活状态以及双方的成员资格。基于昨天的成员资格准入的请求,绝不能依据今天的配置交付字节数据。

这些检查无法撤销已经发生的应用程序副作用。如果请求在响应被拒绝之前已经修改了数据服务,应用程序仍然需要一种方法来协调该变更。对于不可变分片读取而言,这很容易处理;但任务队列或支付服务则需要自己的操作标识(operation identities)。

此功能限于私有 HTTP 路由:请求和响应体大小上限为 1 MiB,超时时间为 10 秒,且不支持 WebSocket 升级或任意 TCP 连接。PostgreSQL 客户端不会仅因其主机名以 .internal 结尾就自动具备私有服务感知能力。此外,在围绕该功能构建工作流之前,必须确保部署已启用该功能,并且 GET /capabilities 端点也明确宣告支持该能力。

CSV 文件需要在命令输出之外拥有独立的生命周期

我们的作业将结果写入 /workspace/metrics.csv。我们在 guest 虚拟机仍在运行时,通过绑定生成版本的文件请求来获取该文件。文件端点传输原始字节,并设有 1 MiB 的大小限制,而不是将二进制数据解码为文本,或将导出内容隐藏在截断的 stdout 流中。

files.js 中的写入路径展示了另一个有用的顺序选择策略:它先在目标所在现有父目录中写入一个临时文件,然后将其重命名覆盖到目标位置。这样可确保读取者不会观察到半上传状态下的常规文件。路径作为位置参数传递,而非插入 shell 代码中进行字符串插值。写入操作会拒绝符号链接目标;而读取操作则允许跟随 guest 内部拥有的链接。

这些保证的范围比针对分析过程的完整事务要窄。一次成功的文件传输并不能证明作业使用了正确的输入或完成了所有行的处理。应用程序应当检查工件(artifact)的模式(schema)和来源(provenance),并在环境销毁前将有用的输出复制到临时机器之外。如果我们希望返回到环境本身,就需要保存工作区(saved workspace)。

快照可能存在却无法恢复

“保存”听起来像是一个单一操作:捕获磁盘、记录结果、停止机器。但实际上,它跨越了三个状态所有者——账户、运行时和提供商——而它们都无法原子性地提交另外两个的状态。

在 workspaces.js 中,账户会在请求捕获之前预留保存容量并持久化该操作。在运行时层面,包含保存标识的收据会在调用 snapshotContainer() 之前被持久化。捕获返回后,运行时会将该 provider handle(提供方句柄)保存到该收据中。随后,账户将句柄提交到其工作区记录中。只有在该提交完成后,stop: true 才能销毁源容器。

如果运行时已保存句柄,但运行时与账户之间的回复丢失,重试时可以读取该收据。我们无需再次执行捕获即可恢复同一次快照。但是,如果在 provider 接受捕获之后、句柄保存之前中断了运行时,那么收据中仅包含一个意图标记。我们知道曾尝试过捕获,却不知道要恢复哪个 provider 对象。

Image 3: After a capture attempt, recovery forks on the durable runtime receipt. With a saved provider handle, a lost reply is repaired by reading that handle and committing it at the account. Without a saved handle, the receipt is unresolved, another capture is blocked, and the save path leaves the source running.

信息丢失的位置决定了恢复策略。仅依靠 provider 侧的磁盘是不够的:我们需要在持久化边界的这一侧拥有其句柄。

运行时拒绝重新捕获该未解决的操作,并返回 workspace_save_unavailable。保存路径会保持源容器完好无损,但仍受其常规租约约束。仅仅为了消除错误而选择一个新的键,相当于发起了一次新的捕获尝试,而不是对旧操作的恢复。这种区别正是幂等性承诺的边界所在。

保存配额也考虑了不确定性带来的成本。准入控制会在捕获前预留源容器大小所需的全部磁盘容量。删除工作区会释放其当前占用的已保存工作区配额,但不会退还历史捕获预算:因为 provider 可能已经完成了相关工作。失败或状态模糊的调用不能成为无限次重复捕获的低成本途径。

恢复就绪的工作区需经过常规的容器准入流程,并消耗一次新的启动机会。恢复后的客户机实例会获得全新的 generation(代际),并且必须使用保存时的大小和网络策略。镜像摘要必须仍然匹配;不兼容的镜像或失败的 provider 恢复操作会产生错误,而不是静默地替换为一台空机器。

保存的状态是一个文件系统。RAM、运行中的进程、预览以及私有服务成员资格不会随之恢复。如果我们的 Python 任务需要断点续传,就必须在文件中记录进度;而恢复后的服务必须启动其进程并注册新的代次(generation)。在捕获状态之前让写入者静默(quiescing)也是应用程序的责任。我们可以完美地保留一个包含相互不一致文件的磁盘。

让迟到的消息送达

检查这些设计选择的最快方法是从后端代码库中运行生命周期测试。它们会延迟调度、在副作用发生后丢弃回复、从已保存的存储中重建控制器,并在投递旧消息之前重用槽位:

文本
node --test containers/container-account.test.mjs \
  containers/user-container.test.mjs \
  containers/workspaces.test.mjs \
  containers/private-services.test.mjs

首先阅读 stopping and reusing a slot fences old delayed dispatches even in the same millisecond(停止并重用槽位可隔离同一毫秒内的旧延迟调度)。然后对照阅读 工作区测试 中的 lost snapshot response reconciles receipt without recapture(丢失快照响应可在不重新捕获的情况下对账收据)和 uncertain capture cannot be repeated(不确定的捕获不可重复)。前者恢复了已保存的答案,后者则保留了“答案缺失”这一事实。这是智能体(agent)在安全决定下一步行动之前,需要从计算后端获得的决策依据。

源码审查:后端提交 6e5bef3,2026 年 10 月 7 日。示例和图表旨在解释控制流,并非生产环境追踪或延迟测量数据。生命周期测试使用模拟的提供商状态。公共请求格式和部署能力已在 API.md 和 智能体工作流 中文档化。