调研对象:E2B、CubeSandbox、OpenSandbox、agent-sandbox(源码级)
前五篇讲单个沙箱。本篇讲集群侧:创建落在哪台节点、资源怎么记账、满了怎么拒绝或排队、池与节点如何伸缩。
一、问题
编排层主要是五件事:
- 「节点选择」:创建请求落到哪台机器;节点信息从哪来,过期了怎么处理
- 「容量与限额」:创建规格怎么声明,上限怎么真正卡住;一台节点「还能再装几个」按什么口径算
- 「计量与核算」:实际用量怎么采、按什么口径记账、数据给谁用(选点、监控、核对谁还在跑);宿主与 guest、快照前后的数字如何避免算乱
- 「饱和与拒绝」:满了是排队还是立刻失败;错误码能不能让客户端知道该不该重试
- 「伸缩与预热」:池怎么补、节点怎么加;补池的写入别跟业务创建抢控制面(如 apiserver)的处理能力
一个贯穿判断:沙箱如果就是 Pod,节点选择可以交给 kube-scheduler;沙箱如果是自己管的 VM 进程,才需要自写选点逻辑。编排复杂度跟基础设施成正比,「调度器」本身反而在变可选。
四家不在同一层:
- Cube:自管节点,在 CubeMaster 上过滤 + 打分
- E2B:自研 slot 编排,在 API 侧做 Best-of-K 选点
- OpenSandbox:Docker 几乎无调度;K8s 外包给 kube-scheduler;fsb(Firecracker 快路径)自管 Fastlet 池
- agent-sandbox:不写调度器,力气花在 warm pool 补货节奏和多集群 fleet
信息流同构:节点上报 → 中心缓存 → 选点逻辑消费。差别在选点逻辑有多复杂。
限额从两个口径来看:
- 「逻辑配额」:创建时声明的 CPU / 内存上限,是隔离上限
- 「物理占用」:真正进物理内存的页,以及 CoW 下每个沙箱实际摊到的那一份------容量规划看的是这个,不是逻辑配额简单相加
二、四家怎么做
1. CubeSandbox
「节点选择」
调度器是 kube-scheduler 的微缩版,四步走完:
- 解析约束:规格、模板 ID、亲和、显式指定的 host
- 过滤:不健康、指标过期(超过
metric_update_timeout300s)、已超 MVM 上限、「本机没有模板副本」 (template_locality)、创建并发过高、磁盘水位。指标过期的节点直接踢出候选,不是打个低分继续比 - 打分:四个因子加权------现有沙箱数、在途创建数、CPU 配额占用、内存配额占用
- 终选:最高分里取 top-N(默认 3)再随机挑一个,避免所有创建同时砸到同一台
节点元数据链路:Cubelet 约每秒上报 → CubeOps 落库 → CubeMaster 缓存后供调度读。扩容后新节点「注册成功」还不够------必须 tpl redo 把模板副本同步过去;否则 template_locality 会一直滤掉新节点,创建会持续落旧机,或者直接失败。
「容量与限额」
创建请求携带规格(instance_type、CPU / 内存等)。真正把上限卡住靠 cgroup:宿主侧有 sandbox cgroup,guest 里每个容器还有自己的 cgroup。规格写 2 GiB,表示最多能用到 2 GiB,并不表示一创建就立刻占满 2 GiB。
节点配额由 Cubelet 申报(CPU / 内存 / 最大 MVM 数等),不是 CubeMaster 中心一张总表。配置写 0 或空,表示按宿主资源推默认值,文档强调「不是无限」。调度打分看这些配额的占用比例;另有创建并发闸门,避免镜像拉取、磁盘、VMM 被突发打穿。
密度来自 「CoW 页共享」(多个沙箱先共享同一份物理页,谁改写谁才复制),不是 CPU 时间超卖:
- 同模板多个沙箱,未改写的内存页继续共享模板物理页
- 规格 2 GiB 的沙箱,空闲时每个实际摊到大约只有 ~25 MB
- 375 GiB 机器按限额硬除约
375 ÷ 2 ≈ 185(满载上界);轻载可到上千,因为多数页尚未写入、仍在共享 - 限额是隔离天花板;容量规划看的是「每个摊多少 × 期望写入率」,不是把限额简单相加
还有一个硬顶:TAP 池默认 500。虚拟网卡名额用完,内存有余也会在网络分配上卡住------冲 1000 密度前要先把池调大。
「计量与核算」
跑起来之后要回答:这个沙箱实际吃了多少资源、数字记在哪本账上。Cube 拆成两个会计域:
host_sandbox:宿主侧,含 shim / VMM 等guest_workload:guest 内纯负载(尽量排除管理进程)
两域不得相加。共享快照页可能让 host 读数低于 guest;VMM / CoW 开销又可能让 host 更高------硬加得不到有意义的「总量」。
快照会带上 cgroup 累计计数器。直接导出的话,新沙箱会带着模板时期的 CPU 历史,rollback 后计数还可能无故变小。做法是 「metrics epoch」 :以首次成功采样为基线,导出值 = 当前裸值 − 基线;rollback 后建新 epoch。Prometheus 要用 changes(...epoch...) 识别,不能靠 rate() 猜 reset。
采集先写内存缓存,再给 Prometheus 刮取,避免每次 scrape 触发 O(沙箱数) 的运行时 RPC。
「饱和与拒绝」
资源不够时,Cube 的策略很直接:过滤阶段把装不下的节点踢掉,创建并发、磁盘水位也在同一层处理。剩下的候选装不下就换下一台;候选耗尽就创建失败。没有「全集群挂一个长队、慢慢消化」------当下装不下就是失败,靠扩节点、补模板解决下一波。
「伸缩与预热」
模板本地性是硬约束。扩容顺序需要写成运维清单:装节点 → 确认健康 → redo 模板 → 等完成再开流量。跳过 redo,调度器会一直认为新节点不可用。
「实测」(BMI5,96C / 375G)
- 1000 沙箱:每个摊到约 25 MB(逻辑限额仍是 2 GiB)
- 满载按限额粗算约 185 个;轻载可到几千------规划分母是「摊到的开销 × 写入率」,不是限额总和
- 串行创建约 48ms,20 并发吞吐 180.9 / s
2. E2B
「节点选择」
选点在 API 侧,算法是 「Best-of-K」:从合格节点里随机抽出 K=3 个,取评分最低(负载最轻)的那个------不遍历全集群。
评分公式直接写出超卖意愿(一台机器上声明的 vCPU 总量可以大于物理核数):
- CPU:分母是
R × 物理核,「R=4」(愿意按 4 倍物理核来排 vCPU);分子是已请求 + 已保留 + 一半实测(Alpha=0.5),声明量和实测量各占一半 - 内存:份额池模型,used + reserved + in-flight + requested ≤ Limit × Capacity;「正在创建中」的占用在同一把锁里原子记账,防止多个创建同时看中同一份余量
遇到 ResourceExhausted 会换节点重试(节点仍留在候选集);其它错误才剔除。恢复请求优先回原节点(快照 / 缓存在那),超时再改映到 WarmedNode。模板构建反而是纯随机 + CPU 架构匹配------builder 上集中负载没意义。
「容量与限额」
创建时请求携带 CPU / 内存(vCPU 数、RAM MB),先过团队上限 TeamLimits(MaxVcpu / MaxRamMb 等),超出直接拒;通过后按该规格起 Firecracker 并配置 cgroup。租户并发(同时开多少个沙箱)是另一本个数账,与「单个沙箱允许多大」分开。
单个沙箱的上限怎么真正卡住:
- 「CPU」 :只限 guest 里的 vCPU 线程(threaded 子 cgroup,把
fc_vcpu*线程迁进去)。不限整个 Firecracker 进程------否则 VMM 管理线程被饿死,会拖垮同节点其它沙箱 - 「内存」 :不写 cgroup 的
memory.max;上限是交给 Firecracker 的mem_size_mib(虚拟机内存大小)+ balloon(气球驱动,可动态收回空闲页),大页按该尺寸预留 - 「磁盘」 :日常不硬限;pause 落盘前用
statfs检查磁盘够不够,同盘还要再拷一份时按 ×2 算
节点密度的超卖写在选点评分里,不靠放宽单个沙箱的上限:
- CPU 分母
R × 物理核,「R=4」:16 核节点按 64 vCPU 当量排;分子 = 已请求 + 已保留 + 一半实测 - 内存份额池按真正进物理内存的页记账,不按每沙箱
mem_size_mib全额累加 - 硬闸门:每节点最多 200 沙箱;同时在途的创建最多 3 个
「计量与核算」
一边采实际用量,一边核对「谁还在跑」。状态分三处记账,不能混成一笔:
- orchestrator:本机进程、cgroup、slot、模板 pin
- Redis:「哪些沙箱在跑」的权威名单------节点上有、存储里没有的当孤儿杀掉
- Postgres:持久实体
核对时,缺 sandboxID 的条目跳过不杀------杀错运行中沙箱,错误扣减比漏杀更难收敛。
主机指标约 10s 一轮,喂给 API 选点;每沙箱约 1s 采 cgroup 用量进 ClickHouse,供监控与用量分析。差分要用饱和减法:guest 内 systemd 重启会把计数器清零,uint64 直接相减会得到天文数字,污染报表。
「饱和与拒绝」
资源不够时,E2B 按路径区分,不混成一种失败:
- 「普通创建」 :节点上的信号量
TryAcquire拿不到就立刻ResourceExhausted,提示 please retry------客户端应换时机或换节点重试 - 「快照恢复」 :走
waitForAcquire等待,超时再报太忙------创建可立刻重试,恢复值得排队(快照已经占过资源) - 「租户并发超限」 :走 Redis Lua,超限返回 429;pending 预留 「90s 过期」,API 副本崩溃后配额不会被永久占住
- 「节点紧张要腾地方」:三级降级------完整暂停 → 仅文件系统快照 → 杀死;产品语义是至少保住用户文件
选点层本身不排队;排队发生在 API 边界(503 + Retry-After)。构建并发仍是数据库简单计数,注释承认有 TOCTOU 窗口(先读后写之间状态可能已变)------高频付费路径才值得付 Redis 正确性代价。
「伸缩与预热」
网络 slot 池 + WarmedNode。slot 归还带 ReturnDelay,等防火墙规则传播、conntrack 排空;排不干净就 ErrSlotRetained------宁泄漏,不双挂载。
节点伸缩靠 Nomad autoscaler;有活跃部署时 scale-out 会失败,避免和发布互相踩。进程崩溃后的清扫顺序固定:先杀 firecracker,再拆 nbd → slot → cgroup → 文件,避免半死设备被下一任沙箱误用。
3. OpenSandbox
「节点选择」
按 runtime 分成三条路,没有统一调度器:
- 「Docker」 :代码里没有多 host 放置;连哪台 daemon 由
DOCKER_HOST决定。唯一要协调的是宿主端口------范围内随机选、探测、再用进程内集合填「探测到启动」之间的窗口 - 「K8s」:Pod 落点外包 kube-scheduler。用户 API 几乎没有亲和字段;BatchSandbox 进哪个 Pool,由 operator 用谓词 + LeastAllocated / MostAllocated 打分。进到 Pod 之后任务按 FIFO 派,不感知负载
- 「fsb」 :FastPath 排名------优先 image-cache 亲和,再看归一化负载,最后稳定哈希打平。选中 Fastlet 后原子检查并放行。资源规格由池的
sandboxResources写死,单个沙箱不能另报一套 limits
「容量与限额」
Docker / K8s 路径上,创建请求可带 resourceLimits(cpu / memory / gpu...),按声明落到 HostConfig 或 Pod resources。fsb 路径上,规格写在 SandboxPool 的 sandboxResources,创建只选池;若请求仍带 resourceLimits,必须与池定义逐键一致,多了少了都 400。
单个沙箱的上限怎么真正卡住:
- Docker:
mem_limit/nano_cpus/pids_limit等;磁盘基本不限 - K8s:标准 requests/limits(Burstable / Guaranteed)
- fsb:Firecracker profile / 池配置定死,每沙箱不再带独立资源字段
几乎不超卖:声明多少就按硬限额占住;更高密度靠多个沙箱共享 Fastlet / Pool 宿主。server 不算每 API key 并发;租户总配额默认交给 K8s ResourceQuota / LimitRange(API key → namespace)。
「计量与核算」
用量与生命周期状态分开落:
- Docker:容器 label + 每沙箱状态文件,重启后重建
- K8s:operator 内存 store + 注解两阶段写------分配「内存先、注解失败回滚」,释放「注解先落、再删内存」,防止 Pod 在持久化完成前被二次分配
- fsb:状态全在 CR;控制面单副本,用 CR 持久化换免共识
沙箱内 execd 提供 CPU / 内存等指标。OOM 计数不跨 binding:交接区间丢掉,避免算到新租户头上。控制器指标不加 sandbox_name label,控制时间序列基数。
「饱和与拒绝」
资源不够时,OpenSandbox 刻意区分错误码,让客户端知道该不该重试:
- 池容量等超时 → 「429」 + Retry-After(可重试)
- namespace ResourceQuota 超限 → 「403」 立刻失败,不留半成品对象
- fsb 放行检查失败 → 也是 「429」,但保留原始 message,避免被误读成「池耗尽」
过期语义也按 runtime 分家:Docker 用 per-sandbox Timer(触发前再三重核对是否已续期);K8s 只把「真被池容量堵住」的秒数计入超时,镜像等待另算,Retry-After 才干净;fsb 强制过期,没有「永不过期」沙箱。闲置 pause 只在 fsb:Redis 活动键挂了只能推迟 pause,从不破坏 wake。
「伸缩与预热」
K8s Pool 回温公式大致是:
期望 = max(allocated + supply + desiredBuffer, poolMin)
ready 空闲被夹在 bufferMin / bufferMax 之间;扩缩还受 PoolMax、maxUnavailable 限制,并用 scale expectations 防抖。Pod 归还可选 Delete / Restart / Noop。
「实测」(两节点,5 Fastlet × 4 slot)
- 暖创建 p50 64ms;并发 10 全成;并发 20 成功率约 51%(大量 429)------成功延迟分布里看不到饱和,饱和证据在失败数
- runtime resume 约 35ms,SDK resume 约 62s------差距文档标明未定位,不能拿 runtime 数字当产品数字
4. agent-sandbox
「节点选择」
没有沙箱调度器。单个集群里 Pod 落点 = kube-scheduler,亲和等经 PodSpec 透传。
跨集群时,fleet 层才按容量选集群:选点是纯函数 (spec, 各集群容量报告),协调面是对象存储桶,无中枢 apiserver、无共享 etcd。eligibility(报告过期、weight=0)必须当前置过滤器------只靠打分挡不住「先均匀铺一圈」的预排逻辑,把任务仍派进不该进的集群。
「容量与限额」
CPU / 内存写在 SandboxTemplate / WarmPool 的 PodSpec resources 里;claim 选模板或领池时沿用蓝图规格。claim 自带 env 或新卷会强制冷启动,warm 路径保持模板既有资源配置。
真正限住资源,靠的就是标准 K8s Pod resources(Guaranteed / Burstable)+ PVC;隔离交给 RuntimeClass 与 kubelet。
集群侧按 Pod 密度和控制面承受力规划,不按内存超卖:
- 约 236 sandbox pods / 节点,999 节点约 23.4 万------单集群量级上限
- 再大靠 fleet 多集群
- 常先撞 apiserver / etcd:小集群控制面核数少,只看节点余量仍会灌满,创建在 APF 里被饿死
「节点上还能塞多少 Pod」和「控制面还能消化多少 create / watch」是两本账。warm pool 的 replicas 换的是冷启动延迟,不抬高这两本账的天花板。
「计量与核算」
单集群以 etcd 为事实来源;用量与就绪态跟普通 Pod 指标走。fleet 用对象存储桶里的 assignments / capacity / spec 协调;member 定期上报容量,fleetctl 无状态规划,不持各集群 K8s 凭证。
高压下 status.readyReplicas 可能停摆(长时间偏低),统计就绪要用每个 Pod 自己的 Ready condition,不能只信汇总字段。
「饱和与拒绝」
资源不够时,agent-sandbox 分两层:
- 「池侧」:没有现成 warm 成员时会阻塞等待,而不是立刻甩失败
- 「控制面侧」:可启用 APF(API Priority and Fairness),把控制器写和业务写隔离开。文档写明:APF 不是日常延迟优化,价值在争抢场景------自己的补池突发不该挤掉自己的认领写,别人的 workload 也不该淹掉你的创建
瓶颈经常不在节点内存,而在 apiserver / etcd 消化不过来。
「伸缩与预热」
工程主要花在这里,因为放置已经外包:
- 「SandboxWarmPool」:按 replicas 声明式预热。蓝图(镜像 / 资源 / 卷)实质变化才算 stale;认领后所有权离开池,池不再替换它。带 env 或新卷的 claim 强制冷启动,且冷启动不消耗 warm 池深度
- 「ReplenishDelay」:成员被领走后延迟再补,让 claim 突发先用掉 apiserver 处理能力。持续到达的 claim 会让 delay 无限续期、池抽到 0------持续负载必须再开 Rate
- 「MaxRefillRate」:token bucket 限制每池每秒创建数,把「亏多少补多少」的尖峰拉成平滑流。Delay 推迟开始补货,Rate 控制补货速度
- 单池过大(大约 >500--1000 副本)就要分片:per-pool workqueue 串行、expectations gate(要等 informer 看到 ADD 才放下一批)、etcd / watch 压力都是硬天花板
容量经验公式:pools ≥ ceil(claim率 / 每池补货率)。
「实测」
- 300 成员池 0→Ready 约 6.4s;CREATE 约 85 / s(APF 排队下)
- 六集群、4567 节点、106.9 万并发沙箱;长尾出在单集群控制面,不是选点公式本身
三、横向对比
1. 节点选择
- E2B:Best-of-K,CPU R=4 显式超卖(声明总量可大于物理核)
- Cube:过滤 + 加权打分,top-N 随机;无显式超卖比率
- OpenSandbox:Docker 无;K8s 外包;fsb 排名后放行;无超卖
- agent-sandbox:kube-scheduler;fleet 层按容量选集群
沙箱离 Pod 越近,自己要写的选点逻辑越少。
2. 容量与限额 / 密度
- 「E2B」:创建请求带 CPU / 内存,受 TeamLimits;真正限住靠 FC 参数 + vCPU cgroup;节点密度靠选点评分超卖(CPU×4,内存按实占页)
- 「Cube」:创建带 instance_type / 资源规格;host + guest cgroup 真正限住;密度靠 CoW 共享页,规划看「每个摊多少 × 写入率」
- 「OpenSandbox」:Docker/K8s 请求带 limits,fsb 规格在池上;硬限额,租户总账靠 ResourceQuota;密度靠池化共享宿主
- 「agent-sandbox」:规格在模板 / WarmPool 蓝图;标准 Pod resources;天花板常在控制面,再大靠多集群
装箱路径分叉:超卖、CoW,或硬限额加池化。逻辑限额与物理占用不能当成同一个数。
3. 计量与核算
限额回答「最多能用多少」;计量回答「实际用了多少、账记在谁头上」。
- E2B:主机与每沙箱 cgroup 用量喂选点和 ClickHouse;Redis 核对「哪些在跑」,分歧当孤儿
- Cube:host / guest 两本账分开记,不得相加;epoch 消化快照继承的计数器
- OpenSandbox:execd 出指标;生命周期状态按 runtime 分开记(文件 / 注解 / CR);OOM 不跨 binding
- agent-sandbox:沿用 Pod / etcd;fleet 用对象存储汇总容量
恢复路径上不确定的按「还在用」处理------缺身份的孤儿跳过不杀,槽位宁可泄漏不双挂载。杀错运行中沙箱,比漏杀更难收敛。
4. 饱和与拒绝
资源不够时,四家的组合不一样:
- 「Cube」:换候选节点;换不过就失败;中心不排队
- 「E2B」:创建立刻失败可重试;恢复可短暂排队;租户超限 429;紧张时三级降级保住文件;选点层不排队,排队在 API 边界
- 「OpenSandbox」:池堵住给 429(可重试);Quota 超限给 403(立刻失败、不留垃圾);fsb 放行失败也是 429,但 message 保留原因
- 「agent-sandbox」:池侧阻塞等待;控制面用 APF 排队隔离;瓶颈常在 apiserver,不在节点内存
排队发生在哪,语义承诺就在哪。四家都没有跨租户全局公平优先级。饱和看失败数,不只看成功延迟。
5. 伸缩与预热
- E2B:slot 池 + WarmedNode;归还带 Delay
- Cube:
template_locality+ TAP 池与密度挂钩 - OpenSandbox:Pool CRD 的 buffer / max / maxUnavailable
- agent-sandbox:ReplenishDelay + MaxRefillRate;容量公式
pools ≥ ceil(claim率 / 每池率)
K8s 路线共同点:控制器对 apiserver 的写压是资源。一个用 APF,一个用控指标基数。
四、自研建议
- 调度器复杂度按离 Pod 的距离定。沙箱 = Pod 就外包 kube-scheduler;沙箱 = VM 从 Best-of-K 起步,全集群打分是规模上来之后的事。
- 超卖要写进评分公式,并配上用量计量护栏。比率写进评分分母;内存分母用 committed / 真正进物理内存的页。CoW「每个摊多少」是另一条路。
- CPU 限额只约束 vCPU 线程;VMM 线程不限额。内存不设 cgroup 限额。
- 拒绝语义做成矩阵:创建可重试 vs 恢复排队;池阻塞单独计时;Quota 立刻失败;pending 预留带 TTL。紧张时「至少保住文件」可直接抄。
- 多租户配额按路径分档:高频走 Redis Lua;低频简单计数,窗口写进注释。
- 补池必须控节奏:Delay + Rate + expectations;单池过大就分片。
- K8s 路线把 apiserver 并发当一等资源;APF 主要救争抢,不承诺优化典型延迟。
- 计量账本分开;继承计数必须基线扣除;宿主与 guest 不得相加。
- 模板本地性是调度第一公民。扩容:装节点 → 健康 → redo 模板 → 再开流量。网络槽位池与密度目标挂钩。
- 百万走多集群:无中枢、选点纯函数、eligibility(能不能接活)前置过滤。控制面等级是看不见的供给属性。
五、小结
编排层回答集群侧问题:节点选择、限额怎么真正卡住、满了怎么拒绝、池怎么伸缩。选点逻辑的复杂度,大体由沙箱对象离 Pod 有多远决定;离得近,剩下的问题更多是控制面资源本身。
密度三条路:超卖共享 CPU、CoW 共享页、池化共享预热成本。选哪条,取决于有没有可信占用口径,以及愿不愿意为控制面处理能力付工程。
下一篇:安全加固------默认开了什么、生产还要做哪些配置。