跳到主要内容

客户端 SDK:Go / Python 的 Sandbox 生命周期与连接模型

30 秒导读: 前四章讲的是集群里的"服务端"(CRD、控制器、扩展、router)。本章换到调用方视角:一个 agent / 应用怎么用几行代码创建一个沙箱、等它就绪、往里跑命令读写文件、用完删掉。主线是 Go SDK(clients/go/sandbox/),它把这件事拆成两条清晰的线——控制面(和 Kubernetes API 打交道,管 Claim/Sandbox 生命周期)与数据面(和沙箱里的 HTTP 服务打交道,发命令/传文件),中间用一层"连接策略"决定网络怎么通。Python SDK 是同一模型的另一种实现,用来横向印证。


1. 这是什么(零基础也能懂)

一句话定义: 客户端 SDK 是一个编程库,让你的程序不必手写 kubectl 和 HTTP,就能"要一个沙箱 → 在里面执行代码 → 收拾干净"。

给谁用、解决什么问题: 假设你在写一个 AI agent,它需要一个隔离的 Linux 环境来跑模型生成的代码。你不想关心"怎么创建 CRD、怎么等 Pod 起来、Pod 的 IP 是多少、请求怎么路由进去"。SDK 把这些全包了,只暴露四个动作:

动作Go 方法干什么
要一个沙箱client.CreateSandbox(...)创建 Claim,阻塞到 Sandbox Ready,建好连接
跑命令sb.Run(ctx, "echo hi")在沙箱里执行 shell,拿回 stdout/stderr/exit code
读写文件sb.Write / sb.Read / sb.List / sb.Exists上传、下载、列目录、判存在
收拾sb.Close(ctx) / client.DeleteAll(ctx)删 Claim、断连接、释放本地资源

用起来什么样(Go,真实示意):

client, _ := sandbox.NewClient(ctx, sandbox.Options{Namespace: "default"})
defer client.DeleteAll(ctx)

sb, _ := client.CreateSandbox(ctx, "my-warmpool", "default") // 阻塞到就绪
result, _ := sb.Run(ctx, "echo 'Hello from Go!'")
fmt.Println(result.Stdout, result.ExitCode)

一句话直觉: 把 SDK 想成"云主机的 SDK",只不过"主机"是一个 Kubernetes 沙箱 Pod:CreateSandbox ≈ 开机并拿到 SSH,Run/Read/Write ≈ 在里面敲命令,Close ≈ 关机销毁。

本章不重复 CRD 字段(见 01-sandbox-api-model.md)和 router 内部机制(见 04-sandbox-router.md),只讲客户端怎么消费它们。


2. 顶层全景(SDK 大概怎么转)

Go SDK 的 sandbox 包里,一个请求会穿过这几层。先看图,再看每层职责。

怎么读这张图: 上半是控制面(和 K8s API 交互,一次性的生命周期动作),下半是数据面(和沙箱 HTTP 交互,每次 Run/Read 都走)。connector 是数据面的枢纽,它把"URL 怎么发现"这件事甩给可替换的 strategy

你的 agent 代码

┌────────────┴─────────────┐
│ Client (注册表 + 批量清理) │ client.go
└────────────┬─────────────┘
│ 持有 / 创建
┌────────────▼─────────────┐
│ Sandbox 句柄 │ sandbox.go
│ Open/Close/Disconnect │
└───┬──────────────────┬───┘
控制面 ↑ │ │ ↓ 数据面
┌────────▼───────┐ ┌──────▼──────────┐
│ K8sHelper │ │ connector │ connector.go
│ 建Claim/等Ready │ │ 身份头+重试+reqID │
│ 取 podIP/注解 │ └──────┬──────────┘
└────────┬───────┘ │ 委托 URL 发现
k8s.go ┌────▼────────────────┐
│ │ ConnectionStrategy │ strategy.go
┌────────▼───────┐ │ gateway / tunnel / │
│ 生成 clientset │ │ direct │
│ (typed CRUD) │ └────┬─────────┬───────┘
└────────────────┘ gateway.go tunnel.go
│ │
Gateway 外部IP SPDY 端口转发
└────┬────┘

sandbox-router → 沙箱 Pod

各部件一句话职责:

