一个 MCP 沙箱路由调度框架的架构分析

本文分析一个用 Go 实现、面向 MCP(Model Context Protocol)的沙箱路由与调度框架。它把"给 agent 起一个隔离环境跑工具"这件事标准化、规模化、多引擎化。

一、它是什么

一句话:一个面向 MCP 的沙箱路由与调度框架,用 Go 写成,直接基于官方 modelcontextprotocol/go-sdk,把沙箱的创建、路由、调度、生命周期、计量都做成了一套基础设施。

它跟"控制面 Python + 数据面 Go、协议优先的通用沙箱平台"是两种不同路线:本框架全 GoMCP 原生(直接用官方 SDK),更强调调度(快照+Epoch 的确定性调度)和路由(二层网络高可用网格)。它的"沙箱"抽象本质是一个 http.RoundTripper——工具调用就是 HTTP,没有单独的执行协议。

核心能力:

  • 沙箱路由:按沙箱类型分发、MCP 会话生命周期管理、按下游负载动态调整、二层网络高可用。
  • 沙箱引擎:Echo / FileServer / Git / Process / Container / Combination,计划 VM。
  • MCP 代理:把现有 MCP 服务代理接入,统一管理调度。

二、整体架构

八个二进制(build 配置里的 build 段):

二进制 角色 关键包
gateway 新入口,前置调度器选 runtime pkg/gateway
router MCP 沙箱路由,二层 WS 网格 pkg/router
scheduler-server 调度决策中心,快照+Epoch pkg/scheduler/server
sandbox-manager 节点级沙箱生命周期 owner pkg/manager
sandbox-agent 跑在沙箱内部,暴露 /tools pkg/agent
mcp-proxy 把远端 MCP server 接成 router 叶子 pkg/mcpproxy
sandbox-sidecar Firecracker VM 内的存储挂载助手 pkg/engine/container/sidecar
runtime-sidecar 调度器执行器 HTTP 接口(非 Go runtime 用) pkg/scheduler/runtime/server

