数据截至 (上游 commit ac864a6fe3bd)
数据面:sandbox-router 反向代理、Pod-IP 缓存快路与鉴权
30 秒导读: 控制器负责"造沙箱"(见 02-sandbox-controller.md),而 sandbox-router 负责"把流量送进沙箱"。它是一个独立的可执行体、无状态的 HTTP 反向代理:从请求头
X-Sandbox-ID等认出目标是哪个沙箱,用"Pod-IP 直连 → 缓存快路 → 集群 DNS"三级优先级找到后端地址,再用httputil.ReverseProxy把请求原样透传过去。核心价值:成千上万个短命沙箱 Pod,不用每个都建一个 Service,也能被 HTTP 精确寻址。
1. 这是什么(零基础也能懂)
一句话定义: sandbox-router 是 Agent Sandbox 的数据面网关——一个把外部 HTTP 请求路由到正确的沙箱 Pod 的反向代理(reverse proxy,替客户端转发请求、再把响应带回来的中间人)。
它解决的问题
假设你的平台上同时跑着几千个 AI agent 的临时沙箱,每个沙箱是一个 Pod,里面开着 code-server、Jupyter 或一个 HTTP 服务。现在一个客户端想访问"沙箱 sb-abc123 的 8888 端口"。
难点在于:这些 Pod 朝生暮死、数量巨大。在 Kubernetes 里给每个 Pod 建一个 Service 来提供稳定地址,会把 API server 和 kube-proxy 压垮。
router 的答案:客户端不直接连 Pod,而是都连到 router,把"我要找哪个沙箱"写在请求头里,router 负责把这一跳落到真实的 Pod 上。
它和控制器的关系(两个可执行体)
Agent Sandbox 把"创建"和"路由"拆成两个独立的二进制:
| 可执行体 | 入口 | 职责 | 与 K8s API 的关系 |
|---|---|---|---|
| controller-manager | cmd/...(见 02) | 控制面:reconcile Sandbox CR,创建 Pod / Service | 读写 Sandbox、Pod 等资源 |
| sandbox-router | sandbox-router/cmd/main.go | 数据面:转发 HTTP 到沙箱 Pod | 从不创建/查找 Sandbox;只(可选)读 Pod 做 IP 缓存 |
一句话:router 从不碰 Sandbox 资源。 如果目标沙箱不存在,请求经过短暂重试后以 502 失败——router 只管转发,不管沙箱的死活。它本质上是把一个原有的 Python 反向代理用 Go 重写,并保持了同样的 X-Sandbox-* 请求头契约,再加上 TLS/mTLS、指标、追踪、优雅停机等企业级能力(sandbox-router/cmd/main.go:15-18 的包注释)。
用起来什么样
客户端只需连到 router,把目标写进请求头:
curl http://sandbox-router-svc/ \
-H 'X-Sandbox-ID: sb-abc123' \
-H 'X-Sandbox-Namespace: team-a' \
-H 'X-Sandbox-Port: 8888'
router 收到后,把它转成对 sb-abc123.team-a.svc.cluster.local 上 8888 端口的请求(或直连该 Pod 的 IP),再把响应带回来。用 Go/Python SDK 的用户根本看不到这一层——SDK 把 sandbox-router-svc 写死为目标、自动填好这些头(见 05-client-sdks.md)。
一句话直觉: 把 router 想成机场的"航班信息屏 + 摆渡车":你只报"航班号"(X-Sandbox-ID),它查出登机口(Pod IP)并把你送过去,你不需要知道每个登机口的固定地址。
2. 顶层全景(一次代理请求怎么走)
router 是无状态的:每个请求独立处理,不保存会话。下面这张图是一次代理请求的完整生命周期——从左到右顺 序执行,任一步失败就直接写 JSON 错误返回。
inbound HTTP
│
▼
┌──────────────────── ServeHTTP (proxy/proxy.go:114) ────────────────────┐
│ │
│ ① 解析请求头 ParseSandboxHeaders(headers.go:76) │
│ X-Sandbox-{ID,UID,Namespace,Port,Pod-IP} → Target │
│ 校验失败 → 400 JSON ────────────────────────────────► 返回 │
│ │ │
│ ▼ │
│ ② 鉴权 authz.Authorize(proxy.go:134) │
│ 默认 AllowAll 直接放行;tokenreview 校验 Bearer │
│ 拒绝 → 401 / 403 JSON ───────────────────────────────► 返回 │
│ │ │
│ ▼ │
│ ③ 解析后端地址 Target.Resolve(resolve.go:67) │
│ ┌─ 有 X-Sandbox-Pod-IP? ──► 直连该 IP (SourcePodIP) │
│ ├─ 缓存命中 UID? ──► 直连缓存的 Pod IP (SourceCache 快路) │
│ └─ 否则 ──► <id>.<ns>.svc.<domain> (SourceDNS) │
│ │ │
│ ▼ │
│ ④ httputil.ReverseProxy 透传(proxy.go:162) │
│ Rewrite: 改写 URL/Host、删 Authorization、写 X-Forwarded-*、注入 trace │
│ Transport: 带重试的 RoundTripper(retry.go) │
│ │ │
│ ├─ 拨号失败 → ErrorHandler(proxy.go:215):失效缓存 + 502 JSON │
│ └─ 成功 → 流式把响应写回客户端(FlushInterval=-1) │
└──────────────────────────────────────────────────────────────────────────┘
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Handler / ServeHTTP | 请求核心:串起解析→鉴权→解析地址→透传 | sandbox-router/proxy/proxy.go |
ParseSandboxHeaders | 从 X-Sandbox-* 头解析并校验出 Target | sandbox-router/proxy/headers.go |
Target.Resolve | 三级优先级挑出后端 host:port | sandbox-router/proxy/resolve.go |
Cache | UID→PodIP 的 informer 缓存,支持主动失效 | sandbox-router/cache/cache.go |
retryTransport | 只对拨号类错误重试的 RoundTripper | sandbox-router/proxy/retry.go |
Authorizer | 每请求鉴权(AllowAll / TokenReview) | sandbox-router/authz/ |
Server | 装配 4 个 HTTP 监听器与生命周期 | sandbox-router/server/server.go |
main | 组装以上所有部件的可执行入口 | sandbox-router/cmd/main.go |
3. 核心机制
3.1 请求核心:一个 Handler 串起全流程
它要解决的小问题: 把"认目标、鉴权、找地址、转发"这四件事,按固定顺序、可复用地组织成一个 http.Handler。
结构。 Handler(proxy/proxy.go:40-48)持有配置、指标、缓存、鉴权器等依赖;NewHandler(proxy/proxy.go:70-111)从 Options 组装它。Options 的巧妙点是可选依赖 nil 时都有合理降级(proxy/proxy.go:54-67):Cache 为 nil → 纯 DNS 解析;Authorizer 为 nil → AllowAll;Propagator 为 nil → 无操作。这让单测能只给最小依赖就跑起来。
ServeHTTP 的骨架(proxy/proxy.go:114-279)严格按图中顺序:
// 示意,非源码:ServeHTTP 的主干
target, perr := ParseSandboxHeaders(r.Header, opts) // ① 解析
if perr != nil { WriteJSONError(w, perr); return }
if err := h.authz.Authorize(...); err != nil { ... return } // ② 鉴权
url, src := target.Resolve("http", clusterDomain, path, query, h.cache) // ③ 找地址
rp := &httputil.ReverseProxy{ Rewrite: ..., Transport: h.transport, ErrorHandler: ... }
rp.ServeHTTP(w, r.WithContext(ctx)) // ④ 透传
透传的关键动作都在 Rewrite 回调里(proxy/proxy.go:163-212)。这段做的几件事值得逐一点出——每件都是安 全或兼容性考量:
| 动作 | 代码 | 为什么 |
|---|---|---|
| 覆盖出站 URL、清空 Host | proxy.go:164-167 | 让 net/http 用后端地址;与 Python router 一致 |
删掉 Authorization | proxy.go:175 pr.Out.Header.Del | router 自己消费凭证;沙箱绝不能看到调用者的 token,否则任意沙箱可冒充调用者 |
删掉入站 X-Forwarded-For 再重写 | proxy.go:187-193 SetXForwarded | 防止客户端伪造转发链;只写 router 观测到的真实客户端 IP |
升级请求时删 Origin | proxy.go:206-208 | WebSocket 后端(如 vscode-server)校验 Origin==Host,改写 Host 后必然不匹配;删掉让 CSRF 感知后端放行 |
| 注入 trace context | proxy.go:211 propagator.Inject | 让沙箱侧看到的是同一条链路的延续 |
流式与超时两个细节:
FlushInterval: -1(proxy/proxy.go:214)让 SSE / 流式响应立即刷新,不缓冲。- 普通请求用
ProxyTimeout兜底,但协议升级(WebSocket)故意跳过超时(proxy/proxy.go:273-277)——升级连接天生长命(code-server 一整场编辑就一条 WebSocket),套上 180s 超时会在边界处把健康会话拆掉,客户端看到 1006 关闭。是否升级由isUpgradeRequest(proxy/proxy.go:287-292)判定。