跳到主要内容

抽象层与自动扩缩

前三章是「请求怎么变成容器」。这一章往上一层:这些请求一开始是怎么产生的——也就是 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)都复用同一个基类 AutoscaledInstancepkg/abstractions/common/instance.go:57)和同一个泛型 Autoscalerpkg/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.Startautoscaler.go:40)极简:每 sampleRate(1 秒)tick 一次 → sampleFunc 采样 → scaleFunc 决策 → 若结果有效就 ConsumeScaleResult 回传给 instance。窗口大小 windowSize=60autoscaler.go:25)。

决策怎么落地

回传的「想要 N 个容器」最终到 HandleScalingEventinstance.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 的实现 startContainerspkg/abstractions/endpoint/instance.go:51)构造 ContainerRequest(填 image、cpu、gpu、entrypoint、stub id…)然后调 i.Scheduler.Run(runRequest)——这就接回了第 01 章的调度入口。整个系统在这里闭环。

队列深度自动扩缩(endpoint 为例)

endpoint 的策略 endpointDeploymentScaleFuncpkg/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 这一行实现。

endpointSampleFuncautoscaler.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 给你一个一次性的隔离容器,往里塞代码、执行、收输出、销毁。

服务端是 GenericPodServicepkg/abstractions/pod/sandbox.go),暴露一组很「文件系统 + 进程」味的 gRPC 接口——这正是一个 agent 操作远程沙箱需要的全套原语:

能力方法
执行命令/代码SandboxExecsandbox.go:44
查状态SandboxStatus
取 stdout/stderrSandboxStdoutSandboxStderr
杀进程SandboxKill
文件读写/增删SandboxUploadFileSandboxDownloadFileSandboxDeleteFileSandboxStatFileSandboxListFiles
目录操作SandboxCreateDirectorySandboxDeleteDirectory
代码编辑SandboxReplaceInFilesSandboxFindInFiles
暴露端口SandboxExposePort
网络权限SandboxUpdateNetworkPermissions

sandbox 容器内有一个进程管理器(worker 侧在 spawn 里把入口改成进程管理器二进制,见 02 章 lifecycle.go:1109),gateway 的这些方法通过它来真正在容器里执行操作。SandboxExec 还带连接重试(sandboxExecWithConnectRetrysandbox.go:72),因为容器可能刚起、进程管理器还没就绪。

隔离强度由运行时决定:选 gVisorpkg/runtime/runsc.go)能得到用户态内核级隔离,更适合跑真正不可信的代码——这是 agent 沙箱场景里最该关注的取舍点。值得澄清的是:热启动(CRIU)并不和隔离强度对立。在本仓里 runc 和 gVisor 两个运行时的 Capabilities().CheckpointRestore 都声明为 truerunc.go:61runsc.go:72),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.goAutoscaler.StartNewAutoscaler
抽象实例基类pkg/abstractions/common/instance.goAutoscaledInstanceIAutoscaledInstance
扩缩落地pkg/abstractions/common/instance.goHandleScalingEvent
endpoint 采样/决策pkg/abstractions/endpoint/autoscaler.goendpointSampleFuncendpointDeploymentScaleFunc
endpoint 开容器→调度pkg/abstractions/endpoint/instance.goendpointInstance.startContainers(调 Scheduler.Run
taskqueuepkg/abstractions/taskqueue/taskqueue.goautoscaler.go
sandbox 服务pkg/abstractions/pod/sandbox.goGenericPodService.SandboxExec
sandbox 进程管理器接入pkg/worker/lifecycle.gospawn(sandbox 入口改写处)
运行时隔离pkg/runtime/runsc.gorunc.goRunscRunc