部件干什么文件
Client可选的注册表层:按 (namespace, claimName) 缓存句柄、批量删除、信号自动清理clients/go/sandbox/client.go
Sandbox单个沙箱句柄:生命周期(Open/Close/Disconnect)+ 优雅关闭状态机clients/go/sandbox/sandbox.go
K8sHelper控制面:建/删 SandboxClaim、watch 到 Ready、抽取 Pod IP 与注解clients/go/sandbox/k8s.go
connector数据面枢纽:拼 URL、加 X-Sandbox-* 头、带重试和 requestID 发 HTTPclients/go/sandbox/connector.go
ConnectionStrategy可插拔选路:URL 从哪来(网关地址 / 本地隧道 / 直连)strategy.go gateway.go tunnel.go
Commands / Files数据面操作面:Runexecute,文件打 upload/download/list/existscommands.go files.go
生成的 clientsetcode-gen 出来的 typed client,K8sHelper 底层用它做 CRUDclients/k8s/clientset/versioned/...

主线走一遍(高层): CreateSandbox → K8sHelper 建 Claim、watch 到 Sandbox Ready、拿到 sandboxName + podIP → connector 记下身份、让 strategy 发现 base URL、Connect → 之后每次 Run 都由 connector 拼 baseURL + /execute、加头、发请求、按需重试。


3. 生命周期:New / Open / Close / Disconnect

这节讲一个 Sandbox 句柄从生到死的四个动作,以及它们怎么在并发下不打架

3.1 四个动作各管什么

方法做什么服务端影响
New只构造句柄:装配 strategy / connector / Commands / Files,不碰网络
Open建 Claim → 等 Sandbox Ready → 建传输连接创建 Claim/Sandbox
Disconnect断开传输,但保留 Claim(沙箱还活着,可再 Open 重连)无(沙箱存活)
Close排空在途操作 → 删 Claim → 关传输删除 Claim

Newsandbox.go:64-170 里根据 Options 选连接策略:给了 APIURLDirectStrategy,给了 GatewayNamegatewayStrategy,都没给就默认 tunnelStrategy(端口转发)——见 sandbox.go:83-110switch

Open(sandbox.go:176-277)是主流程:createClaimresolveSandboxName(热池下 Sandbox 名 ≠ Claim 名)→ waitForSandboxReadyconnector.SetPodIPconnector.Connect。任何一步失败都会 rollbackOpen(sandbox.go:341)自动删掉刚建的 Claim,避免留下孤儿。

3.2 优雅关闭:draining + inflightOps + lifecycleSem

要解决的小问题: 你在另一个 goroutine 里正 sb.Run(...),同时主线程调了 sb.Close()。如果 Close 直接删 Claim,那条正在跑的请求就会打到一个正在消失的沙箱上。SDK 要先排空在途操作,再删

三个字段各司其职(sandbox.go:46-49):

字段类型作用
lifecycleSemchan struct{}(容量 1)串行化所有生命周期动作:Open/Close/Disconnect 互斥,同一时刻只有一个在跑
inflightOps*sync.WaitGroup数一数有多少个 Run/Read/Write 正在飞
drainingboolClose 已开始排空的标志位

排空的关键舞步(Close,sandbox.go:371-468):

  1. connector.Close() 拆传输——让新操作立刻拿到 ErrNotReady 快速失败,而不是排队。
  2. s.mu 锁内原子地draining = true换掉 WaitGroup(s.inflightOps = &sync.WaitGroup{}),把旧的那个交给 drain goroutine 去 Wait。这样 trackOp 之后再进来只会看到 draining 而空转,不会再往旧 WG 加人。
  3. 给排空一半的时间预算,另一半留给删 Claim(drainBudget := time.Until(closeDeadline)/2)。

每个数据面操作开头都 defer c.trackOp()()(见 commands.go:53files.go:103)。trackOp(sandbox.go:590-600)在锁内检查:若 draining 就返回空函数,否则 wg.Add(1) 并返回 wg.Done。这套"先拆传输、再换 WG"的顺序,专门堵住了"操作刚好从 trackOp 溜过去(看到 draining=false)又摸到活传输"的竞态——注释写在 sandbox.go:585-589

一句话记忆: lifecycleSem 管"生命周期动作之间"的互斥,inflightOps+draining 管"生命周期动作 vs 在途数据请求"的排空。

3.3 Client:注册表层(可选的更高一层)

