跳到主要内容

数据面: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-managercmd/...(见 02)控制面:reconcile Sandbox CR,创建 Pod / Service读写 Sandbox、Pod 等资源
sandbox-routersandbox-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
ParseSandboxHeadersX-Sandbox-* 头解析并校验出 Targetsandbox-router/proxy/headers.go
Target.Resolve三级优先级挑出后端 host:portsandbox-router/proxy/resolve.go
CacheUID→PodIP 的 informer 缓存,支持主动失效sandbox-router/cache/cache.go
retryTransport只对拨号类错误重试的 RoundTrippersandbox-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-263)严格按图中顺序:

// 示意,非源码: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、清空 Hostproxy.go:164-167让 net/http 用后端地址;与 Python router 一致
删掉 Authorizationproxy.go:175 pr.Out.Header.Delrouter 自己消费凭证;沙箱绝不能看到调用者的 token,否则任意沙箱可冒充调用者
删掉入站 X-Forwarded-For 再重写proxy.go:187-193 SetXForwarded防止客户端伪造转发链;只写 router 观测到的真实客户端 IP
升级请求时删 Originproxy.go:206-208WebSocket 后端(如 vscode-server)校验 Origin==Host,改写 Host 后必然不匹配;删掉让 CSRF 感知后端放行
注入 trace contextproxy.go:211 propagator.Inject让沙箱侧看到的是同一条链路的延续

流式与超时两个细节:

  • FlushInterval: -1(proxy/proxy.go:214)让 SSE / 流式响应立即刷新,不缓冲。
  • 普通请求用 ProxyTimeout 兜底,但协议升级(WebSocket)故意跳过超时(proxy/proxy.go:257-261)——升级连接天生长命(code-server 一整场编辑就一条 WebSocket),套上 180s 超时会在边界处把健康会话拆掉,客户端看到 1006 关闭。是否升级由 isUpgradeRequest(proxy/proxy.go:271-276)判定。

3.2 三级解析优先级(整章最巧妙的点)

它要解决的小问题: 同样是"找到沙箱的地址",不同调用者掌握的信息不一样——有的已经知道 Pod IP,有的只知道 UID,有的什么都不知道。要在尽量快总能工作之间取平衡。

思路: 按"信息越充分越快"排出固定优先级,首个命中即停Target.Resolve(proxy/resolve.go:67-102)实现,并返回一个 Source 枚举(proxy/resolve.go:40-52)记录走了哪条路,供日志和指标归因。

三级优先级(proxy/resolve.go:74-89):

┌── 从左到右是解析优先级,命中即停 ──┐

X-Sandbox-Pod-IP 非空? ──是──► 直连该 IP Source = pod-ip
│否 (SDK 刚创建沙箱、已知 IP,连发现都省了)

cache≠nil 且 UID 非空,查缓存命中? ──是──► 直连缓存的 Pod IP Source = cache
│否 / 缓存未命中 (KEP 安全快路,绕过 DNS)

回落集群内 DNS 形式 <id>.<ns>.svc.<cluster-domain> Source = dns
(永远可用,等价于 Python router)

为什么这样排:

  • pod-ip 第一(resolve.go:75-77): 调用者(通常是刚创建完沙箱的 SDK)已经知道 Pod IP,直连最快,连缓存和 DNS 都跳过。
  • cache 第二(resolve.go:78-82): 只在 cache≠nil && UID≠"" 时尝试;命中就拿到活的 Pod IP,绕过 DNS 解析这一跳。这是 KEP 描述的"快而安全"路径。
  • dns 兜底(resolve.go:84-89): 前两者都没戏时,拼出 <id>.<ns>.svc.<clusterDomain> 的集群内 DNS 名。这条永远能用、不依赖 informer 缓存或 UID,与原 Python router 行为一致。

IPv6 细节(容易忽略、但很关键): 拼 host:port 时用 net.JoinHostPort(proxy/resolve.go:93-98),因为双栈 / 纯 IPv6 集群里 Pod.Status.PodIP 是裸 IPv6 字符串,不加方括号的 ::1:8080 会与地址本身歧义,net/http 会解析失败。JoinHostPort 会按 RFC 3986 给 IPv6 字面量加上 []

注:proxy/target.go:34-51 还有一个更早的 UpstreamURL,只在 PodIP 和 DNS 之间选、不查缓存。带缓存的解析统一走 resolve.goResolve;两者共用同样的 IPv6/DNS 拼接规则。

3.3 Pod-IP 缓存:informer 维护 + 拨号失败主动失效

它要解决的小问题: 3.2 的"cache 快路"要有个东西持续维护 UID→PodIP 的映射,而且当某个缓存的 IP 变"死"时要能及时纠正。

