E2B 的沙箱设计:diff 链、UFFD、NBD 与跨机恢复

E2B 给 AI Agent 提供沙箱:每个沙箱是一台 Firecracker 微虚拟机,几百毫秒内就能拿到手,用完可以暂停,过几个小时再恢复,里面的进程、内存、文件都和暂停前一模一样。它的后端是开源的,仓库在 e2b-dev/runtime(原名 e2b-dev/infra),整套用 Go 写成。

这篇文章只讲其中最有意思的一块:沙箱的数据怎么在机器之间流动。暂停发生在节点 A,恢复可能落在节点 B,这中间内存和磁盘是怎么存的、怎么拼的、怎么按需取回来的。涉及四样东西:diff 链、userfaultfd、NBD 和对等节点读取。前三个名词不少人第一次见,所以先把它们讲清楚,再串起整条路径。

先认识几个名词

Firecracker 快照

Firecracker 是 AWS 开源的轻量虚拟机监控器,一台微虚拟机就是宿主机上的一个 Firecracker 进程。它能给正在运行的虚拟机拍快照,一份快照由三部分组成:

从快照恢复,相当于让虚拟机“从拍快照那一刻接着跑”。内核不用重新启动,进程不用重新拉起,这是 E2B 启动快的根本原因。

orchestrator

E2B 的集群分两层。上面是控制面:对外的 REST API、记录状态的 PostgreSQL 和 Redis。下面是一排跑沙箱的节点,每台节点上跑一个叫 orchestrator 的常驻进程。

orchestrator 可以类比成 Kubernetes 里 kubelet 加容器运行时的组合,只不过它管的是虚拟机。API 通过 gRPC 叫它创建、暂停、恢复、销毁沙箱,它负责把事情真正做出来:拉起 Firecracker 进程,准备网络命名空间和 tap 设备,准备磁盘和内存的数据来源,把快照文件从对象存储取到本地缓存,暂停时把差异算出来传回去。本文要讲的 userfaultfd 处理器、NBD 服务、对等节点的 chunk 服务,全都跑在 orchestrator 这个进程里。

用户的 SDK 不直接和 orchestrator 说话。SDK 调 API,API 选好节点再转给 orchestrator;访问沙箱里服务的流量则经过 client-proxy 直接到节点,不经过 API。

userfaultfd(UFFD)

先回忆一下缺页。进程看到的是虚拟地址,虚拟地址要映射到物理内存页才能访问。很多页在第一次被访问前根本没有分配物理内存,CPU 访问它时会触发缺页异常,陷入内核,由内核决定给这个地址放什么:匿名内存就分一页清零的物理页,文件映射就从文件里读出对应的内容。处理完,内核让那条访存指令重新执行,进程毫无察觉。

userfaultfd 是 Linux 提供的一个系统调用,名字直译就是“用户态缺页文件描述符”,常缩写成 UFFD。它让一个进程对内核说:这段内存的缺页别由你处理,交给我。登记之后,这段内存里某一页第一次被访问时,内核会:

  1. 挂起触发缺页的线程;
  1. 往 userfaultfd 这个文件描述符里写一条事件,里面有出错的地址;
  1. 等。

另一个线程(甚至另一个进程)从这个文件描述符里读出事件,自己想办法弄到这一页该有的数据,然后调用 ioctl(UFFDIO_COPY):内核把数据拷进那一页、建好页表,再唤醒被挂起的线程。如果这一页应该全是 0,就用 UFFDIO_ZEROPAGE,连数据都不用准备。

这个机制最初是为 QEMU 的“后拷贝”热迁移做的:虚拟机先在目标机器上跑起来,内存页用到哪页再从源机器拉哪页。E2B 用它做的是同一件事,只不过数据源从“源机器”换成了“本地缓存、别的节点或对象存储”。

NBD

NBD(Network Block Device)是 Linux 里一个很老的驱动。加载之后会出现 /dev/nbd0、/dev/nbd1 这样的设备,对系统里的其他程序来说,它们就是普通磁盘,可以格式化、挂载、当虚拟机的硬盘。不同之处在于:对这块“磁盘”的每次读写,内核都会翻译成一条消息,经一个 socket 发给某个用户态程序,叫 NBD 服务端;读请求返回什么数据,写请求落到哪里,全由服务端决定。