Client(client.go:37-48)是 Sandbox 之上的便利层,内部维护 registry map[Key]*Sandbox。它多给了三样东西:

  • CreateSandbox(client.go:80)= New + Open + 登记进注册表。
  • GetSandbox(client.go:112)= 按 Claim 名重连:缓存里有且 IsReady 就直接返回,否则重建句柄、注入身份走 Open 的 reconnect 分支。
  • EnableAutoCleanup(client.go:235)= 挂 SIGINT/SIGTERM 处理器,进程被 Ctrl-C 时自动 DeleteAll 再重新抛信号退出——避免跑测试时留下一堆沙箱。

examples/basic/main.go 用的就是这一层;examples/gateway/main.go 则直接用底层 sandbox.New + Open


4. K8sHelper:控制面(和 Kubernetes 打交道的那一半)

这节讲"要沙箱"的具体步骤。K8sHelper(k8s.go:53-62)聚合了五个 Kubernetes 客户端,可在多个 Sandbox 间共享。

4.1 客户端从哪来

NewK8sHelper(k8s.go:67-122)的配置解析顺序很实用:传入的 restConfig 优先 → 否则试 in-cluster config(SDK 跑在 Pod 里)→ 再退回 ~/.kube/config。然后用同一个 HTTP client 构造 agents / extensions / dynamic / core / discovery 五个客户端。

注意:即使用 Direct URL 模式,SDK 仍然需要 kubeconfig,因为 Claim 的生命周期(建/删)始终走 Kubernetes API,只有数据面才走 HTTP。(clients/go/README.md:24)

4.2 建 Claim → 等 Ready → 取状态

三步都在 k8s.go:

  1. createClaim(k8s.go:125):用 GenerateName: "sandbox-claim-" 创建 SandboxClaim,打上 created-by: go-client 标签,并把当前 trace context 塞进注解做分布式追踪。
  2. resolveSandboxName(k8s.go:183):watch Claim 的 Status.SandboxStatus.Name——因为热池领取下,拿到的 Sandbox 名和 Claim 名不同(见 03-extensions-warmpool-claim.md)。带指数退避、watch 断了自动重建。
  3. waitForSandboxReady(k8s.go:276):watch Sandbox 直到 Ready 条件为 True(isSandboxReady k8s.go:410),然后 extractState(k8s.go:419)抽出这份"就绪快照":
sandboxState 字段来源
SandboxNamesb.Name
PodName注解 agents.x-k8s.io/pod-name,缺省回退到 Sandbox 名
PodIPsb.Status.PodIPs,经 selectPodIP 择优
AnnotationsSandbox 全量注解的拷贝

这份 state 回填进 Sandbox(setState sandbox.go:602),PodIP 同时喂给 connector 走"直连 Pod 快路"(见 §6.3)。

4.3 重连路径

Open 若发现句柄已有 claimName(比如经 GetSandbox 注入),就走 reconnect(sandbox.go:279):先 verifyClaimExistsverifySandboxAlive,确认服务端还活着,只重建传输而不重建沙箱。这让"客户端重启后重新接管一个存活沙箱"变得廉价。


5. connector:数据面枢纽(拼 URL、加头、重试)

这节是客户端"发一次请求"的核心。connector(connector.go:56-77)拥有 HTTP client、重试逻辑和身份状态,把"URL 从哪发现"委托给 strategy。

5.1 三个状态设置器

方法干什么位置
SetIdentity(name)记下 Sandbox 名,作为 X-Sandbox-IDconnector.go:126
SetPodIP(ip)记下 Pod IP,作为 X-Sandbox-Pod-IP 头;先校验防止 header 注入connector.go:134
Connect(ctx)调 strategy 发现 base URL 并存下connector.go:148

5.2 SendRequest:重试 + requestID + 身份头

