跳到主要内容

顶层架构:控制面、数据面与一次"创建沙箱"的旅程

30 秒导读: E2B Infra 把"要不要开一台沙箱、开在哪台机器"和"真的把 Firecracker 虚拟机跑起来"拆成了两个服务。前者叫 API(控制面,做决策),后者叫 orchestrator(数据面,干脏活)。它们只通过一份 gRPC 契约说话。本章带你从一个 POST /sandboxes 请求进来,一路走到某台机器上真的诞生一台 microVM,把这条主线钉死。

本章是全景章。更深的机制——VM 生命周期、秒级恢复、块存储、网络隔离、边缘路由——分别在 0206。这里只讲"谁负责什么、调用链怎么走、状态存哪"。


1. 先分清两个面:控制面 vs 数据面

E2B 要解决的核心难题是:海量用户随时要开/关沙箱,得有人决定资源怎么分,又得有人真的把虚拟机拉起来。 把这两件事塞进一个进程会很糟——决策逻辑(鉴权、配额、选机器)是无状态、可水平扩展的;而跑 VM 需要 root、要摸 Firecracker、要管内存快照,天生绑在具体机器上。

所以 E2B 按这条线切开:

服务职责跑在哪需要 root
控制面API (packages/api)鉴权、解析模板、查配额、选节点、转发请求若干无状态副本
数据面orchestrator (packages/orchestrator)真正启动/恢复/暂停 Firecracker microVM每台"沙箱宿主机"上一个
边缘client-proxy (packages/client-proxy)把用户流量路由到具体沙箱所在的机器边缘副本

一句话直觉: API 像餐厅前台(记单、分桌、算账),orchestrator 像后厨(真的炒菜),client-proxy 像传菜员(把菜端到对的那桌)。前台可以开很多个,后厨绑在灶台上。

一个关键事实:API 自己不碰 Firecracker 一行代码。 它做完所有决策后,把一个 SandboxCreateRequest 通过 gRPC 丢给某台机器上的 orchestrator,由后者去拉 VM。这就是控制面/数据面分离的落地。


2. 顶层全景图

先给一张"怎么读":从上到下是一次创建沙箱的控制流,左边是 API(决策),右边是被它调用的 orchestrator(执行);底部虚线框是状态存储,被多个部件读写。

用户 / SDK
│ POST /sandboxes (REST + API Key)

┌───────────────────────────────────┐
│ API (控制面 · packages/api) │
│ │
│ ① 鉴权中间件 (API Key → Team) │
│ ② PostSandboxes 解析模板/校验参数 │
│ ③ CreateSandbox 预留配额 + 组装请求 │
│ ④ PlaceSandbox 选一台节点 │
└───────────────┬───────────────────┘
│ gRPC SandboxService.Create

┌───────────────────────────────────┐
│ orchestrator (数据面 · 每台宿主机) │
│ │
│ ⑤ 限流 (每节点并发上限) │
│ ⑥ 取模板快照 → ResumeSandbox │
│ ⑦ 真的启动 Firecracker microVM │
└───────────────┬───────────────────┘
│ VM 内

envd 守护进程 (见 06)

─ ─ ─ ─ ─ ─ ─ ─ ─ 状态存储(被 API 读写)─ ─ ─ ─ ─ ─ ─ ─ ─
Postgres: 模板/构建/团队/卷/快照 Redis: 配额预留·活跃沙箱·路由表 ClickHouse: 分析指标

每个部件一句话职责:

部件干什么关键文件 · 符号
API 入口装配 Gin、注册路由、起 HTTP + 两个 gRPC 监听packages/api/main.go · NewGinServer / run
APIStoreAPI 的"大对象",持有所有下游客户端(DB/Redis/orchestrator 等)packages/api/internal/handlers/store.go · APIStore
创建沙箱 handlerREST 层主逻辑:解析模板、校验、组装元数据packages/api/internal/handlers/sandbox_create.go · PostSandboxes
Orchestrator(API 侧)编排决策层:配额预留、选节点、发 gRPCpackages/api/internal/orchestrator/create_instance.go · CreateSandbox
节点选择在候选节点里挑一台、失败重试packages/api/internal/orchestrator/placement/placement.go · PlaceSandbox
gRPC 服务端数据面:真的跑 Firecrackerpackages/orchestrator/pkg/server/sandboxes.go · Server.Create
服务间契约proto 定义的 6 个 RPCpackages/orchestrator/orchestrator.proto · service SandboxService

3. 主线走一遍:一次 POST /sandboxes 的旅程

这是本章的骨。我们按请求实际经过的顺序,分 7 站讲。每一站给"它在干嘛 + 真源码锚点",不贴大段代码。

站 0 · 进程启动时装好了什么

run() 里把所有东西拼起来:建 APIStore(持有 DB、Redis、orchestrator 客户端),然后同时起三个监听——一个 HTTP(REST,给 SDK)和两个 gRPC(一个内部、一个给 edge/client-proxy)。见 packages/api/main.go:441(NewAPIStore)、:494(HTTP)、:521:532(两个 gRPC Serve)。

REST 路由由 OpenAPI 生成的代码统一注册:api.RegisterHandlersWithOptions(r, apiStore, …)(main.go:234)。apiStore 实现了生成的 ServerInterface(store.go:65var _ api.ServerInterface = (*APIStore)(nil)),所以 POST /sandboxes 就落到 APIStore.PostSandboxes

站 1 · 鉴权:API Key → Team

请求先过一串 Gin 中间件。核心是 OpenAPI 校验中间件里挂的 AuthenticationFunc(main.go:189CreateAuthenticationFunc),它按顺序试多种认证器:

// packages/api/main.go:189 —— 示意,非源码结构简化
auth.NewApiKeyAuthenticator(apiStore.GetTeamFromAPIKey) // 团队 API Key
auth.NewAccessTokenAuthenticator(apiStore.GetUserFromAccessToken)
auth.NewAuthProviderBearerAuthenticator(...) // OIDC JWT
// …还有 admin token 等

认证成功后,团队信息被塞进 gin context;handler 里用 auth.MustGetTeamInfo(c) 取出(sandbox_create.go:63)。这一站决定了"你是谁、你属于哪个团队、你的配额是多少",后面选节点、限并发都靠它。

站 2 · REST handler:把"模板名"翻译成"能跑的构建"

进入 PostSandboxes(sandbox_create.go:59),它做一连串纯决策/校验,不碰 VM:

  1. 解析模板引用:id.ParseName(body.TemplateID) 拆出 identifier + tag(:82)。
  2. 解析别名:templateCache.ResolveAlias(...)(:91)把用户写的别名(可能是 team-slug/my-template)解析成真正的 TemplateID
  3. 取模板 + 构建:templateCache.Get(...)(:100)拿到 env(模板)和 build(某次具体构建:内核版本、Firecracker 版本、envd 版本、CPU/内存/磁盘规格)。同时做团队可见性检查。
  4. 生成沙箱 ID:InstanceIDPrefix + id.Generate()(:124),即 i 开头的随机串。
  5. 参数校验:超时不得超过团队 MaxLengthHours(:157);secure 标志、network(出入站规则)、volumeMounts(持久卷)各有一大段校验(:187:285)——这些是"前置校验",把非法请求挡在真正开 VM 之前。

最后它把所有决策结果打包成一个闭包 getSandboxData(:287),延迟到"配额预留成功后"才求值——这样拿到的是最新数据。然后调用 a.startSandbox(...)(:307)。

为什么用闭包? 注释点破了:数据"不会被同一沙箱上的其它操作影响,所以可安全复用"(sandbox_create.go:288)。真正的调用点在配额锁拿到之后,见站 3。

