Modal 的工程师重新构建了沙箱基础设施,目标是支撑数百万个并发沙箱,以及每秒数万个沙箱创建请求。这个量级改变的不只是集群规模:调度、镜像准备、网络配置、状态存储和故障恢复都必须围绕“极短生命周期、高突发流量”重新设计。
“Beyond Kubernetes”并不等于 Kubernetes 失去价值。更准确的理解是:当每个沙箱都进入通用 Kubernetes 控制面的核心路径时,系统可能要为并不需要完整 Pod 语义的临时工作负载承担额外协调成本。工程上的关键,是判断哪些能力应该留在 Kubernetes,哪些高频操作需要更短、更可控的数据路径。
百万并发与每秒数万次创建是两个不同问题
“同时运行 100 万个沙箱”和“每秒创建数万个沙箱”分别考验系统的不同部分。
百万并发更偏向容量问题,包括:
- 单机能够安全承载多少沙箱;
- 内存、CPU、文件描述符和网络命名空间是否足够;
- 如何隔离恶意代码或资源争抢;
- 活跃状态是否能够低成本地存储和查询;
- 沙箱退出后,资源能否及时回收。
每秒数万次创建则首先冲击控制面:
- 接入层是否具备背压和限流能力;
- 调度决策是否需要访问集中式数据库;
- 创建一次沙箱要经过多少次持久化写入;
- 镜像、根文件系统和网络是否位于启动关键路径;
- 重试会不会把短暂故障放大成请求风暴。
因此,只看“当前活跃沙箱数”是不够的。一个系统可以容纳大量稳定运行的实例,却在突发创建时被调度队列、数据库事务或镜像拉取拖垮。
为什么通用 Kubernetes 路径可能变得昂贵
Kubernetes 擅长维护期望状态:用户声明资源,控制器持续协调,调度器选择节点,节点代理再把状态收敛到目标值。这种模型非常适合长期运行的服务和需要丰富编排语义的工作负载。
但如果把每个短命沙箱都建模成完整 Pod,高频创建可能依次经过 API 服务、持久化存储、调度器、多个控制器、节点代理、容器运行时和网络插件。这里并不是说某个组件必然无法扩展,而是整个协调链路的成本会随着对象数量、事件数量和状态写入频率一起增长。
一种可以实践的拆分方式,是把系统划成四层:
- 接入与准入层:验证请求、设置租户配额、执行限流,并尽早拒绝无法兑现的请求。
- 快速放置层:基于内存中的节点容量快照选择执行位置,避免每次决策都访问强一致数据库。
- 节点运行层:在已经预热的机器上创建隔离环境,并复用镜像层、文件系统模板和网络资源。
- 异步协调层:持久化审计信息、修正容量偏差、清理泄漏资源,而不是阻塞创建响应。
这种设计的重点是把“启动沙箱必需的步骤”和“最终必须完成的管理工作”分开。快路径应当短、有限且可降级;慢路径则负责最终一致性和修复。
超越 Kubernetes 的热路径,也不代表必须清空 Kubernetes。团队仍可以用它部署 API、控制服务、监控系统或节点管理组件,只让高频沙箱生命周期进入专用调度与运行时路径。
用黑盒压测验证创建路径
下面是一个可直接改造的异步压测脚本。这里明确采用一个假设接口:POST /v1/sandboxes 接收镜像和命令,任意 2xx 响应表示请求已被接受。运行前需要把地址、鉴权方式和请求体替换成自己的沙箱 API。
先安装依赖并保存脚本:
python -m pip install aiohttp
cat > sandbox_load.py <<'PY'
import argparse
import asyncio
import time
import uuid
from collections import Counter
import aiohttp
def percentile(values, p):
if not values:
return 0.0
values = sorted(values)
index = min(len(values) - 1, int((len(values) - 1) * p))
return values[index]
async def worker(queue, session, endpoint, headers, latencies, statuses, errors):
while True:
item = await queue.get()
if item is None:
queue.task_done()
return
request_id = str(uuid.uuid4())
body = {
"request_id": request_id,
"image": "busybox:latest",
"command": ["sh", "-lc", "sleep 60"],
}
request_headers = {
**headers,
"Idempotency-Key": request_id,
}
started = time.perf_counter()
try:
async with session.post(
endpoint,
json=body,
headers=request_headers,
) as response:
await response.read()
statuses[response.status] += 1
latencies.append(time.perf_counter() - started)
except Exception as exc:
errors[type(exc).__name__] += 1
finally:
queue.task_done()
async def run(args):
total = args.rate * args.duration
queue = asyncio.Queue(maxsize=args.concurrency * 4)
latencies = []
statuses = Counter()
errors = Counter()
headers = {}
if args.token:
headers["Authorization"] = f"Bearer {args.token}"
timeout = aiohttp.ClientTimeout(total=args.timeout)
connector = aiohttp.TCPConnector(limit=args.concurrency)
wall_started = time.perf_counter()
async with aiohttp.ClientSession(
timeout=timeout,
connector=connector,
) as session:
workers = [
asyncio.create_task(
worker(
queue,
session,
args.endpoint,
headers,
latencies,
statuses,
errors,
)
)
for _ in range(args.concurrency)
]
schedule_started = time.perf_counter()
for sequence in range(total):
target = schedule_started + sequence / args.rate
delay = target - time.perf_counter()
if delay > 0:
await asyncio.sleep(delay)
await queue.put(sequence)
for _ in workers:
await queue.put(None)
await queue.join()
await asyncio.gather(*workers)
elapsed = time.perf_counter() - wall_started
accepted = sum(count for code, count in statuses.items() if 200 <= code < 300)
print(f"scheduled={total}")
print(f"accepted={accepted}")
print(f"elapsed_seconds={elapsed:.2f}")
print(f"completed_rps={sum(statuses.values()) / elapsed:.2f}")
print(f"p50_ms={percentile(latencies, 0.50) * 1000:.2f}")
print(f"p95_ms={percentile(latencies, 0.95) * 1000:.2f}")
print(f"p99_ms={percentile(latencies, 0.99) * 1000:.2f}")
print(f"statuses={dict(statuses)}")
print(f"errors={dict(errors)}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", required=True)
parser.add_argument("--token", default="")
parser.add_argument("--rate", type=int, default=100)
parser.add_argument("--duration", type=int, default=30)
parser.add_argument("--concurrency", type=int, default=200)
parser.add_argument("--timeout", type=float, default=10.0)
asyncio.run(run(parser.parse_args()))
PY
先用较低速率验证接口,再逐级增加压力:
python sandbox_load.py \
--endpoint https://sandbox.example.com/v1/sandboxes \
--token "$SANDBOX_TOKEN" \
--rate 100 \
--duration 30 \
--concurrency 200
这个脚本只测量“创建请求被 API 接受”的延迟,不等同于沙箱已经可以执行代码。实际测试还应记录两个时间点:
accepted_at:控制面接受请求;ready_at:沙箱真正能够执行第一条命令。
两者之间的差值才是启动延迟。达到每秒数万次请求时,单台压测机本身通常会成为瓶颈,应使用多台负载生成器,并用唯一请求 ID 去重和汇总结果。测试结束后还要批量销毁沙箱,避免压测资源残留。
上线前不要只盯着平均延迟
这类平台至少应该持续观测以下指标:
- 每秒接收、放置、就绪和销毁的沙箱数量;
- 创建 API 与实际就绪时间的 P50、P95、P99;
- 因配额、容量不足、超时和内部错误导致的失败率;
- 调度队列长度及最老请求等待时间;
- 节点容量快照与实际资源使用量之间的偏差;
- 沙箱退出后的资源清理延迟;
- 按租户划分的吞吐与等待时间,防止大客户挤占系统;
- 重试次数和重复创建数量。
平均值很容易掩盖尾部问题。即使平均启动时间只有几十毫秒,只要 P99 在突发流量下持续上升,队列就可能迅速累积,并进一步触发客户端重试。
是否值得自建专用控制面
重写基础设施的成本很高,不应只因为“规模听起来很大”就启动。更稳妥的决策顺序是:
- 先确认瓶颈位于 Kubernetes 控制面、节点运行时、镜像分发、网络配置,还是业务 API;
- 尝试批处理、预热、配额、优先级和背压,观察是否已能满足目标;
- 将一个高频环节替换为专用组件,而不是一次性重写所有层;
- 用影子流量比较旧路径与新路径的就绪延迟、失败率和资源成本;
- 为新控制面准备过载保护、租户隔离、审计、回滚和资源泄漏修复机制。
百万级并发沙箱的难点,不是把一个更大的数字写进容量规划表,而是减少每次创建必须发生的协调工作。保留 Kubernetes 擅长的声明式管理,把极高频、极短生命周期的操作移到更轻的热路径,往往比简单扩大集群更有价值。