思路: 用一个 Kubernetes Pod informer 在后台把映射喂进一张内存 map;请求热路径只做一次带读锁的 map 读(O(1),常态无锁竞争)。

缓存结构。 Entry(cache/cache.go:72-84)存 PodIP / SandboxName / Namespace;Cache(cache/cache.go:89-98)是 map[types.UID]Entry + sync.RWMutexGet(cache/cache.go:183-188)是热路径,RLock + map 读即返回。

informer 只关心沙箱 Pod。 New(cache/cache.go:112-156)在服务端就做了两层过滤(cache/cache.go:126-131):

  • 标签选择器只要带 agents.x-k8s.io/sandbox-name-hash 标签的 Pod——在混合集群里大幅减少 informer 内存和 API 流量;
  • 字段选择器 status.podIP != "" 跳过还没拿到 IP 的 Pod(它们不可能是有用的路由目标)。

UID 从哪来: 不是从 Pod 标签,而是从 Pod 的控制器 OwnerReference里取 Sandbox 的 UID(sandboxUIDOf,cache/cache.go:289-306)——匹配 Kind==Sandbox 且 API group 是 agents.x-k8s.io

只缓存 Ready 且有 IP 的 Pod。 onAddOrUpdate(cache/cache.go:209-227)在每次 Add/Update 时判断:podReady(cache/cache.go:326-334,要求 PodReady==True)且 PodIP!="" 才 upsert,否则把该 UID 从缓存移除。理由:向没通过就绪探针的 Pod 转发只会给调用者制造一堆 502。

主动失效(KEP 的关键设计)。 informer 有同步延迟——一个 Pod 死了、IP 变了,informer 可能还没来得及更新。于是缓存暴露 Invalidate(cache/cache.go:274-283),由代理的 ErrorHandler用缓存 IP 拨号失败时主动调用:

// 示意,非源码:proxy.go 的 ErrorHandler 里
if src == SourceCache && cache != nil && isRetriableDialError(err) && uid != "" {
cache.Invalidate(types.UID(uid)) // 踢掉这条,下一次请求就回落到 DNS
}

真实实现见 proxy/proxy.go:228-232。注意两个精细判断:

  1. 只在 src == SourceCache(这次用的 IP 确实来自缓存)时才失效——DNS 或 PodIP-header 失败时缓存里根本没有可踢的东西。
  2. isRetriableDialError(与重试共用)判断,而不是用指标里的字符串标签。因为拨号超时被 classifyError 标成 "timeout",但它仍是 dial 失败、IP 一样是死的;若用字符串匹配 "dial" 会漏掉这类,把流量困在死条目上。

代理只依赖缓存的一个窄接口 Lookup(proxy/resolve.go:31-36,只有 GetInvalidate),这样 proxy 包不必把 informer 那套依赖拖进自己的依赖图,单测也能塞个假实现。

3.4 重试:只对拨号类错误重试

它要解决的小问题: 刚创建的沙箱,DNS 可能还没注册、Pod 可能还没开始 listen,第一次拨号常会失败。想重试,但绝不能重发已经发出去一半的请求体(会重复副作用)。

思路: 把重试做成一层 http.RoundTripper 包装(retryTransport,proxy/retry.go:34-40),只在拨号阶段失败时重试。Go 的 http.Transport 在拨号成功之前不会碰请求体,所以拨号失败重试对 POST/PUT 也安全;拨号之后的失败(响应超时、中途 EOF)直接原样返回(proxy/retry.go:24-29 的注释)。

RoundTrip(proxy/retry.go:63-93)是指数退避循环:失败后判 isRetriableDialError,可重试且没到上限就 sleep 后再试,退避翻倍并封顶。isRetriableDialError(proxy/retry.go:103-121)认两类:net.DNSError(Service 还没注册),以及 net.OpError.Op == "dial"(连接被拒 / 超时 / 无路由)。其它 Op(read/write)发生在碰过请求体之后,故意不重试。

重试预算同时受 maxAttempts每请求 context 截止时间双重约束;sleep 会被 context 取消打断,并把原始拨号错误(而非 context 错误)透出去,好让 ErrorHandler 报告真实的上游失败(proxy/retry.go:79-85)。默认重试次数由 config.UpstreamMaxRetries 控制(默认 3,config/config.go:187),NewHandler 里只有 >0 时才包上重试层,否则请求路径保持单次 Dial(proxy/proxy.go:80-97)。

3.5 鉴权:默认放行,可切 TokenReview

它要解决的小问题: 既要保持与 Python router "不鉴权"的旧契约兼容,又要给需要的部署提供真正的按请求鉴权。