站 3 · 编排层:预留配额、组装 gRPC 请求

startSandbox 只是薄封装(sandbox.go:23),真身是 startSandboxInternal(:51),它生成一个 executionID(一次"从启动到停止"的唯一 ID)并调用 a.orchestrator.CreateSandbox(...)(sandbox.go:69)。

进入 API 侧的 Orchestrator.CreateSandbox(create_instance.go:135),这是编排决策的核心:

a) 配额预留(并发上限)。sandboxStore.Reserve(...)(create_instance.go:154),用团队的 SandboxConcurrency 限制并发数。若超限,直接返回 429 Too Many Requests(:161)。这个预留是 Redis 支撑的(见 §5),所以多个 API 副本共享同一个配额账本。

b) 去重并发创建。 若同一沙箱正在被别的请求创建,Reserve 返回一个 waitForStart,当前请求就"搭车"等它好(:178:201),而不是开第二台。

c) 求值闭包 + 组装请求。 拿到 sbxData(站 2 那个闭包的结果,:214),把它翻译成一个 gRPC 消息 SandboxCreateRequest(:272)——里面塞进模板 ID、构建 ID、CPU/内存、内核/Firecracker/envd 版本、网络配置、卷挂载、是否 Snapshot(恢复)等等。这就是控制面递给数据面的"工单"

站 4 · 选一台节点

组装好工单后要决定"发给哪台机器"。CreateSandbox 收集候选节点(GetClusterNodes,:317),算出所需标签(generateRequiredNodeLabels,:319),然后交给 placement.PlaceSandbox(...)(:321)。

PlaceSandbox(placement/placement.go:43)是一个带重试的放置循环:

选节点 (BestOfK 算法) ──► node.SandboxCreate(gRPC) ──► 成功? ──► 返回该节点
▲ │
│ ResourceExhausted(该节点满了) │ 其它错误
└──────────── 跳过它,换一台 ◄──────────────────┘ 记为失败,排除后重试
  • 选节点用 BestOfK 算法(placement.NewBestOfK,随机采 K 个候选挑最优,orchestrator.go:128),兼顾负载均衡与"别把请求都堆到一台"。
  • 真正发起创建的是 node.SandboxCreate(ctx, sbxRequest)(placement.go:118)。
  • 若节点回 codes.ResourceExhausted(满了),不算硬失败,换一台继续(:150);其它错误则把该节点排除、计一次 attempt(:153)。
  • 恢复(resume)特例:如果是从快照恢复,会优先尝试快照所在的原节点(create_instance.go:306isResume && sbxData.NodeID != nil),因为那台机器的缓存最热。

node.SandboxCreate 本身极薄——它就是发一次 gRPC(nodemanager/sandbox_create.go:11):

// packages/api/internal/orchestrator/nodemanager/sandbox_create.go:9
func (n *Node) SandboxCreate(ctx context.Context, sbxRequest *orchestrator.SandboxCreateRequest) error {
client, ctx := n.GetSandboxCreateCtx(ctx, sbxRequest)
_, err := client.Sandbox.Create(ctx, sbxRequest) // ← 跨进程,进入数据面
return err
}

到这里,控制面的活基本干完了。 边界正式跨过:下一站在另一个进程、另一台机器上。

站 5 · 数据面:orchestrator 真的跑 VM

