沙箱核心功能系列 06:编排调度与资源管理

调研对象:E2B、CubeSandbox、OpenSandbox、agent-sandbox(源码级)

前五篇讲单个沙箱。本篇讲集群侧:创建落在哪台节点、资源怎么记账、满了怎么拒绝或排队、池与节点如何伸缩。


一、问题

编排层主要是五件事:

  1. 「节点选择」:创建请求落到哪台机器;节点信息从哪来,过期了怎么处理
  2. 「容量与限额」:创建规格怎么声明,上限怎么真正卡住;一台节点「还能再装几个」按什么口径算
  3. 「计量与核算」:实际用量怎么采、按什么口径记账、数据给谁用(选点、监控、核对谁还在跑);宿主与 guest、快照前后的数字如何避免算乱
  4. 「饱和与拒绝」:满了是排队还是立刻失败;错误码能不能让客户端知道该不该重试
  5. 「伸缩与预热」:池怎么补、节点怎么加;补池的写入别跟业务创建抢控制面(如 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 的微缩版,四步走完:

  1. 解析约束:规格、模板 ID、亲和、显式指定的 host
  2. 过滤:不健康、指标过期(超过 metric_update_timeout 300s)、已超 MVM 上限、「本机没有模板副本」 (template_locality)、创建并发过高、磁盘水位。指标过期的节点直接踢出候选,不是打个低分继续比
  3. 打分:四个因子加权------现有沙箱数、在途创建数、CPU 配额占用、内存配额占用
  4. 终选:最高分里取 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 个

「计量与核算」

一边采实际用量,一边核对「谁还在跑」。状态分三处记账,不能混成一笔:

  1. orchestrator:本机进程、cgroup、slot、模板 pin
  2. Redis:「哪些沙箱在跑」的权威名单------节点上有、存储里没有的当孤儿杀掉
  3. 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 分成三条路,没有统一调度器:

  1. 「Docker」 :代码里没有多 host 放置;连哪台 daemon 由 DOCKER_HOST 决定。唯一要协调的是宿主端口------范围内随机选、探测、再用进程内集合填「探测到启动」之间的窗口
  2. 「K8s」:Pod 落点外包 kube-scheduler。用户 API 几乎没有亲和字段;BatchSandbox 进哪个 Pool,由 operator 用谓词 + LeastAllocated / MostAllocated 打分。进到 Pod 之后任务按 FIFO 派,不感知负载
  3. 「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 消化不过来。

「伸缩与预热」

工程主要花在这里,因为放置已经外包:

  1. 「SandboxWarmPool」:按 replicas 声明式预热。蓝图(镜像 / 资源 / 卷)实质变化才算 stale;认领后所有权离开池,池不再替换它。带 env 或新卷的 claim 强制冷启动,且冷启动不消耗 warm 池深度
  2. 「ReplenishDelay」:成员被领走后延迟再补,让 claim 突发先用掉 apiserver 处理能力。持续到达的 claim 会让 delay 无限续期、池抽到 0------持续负载必须再开 Rate
  3. 「MaxRefillRate」:token bucket 限制每池每秒创建数,把「亏多少补多少」的尖峰拉成平滑流。Delay 推迟开始补货,Rate 控制补货速度
  4. 单池过大(大约 >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,一个用控指标基数。


四、自研建议

  1. 调度器复杂度按离 Pod 的距离定。沙箱 = Pod 就外包 kube-scheduler;沙箱 = VM 从 Best-of-K 起步,全集群打分是规模上来之后的事。
  2. 超卖要写进评分公式,并配上用量计量护栏。比率写进评分分母;内存分母用 committed / 真正进物理内存的页。CoW「每个摊多少」是另一条路。
  3. CPU 限额只约束 vCPU 线程;VMM 线程不限额。内存不设 cgroup 限额。
  4. 拒绝语义做成矩阵:创建可重试 vs 恢复排队;池阻塞单独计时;Quota 立刻失败;pending 预留带 TTL。紧张时「至少保住文件」可直接抄。
  5. 多租户配额按路径分档:高频走 Redis Lua;低频简单计数,窗口写进注释。
  6. 补池必须控节奏:Delay + Rate + expectations;单池过大就分片。
  7. K8s 路线把 apiserver 并发当一等资源;APF 主要救争抢,不承诺优化典型延迟。
  8. 计量账本分开;继承计数必须基线扣除;宿主与 guest 不得相加。
  9. 模板本地性是调度第一公民。扩容:装节点 → 健康 → redo 模板 → 再开流量。网络槽位池与密度目标挂钩。
  10. 百万走多集群:无中枢、选点纯函数、eligibility(能不能接活)前置过滤。控制面等级是看不见的供给属性。

五、小结

编排层回答集群侧问题:节点选择、限额怎么真正卡住、满了怎么拒绝、池怎么伸缩。选点逻辑的复杂度,大体由沙箱对象离 Pod 有多远决定;离得近,剩下的问题更多是控制面资源本身。

密度三条路:超卖共享 CPU、CoW 共享页、池化共享预热成本。选哪条,取决于有没有可信占用口径,以及愿不愿意为控制面处理能力付工程。

下一篇:安全加固------默认开了什么、生产还要做哪些配置。

相关推荐
小兔子26 分钟前
if (system(cmd) != 0) 判不出命令失败:它返回的是等待状态,不是退出码
后端
艾迪的技术之路27 分钟前
16k 条慢查询,128 张分表,我这么解决
后端·github
dpharness27 分钟前
我踩过的四个记忆坑,从预算到换机
前端·后端
加宝的起点雷达27 分钟前
三个定时任务被删那天,系统一声不吭
人工智能·后端
泡海椒28 分钟前
jquick-pdf 热力图(HEATMAP)实战:24 格营收矩阵嵌入评级报告的模板逐行拆解
后端
对象存储与RustFS31 分钟前
Velero 接 RustFS 实操:K8s 集群备份落到自建对象存储
后端·docker·云原生
leo在掘金34 分钟前
2.8万亿MoE跑昇腾:0 Day适配改了啥
后端·架构
小兔子35 分钟前
proxy_pass 末尾写不写路径,是两套规则:上线后接口路径莫名多了一段
后端
IT枫斗者枫哥37 分钟前
用了BigDecimal还差一分?用四组Java代码查清原因
java·后端