契约。 Authorizer 接口只有一个方法(authz/authorizer.go:83-85):Authorize(ctx, r, sandboxNamespace, sandboxName) error。返回 nil = 放行,返回哨兵错误 = 拒绝。HTTPStatusFor(authz/authorizer.go:103-114)把 ErrUnauthenticated→401、ErrForbidden→403、其它→500。

两个内置实现:

实现行为适用
AllowAll(authz/authorizer.go:120-125)永远返回 nil,全放行默认;开发集群,或前面有 Envoy/Gateway/mesh 兜鉴权
TokenReviewAuthorizer(authz/tokenreview.go:90-98)把 Bearer token 交给 K8s TokenReview API 验证需要按请求认证的生产部署

默认 AllowAll 保留了 Python router 的无鉴权契约——NewHandlerAuthorizer 为 nil 时装配它(proxy/proxy.go:98-101),ServeHTTP 里鉴权失败才写错误(proxy/proxy.go:134-147)。

TokenReview 的实现要点(authz/tokenreview.go:156-219Authorize):

  • Authorization: Bearer ... 取 token(authz/identity.go:75-91,scheme 大小写不敏感、token 原样);没有 token 时,RequireToken 决定拒绝还是放行(供滚动升级过渡期用)。
  • 决策带 TTL + LRU 缓存,把认证开销挪出热路径。缓存按 token 的 SHA-256 哈希存(hashToken,authz/tokenreview.go:249-252)——内存里绝不放原始 token,即便内存被 dump 也不会泄露所有会话。
  • API 调用失败时以更短的 TTL(TTL/3,最小 1s)缓存错误(authz/tokenreview.go:189-199),避免打爆抖动的 apiserver,又能快速自愈。
  • 身份只在首次插入缓存那一刻记一次日志(authz/tokenreview.go:203-217),后续 cache hit 的 decide(authz/tokenreview.go:223-243)只记非身份字段,避免把 Username/UID/Groups 在每请求重复打出来。

已知边界(v1): TokenReview 只做认证,不做按沙箱的授权——任何通过认证的调用者都能访问它在 X-Sandbox-ID 里点名的任意沙箱。收紧它需要在 Sandbox CR 上约定所有权契约,是 KEP 落地后的后续工作(authz/tokenreview.go:83-89 的注释)。Identity 也支持从 mTLS 客户端证书提取(IdentityFromTLS,authz/identity.go:41-66,优先级 SPIFFE URI → DNS SAN → CN)。


4. 服务器装配与可观测(略点)

四个监听器。 Server(server/server.go:34-44)统一管理四个 HTTP server:明文代理、TLS 代理、/metrics、健康探针。New(server/server.go:66-130)要求至少一个代理地址,HTTPS 必须配 TLSConfig

先绑定后就绪(避免流量打空)。 Run(server/server.go:143-232)同步预绑定每个端口(server/server.go:160-175),任一 bind 失败就立即返回、绝不宣告就绪;全部绑定成功后才 MarkReady(server/server.go:197)。这样 /readyz 翻绿时,LB 送来的流量一定有端口在接。停机时对每个 server 并发 Shutdown,防止一个慢 drain 吃掉整个超时预算。

探针契约。 Probes(server/probes.go:25-27):/healthz 恒定 200 且返回 Python 兼容的 {"status":"ok"}(server/probes.go:47-51);/readyzMarkUnready 后翻 503,让 LB 在停机前先摘流(server/probes.go:54-62)。

可观测三件套。

  • 指标(observability/metrics.go:35-108):sandbox_router_requests_totalrequest_duration_secondsupstream_errors_totalcache_invalidations_totalauthz_decisions_total 等,注册进私有 registry 以免和 controller-runtime 的指标串味。Middleware(observability/metrics.go:135-155)包裹的 statusRecorder 特意实现了 Hijack(observability/metrics.go:199-204)——否则 ReverseProxyhttp.Hijacker 的类型断言失败,会静默地把每条 WebSocket 都弄断。
  • 访问日志(observability/access_log.go:104-129):每请求一行结构化日志,SkipHealthAndMetrics(observability/access_log.go:134-140)跳过高频探针端点以免淹没信号。
  • 追踪:TracingMiddleware 开 span 并把带 trace_id 的 per-request logger 挂到 context;代理侧的 propagator.Inject 让沙箱看到同一条链路。

TLS/mTLS。 tlsutil.BuildServerTLS(tlsutil/config.go)组装服务器 tls.Config,证书由热重载的 CertReloader 通过 GetCertificate 提供;MTLSMode 非 off 时加载客户端 CA 做双向认证。