名字里有 Network,是因为它最初用来把远程机器上的磁盘挂到本地。但 socket 不一定要跨网络:E2B 用 socketpair 在本机建一对互通的 Unix socket,一端交给内核的 nbd 驱动,另一端由 orchestrator 自己读,于是 NBD 服务端就是 orchestrator 里的几个协程,所谓“进程内的 NBD 服务”。

E2B 需要它,是因为 Firecracker 的磁盘只认一个文件路径,它对文件做的就是普通的读和写。可 E2B 想要的磁盘是“只读模板之上叠一层这台沙箱自己的修改”,而且模板的大部分块还在远端,读到才去取。这种逻辑没法塞进 Firecracker,于是用 NBD 造一个假磁盘交给 Firecracker,真正的逻辑写在服务端。

build 和 header

E2B 里每一份快照都叫一个 build,有自己的 build ID。模板构建出来是一个 build,沙箱每暂停一次产生一个新的 build。对象存储里按 build ID 分目录,每个目录下有 memfile、rootfs.ext4、snapfile、metadata.json,以及两份索引:memfile.header 和 rootfs.ext4.header。

header 是本文的关键。它回答一个问题:这份内存(或磁盘)的第 N 块,数据存在哪个 build 的文件里、在那个文件的什么位置。有了它,一个 build 就不用存完整的数据,只存自己改过的块。

整体架构

下图是 E2B 的组件和它们之间的关系,可以点按钮依次看控制面、沙箱流量和跨机的数据路径。