SendRequest(connector.go:227)是所有数据面操作的底座。它做的事:

  • 每次请求生成一个 requestID(generateRequestID connector.go:419,8 字节随机 hex),写进 X-Request-ID贴到 trace span 上——这样一次请求在客户端日志、router 日志、trace 里能用同一个 ID 串起来。
  • 每个 attempt 前重读 baseURL(connector.go:249):若为空(比如隧道刚死),立刻返回 ErrNotReady,不做无谓重试。
  • 注入五个头(connector.go:292-308,常量在 types.go:33-38):X-Sandbox-IDX-Sandbox-NamespaceX-Sandbox-PortX-Request-ID,以及可选的 X-Sandbox-Pod-IP;还会把剩余 deadline 换算成 X-Sandbox-Timeout 传给 router。
  • 只对瞬时失败重试:重试状态码限定 500/502/503/504(retryableStatusCodes connector.go:47),指数退避带抖动(backoff connector.go:397,base 500ms、cap 8s、最多 6 次)。
  • 不可重试的 body 要能重放:若 body 实现了 io.Seeker 就 Seek 回 0 重发,否则第二次起直接报错(connector.go:268-280)——这也是为什么文件上传要先整个缓存进内存。
  • 安全细节:关掉自动重定向(CheckRedirect 返回 ErrUseLastResponse,connector.go:115),把 3xx 原样交回调用方,防客户端 SSRF。

命令执行默认不重试(RunmaxAttempts 缺省为 1,commands.go:56),因为 execute 非幂等;想让幂等命令重试要显式 sandbox.WithMaxAttempts(6)。文件操作则默认走 6 次重试。


6. 两种连通方式对照:gateway vs port-forward

同一个 connector,靠不同的 ConnectionStrategy(接口在 strategy.go:20-23,只有 Connect/Close 两个方法)通向 router。README 把它们叫三种"模式"。

6.1 三种策略一览

模式策略类型URL 怎么来典型场景
生产 / GatewaygatewayStrategy(gateway.go)watch Gateway 资源拿外部 IP/域名集群有公网 Gateway LB
开发 / Port-ForwardtunnelStrategy(tunnel.go)起 SPDY 端口转发,URL 是 127.0.0.1:随机端口本地开发 / CI
高级 / DirectDirectStrategy(strategy.go:26)直接用给定 APIURLin-cluster 或自定义域名

选哪个由 New 里的 switch(sandbox.go:83-110)按 Options 决定:APIURL > GatewayName > 默认隧道。

6.2 Gateway 策略:watch 到外部地址,还要防 SSRF

gatewayStrategy.Connect(gateway.go:52)watch gateway.networking.k8s.io/v1 的 Gateway 资源,等 status.addresses[0].value 出现。拿到地址后不是直接拼 URL——extractGatewayAddress(gateway.go:178)会校验:含 /?#@ 一律拒绝,且必须是合法 IP 或 isValidGatewayHostname(gateway.go:210,只允许 DNS 字符)通过的主机名。这是防"被篡改的 Gateway 资源把路径/userinfo 注进 URL"的 SSRF 防线。

6.3 Port-Forward 策略:原生 SPDY 隧道 + 死亡监控

tunnelStrategy.Connect(tunnel.go:129)不 fork kubectl,而是用 client-go 原生 SPDY建隧道:

  1. resolveRouterPod(tunnel.go:315):按 kubernetes.io/service-name=sandbox-router-svc 查 EndpointSlices,挑一个 Ready 的 router Pod。
  2. spdy.RoundTripperFor + portforward.NewForStreaming,转发规格 "0:8080"(本地 0 = 随机端口 → router 的 8080),成功后 baseURL = http://127.0.0.1:<随机端口>
  3. 起一个 monitorPortForward(tunnel.go:261)goroutine 守着:隧道一死就 connector.SetLastError(ErrPortForwardDied)——于是下一个 SendRequest 读到空 baseURL + 这个错,立刻返回 ErrNotReady 并带死因,而不是傻等超时。

trackingDialer(tunnel.go:74)包住 SPDY 连接,保证 stop channel 不足以解阻塞 ForwardPorts 时能强制关掉底层连接,避免 goroutine 泄漏。

6.4 选路里的 Pod-IP 快路(ip.go)

无论哪种策略,只要 K8sHelper 拿到了 Pod IP,就通过 X-Sandbox-Pod-IP 头带给 router,让 router 走"Pod-IP 缓存快路"(见 04-sandbox-router.md)。selectPodIP(ip.go:28)在双栈环境显式偏好 IPv4:扫描 PodIPs,命中第一个合法 IPv4 就返回,否则回退第一个合法 IP。SetPodIP(connector.go:134)会先跑一遍这个校验,非法 IP 直接丢弃并降级日志,防 header 注入。