组装顺序看 main。 run(cmd/main.go:99-287)是把上述部件拼起来的地方:config → 可选 tracing/OTel 指标 → 可选 TLS → 可选 K8s client(缓存和 tokenreview 共用一个 client,cmd/main.go:158-165)→ 可选 Pod 缓存(就绪前阻塞等首次 LIST,cmd/main.go:169-191)→ 鉴权器 → proxy handler → 中间件层叠 → Server.Run


5. 巧妙之处(可借鉴的技术)

  • 三级解析 + Source 归因。 用一个枚举记录"这次走了哪条路"(resolve.go:40-52),让下游的日志、指标、缓存失效都能按解析来源精确决策——尤其是"只在 SourceCache 时才失效缓存"(proxy.go:228)。
  • 主动失效补 informer 的同步窗口。 不盲等 informer,而是在真实拨号失败的那一刻就把死条目踢掉(cache.go:274-283),下一请求立即回落 DNS。用 isRetriableDialError 而非字符串标签判断,避免漏掉被标成 "timeout" 的拨号超时(proxy.go:220-228)。
  • 只对拨号失败重试。 精准地把"body 还没被碰过"的失败点单独拎出来重试(retry.go:103-121),既救活了冷启动竞态,又不冒重发请求体的风险。
  • 升级请求的两处特判。 WebSocket 既删 Origin(过 CSRF 校验)又跳过 ProxyTimeout(不误杀长会话),两处用同一个 upgrade 判定保持同步(proxy.go:161, 206, 257)。
  • token 只存哈希。 鉴权缓存按 SHA-256 存决策(tokenreview.go:249-252),内存泄露也不交出会话。
  • 窄接口解耦。 proxy 只依赖 Lookup{Get, Invalidate}(resolve.go:31-36),而非整个 Cache,把 informer 依赖挡在包外、方便测试。

6. 边界与局限

  • router 不认识 Sandbox 资源。 目标不存在就是 502(经短重试),它不会替你去 API server 查或建(README "Where it sits")。
  • v1 的 TokenReview 只认证不授权。 通过认证的调用者能访问它点名的任意沙箱(tokenreview.go:83-89)。
  • 缓存只在 --cache-enabled=true 时存在。 否则退化为纯 DNS,与 Python router 等价(config/config.go:136-141),这时快路不可用、每请求都走 DNS。
  • 访问日志的 client_ip 不认 X-Forwarded-For(v1)。 安全地支持它需要配置可信代理链,暂缓(access_log.go:142-146)。
  • 不默认走环境代理。 defaultTransport 故意不设 Proxy(proxy.go:284-293),避免 HTTP_PROXY 把集群内拨号错误地送出集群。
  • 默认不给沙箱开 HTTP/2。 ForceAttemptHTTP2:false(proxy.go:299),沙箱目前是 h1。

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

主题文件路径符号
请求核心 / 组装sandbox-router/proxy/proxy.goHandlerNewHandlerServeHTTP
透传改写 / 错误处理sandbox-router/proxy/proxy.goReverseProxy.RewriteErrorHandlerisUpgradeRequestclassifyError
请求头解析与校验sandbox-router/proxy/headers.goParseSandboxHeadersTargetvalidDNSLabelvalidPodIP
三级地址解析sandbox-router/proxy/resolve.goTarget.ResolveSourceLookup
非缓存地址构造sandbox-router/proxy/target.goTarget.UpstreamURL
Pod-IP 缓存sandbox-router/cache/cache.goCacheEntryGetInvalidateonAddOrUpdatesandboxUIDOfpodReady
拨号重试sandbox-router/proxy/retry.goretryTransportRoundTripisRetriableDialError
鉴权契约与 AllowAllsandbox-router/authz/authorizer.goAuthorizerIdentityAllowAllHTTPStatusFor
TokenReview 鉴权sandbox-router/authz/tokenreview.goTokenReviewAuthorizerAuthorizehashToken
身份提取sandbox-router/authz/identity.goIdentityFromTLSBearerTokenFromRequest
JSON 错误响应sandbox-router/proxy/errors.goErrorWriteJSONError
服务器与探针sandbox-router/server/server.goprobes.goServer.RunProbes
指标 / 访问日志sandbox-router/observability/metrics.goaccess_log.goMetricsMiddlewareAccessLogMiddleware
配置sandbox-router/config/config.goConfigDefaultsValidate
可执行入口sandbox-router/cmd/main.gomainrunbuildKubernetesClient

相关章节: 控制器怎么造出被路由的沙箱见 02-sandbox-controller.md;SDK 如何自动填 X-Sandbox-* 头并连到 router 见 05-client-sdks.md;数据模型见 01-sandbox-api-model.md