控制面决定沙箱放在哪台节点、 记录它在跑; 节点上的 orchestrator 负责 真正把虚拟机跑起来。 沙箱流量和快照数据 都不经过 API。 浏览器 / HTTP 客户端 3000-<沙箱>.域名 SDK / CLI 控制面 client-proxy 按域名查 Redis 找节点 API 放置 · 生命周期 · 鉴权 PostgreSQL 模板 · build · 快照记录 Redis 运行中的沙箱 · 路由表 peer:<buildID> → 节点 gRPC:Create / Pause / Resume 转发到节点 :5007 节点 A 节点 B orchestrator orchestrator 每台节点一个,Go 写的 每台节点一个,Go 写的 UFFD 处理器 NBD 服务 ChunkService 本地缓存:模板 + 暂停产生的 diff peerclient gRPC UFFD 处理器 NBD 服务 本地缓存 Firecracker 微虚拟机 内存 ← UFFD · 磁盘 ← /dev/nbdN envd 进程 / 文件 API Firecracker 微虚拟机 在别的节点上恢复的沙箱 envd 对象存储 GCS / S3(按 build ID 分目录) {buildID}/memfile rootfs.ext4 snapfile metadata.json memfile.header rootfs.ext4.header 异步上传 上传完成后从这里读

    控制面这边:

    节点这边,每台机器一个 orchestrator,每个沙箱一个 Firecracker 进程,虚拟机里跑着一个叫 envd 的小守护进程,SDK 让沙箱“执行命令”“读写文件”,最终都是 envd 在做。

    和本文有关的是图里节点内部的几样东西:UFFD 处理器给虚拟机供内存,NBD 服务给虚拟机供磁盘,本地缓存放着模板和暂停产生的 diff,ChunkService 让别的节点能读这份缓存。最底下的对象存储(GCS 或 S3)是所有 build 的最终归宿。

    这里有一条设计原则:控制面只决定沙箱放在哪,数据不经过它。不管是用户访问沙箱的流量,还是几个 GB 的内存快照,都在节点之间、节点和对象存储之间直接流动。

    沙箱就是被恢复的快照

    E2B 的出发点是一句话:沙箱就是一个被恢复的快照。

    模板不是一个等着启动的磁盘镜像,而是一台已经开过机的虚拟机的快照。构建模板时,template-manager 会真的启动一台 Firecracker,把 Docker 镜像的内容装进去,执行用户写的构建步骤,跑起启动命令,然后拍快照,把四个文件传到对象存储。

    之后的三种操作走的都是同一条路:

    暂停产生的快照和模板的格式完全一样,都是一个 build,只是它的文件里存的是相对父 build 的差异。所以对 orchestrator 来说,“从模板新建”和“从暂停恢复”没有区别,都是给我一个 build ID,我把它恢复出来。这一点对跨机很重要:任何一台节点,只要能读到这个 build 和它的祖先,就能恢复这台沙箱。

    diff 链与 header

    沙箱暂停一次,内存里通常只有一小部分页被改过,磁盘上也只有少量块被写过。每次都把 2 GiB 内存和几 GiB 磁盘完整存一份,存储和上传都扛不住。所以 E2B 的每个 build 只存相对父 build 改过的块,一串 build 形成一条 diff 链:模板 T → 第一次暂停 P1 → 第二次暂停 P2 → ……

    问题是恢复时怎么知道某一块该从哪个 build 取。最笨的办法是从 P2 开始往回找,P2 没有就看 P1,再没有看 T。E2B 不这么做,它在每个 build 里放一份 header,直接记下完整视图:每一段连续的块属于哪个 build、在那个 build 文件里的偏移。下面的演示从模板开始,暂停两次,再看恢复时怎么查表:

    各 build 在对象存储里的文件 每格一块:内存里是一页,根文件系统里是 4 KiB T模板 P1暂停 1 P2暂停 2 header 当前最新 build 的完整视图

      header 的结构在 packages/shared/pkg/storage/header 里,核心是两部分。

      一部分是 Metadata:块大小 BlockSize、设备总大小 Size、本 build 的 BuildId、最底层模板的 BaseBuildId,以及代数 Generation,每暂停一次加一。

      另一部分是映射表,由一条条 BuildMap 组成:

      type BuildMap struct {
          Offset             uint64    // 在完整视图里的起始字节
          Length             uint64    // 长度
          BuildId            uuid.UUID // 数据存在哪个 build 里
          BuildStorageOffset uint64    // 在那个 build 文件里的偏移
      }

      暂停时生成新 header 的方法很直接:拿父 build 的映射表,把这次改过的块盖上去,盖上去的那些块指向新 build。新 build 的文件里,改过的块是紧凑排列的:第 2、3、9 块被改了,文件里就只有三块,依次放在 0、1、2 号位置,header 负责记住“完整视图第 9 块 = P1 文件第 2 块”。全是 0 的块不存数据,映射到一个特殊的空 build,读的时候直接返回 0。

      几个粒度值得记一下:根文件系统的映射以 4 KiB 为一块(RootfsBlockSize);内存的映射以页为单位(普通页 4 KiB,大页 2 MiB);而从对象存储取内存数据时,一次取一个 4 MiB 的 chunk(storage.MemoryChunkSize)。映射粒度细,是为了暂停时只存真正改过的东西;取数据的粒度粗,是因为对象存储的每次请求都有固定的延迟,一次多取一些,邻近的页之后就不用再走网络。

      header 本身也有版本:V3 不压缩,V4 支持按帧压缩数据文件并在 header 里记录帧表,V5 把映射表改成更紧凑的列式编码。这些细节不影响理解主线。

      暂停:怎么算出差异

      暂停请求到了 orchestrator,大致按这个顺序走:

      1. 停住虚拟机,让 Firecracker 写出 snapfile。
      1. 找出内存里的脏页。默认问 Firecracker 要一份脏页集合,它从内核的页表信息里读出哪些页被写过;如果沙箱恢复时开了 userfaultfd 的写保护跟踪,也可以直接用 orchestrator 自己记下的集合。脏页内容先拷进一块 memfd(Linux 的匿名内存文件)。
      1. 内存去重(可选)。脏页不一定真的变了:有的页被写过又写回原值,有的页被清零了。orchestrator 在后台把每个脏页和父 build 里同一位置的页逐一比较,内容一样的丢掉,继续指向父 build;全 0 的记为空。只有真正不同的页才写进新 build 的 memfile。
      1. 找出磁盘的脏块。根文件系统的写入本来就进了这台沙箱自己的写时复制缓存(下文 NBD 一节会讲),缓存里被写过的块就是磁盘的 diff。
      1. 生成两份新 header,把这次的 diff 盖在父 header 上。
      1. 放进本地缓存,再异步上传。新 build 先作为一个完整可用的模板登记到本机的模板缓存里,这时暂停请求就可以返回了;上传到对象存储在后台进行。

      第 6 步意味着,暂停刚结束的一段时间里,这个 build 的最新一层只存在于源节点的本地盘上。这段时间有多长,取决于 diff 的大小和网络,几秒到几十秒都有可能。后面讲的跨机机制,处理的主要就是这段窗口。

      恢复内存:userfaultfd

      恢复时,E2B 不让 Firecracker 把 memfile 读进内存。原因很简单:大部分内存页在恢复后的几秒里根本不会被碰到,提前读是浪费;而且 memfile 本身是一条 diff 链,散在好几个 build 里,有的还在远端。

      Firecracker 支持一种内存后端叫 UFFD。加载快照时,它只为客户机内存建一片空的匿名映射,把这片内存登记到一个 userfaultfd 上,然后通过一个 Unix socket,把这个文件描述符连同内存区域表(每段区域的起始地址、大小、页大小、对应 memfile 的偏移)一起发给 orchestrator。文件描述符跨进程传递用的是 Unix socket 的 SCM_RIGHTS 附带消息。

      从此,客户机内存的每一页第一次被访问时,都要经过下面这个流程:

      Firecracker 进程 + 内核 orchestrator 进程 客户机 vCPU 线程 mov rax, [0x7f..5000] Linux 内核:缺页处理 这段内存登记给了 userfaultfd userfaultfd 一个文件描述符,读出缺页事件 数据源(按顺序问) 本地缓存 → 对等节点 → 对象存储,按 4 MiB 取 header 查表 偏移 → (build, 存储偏移) UFFD 处理器 地址 − 区域起点 = 偏移 缺页 唤醒 事件 read() ioctl(UFFDIO_COPY) 客户机内存(16 页示意) 未填充 缺页中 已填充 预取填入

        orchestrator 里的 UFFD 处理器(packages/orchestrator/pkg/sandbox/uffd)是一个循环:从 userfaultfd 读事件,用出错地址减去所在区域的起点算出 memfile 里的偏移,查 header 得到“哪个 build、哪个偏移”,然后去取数据。取数据的顺序是本地缓存、对等节点、对象存储,取到的 4 MiB chunk 留在本地缓存里。数据到手后调 UFFDIO_COPY 填页,内核唤醒客户机的 vCPU 线程。

        这样做的代价是首次访问每一页都多了一次缺页往返,数据不在本地时还要加上网络延迟。为了把这部分延迟藏起来,E2B 加了预取。模板的 metadata.json 里有一个 prefetch.memory 字段,记录一串块号和访问类型(读、写):

        {
          "prefetch": {
            "memory": { "indices": [12, 13, 40, 41], "access_types": ["r", "r", "w", "r"], "block_size": 4096 },
            "origin": "build"
          }
        }

        这份列表来自模板构建的最后一个阶段 optimize:构建器把刚做好的模板实际恢复一次,记下恢复后哪些页被碰到、按什么顺序。暂停产生的快照也可以有,做法是暂停后在后台“试恢复”一次收集。真正恢复沙箱时,预取器按列表把相邻的块合并成大段读取,在客户机碰到之前就用 UFFDIO_COPY 填好。

        恢复磁盘:进程内的 NBD 服务

        内存是按页懒加载,磁盘也一样,只不过用的是块设备这一层。下面演示一次读、一次写、再一次读,以及暂停时缓存怎样变成 diff:

        客户机 ext4 /dev/vda Firecracker virtio-blk,后端是文件 内核 nbd 驱动 /dev/nbd3 NBD 服务 orchestrator 里的协程 socketpair Overlay(服务端看到的块设备) 写时复制缓存 本机文件,4 KiB 一块 只读 rootfs header 拼出的 diff 链 缺的块去远端取

          创建沙箱时,orchestrator 从一个设备池里领一个空闲的 /dev/nbdN,建一对或几对 socketpair,通过 netlink 告诉内核:这个 nbd 设备用这些 socket 连到服务端,块大小多少,超时多少。socket 的另一端交给几个协程,它们解析内核发来的 NBD 请求(READ、WRITE、FLUSH、TRIM、WRITE_ZEROES),转给后端处理(pkg/sandbox/nbd)。

          后端是一个 Overlay(pkg/sandbox/block),两层:

          Firecracker 的磁盘配置里填的就是 /dev/nbdN,在客户机里它是 /dev/vda。Firecracker 完全不知道背后有 diff 链和远端存储。

          暂停时,缓存里被写过的块就是这次的磁盘 diff。新版本还可以在暂停时只把缓存“摘下来”,在后台用 reflink(文件系统层面的写时复制拷贝)封存成 diff 文件,暂停请求不用等这一步。

          NBD 的代价是每次 I/O 都要从内核经 socket 到用户态再回来,多了上下文切换和数据拷贝。Kimi 的 AgentENV 在同样的位置用的是 ublk,靠 io_uring 和共享内存把这部分开销压得更低,见 Kimi AgentENV:大规模 Agent 训练的执行环境。E2B 选 NBD,换来的是一个所有内核都有、行为稳定的老驱动。

          跨机:调度、对等节点和对象存储

          有了上面这些,就可以讲跨机的部分了。设想沙箱 S 在节点 A 上暂停,产生 build P2,它的父层是 P1 和模板 T。现在用户要恢复 S。

          节点 A(源节点) 本地缓存 P2 的 memfile / rootfs diff、header ChunkService(peerserver) P2 上传到对象存储 Redis 对等节点登记表 API 放置 恢复时优先源节点 节点 B routingProvider 每个 build 各自找来源 peerStorageProvider(P2) 对象存储 GCS / S3 gRPC ChunkService

            第一选择:放回源节点

            API 的快照记录里存着这个快照是在哪台节点上拍的(origin node)。恢复时,API 把它作为首选节点传给放置算法;只要这台节点的 CPU 型号兼容、orchestrator 版本支持需要的功能,就直接放上去,不参与随机挑选(packages/api/internal/orchestrator/placement)。CPU 要检查,是因为快照里保存了 vCPU 的状态,换一种不兼容的 CPU 恢复会出问题。

            放回源节点的好处是显而易见的:P2、P1、T 都在它的本地缓存里,内存缺页和磁盘读全部命中本地,一次对象存储读都不需要。

            分叉是例外。从一个快照分叉出几十个沙箱,全挤在源节点上不合理,所以 fork 出来的沙箱不带首选节点,正常分散。

            换一台节点:先问源节点

            源节点满了,API 只好把 S 放到节点 B。B 要恢复 P2,需要 P2、P1、T 三层的数据。T 和 P1 早就传到了对象存储,B 自己的缓存里可能也有;麻烦的是 P2,它可能还在上传。

            E2B 的做法是让 orchestrator 之间直接互读。这套机制在 packages/orchestrator/pkg/sandbox/template/peerclient 和 peerserver 里,由一个功能开关控制:

            1. 登记。A 暂停完、把 P2 放进本地缓存的同时,在 Redis 里写一条 peer:P2 → A 的地址,带过期时间。
            1. 按层路由。B 打开 P2 时,对每个 build 单独查 Redis:P2 有记录,就从 A 读;P1、T 没有记录,就走自己的缓存或对象存储。一条 diff 链里的不同层,可以来自不同的地方。
            1. 读数据。A 的 orchestrator 上跑着一个 gRPC 服务 ChunkService,只有四个方法:
            方法作用
            GetBuildFileSize 问 memfile / rootfs.ext4 的大小
            GetBuildFileExists 问 snapfile、metadata.json 是否存在
            ReadAtBuildSeekable 按偏移和长度读 memfile / rootfs.ext4,流式返回
            GetBuildBlob 整个取回 snapfile、metadata.json、两份 header,流式返回

            A 从自己的本地 diff 缓存里找到数据,按 1 MiB 一条消息发回。B 的 UFFD 处理器和 NBD 服务并不知道数据来自 A,它们只是调用存储接口的 ReadAt,下面的 peerStorageProvider 决定去哪里读。

            1. 切换。每个响应都带一个 PeerAvailability,里面有两个标志。not_available 表示 A 这里没有,B 退回对象存储。use_storage 表示 A 已经传完了,B 收到后把这一层的 uploaded 标志置位,此后这一层的读全部直接走对象存储,不再打扰 A。A 这边上传一完成,也会删掉 Redis 里的 peer:P2。

            所以对等读只是一条加速路径,不是必经之路:A 不可达、Redis 里没记录、A 说没有,B 都会退回对象存储。它解决的只是“刚暂停完、还没传完”这段窗口里,别的节点也能马上恢复,而不是等上传结束。

            一个细节:临时 build ID(#3166)

            回到暂停流程的第 3 步:内存去重。去重分两段,先比较(每个脏页和父层比,得出哪些页要存、哪些丢掉,产出 header),再整理(把要存的页紧凑地写进新的 memfile)。脏页多的时候,比较要花好几秒。

            问题出在:去重比较完之前,新 build 没有 header,也就没法恢复。如果沙箱刚暂停,用户马上又要恢复,请求落回源节点,就只能干等去重。PR #3166 解决了这个问题,同时小心地把影响限制在源节点内部。下面按时间顺序演示:

            时间 → 去重(A 后台) A 上的恢复 B 的对等读 memfd 临时 header 只在节点 A 内存里 持久 header 去重后,会上传 父层(T / P1) 临时 ID,数据在 memfd 原位 P2,紧凑存放 空(全 0)

              思路是:恢复真正需要的数据,“父层 + 脏页”,在暂停那一刻就齐了,脏页就躺在 memfd 里;慢的只是为上传做的压缩整理。于是暂停时先生成一份临时 header(ToProvisionalDiffHeader):所有脏页都算在一个新造的临时 build ID 名下,存储偏移就等于设备偏移,因为数据原样放在 memfd 里,不用紧凑排列。同一台节点上的恢复拿这份 header 立刻就能开始,缺页时直接从 memfd 读。去重比较完成后,得到真正的 header,orchestrator 用 compare-and-swap 把读路径换过去;memfd 要等换完才释放。

              这个临时 build ID 有一个致命的局限:它只在源节点上有意义。它没有登记在 Redis 里,对象存储里也没有这个目录,数据只在 A 的一块内存文件里。如果它泄露出去,拿到它的人就会得到一张读不通的映射表。所以 PR 里所有可能把 header 传出源节点的地方,都改成了只认“持久 header”:

              上传的始终是持久 header 和去重后的数据,存进对象存储的东西和改动前一个字节都不差。PR 描述里的测试数据:人为让去重比较卡 15 秒,紧跟暂停的同机恢复从约 14.9 秒降到约 0.5 秒。这个功能由开关 memfd-dedup-inflight-serve 控制,默认关闭,逐步放量。

              把整条路串起来

              最后用一次跨机恢复,把前面的东西串一遍:

              1. 沙箱 S 在 A 上暂停。内存脏页进 memfd、去重;磁盘写缓存里的脏块成为 diff;生成新 header,build P2 登记进 A 的本地缓存和 Redis,后台开始上传。
              1. 用户恢复 S。API 首选 A,A 满了,放到 B。
              1. B 的 orchestrator 打开 P2:查 Redis 发现 P2 在 A 上,从 A 取 snapfile、metadata 和持久 header;P1、T 走自己的缓存或对象存储。
              1. B 给新的 Firecracker 准备磁盘:领一个 /dev/nbdN,后端是 Overlay,只读层是按 P2 的 rootfs header 拼出的 diff 链,上层是一块空的写缓存。
              1. Firecracker 加载 snapfile,内存走 UFFD,把 userfaultfd 交给 B 的 orchestrator。预取器按 prefetch 列表开始填热页。
              1. 虚拟机接着跑。每次缺页、每次磁盘读,都先查 header 定位到 build,再按“本地缓存 → A → 对象存储”的顺序取数据。
              1. A 的上传完成,回答 use_storage,B 之后对 P2 的读改走对象存储。

              回头看,E2B 的跨机设计可以概括成三句话。数据格式上,每个 build 只存差异,header 给出完整视图,所以任何节点都能拼出任何快照。数据通路上,内存靠 userfaultfd、磁盘靠 NBD,都是用到才取,所以恢复不用等数据搬完。调度上,能回源节点就回源节点,回不去就先从源节点读、等上传完再换对象存储,所以暂停后马上在别处恢复也不必等上传。

              代码位置

              内容路径
              header、映射、临时 header packages/shared/pkg/storage/header
              UFFD 处理器、预取 packages/orchestrator/pkg/sandbox/uffd
              NBD 设备池和请求分发 packages/orchestrator/pkg/sandbox/nbd
              Overlay、写缓存、去重 packages/orchestrator/pkg/sandbox/block
              对等读的客户端和服务端 packages/orchestrator/pkg/sandbox/template/peerclient、peerserver
              ChunkService 定义 packages/orchestrator/chunks.proto
              放置与首选节点 packages/api/internal/orchestrator/placement
              架构总览 docs/ARCHITECTURE.md