7. 命令与文件操作面

数据面对外只有两个子对象:CommandsFiles,都通过 sb.Commands() / sb.Files() 取,或用 Sandbox 上的便利别名(sb.Run/sb.Read/... sandbox.go:521-537)。

7.1 Commands.Run

Run(commands.go:52)POST 到 execute 端点,body 是 {"command": "..."},响应 JSON 解成 ExecutionResult{Stdout, Stderr, ExitCode}(types.go:126)。要点:响应体限 16MB(超了返回 ErrResponseTooLarge);默认单次尝试(非幂等);还会把命令的可执行名抽出来贴到 span 属性(commandExecutable commands.go:100)。

7.2 Files:四个操作 + 端点映射

方法HTTP端点备注
Write(files.go:102)POSTupload只收纯文件名(不含目录);整体缓存成 multipart 以支持重试;默认限 256MB
Read(files.go:157)GETdownload/<路径>路径 percent-encode;下载超 MaxDownloadSize 报错
List(files.go:201)GETlist/<路径>解成 []FileEntry,过滤掉非 file/directory 的项
Exists(files.go:250)GETexists/<路径>{"exists": bool}

路径编码值得一提:percentEncode(files.go:40)对 RFC 3986 非保留集之外的所有字节(含 /)编码,. / .. 还会被特判成 %2E(encodeFilePath files.go:57),防路径穿越。


8. Python SDK 横向印证 + 生成 clientset + KEP-359

8.1 同一模型,另一种实现

Python SDK(clients/python/agentic-sandbox-client/k8s_agent_sandbox/)和 Go 是同构的,便于交叉验证:

概念GoPython
注册表客户端Client(client.go)SandboxClient(sandbox_client.py:48)
单沙箱句柄Sandbox(sandbox.go)Sandbox(sandbox.py:31)
数据面枢纽connector(connector.go)SandboxConnector(connector.py:279)
连接策略ConnectionStrategyConnectionStrategy(connector.py:60)
控制面K8sHelper(typed clientset)K8sHelper(k8s_helper.py:35,用 CustomObjectsApi 动态客户端)
命令/文件Commands/FilesCommandExecutor/Filesystem

对齐点很清楚:send_request(connector.py:341)注入的头和 Go 一字不差(X-Sandbox-ID/Namespace/Port/Timeout/Pod-IP,connector.py:386-407),同样关重定向防 SSRF(allow_redirects=False,connector.py:417),同样用 urllib3Retry 对 500/502/503/504 重试(connector.py:306)。生命周期也一致:create_sandbox(sandbox_client.py:94)= 建 Claim → resolve_sandbox_namewait_for_sandbox_ready,失败即删孤儿 Claim。

两点差异值得记:

  1. 端口转发方式不同。 Python 的 LocalTunnelConnectionStrategy(connector.py:136)直接 subprocess.Popen(["kubectl", "port-forward", ...]) fork 外部进程;Go 用 client-go 原生 SPDY(无需 kubectl 二进制)。
  2. Python 多一种策略。 InClusterConnectionStrategy(connector.py:234)绕过 router,直接打 http://<sandbox>.<ns>.svc.cluster.local:<port>,且 should_inject_router_headers() 返回 False——SDK 跑在集群内时的直连模式。

Python 还提供全套异步版(async_sandbox_client.pyasync_sandbox.pyasync_connector.pyasync_k8s_helper.py,如 AsyncSandboxClient async_sandbox_client.py:43),把同一模型搬到 asyncio 上。

8.2 生成的 clientset:只是底层 typed CRUD

clients/k8s/clientset/versioned/...code-gen 出来的 typed client。它不是 SDK 的对外面,只是 Go 版 K8sHelper 的底层齿轮:SandboxInterface(typed/api/v1beta1/sandbox.go:37)提供 Create/Get/List/Watch/Update/UpdateStatus/Delete/Patch 标准 CRUD,实现委托给 gentype.ClientWithList。你一般不直接用它;K8sHelper 已把 watch-到-Ready 之类的高层语义封好。

8.3 KEP-359 背景