gRPC 落到 Server.Create(packages/orchestrator/pkg/server/sandboxes.go:75)。这是数据面主 handler,概览:

  1. 每节点限流:先查本机运行中的沙箱数是否超过 MaxSandboxesPerNode(:139),超了回 ResourceExhausted(于是 API 侧会换节点重试);再用信号量 startingSandboxes 限制"同时正在启动"的数量(:155)。
  2. 取模板快照:templateCache.GetTemplate(...)(:164)拿到 rootfs / 内存快照数据(细节见 03/04)。
  3. 启动 VM:根据快照类型分流(:230)——纯文件系统快照走 RebootSandbox(冷启动),内存快照走 ResumeSandbox(秒级恢复)。这一步才真正 fork 出 Firecracker 进程(细节见 02)。
  4. 挂生命周期 + 发事件:setupSandboxLifecycle(:272)起一个 goroutine 等 VM 退出并清理;sbxEventsService.Publish(:289)发"沙箱已创建"事件。
  5. 回执:返回 SandboxCreateResponse{ClientId, SchedulingMetadata}(:307)。注意它不返回 VM 本身——VM 留在这台机器上,回给 API 的只是"我是谁(ClientId)、调度元数据"。

站 6 · 落状态,回给用户

gRPC 成功返回后,回到 API 侧 CreateSandbox:构造内部 sandbox.NewSandbox(...) 模型(create_instance.go:352),然后 sandboxStore.Add(...)(:384)把这台活跃沙箱写入状态存储(Redis + 路由表,见 §5)。若写失败,还会异步把刚建的 VM 杀掉回滚(:391)。

一路返回到 REST 层,PostSandboxes 最后 c.JSON(http.StatusCreated, &sbx)(sandbox_create.go:336),201 Created 带着沙箱信息回到 SDK。旅程结束。


4. 服务间契约:那份 gRPC proto

API 和 orchestrator 之间唯一的对话方式,是 packages/orchestrator/orchestrator.proto 里定义的 SandboxService。这是理解全系统的关键锚点——它就是控制面能对数据面下的全部命令:

RPC干什么数据面 handler
Create启动/恢复一台沙箱Server.Create (sandboxes.go:75)
Update改超时 / 改出站网络规则Server.Update (sandboxes.go:337)
List列出本节点在跑的沙箱Server.List (sandboxes.go:458)
Delete杀掉一台沙箱Server.Delete (sandboxes.go:489)
Pause打内存/文件快照后暂停Server.Pause (sandboxes.go:596)
Checkpoint快照但不停(留存快照后继续跑)Server.Checkpoint (sandboxes.go:695)

proto 定义在 orchestrator.proto:206,生成的 Go 代码在 packages/shared/pkg/grpc/orchestrator/(orchestrator_grpc.pb.go 等)。API 和 orchestrator 都 import 同一个生成包,所以类型天然对齐——这也是为什么 §3 站 3 组装的那个 SandboxCreateRequest 能被站 5 直接 req.GetSandbox().GetXxx() 读出来。

本章只深读了 Create。其余 5 个只点到:比如 DeleteMarkStopping 把沙箱移出可路由集合再异步 Stop(sandboxes.go:520/:540);Pause 把快照上传拆成后台重试任务(uploadSnapshotAsync,:1001)。这些是 02 及后续章的料。

client-proxy 在架构里的位置

client-proxy(packages/client-proxy,进程名 proxy)是边缘入口:用户连到某个沙箱(比如访问沙箱里跑的 web 服务)时,流量先到 client-proxy,它查路由表把连接转到"该沙箱所在那台机器"。它读的正是 API 写进 Redis 的路由目录(e2bcatalog,见 §5)。

注意本系统有两条入口路径:创建/管理沙箱走 REST → API(本章主线);连到已存在沙箱走 client-proxy。API 甚至专门为 client-proxy 开了一个"edge gRPC"监听并带 OIDC 校验(main.go:459:467)。client-proxy 的路由细节和"按需恢复"机制留给 06,本章只交代它的定位。


5. 状态存哪:谁负责记账

E2B 的状态刻意分散在三套存储里,各记各的账。理解这点,才知道"沙箱到底存在哪"。

存储记什么谁写 · 锚点
Postgres (sqlc)模板、构建、团队/配额、持久卷、快照元数据APIStore.sqlcDB,如 templateCache.GetGetVolumesByName (sandbox_create.go:505)
Redis① 并发配额预留 ② 活跃沙箱状态 ③ 沙箱→节点 路由目录Orchestrator 三处后端,见下
ClickHouse分析/用量指标(异步)APIStore.clickhouseStore (store.go:116)

