数据截至 (上游 commit 9194847b2cf8)
抽象层与自动扩缩
前三章是「请求怎么变成容器」。这一章往上一层:这些请求一开始是怎么产生的——也就是 endpoint/taskqueue/sandbox 这些用户直接用的「抽象」,以及它们共用的自动扩缩引擎。代码在
pkg/abstractions/。
1. 它要解决的小问题
用户写的是 @endpoint def handler(): ...,一个普通 Python 函数。从这个 函数到「N 个自动扩缩的远程容器」,中间需要一个东西:一个常驻的控制器,盯着流量、决定该开几个容器、并把多开/少开的指令翻译成对调度器的调用。这个控制器就是「抽象实例」。
2. 顶层结构:一个 stub,一个 instance
几个核心概念先理清:
| 概念 | 是什么 |
|---|---|
| Stub | 一份「部署声明」:哪个镜像、什么资源、什么 autoscaler 配置。用户 deploy 时生成 |
| Instance | 一个 stub 在运行时的活的控制器对象,管它自己的容器集合和扩缩 |
| Autoscaler | 挂在 instance 上的循环,每秒采样 + 决策 |
所有抽象(endpoint、taskqueue、pod/sandbox)都复用同一个基类 AutoscaledInstance(pkg/abstractions/common/instance.go:57)和同一个泛型 Autoscaler(pkg/abstractions/common/autoscaler.go:13),只在「怎么采样、怎么算想要几个」上各自不同。这是典型的模板方法模式:骨架共用,策略各异。
┌──────────── Autoscaler[I, S](泛型,每秒 tick)──────────┐
│ sampleFunc(instance) → sample │
│ scaleFunc(instance, sample) → AutoscalerResult{想要 N 个}│
│ → instance.ConsumeScaleResult │
└───────────────────────┬───────────────────────────────────┘
│
┌───────────────────┼────────────────────┐
▼ ▼ ▼
endpointInstance taskqueueInstance podInstance/sandbox
(各自的 sampleFunc / scaleFunc)
3. 自动扩缩引擎
通用循环
Autoscaler.Start(autoscaler.go:50)极简:每 sampleRate(1 秒)tick 一次 → sampleFunc 采样 → scaleFunc 决策 → 若结果有效就 ConsumeScaleResult 回传给 instance。窗口大小 windowSize=60(autoscaler.go:27)。
决策怎么落地
回传的「想要 N 个容器」最终到 HandleScalingEvent(instance.go:284):
# 示意,非源码:对应 AutoscaledInstance.HandleScalingEvent
def handle_scaling_event(desired):
current = len(self.containers)
delta = desired - current
if delta > 0:
self.start_containers(delta) # 多开
elif delta < 0:
self.stop_containers(-delta) # 少开
StartContainersFunc/StopContainersFunc 是每个抽象注入的回调(instance.go:53)。endpoint 的实现 startContainers(pkg/abstractions/endpoint/instance.go:51)构造 ContainerRequest(填 image、cpu、gpu、entrypoint、stub id…)然后调 i.Scheduler.Run(runRequest)——这就接回了第 01 章的调度入口。整个系统在这里闭环。
队列深度自动扩缩(endpoint 为例)
endpoint 的策略 endpointDeploymentScaleFunc(pkg/abstractions/endpoint/autoscaler.go:39)就是 README 里 QueueDepthAutoscaler 背后的逻辑:
采样:TotalRequests = 在途任务数(TasksInFlight,查 Redis)
决策:
若 TotalRequests == 0 → 想要 0 个容器(scale to zero!)
否则:
desired = ceil(TotalRequests / tasksPerContainer)
desired = min(desired, maxContainers, 网关全局上限)
对应 README 的 QueueDepthAutoscaler(max_containers=5, tasks_per_container=30):每 30 个在途请求开一个容器,最多 5 个。没有请求就缩到零——这是 serverless 的本质,直接由 TotalRequests==0 → desired=0 这一行实现。
endpointSampleFunc(autoscaler.go:17)的采样来源是 TaskRepo.TasksInFlight:在途任务数(已入队未完成)。taskqueue 的策略类似,只是采样的是队列里待处理的任务数。
4. Sandbox:为 agent 跑代码的抽象
这是本项目和「AI agent」最直接相关的部分。Sandbox 建在 pod 抽象上(pkg/abstractions/pod/),README 的例子:
from beam import Image, Sandbox
sandbox = Sandbox(image=Image()).create()
response = sandbox.process.run_code("print('I am running remotely')")
它解决的场景:agent 让 LLM 生成了一段代码,你不敢直接在自己机器上跑(可能是恶意的或会搞坏环境)。Sandbox 给你一个一次性的隔离容器,往里塞代码、执行、收输出、销毁。
服务端是 GenericPodService(pkg/abstractions/pod/sandbox.go),暴露一组很「文件系统 + 进程」味的 gRPC 接口——这正是一个 agent 操作远程沙箱需要的全套原语:
| 能力 | 方法 |
|---|---|
| 执行命令/代码 | SandboxExec(sandbox.go:47) |
| 查状态 | SandboxStatus |
| 取 stdout/stderr | SandboxStdout、SandboxStderr |
| 杀进程 | SandboxKill |
| 文件读写/增删 | SandboxUploadFile、SandboxDownloadFile、SandboxDeleteFile、SandboxStatFile、SandboxListFiles |
| 目录操作 | SandboxCreateDirectory、SandboxDeleteDirectory |
| 代码编辑 | SandboxReplaceInFiles、SandboxFindInFiles |
| 暴露端口 | SandboxExposePort |
| 网络权限 | SandboxUpdateNetworkPermissions |
sandbox 容器内有一个进程管理器(worker 侧在 spawn 里把入口改成进程管理器二进制,见 02 章 lifecycle.go:1688),gateway 的这些方法通过它来真正在容器里执行操作。SandboxExec 还带连接重试(sandboxExecWithConnectRetry,sandbox.go:116),因为容器可能刚起、进程管理器还没就绪。
隔离强度由运行时决定:选 gVisor(pkg/runtime/runsc.go)能得到用户态内核级隔离,更适合跑真正不可信的代码——这是 agent 沙箱场景里最该关注的取舍点。值得澄清的是:热启动(CRIU)并不和隔离强度对立。在本仓里 runc 和 gVisor 两个运行时的 Capabilities().CheckpointRestore 都声明为 true(runc.go:64、runsc.go:92),CRIU 能否用取决于池是否开了 CRIUEnabled、缓存是否可用、以及 GPU/TCP 状态(见 03 章),而不取决于你为隔离选了 runc 还是 gVisor。所以这里没有「安全 vs 启动速度二选一」的内在矛盾,真正的权衡是「更强隔离的 gVisor 在启动开销/兼容性上更重」这类工程取舍。
5. 巧妙之处
- 一套 autoscaler 框架喂三种抽象(泛型
Autoscaler[I,S]+IAutoscaledInstance接口):新增抽象只需实现 sample/scale 两个函数,骨架和容器管理全复用。 - scale-to-zero 是一行决策:
TotalRequests==0 → desired=0,serverless 的灵魂用最直白的方式表达。 - 抽象层与调度层的清晰接缝:抽象只管「想要几个」,通过
Scheduler.Run把「怎么搞到机器」完全甩给调度器——关注点分离得很干净。 - Sandbox 把容器伪装成「带文件系统的远程 shell」:给 agent 提供 exec/读写文件/find-replace 这套贴合「改代码、跑代码」工作流的原语。
6. 边界与局限
- 扩缩是每秒采样的反应式控制,不是预测式:流量尖峰时仍要等容器冷启动(所以第 03 章的快冷启动才这么关键)。
tasksPerContainer等参数要用户自己调,调不好要么排队要么浪费。- Sandbox 的隔离强度取决于部署选的运行时;用 runc 跑不可信代码隔离性弱于 gVisor。注意这只是隔离强度的差异,与能否拿到 CRIU 热启动无关——两种运行时在本仓都声明支持检查点恢复。
7. 横向对比
和纯粹的「agent 代码执行沙箱」库相比,Beam 的 Sandbox 不是一个独立小工具,而是整个 serverless 平台的一个抽象——它自动继承了平台的扩缩、调度、快冷启动、网络。好处是生产级、能缩零、能扛量;代价是要跑起整套 Gateway+Worker+Redis+缓存。轻量沙箱库更适合本地/单机,Beam 更适合「给一个多租户 agent 产品提供托管代码执行」。
8. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 自动扩缩循环 | pkg/abstractions/common/autoscaler.go | Autoscaler.Start、NewAutoscaler |
| 抽象实例基类 | pkg/abstractions/common/instance.go | AutoscaledInstance、IAutoscaledInstance |
| 扩缩落地 | pkg/abstractions/common/instance.go | HandleScalingEvent |
| endpoint 采样/决策 | pkg/abstractions/endpoint/autoscaler.go | endpointSampleFunc、endpointDeploymentScaleFunc |
| endpoint 开容器→调度 | pkg/abstractions/endpoint/instance.go | endpointInstance.startContainers(调 Scheduler.Run) |
| taskqueue | pkg/abstractions/taskqueue/ | taskqueue.go、autoscaler.go |
| sandbox 服务 | pkg/abstractions/pod/sandbox.go | GenericPodService.SandboxExec 等 |
| sandbox 进程管理器接入 | pkg/worker/lifecycle.go | spawn(sandbox 入口改写处) |
| 运行时隔离 | pkg/runtime/runsc.go、runc.go | Runsc、Runc |