突破 Kubernetes 热路径:百万级并发沙箱如何在数秒内扩容

2026-09-23 42 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

Modal 的工程师重新构建了沙箱基础设施,目标是支撑数百万个并发沙箱,以及每秒数万个沙箱创建请求。这个量级改变的不只是集群规模:调度、镜像准备、网络配置、状态存储和故障恢复都必须围绕“极短生命周期、高突发流量”重新设计。

“Beyond Kubernetes”并不等于 Kubernetes 失去价值。更准确的理解是:当每个沙箱都进入通用 Kubernetes 控制面的核心路径时,系统可能要为并不需要完整 Pod 语义的临时工作负载承担额外协调成本。工程上的关键,是判断哪些能力应该留在 Kubernetes,哪些高频操作需要更短、更可控的数据路径。

百万并发与每秒数万次创建是两个不同问题

“同时运行 100 万个沙箱”和“每秒创建数万个沙箱”分别考验系统的不同部分。

百万并发更偏向容量问题,包括:

  • 单机能够安全承载多少沙箱;
  • 内存、CPU、文件描述符和网络命名空间是否足够;
  • 如何隔离恶意代码或资源争抢;
  • 活跃状态是否能够低成本地存储和查询;
  • 沙箱退出后,资源能否及时回收。

每秒数万次创建则首先冲击控制面:

  • 接入层是否具备背压和限流能力;
  • 调度决策是否需要访问集中式数据库;
  • 创建一次沙箱要经过多少次持久化写入;
  • 镜像、根文件系统和网络是否位于启动关键路径;
  • 重试会不会把短暂故障放大成请求风暴。

因此,只看“当前活跃沙箱数”是不够的。一个系统可以容纳大量稳定运行的实例,却在突发创建时被调度队列、数据库事务或镜像拉取拖垮。

为什么通用 Kubernetes 路径可能变得昂贵

Kubernetes 擅长维护期望状态:用户声明资源,控制器持续协调,调度器选择节点,节点代理再把状态收敛到目标值。这种模型非常适合长期运行的服务和需要丰富编排语义的工作负载。

但如果把每个短命沙箱都建模成完整 Pod,高频创建可能依次经过 API 服务、持久化存储、调度器、多个控制器、节点代理、容器运行时和网络插件。这里并不是说某个组件必然无法扩展,而是整个协调链路的成本会随着对象数量、事件数量和状态写入频率一起增长。

一种可以实践的拆分方式,是把系统划成四层:

  1. 接入与准入层:验证请求、设置租户配额、执行限流,并尽早拒绝无法兑现的请求。
  2. 快速放置层:基于内存中的节点容量快照选择执行位置,避免每次决策都访问强一致数据库。
  3. 节点运行层:在已经预热的机器上创建隔离环境,并复用镜像层、文件系统模板和网络资源。
  4. 异步协调层:持久化审计信息、修正容量偏差、清理泄漏资源,而不是阻塞创建响应。

这种设计的重点是把“启动沙箱必需的步骤”和“最终必须完成的管理工作”分开。快路径应当短、有限且可降级;慢路径则负责最终一致性和修复。

超越 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 擅长的声明式管理,把极高频、极短生命周期的操作移到更轻的热路径,往往比简单扩大集群更有价值。


相关推荐