sandbox 这个包的 import 路径是 sigs.k8s.io/agent-sandbox,即 Kubernetes 官方增强提案 KEP-359 "Agent Sandbox" 的参考实现。本章讲的 Go/Python SDK 正是该提案定义的客户端消费层:把"Claim 生命周期(控制面)"与"经 router 的数据面 HTTP"清晰分离,再用可插拔策略统一 gateway / port-forward / 直连三种连通方式。(仓库源码本身未直接引用 KEP 编号,此背景为项目归属信息,inferred)


9. 最小 Go 使用示意

下面这段综合了 examples/basic/main.goexamples/gateway/main.go 的真实用法,演示"注册表层"的完整闭环。

// 示意,非源码(结构参照 clients/go/examples/basic/main.go)
ctx := context.Background()
client, err := sandbox.NewClient(ctx, sandbox.Options{Namespace: "default"})
if err != nil { log.Fatal(err) }

stop := client.EnableAutoCleanup() // Ctrl-C 时自动删所有沙箱
defer stop()
defer client.DeleteAll(ctx)

// 建沙箱:阻塞到 Ready,内部走 New+Open
sb, err := client.CreateSandbox(ctx, "my-warmpool", "default")
if err != nil { log.Fatal(err) }

// 跑命令 / 读写文件
res, _ := sb.Run(ctx, "echo 'Hello from Go!'")
_ = sb.Write(ctx, "hello.txt", []byte("Hello, world!"))
data, _ := sb.Read(ctx, "hello.txt")
fmt.Printf("%s -> exit=%d, file=%q\n", res.Stdout, res.ExitCode, data)

// 按 Claim 名重连(比如进程重启后接管同一个沙箱)
sb2, _ := client.GetSandbox(ctx, sb.ClaimName(), "default")
_, _ = sb2.Run(ctx, "echo 're-attached!'")

要用底层、走 Gateway 模式(examples/gateway/main.go 的真实写法)则是:sandbox.New(ctx, Options{WarmPoolName, Namespace, GatewayName, GatewayNamespace})client.Open(ctx)client.Run(...)defer client.Close(ctx)


10. 代码地图(导航索引)

用符号名 grep 比行号更抗漂移。

主题文件路径符号名
单沙箱句柄 / 生命周期clients/go/sandbox/sandbox.goSandboxNewOpenCloseDisconnectreconnectrollbackOpen
优雅关闭状态机clients/go/sandbox/sandbox.gotrackOpinflightOpsdraininglifecycleSemsetState
注册表客户端clients/go/sandbox/client.goClientCreateSandboxGetSandboxDeleteAllEnableAutoCleanup
控制面 / K8sclients/go/sandbox/k8s.goK8sHelperNewK8sHelpercreateClaimresolveSandboxNamewaitForSandboxReadyextractState
数据面枢纽clients/go/sandbox/connector.goconnectorSetIdentitySetPodIPConnectSendRequestbackoffgenerateRequestID
请求头常量 / 错误 / 类型clients/go/sandbox/types.goheaderSandboxIDExecutionResultFileEntryHandleInfo
连接策略接口 / 直连clients/go/sandbox/strategy.goConnectionStrategyDirectStrategy
Gateway 选路clients/go/sandbox/gateway.gogatewayStrategyextractGatewayAddressisValidGatewayHostname
端口转发隧道clients/go/sandbox/tunnel.gotunnelStrategyresolveRouterPodmonitorPortForwardtrackingDialer
Pod-IP 择优clients/go/sandbox/ip.goselectPodIP
命令 / 文件操作clients/go/sandbox/commands.gofiles.goCommands.RunFiles.Write/Read/List/ExistspercentEncodeencodeFilePath
配置 / 默认值clients/go/sandbox/options.goOptionssetDefaultsvalidateWithTimeoutWithMaxAttempts
Go 示例clients/go/examples/basic/main.gogateway/main.gomain
Python 客户端.../k8s_agent_sandbox/sandbox_client.pysandbox.pySandboxClientSandboxcreate_sandboxget_sandboxterminate
Python 数据面 / 策略.../k8s_agent_sandbox/connector.pySandboxConnector.send_requestGatewayConnectionStrategyLocalTunnelConnectionStrategyInClusterConnectionStrategy
Python 控制面 / 异步.../k8s_agent_sandbox/k8s_helper.pyasync_*.pyK8sHelperAsyncSandboxClient
生成的 typed clientsetclients/k8s/clientset/versioned/typed/api/v1beta1/sandbox.goSandboxInterfacenewSandboxes