Redis 承担了最"活"的三份状态,都在 API 侧 Orchestrator 装配时接线(orchestrator.go):

  • 路由目录 routingCatalog = e2bcatalog.NewRedisSandboxCatalog(redisClient)(orchestrator.go:111)——沙箱创建成功后 addSandboxToRoutingTableroutingCatalog.StoreSandbox(...) 写入(lifecycle.go:41),供 client-proxy 查"这个沙箱在哪台机器"。
  • 活跃沙箱存储 redisStorage = redisbackend.NewStorage(...)(orchestrator.go:130),作为 sandboxStore 的后端(:157)。
  • 配额预留 redisreservations.NewReservationStorage(...)(orchestrator.go:159),支撑 §3 站 3 的 Reserve

一个重要的架构含义: orchestrator(数据面)才是"某台机器上到底在跑哪些 VM"的真相源。API 侧的活跃沙箱列表是从各节点同步来的缓存——keepInSync(cache.go:32)周期性调用每个 Node.Sync(nodemanager/sync.go:17,内部走 gRPC List)把节点上报的实际状态刷进 store。所以 API 重启不会丢沙箱:它会从节点重新学到。代码里也留了注释坦白这点:"right now we load them from Orchestrator"(orchestrator.go:113)。


6. 边界:本章讲到哪为止

  • 不讲 Firecracker 怎么被拉起来——站 5 的 ResumeSandbox/RebootSandbox 内部(内存/rootfs/网络/进程),是 0205
  • 不讲秒级恢复的原理——GetTemplate 背后的内存快照 + userfaultfd 惰性缺页在 03;写时复制块存储在 04
  • 不讲边缘路由/按需恢复——client-proxy 如何把请求送到具体沙箱、如何触发恢复,在 06
  • 不讲模板构建——template-manager / 构建流水线不在本章主线内。

本章只钉一件事:控制面(API)做决策、数据面(orchestrator)跑 VM、两者靠一份 gRPC 契约对话、状态分散在 Postgres/Redis/ClickHouse。


7. 代码地图(导航索引)

主题文件路径符号
进程装配:HTTP + 双 gRPC 监听packages/api/main.gorun / NewGinServer
鉴权器链packages/api/main.goCreateAuthenticationFunc
API 大对象(持有所有下游客户端)packages/api/internal/handlers/store.goAPIStore / NewAPIStore
创建沙箱 REST handlerpackages/api/internal/handlers/sandbox_create.goPostSandboxes
REST→编排 桥接packages/api/internal/handlers/sandbox.gostartSandbox / startSandboxInternal
API 侧编排:预留+组装+选点packages/api/internal/orchestrator/create_instance.goOrchestrator.CreateSandbox
API 侧 Orchestrator 装配packages/api/internal/orchestrator/orchestrator.goOrchestrator / New
节点放置(带重试)packages/api/internal/orchestrator/placement/placement.goPlaceSandbox
放置算法packages/api/internal/orchestrator/placement/placement_best_of_K.goNewBestOfK
单次 gRPC 创建调用packages/api/internal/orchestrator/nodemanager/sandbox_create.goNode.SandboxCreate
节点状态同步packages/api/internal/orchestrator/cache.gokeepInSync / syncNodes
数据面主 handler(跑 VM)packages/orchestrator/pkg/server/sandboxes.goServer.Create
数据面其余 RPCpackages/orchestrator/pkg/server/sandboxes.goUpdate / List / Delete / Pause / Checkpoint
服务间契约(proto)packages/orchestrator/orchestrator.protoservice SandboxService
生成的 gRPC 代码packages/shared/pkg/grpc/orchestrator/orchestrator_grpc.pb.go
边缘入口packages/client-proxy/main.gorun