完整拓扑(两条接入路径 + 调度/路由/叶子/沙箱内 + 旁路组件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
┌──────────────────────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ Go SDK (sdk/golang) Python SDK (sdk/python) │
│ Streamable-HTTP /mcp + 沙箱参数头(JSON) + Mcp-Session-Id │
└──────────────────────────────────────────────────────────────────────────────┘
│ 路径A(新) │ 路径B(旧)
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────────────────────┐
│ gateway(无状态) │ │ router 二层网格 │
│ 抽 sandbox 身份 │ │ ┌──────────────────────────────────────┐ │
│ → ScheduleOne 选 runtime │ │ │ export 层 StatefulSet │ │
│ 反代(经 scheduler 隧道 │ │ │ pod-0..N 稳定DNS + Redis 共享缓存 │ │
│ 或直连) │ │ │ session/leaf/sandbox 三张缓存(TTL) │ │
└──────────┬──────────────────┘ │ └──────────────┬───────────────────────┘ │
│ │ ┌──────────────▼───────────────────────┐ │
│ │ │ connect 层 Deployment │ │
│ │ │ 按序号拨所有 export pod(WS 隧道自愈)│ │
│ │ └──────────────┬───────────────────────┘ │
│ └──────────────────┼──────────────────────────┘
▼ ▼ WS 反向隧道
┌──────────────────────────────────────────────────────────────────────────────┐
│ scheduler-server(调度决策中心) │
│ K8s Lease 选主 ─ Leader / Follower ─ Epoch 同步(WS,最多 5 并发周期) │
│ 快照 Snapshot(Runtimes + ProcessRuntime + ThreadRuntime 两张索引) │
│ 调度管线: DeterministicSelector → ProcessSticky → Availability │
│ → Affinity → ProcessSelector (线程级 sticky 快路径先行) │
│ POST /api/schedule ANY /api/proxy/:runtime/*url(反向隧道,节点无需公网) │
│ runtime 主动连入 :9090/api/runtime/connect + /watch SSE 增量(30s 全量兜底) │
└──────────────────────────────────────────────────────────────────────────────┘
│ 选定 runtime(节点) ▲ runtime 反向拨入
▼ │
┌──────────────────────────────────────────────────────────────────────────────┐
│ 叶子节点(每个 = 一个 runtime):sandbox-manager │
│ │
│ 装饰器栈: metering.Engine( warmpool.Wrapper( container|process|... Engine ))│
│ ├ metering: Create/Destroy 事件 → ClickHouse(start/recycle) │
│ └ warmpool: 预热池 N 个热沙箱 → Activate() 毫秒接管,不计入 Weight │
│ │
│ /mcp MCP server ─ 会话↔沙箱绑定 ─ SandboxLock(引用计数,过期销毁) │
│ 工具 = 经沙箱 RoundTripper GET http://sandbox.local/tools │
│ sraUploadWeight 每 1s → scheduler SetIdleWeight(富余容量) │
│ AddProcess / AddThread → scheduler(会话生命周期上报) │
└───────────────────────────────────────┬──────────────────────────────────────┘
│ RoundTripper(沙箱 = http.RoundTripper)

┌──────────────────────────────────────────────────────────────────────────────┐
│ 沙箱内部:container / process / firecracker-microVM │
│ sandbox-agent(/tools, /tools/:tool) │
│ directLauncher(冷启) | warmupLauncher(热启: /warmup/status, /warmup/active)│
│ toolmanager: config | mcp | filesystem | git | skills │
│ MCP 连接池(64, ping 健康检查) → 外部 MCP server(command/sse/streamable) │
│ [Firecracker] sandbox-sidecar(1391, 存储挂载 direct/pre/active/unmount) │
│ [runtime-sidecar] 非 Go 执行器 HTTP 封装(AddProcess/...) │
└──────────────────────────────────────────────────────────────────────────────┘

旁路组件:
mcp-proxy : 远端 MCP server → 接成 router 叶子(无状态前门,会话可漫游 "*")
ClickHouse : 计量事件(start/recycle, tenant/user)
Redis : router 路由缓存(多副本 export 共享,任一 pod 服务任一会话)
K8s Lease : scheduler 选主(失主/换主 os.Exit 重起)

pkg 下约 244 个 Go 文件,最大的是 engine(约 111)和 agent(约 43)。关键依赖很说明问题:modelcontextprotocol/go-sdk(MCP 官方 SDK)、gingorilla/websocketttlcachemiekg/dnsredisClickHouse(计量)、netlink/netns(沙箱网络)、sprig(参数模板)、jsonschema-go(工具 schema)、spf13/afero(虚拟 FS)、k8s/client-go(Lease 选主),外加若干内部公共库(日志、反向代理隧道等)。

三、核心抽象:Engine / Sandbox / 装饰器

整个框架的脊梁是两个接口,极其简洁(pkg/engine/):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// engines.go
type Engine interface {
Init(ctx context.Context) error
List() ([]SandboxInfo, error)
Get(id string) (Sandbox, error)
Create(id string, params map[string]string) (Sandbox, error)
Destroy(id string) (Sandbox, error)
SandboxDestroyed(sandbox Sandbox) bool
Weight() WeightInfo
GetSupportedClasses() []string
GetLimit() int
Wait()
}

// sandbox.go
type Sandbox interface {
ID() string
Info() SandboxInfo
Refresh(ctx context.Context) error
WaitForReady(ctx context.Context) error
http.RoundTripper // ← 关键:沙箱就是一个 RoundTripper
}

最妙的一笔是 Sandbox 嵌入了 http.RoundTripper。意味着"在沙箱里跑工具"= “往这个 sandbox stamp 一个 header 后发 HTTP 请求”。没有单独的 exec API、没有 RPC,工具调用统一是 HTTP。generic.LocalServer 给每个沙箱起一张 gin 路由表(/tools/tools/:tool),RoundTrip 在请求上盖个沙箱 ID header 路由到对应沙箱的本地 server。

装饰器链:EngineDecorator 包裹一个 Engine 形成新 Engine。pkg/manager/manager.go:88-105 启动时把引擎包成:

1
metering.Engine( warmpool.Wrapper( container.Engine(...) ) )

warmpool 在内层(管预热),metering 在外层(记生命周期事件)。横切关注点(计量、预热)和具体隔离实现(容器/进程/git)正交解耦,很干净。

注册:engines.gosupportedEngines map[string]EngineCreator,各引擎包 init() 自注册。container 包一口气注册三个 kind:containerdockerfirecracker(后者注入 --command=ctr --runtime=aws.firecracker)。选引擎就是 CreateEngine(kind, class, opts, args...) 查表 + 套装饰器。

四、沙箱引擎全家桶

所有引擎 embed generic.GenericEngine[S](generic/engine.go),它把生命周期拆成可覆盖的 hook(SandboxNewE/SandboxStartE/SandboxStopE/SandboxDeleteE),Create 校验 params["class"] 是否在 SupportedClasses 里,Destroy 优先用 SandboxDeleteE 否则 stop+delete。Weight() = Limit - len(Sandboxes)

逐个看:

  • echo(pkg/engine/echo):一个 /echo 路由写 "Hello World!",无隔离,测试用。
  • fileserver(pkg/engine/fileserver + pkg/utils/fileserver):基于 afero(OsFs + BasePathFs + CacheOnReadFs)的文件服务器,ServeHTTP 提供 GET/PUT/POST/DELETE,读时按 sabhiram/go-gitignore 过滤。选项 workdir/mode(web)/readonly/write_echo。还 shell out zip 提供 /zip。工具是 MCP filesystem 工具集。
  • git(pkg/engine/git):全部 exec.CommandContext(git, args) 走 git CLI(不是纯 Go 库)。工具集(pkg/agent/toolmanager/examples/git/executable.go)暴露只读(status/diff/log/show/current_branch)+ 写(commit/add/reset 软/混合/硬=回滚/create_branch/checkout_branch/checkout_files=恢复/init/config)。还有 /git/archive/git/bundle
  • process(pkg/engine/process):起一个 OS 子进程跑 sandbox-agent,用 unix socket(sandbox-agent.sock)当 HTTP 传输层,RoundTrip 把 host 改写成 sandbox.localstop() 杀进程,delete 异步重试到确认死。
  • container(pkg/engine/container):核心引擎。runtime 抽象成 runtime.Runtime 接口,两个实现:
    • DockerRuntime:docker run --rm --name ... -v --mount -p hostport:8080 -e --cpus --memory --network --runtime --dns --privileged 全是 CLI shell-out,docker inspect/port/ps/stop/rm
    • FirecrackerRuntime:用 ctr + nerdctl,namespace 形如 sandbox-firecracker,runtime aws.firecracker——这就是 README 里"计划 VM"的当前实现:在 Firecracker microVM 里跑容器。无 host port 映射,sandbox + sidecars 共用一个 microVM。
    • hooks:NewImageHook(docker build 建镜像)、NewStorageHook(workdir 挂载)、NewNetworkHook(DNS + iptables/tc 出站策略)、NewWarmupHook。container 是唯一实现 ActivatableEngine 的引擎(给 warmpool 用)。
  • combination(pkg/engine/combination):组合的不是引擎,是外部 MCP 后端。YAML 配置一组 SandboxBackend{Address, Headers, Prefix, ToolRename, HideTools, CacheTools, Params, Reroutes},每个 backend 拨一个远端 MCP agent,列出它的工具,按规则重命名/隐藏/加前缀,合并成一个工具命名空间;HTTP Reroutes("/api"→"/v1")用 httputil.ReverseProxy 转发到对应 backend。Params 是对用户参数跑 Go template。每分钟热重载配置。
  • reverseproxy(pkg/engine/reverseproxy):不是完整沙箱,而是把"反向拨进 router 本身"的客户端当成沙箱。GET /:id 盖沙箱 ID header 后用 dialer 转发,客户端连着就算 online,断了 offline。
  • warmpool(pkg/engine/warmpool):装饰器,WarmPoolSize<=0 时 no-op。包一个 ActivatableEngine,用 pool.ObjectPool 保持 N 个预热好的沙箱(params["warm"]="true" + 随机 localID,WaitForReady 通过才入池,1s ticker + Get 触发补充)。Create 弹一个预热沙箱,调 Activate(localID, {UserSandboxID: id, Params})——把用户 ID 接到已热的沙箱上;失败就销毁预热沙箱退回直接建。关键:预热沙箱不计入 Weight 容量,所以节点能立刻对外广告富余容量。
  • metering(pkg/engine/metering):装饰器,拦截 Create/DestroyEvent{start|recycle, timestamp, sandbox_id, class, client_id, tenant_id, user_id, reason} 到 ClickHouse MergeTree 表(clickhouse-go/v2 driver),destroy 前记 tenant/user meta。

五、调度器:快照 + Epoch 的确定性调度

这是我认为全项目设计最考究的部分。调度架构文档很完整,核心是快照驱动的确定性调度

5.1 核心抽象(pkg/scheduler/types.go)

  • Runtime:执行节点(ID + Class + Metadata + IdleWeight 容量权重)。
  • Process:Runtime 上的逻辑实例(如一个沙箱)。同一 processID 必须落在同一 runtime(sticky)
  • Thread:process 下最小调度单元(如一次 MCP 请求/session),全局唯一。
  • Snapshot:所有 Runtime + ProcessRuntime(process→runtime 列表)+ ThreadRuntime("pid/tid"→runtime)两张索引。
  • Epoch:Leader 产出的带版本号 Snapshot,全集群一致。

5.2 调度管线(pkg/scheduler/pipeline.go + server/server.go:DefaultPipeline)

SchedulePipeline = []Filter + Selector,顺序跑 Filter 收窄候选,Selector 选一个。默认链:

1
DeterministicSelector → ProcessStickyFilter → AvailabilityFilter → AffinityFilter → ProcessSelectorFilter
  • ProcessStickyFilter:req.Sticky 且 process 已在某 runtime,只保留那个。
  • AvailabilityFilter:留 Available 且各维 IdleWeight>0 的;饱和 runtime 若已持有该 process 仍放行
  • AffinityFilter:equal/not_equal/exists/latest_version(semver 比较)串行 AND。
  • ProcessSelectorFilter:已持有 process 的 runtime 要满足 thread.ProcessSelector ⊆ ProcessSnapshot.Metadata
  • DeterministicSelector:按 SortBy 排序、把已持 process 的前置、截断 ReplicasLimit、配了 ChooseWeightBy 就加权随机,否则对 thread ID 做确定性 hash(seed = seed*131 + r)——同一 thread 在同一 snapshot 内必落同一 runtime。

Schedule() 在管线前还有个线程级 sticky 快路径:ThreadRuntime["pid/tid"] 命中且可用就直接返回。注意:没有调度队列,候选来自 snapshot.Runtimes[class],分配记录就是 ThreadRuntime 那张 map——典型的快照索引驱动,不是排队驱动。

5.3 选主与 Epoch 同步(pkg/scheduler/epoch.go + server.go)

  • 选主:K8s coordination.k8s.io/v1 Lease。OnStartedLeading→RunLeader;失主或换主直接 os.Exit(0) 靠 restartPolicy 重起。
  • Epoch 同步:Leader↔Follower WebSocket,消息 announce/collect/commit/error。1s ticker + Emit 触发周期,每周期 5s 超时,最多 5 个并发周期(慢 follower 不阻塞),MergeSnapshots 合并,只留最近 5 个 epoch。Follower 有本地 epoch 快照就能本地调度,否则转发给 Leader——这就是 HA 降级。

5.4 反向隧道 + SSE 增量(pkg/scheduler/runtime/runtime.go)

Runtime(Sidecar)嵌在 sandbox-manager 节点里。它不暴露公网地址,而是主动经反向代理隧道库连到 Scheduler 的 :9090/api/runtime/connect(带 X-Runtime-Info),建立反向隧道;Scheduler 通过隧道去拨 Runtime 的 /watch SSE。Runtime 累积 pendingDelta,SSE 推增量(add-then-remove 互相抵消),每 30s 心跳推一次全量快照兜底。重连指数退避。

5.5 HTTP API(server.go:serveHTTP)

1
2
3
4
5
6
7
GET  /api/sync/:id            Follower WS 同步
GET /api/epoch 当前 epoch id
GET /api/epoch/:id/snapshot epoch 快照
POST /api/schedule 调度
GET /api/runtime/:id runtime 信息
ANY /api/proxy/:runtime/*url 反向代理到 runtime(经隧道)
GET/POST/DELETE /api/cache/:key 分布式缓存(TTLCache 900s)

/api/proxy/:runtime/*url 很关键——gateway 选定 runtime 后,通过这条隧道把请求反代到节点,节点无需公网。

六、路由器:二层网络高可用网格

router 是较早的接入路径,"二层网络高可用"指的就是它。注意:这里没有 ARP/VIP/netns 的 L2 帧,netlink/netns 只用于沙箱容器网络(pkg/utils/net.go)。所谓"二层"是 chart 拓扑的两层 + 反向隧道的两层

6.1 二层拓扑(chart/router/templates)

  • export 层(network-statefulset.yaml):StatefulSet,podManagementPolicy: Parallel,headless Service(clusterIP: None,publishNotReadyAddresses: true)→ 每个 export pod 有稳定 DNS 名 {release}-export-{i}.{release}-headless.{ns}。多副本时共享 Redis,路由缓存(sessionLeafCache/sandboxLeafCache)存 Redis——任一 export pod 都能服务任一 MCP 会话,这就是 HA 的基石。helper 还会 fail 掉"多副本无 Redis"的配置。就绪探针 GET /healthz,只有连够 --expected-connectors 个客户端才返回 200。
  • connect 层(network-deployment.yaml):无状态 Deployment,按序号拨所有 export pod(--upstream-address=http://{release}-export-{i}.{release}-headless...),任一 connect pod 都能到任一 export pod。mode: singleton 时退化为单 Deployment。

6.2 路由决策(pkg/router/schedule.go:scheduleOnce)

优先级阶梯,逐级降级:

  1. 会话路由缓存命中(sessionRouteCache);
  2. 显式叶子(header Sandbox-Manager-Id);
  3. 偏好叶子(Prefer-Sandbox-Manager-Id);
  4. 会话-叶子绑定(sessionLeafCache,"*"=会话可漫游,给 mcp-proxy 用);
  5. 任意可用缓存客户端;
  6. 沙箱 ID 绑定(sandboxLeafCache);
  7. class 加权分配(ClientsManager.Allocate)。

schedule 每 200ms 重试。handleAutoProxy 把选择结果盖到隧道参数 header(反向代理参数,base64 JSON),经隧道库反代到叶子 manager 的 /mcp;响应头里的实际叶子 ID 回填三张缓存,holder goroutine 每分钟续 TTL。

6.3 负载感知与加权分配(clients_manager.go)

  • 负载来源:router 每 5s ticker 经隧道拨每个叶子的 GET /weight;叶子端(pkg/manager/handlers.go:handleGetWeight)报 Weights{Type: leaf, Weight: engine.Weight().Weight, SupportedClasses, WarmPoolSize}
  • 打分:AllocateRevMean(叶子的各 class 权重调和平均)乘以客户端权重算分;WarmPoolSize>0 且分>0 的客户端优先(请求往预热好的节点送);否则按分加权随机。
  • router 自重随连接数反比:1000000/(activeConnections+999),忙的 export 自重低,上游分流。
  • 隧道自愈:每条上游连接以 1s 间隔重试重拨;/healthz 连够数才 200。客户端失活从分配池摘除。

七、网关:调度器前置入口

gateway(pkg/gateway)是更新的无状态入口,前置调度器而非 router。handleAutoProxy 三步:从请求抽 sandbox 身份、调 scheduleManager.ScheduleOne(Sticky:true, LoadBalanceConfig) 选 runtime、反代——要么经调度器隧道(/api/proxy/:runtime/:url),要么直连。

身份抽取很灵活(getInfoFromRequest):--class-from/--process-id-from/--thread-id-from 接受 default/random/mcp-session/header:/query:/path_field: 等来源策略。thread-id-from=mcp-session 时从 Mcp-Session-Id 取,没有就生成 uuid 并回写期望会话 ID header。两级重试(外层 20ms→1s 退避,内层固定 50ms),TTL 缓存(scheduler 分布式缓存的前缀包装)15 分钟,holder 每分钟续。默认 LoadBalanceConfig{ReplicasLimit:16, SortBy:"-weight", ChooseWeightBy:"weight"}

八、沙箱管理器 + 沙箱代理 + sidecar

8.1 sandbox-manager(pkg/manager)

节点级 owner,管沙箱记录(内存 Sandboxes map,不用 CRD),生命周期委托给 engine.Engine。装饰器栈就是 metering → warmpool → 具体引擎

HTTP API(handlers.go:registerHandlers):GET /weightGET/POST/DELETE/POST refresh /sandboxes/:idPOST /sandboxes/:id/toolcallANY /sandboxes/:id/proxy/*path、会话版 /sessions/:session/toolcall|proxy/*pathANY /mcp(MCP 入口)。建沙箱路径:合并 header/query/JSON 参数 → 默认 id/class → RenderSandboxParams 模板渲染engine.Create;失败销毁半成品以便同 ID 重试。

会话↔沙箱绑定(mcp.go:handleMcp):HTTPWriterHeaderHandlersessions[sessionID]=sandboxID,按 expire_seconds 给每个沙箱加 SandboxLock(引用计数,过期销毁要等在用完),并向调度器 runtime 报 AddProcess(sandboxId) + AddThread(sandboxId, sessionId)。MCP server 工具来自 fetchSandboxTools = toolmanager.Tools(ctx, sandbox) = 经沙箱自己的 RoundTripper GET http://sandbox.local/tools

权重上报:sraUploadWeight 每 1s 调 engine.Weight()sra.SetIdleWeight(weightMap);warm pool 不计预热沙箱,所以上报的是真实富余。

8.2 sandbox-agent(pkg/agent)

跑在沙箱内部(容器镜像内或挂载为 /sandbox-agent)。两种启动器:directLauncher(冷启:立刻 activate 并服务)、warmupLauncher(热启:先暴露 /warmup/status + POST /warmup/active,之后全转给激活后的 router)。

init(agent_init.go)按 params.Type 建 toolmanager:config(声明式 script/http 工具)、mcp(外部 MCP server)、filesystemgitskills。工具 API:GET /toolsPOST /tools/:tool

MCP 连接池pkg/agent/toolmanager/examples/mcp/mcp.go:MCPToolManagerpoolSize=64chan *mcp.ClientSessionGetSession 取池里 session,ping 健康检查,死掉丢弃新建,池空即时建;ReturnSession 满则关。Call 每调用借一个 session 跑 sess.CallTool(ctx, &mcp.CallToolParams{Meta: meta, Name, Arguments}),Meta 从 additionalParams["_user_meta"] 透传。这是给"沙箱内 agent 调外部 MCP server"用的连接池。

8.3 两个 sidecar

  • sandbox-sidecar(pkg/engine/container/sidecar/server.go):HTTP(默认 1391),暴露存储挂载 API(/storage/:type/{direct-mount,pre-mount,active-mount,unmount}/storage/hostpath/upload)。只在 Firecracker VM 里注入——每个 SidecarConfig 变成共享 VM 的 containerd 容器,manager 端 storage manager 调它执行挂载。Docker 模式不启动它。有 IP 校验中间件拒 VM 本地 IP。
  • runtime-sidecar(cmd/runtime-sidecar):和沙箱容器无关,是给非 Go 的调度器执行器用的 HTTP 封装(POST /AddProcess /RemoveProcess /AddThread /RemoveThread /SetIdleWeight),包 pkg/scheduler/runtime.Runtime

Firecracker 部署示例(chart 里的 firecracker-example.yaml)把 VM 支持说得很清楚:runtime.image 用 firecracker-containerd 运行时镜像 + FC 命名空间/containerd sock/CNI 子网,engineArgs: --privileged --mount-sandbox-agent=/opt/bin/sandbox-agent,sidecars 里一个 fc-sidecar(command: sandbox-sidecar, privileged, hostPID),className 形如 firecracker-sandbox,limit: 8,expireSeconds: 60

九、MCP 代理 + SDK

9.1 mcp-proxy(pkg/mcpproxy)

把远端 MCP server 接成 router 的叶子。httputil.NewSingleHostReverseProxy 转发,Director 强制 req.Host + 注入配置 header。路由:GET /weight(报叶子权重 + SupportedClasses)、/mcp(改写 path 转发)、自定义 ProxyLocations。它无状态,MCP session 由上游 MCP server 发,自己只做"带 header 注入、注册成叶子、负载均衡的前门"。ClientID = "proxy-"+uuid,有 upstream 就连上游 router。

9.2 SDK

Go(sdk/golang)和 Python(sdk/python)。都是 Streamable-HTTP MCP 打 /mcp,沙箱参数塞自定义沙箱参数 header(JSON),per-request 带 Mcp-Session-Id

Go SDK agent_http.go:NewSandboxAgentmcp.StreamableClientTransport{Endpoint: server+"/mcp"},HTTPClient 套 NewModifierTransport:首次连接(无 session)带全参数含 id;后续(有 session)带折叠参数 {id, class, no_create:true} 缩小 header。CallTool 默认 POST {server}/sessions/{sessionID}/proxy/tools/{tool}(带 Mcp-Session-Id + 用户 meta header),WithRawMCP 模式直连 /mcpsessionManagerSandboxLock 串行会话,ping 失败重连。

Python SDK sandbox_mcp.py:SandboxMcpTransport(StreamableHttpTransport) 合并 FastMCP headers;sandbox.py 同样把请求改写到 {server}/sessions/{session_id}/proxy{path}。依赖 mcp>=1.12.0fastmcp>=0.4.0

十、参数渲染与密钥引用

参数渲染与密钥引用是两个值得讲的特性。pkg/utils/sandbox_params.go:RenderSandboxParams:参数值含 {{` 就用 `text/template` + `sprig.TxtFuncMap()` 渲染,数据是 `{"config": configmap, "secrets": secrets}`,来自两个 header: - 沙箱配置头(JSON map[string]string)→ `.config`; - 沙箱密钥头(JSON map[string]SecretRef)→ `.secrets`。 **密钥引用**(`pkg/utils/params_render/`):`SecretProvider` 接口 `FetchSecret(ref)`,当前支持一种平台 KMS provider。其 `FetchSecret`(`agent_platform.go`):payload `AgentPlatformSecretInfo{SecretId, Address, SecretData, WrappedDek, Algo, KeyVersion}`,带 `Authorization: Bearer ` 调 `POST {address}/api/v2/DecryptDEK`(解包 DEK),再本地 `aesGCMOpen` AES-256-GCM 解密,反序列化成 `map[string]any`。所以真实 secret 从不进工作负载——manager 在渲染参数时解密注入,渲染后的明文只在 debug 模式时留存,正常只留含引用的 `printable_params` 给 tracer。这套是把"沙箱参数里引用平台 KMS 托管的加密字段"做得相当完整。 ## 十一、warmpool 如何降延迟 单独拎出来讲,因为它是"通用沙箱平台"和"agent 实战"的关键桥梁。冷启一个容器沙箱要秒级(pull/build/起),agent 等不起。warmpool 的解法: 1. 节点启动时 `pool.NewObjectPool(WarmPoolSize, factory, 1s)` 预建 N 个沙箱,`WaitForReady`(running + warmup probe)通过才入池; 2. 池子 1s ticker + 每次 Get 触发补充,保持 `ready+creating ≤ capacity`; 3. 来请求时 `acquireWarmSandbox` 弹一个热的,调 `Activate(localID, {UserSandboxID: id, Params})`——把用户 ID 接到已热的沙箱上(`POST /warmup/active`),毫秒级; 4. 激活失败就销毁热的退回直接建(优雅降级); 5. **预热沙箱不计入 `Weight`**,节点能立刻广告富余容量,调度器把请求分过来时已经有热的等着。 关键接口 `ActivatableEngine.Activate(id, ActivateParams)`,只有 container 引擎实现——因为只有容器沙箱能在"已起"和"已绑定用户"之间分离(warmup 期就起好容器、装好依赖、跑好 agent,只等绑定 workdir 和 user id)。 ## 十二、端到端链路实例 举个具体场景把整条链路走一遍。假设一个 **Python agent** 要接一个**容器沙箱**跑 `git` 类工具,走"新路径"(gateway → scheduler)。 ### 12.1 场景设定 - gateway 地址:`gateway.svc:8080` - 沙箱参数:`{id: "sbx-123", class: "docker", expire_seconds: 300}` - 参数里引用一个平台托管的密钥(`{{ .secrets.db_token }})和一个配置项({{ .config.repo }})

  • 目标工具:git_clone(沙箱内 sandbox-agent 经 toolmanager 暴露)

注:沙箱的"起法"是一个隔离强度谱系,由 engine kind + runtime 决定,本例取 docker 一档为例:

1
进程内 HTTP(echo/git/fileserver)  <  OS 子进程(process)  <  容器 docker run(container/Docker)  <  Firecracker microVM(container/Firecracker)

不是只有 docker run:container 引擎内部把"怎么跑"抽象成 runtime.Runtime 接口,DockerRuntimedocker run,FirecrackerRuntimectr/nerdctl 在 Firecracker microVM 里起容器(namespace sandbox-firecracker、runtime aws.firecracker、无 host port 映射、靠 sandbox-sidecar 做 VM 内存储挂载);更轻的 process 引擎干脆不起容器/VM,只起 OS 子进程 + unix socket。预热池对 Docker 和 Firecracker 都适用(container 是唯一实现 ActivatableEngine 的引擎,可预建一批 microVM 等着接管)。本文链路里的 engine.Create / warmpool 弹热沙箱 / RoundTrip 转发对各档都成立,差别只在底层是 docker runctr 起 microVM、还是子进程 + unix socket。

12.2 端到端时序(带具体值与 header)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
[1] 首次连接(建会话 + 建沙箱)
agent(Python SDK)
│ POST gateway/mcp
│ headers: Mcp-Session-Id: <new> (SDK 生成)
│ 沙箱参数头: {"id":"sbx-123","class":"docker","expire_seconds":300}
│ 沙箱配置头: {"repo":"https://..."}
│ 沙箱密钥头: {"db_token": {provider:"agent-platform", secret_id:"S42", address:"kms.svc"}}

gateway.handleAutoProxy
│ 抽:processID=sbx-123, class=docker, threadID=<new>
│ 查 threadCache/processCache → 未命中
│ → scheduleManager.ScheduleOne(Sticky:true, LoadBalanceConfig)

scheduler-server /api/schedule
│ 快路径 ThreadRuntime["sbx-123/<new>"] → 未命中
│ 管线:
│ ProcessStickyFilter → 首次无绑定,全候选
│ AvailabilityFilter → 留 IdleWeight 富余的节点(预热沙箱不计容量,热节点富余多)
│ AffinityFilter → 按 class=docker 等约束收窄
│ DeterministicSelector → 按 -weight 排序 + threadID 确定性 hash → 选定节点 X

gateway 反代: ANY /api/proxy/{nodeX}/mcp (经反向隧道,节点 X 无需公网)

sandbox-manager@nodeX /mcp (handleMcp)
│ a. RenderSandboxParams:把 {{.config.repo}}、{{.secrets.db_token}} 渲染掉
│ → 密钥:调 KMS DecryptDEK(wrapped_dek) 拿 DEK,本地 AES-256-GCM 解密出明文 token
│ → 明文只注入到给沙箱的参数,不落盘(printable_params 只留引用)
│ b. 会话绑定 sessions[<new>]=sbx-123 + SandboxLock(引用计数,300s 过期)
│ c. 沙箱不存在 → engine.Create("sbx-123", params):
│ warmpool 弹一个预热好的容器沙箱(已起好容器+agent,只差绑 workdir/userid)
│ → Activate(localID, {UserSandboxID:"sbx-123", Params})
│ → POST /warmup/active 给沙箱内 agent,毫秒级接管
│ (没热的就 docker run 新建,WaitForReady)
│ d. 向 scheduler 上报: AddProcess("sbx-123") + AddThread("sbx-123", <new>)
│ → SSE 增量推给 scheduler,写进下个 Epoch 快照(以后同 sandbox/thread 自动 sticky 到 X)
│ e. 注册工具:经沙箱 RoundTripper GET http://sandbox.local/tools → [git_clone, ...]

sandbox-agent(容器内) /tools
│ toolmanager 建 git 类(ExecutableGitToolManager)
│ 返回工具列表

响应回流: MCP initialize + tools/list,带 Mcp-Session-Id: <new>(后续复用)

agent 拿到会话 ID


[2] 后续工具调用(复用会话,折叠参数)
agent
│ POST gateway/sessions/<new>/proxy/tools/git_clone
│ headers: Mcp-Session-Id: <new>
│ 沙箱参数头(折叠): {"id":"sbx-123","class":"docker","no_create":true} ← header 变小
│ 用户 meta 头: {"user":"alice"}

gateway → threadCache 命中(<new> → nodeX)→ 直接反代(不再调度)

sandbox-manager@nodeX
│ 会话→沙箱映射命中 sbx-123,经沙箱 RoundTripper
│ POST http://sandbox.local/tools/git_clone (带用户 meta 头)

sandbox-agent.HandleToolCall
│ ToolManager → invokeApprover(白名单/禁用 gate)
│ → ToolSource.Call(git_clone, args, meta)
│ → exec git clone ... (CLI)

结果回流: ToolCallResult → ToMCPCallToolResult → MCP CallToolResult(JSON)

agent 拿到结果(stdout/exitcode/数据)


[3] 会话结束与销毁
agent DELETE /sessions/<new> (2xx)

sandbox-manager: 释放 SandboxLock + RemoveThread("sbx-123", <new>)
(沙箱本身还在,TTL 300s 内可被同/新会话复用)


[4] 过期回收(无人用后)
300s 到期 → solveLockExpiration(等在用完)
→ engine.Destroy("sbx-123")
metering 装饰器记 Event{recycle, sandbox_id:sbx-123, tenant/user} → ClickHouse
warmpool/容器销毁,池子补充新预热沙箱

12.3 关键环节点睛

  1. 为什么选到节点 X:X 装了 warmpool,预热沙箱不计入 Weight,所以 IdleWeight 富余多,AvailabilityFilter + DeterministicSelector 偏好它——这是"负载感知 + 预热偏好"的落点。
  2. 为什么第二次请求直接打到 X 不再调度:AddProcess/AddThreadsbx-123→X、sbx-123/<new>→X 写进了 scheduler 快照,gateway 的 threadCache 也缓存了 <new>→X。sticky 在调度器(guaranteed)和网关(快路径)两层都有。
  3. 沙箱为什么是毫秒级起来:warmpool 早就预建好了容器、装好依赖、跑好 sandbox-agent,只等 Activate 绑 workdir + user id。冷启的 docker run 只在池空时兜底。
  4. 密钥怎么不泄漏:{{ .secrets.db_token }} 在 manager 渲染参数时才解密,DEK 走 KMS DecryptDEK,明文 AES-GCM 在本地解出后只塞进给沙箱的运行参数;落盘/打日志的 printable_params 只保留引用形式,debug 模式才留明文。
  5. 工具调用为什么是 HTTP:沙箱 = http.RoundTripper,manager 给请求盖个沙箱 ID header 后转发到沙箱内 sandbox-agent 的 /tools/:tool。没有单独 exec 协议,SDK/manager/agent 全用 HTTP。
  6. 计贯穿穿全程:Create 时记 start,Destroy 时记 recycle,都带 tenant/user/sandbox_id 进 ClickHouse——这是 metering 装饰器在 engine 栈最外层拦截到的。

12.4 路径 B(router)的差异

如果走老路径(router 二层网格):SDK 打到 router export 层而非 gateway;router 不调 scheduler,而是用七级阶梯(会话缓存→显式叶子→偏好叶子→会话绑定→任意可用→沙箱 ID 绑定→class 加权)选叶子,经 WS 反向隧道到 sandbox-manager 的 /mcp,之后从 sandbox-manager 往下完全一样。多副本 export 时会话缓存存 Redis,任一 export pod 都能服务同一会话——这是 router 的 HA 方式,而 gateway 的 HA 靠 scheduler 集群 + 选主。

12.5 一句话

agent 用 SDK 带 id+class+Mcp-Session-Id 打 gateway → scheduler 按快照确定性选个富余节点 → 经反向隧道到该节点 sandbox-manager → 渲染参数(含密钥解密)→ warmpool 弹热沙箱 Activate 接管 → 会话绑定 + 上报 scheduler(下次 sticky)→ agent 复用会话折叠参数打工具 → manager 经 RoundTripper 转到沙箱内 agent 的 /tools → 结果回流 → 会话结束释放锁,沙箱 TTL 到期销毁并记计量。 整条链路里"建沙箱"只发生一次,之后全是 HTTP 工具调用 + sticky 路由。

十三、读后感

读完这个框架,几个设计我印象很深:

  1. Sandbox = http.RoundTripper:一个接口把"沙箱"抽象到极致,工具调用统一 HTTP,没有多余协议层。优雅。
  2. 装饰器链:metering/warmpool 与具体引擎正交,横切关注点组合得很干净。
  3. 快照+Epoch 确定性调度:同一 thread 必落同一 runtime、leader/follower 降级、反向隧道节点无需公网、SSE 增量+30s 全量——这是把"分布式调度"做扎实了的工程。
  4. router 二层网格:export StatefulSet(稳定 DNS + Redis 共享会话缓存)+ connect Deployment(拨所有 export),自愈隧道 + 调和平均加权 + 预热偏好。HA 想得很透。
  5. 密钥渲染:参数里 {{ .secrets.xxx }} 引用 KMS,本地 AES-GCM 解密注入,明文不落盘——agent 场景的安全细节到位。

它把"沙箱"这件历史上散落在脚本里的事,做成了可调度、可观测、可组合的基础设施:全栈 Go 工程化、MCP 原生、调度做强。对照"协议优先 + 多语言 SDK + 进程级强隔离"的通用沙箱平台路线,这是另一种值得借鉴的打法。

后续可深挖:pkg/scheduler/epoch.go 的并发周期合并细节、router ClientsManager.Allocate 的调和平均实现、combination 的多 backend 工具合并冲突处理、Firecracker microVM 的 CNI 网络与 sidecar 挂载时序。

参考

  • MCP Go SDK:github.com/modelcontextprotocol/go-sdk
  • 调度架构文档:docs/scheduler-architecture.md
  • 接口定义:pkg/engine/engines.gopkg/engine/sandbox.go
  • 调度管线:pkg/scheduler/pipeline.gopkg/scheduler/server/server.go
  • 路由:pkg/router/router.gopkg/router/schedule.gopkg/router/clients_manager.go
  • 引擎:pkg/engine/{engines.go, generic/, container/, warmpool/, metering/, combination/}
  • 密钥渲染:pkg/utils/{sandbox_params.go, params_render/}