基于 Claude Agent SDK 的 Kubernetes 自托管部署实战:pod-per-session 架构与网络级 egress 隔离
【免费下载链接】claude-cookbooksA collection of notebooks/recipes showcasing some fun and effective ways of using Claude.项目地址: https://gitcode.com/GitHub_Trending/an/claude-cookbooks
本指南是 Agent SDK hosting cookbook 的 Tier 3 章节配套文档解析与源码级详解,面向需要在自有 Kubernetes 集群上以“每会话一个 Pod”方式托管 Claude Agent 的团队。读完本文你将掌握:网关如何按需创建/回收 agent Pod、如何借助 NetworkPolicy + Egress Proxy 把 agent 的网络出口严格锁死到 Anthropic API、standby 预热池如何消除冷启动延迟,以及如何在 kind 上本地验证整套机制后平滑迁移到 EKS / AKS / GKE 等生产集群。
本 cookbook 将同一份 research agent 镜像通过三种方式托管:Tier 1(本地 Docker)、Tier 2(Modal Sandbox)与Tier 3(Kubernetes)。三种方式的agent 镜像、HTTP 接口契约完全一致,改变的只是容器外的编排与网络机制(见 hosting 目录总览)。
在你决定自建之前:如果只是想托管 agent 而不想运维基础设施,应优先使用 Anthropic 的托管方案,参见 Hosting 总览。本指南面向需要把 agent 放到自己 Kubernetes 集群上的团队——例如受监管环境、已有平台、需要自定义网络的场景。
一、总体架构:为什么是 pod-per-session
Tier 3 的核心思想:每个用户会话拥有一个完全隔离的 Pod,同时通过网络层控制保证 agent Pod 只能访问 Anthropic API。整张拓扑图如下(摘自 kubernetes/README.md):
┌──────────────────────────────────────────────────┐ │ Kubernetes │ │ │ curl / SDK ──────► Gateway (FastAPI) │ │ ├─ creates/deletes agent pods via K8s API │ │ ├─ routes /sessions/{id}/messages to right pod │ │ └─ session → pod mapping stored in Redis │ │ │ ┌──────┴──────┐ │ │ │ │ Agent Pod Agent Pod ──► Egress Proxy ──► api.anthropic.com │ (session A) (session B) ▲ │ │ │ │ │ │ NetworkPolicy: pods can ONLY reach egress-proxy │ │ │ Redis (session → pod-IP mapping) │ │ │ └──────────────────────────────────────────────────┘关键事实:agent 镜像就是 Tier 1 用 hosting/Dockerfile 构建出的同一个镜像。同一镜像、不同的编排机制——不再是单个容器或 Modal Sandbox,而是由网关给每个会话分配独立 Pod,并由集群强制该 Pod 的可达范围。
这里必须理解托管层定义并严格遵守的HTTP 接口契约(见 hosting/README.md):
GET /health→200 {"status": "ok"},作为存活探针;POST /sessions/{session_id}/messages,请求体为{"prompt": "<用户消息>"},返回200 text/event-stream,事件流中包含event: message(data 是序列化的 SDK 消息:SystemMessage / AssistantMessage / ResultMessage)、event: done(本轮结束)、event: error;session_id必须匹配^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$,非法则返回 400;- 容器端口 8000,必需环境变量
ANTHROPIC_API_KEY; - 该 server 默认无认证,必须放在网关后面,禁止直接暴露到公网。
正因为三层共用同一契约,网关代码注释里才写明:针对 Docker / Modal 层写好的客户端代码在 Kubernetes 层无需修改即可复用,只是 base URL 变了。
二、每个组件为什么存在
Gateway(网关)——按需调度 Pod 的唯一入口
每个用户会话都有自己的 agent Pod,那么必须有东西按需创建 Pod、把流量路由到正确 Pod、在会话空闲时回收——这就是网关。它通过 Kubernetes API 管理 Pod 生命周期,并用 Redis 记住"哪个 session 映射到哪个 Pod IP"。
从源码看,gateway/main.py 的职责非常收敛:
- 用 Redis hash
session:{id}保存session_id → pod_ip映射与归属租户(ownership); POST /sessions/{id}/messages先_ensure_session_pod()拿到 Pod IP,再由relay_sse()把请求转发给 agent Pod 并把 SSE 字节流原样回传(proxy.py);- 后台
_reap_idle_loop()每 60 秒扫描一次sessions:active集合,把超过IDLE_TIMEOUT_S无活动的会话 Pod 删除。
网关自身是无状态的(路由元数据全在 Redis),因此可以在负载均衡器后面横向扩展多个副本。Pod 管理相关的函数(create_agent_pod、delete_agent_pod、get_pool_status、initialize_standby_pool)全部集中在 gateway/k8s.py,main.py只做路由与回收。
Egress Proxy + NetworkPolicy —— 网络层出口白名单
Agent 会执行模型决定运行的任意代码。这一对机制保证 agent Pod 能访问api.anthropic.com并且只能访问它:
- NetworkPolicy 拦截所有出站流量,只放行到 egress proxy(443 端口)与 DNS(53 端口);
- Egress proxy 终结来自 agent 的 TLS,再重新加密转发到 Anthropic API;
- 任何访问互联网、其他服务或其他命名空间的尝试,都会在网络层被丢弃。
具体到 network-policy.yaml,它选中所有role: agent标签的 Pod,同时声明Ingress与Egress两类策略:
- 入站:只允许带
app: gateway标签的 Pod 连接 agent Pod,其余一律丢弃——集群里没有任何其他服务能直接触达 agent; - 出站:仅允许连到
app: egress-proxy(TCP 443)与任意目标做 DNS 查询(UDP/TCP 53),其余全部丢弃。
在 nginx.conf 这一侧,nginx 把anthropic_apiupstream 指向api.anthropic.com:443,开启proxy_ssl_verify on校验上游证书、proxy_ssl_server_name on+proxy_ssl_name api.anthropic.com设置 SNI,并关闭proxy_buffering以支持流式响应(长连接超时给到proxy_read_timeout 300s)。nginx 配置里还专门声明了两层防护互补的模型:NetworkPolicy 保证 agent 只能到达 proxy,proxy 则保证只转发到 Anthropic API——两层共同构成严格的网络 allowlist。
值得注意的工程取舍(写在该配置头注释里):
api.anthropic.com只在 nginx 启动时通过集群 DNS 解析一次,不依赖外部解析器——这意味着这个隔离组件对外部零依赖、零元数据泄露;代价是若 Anthropic API 的 IP 长期漂移,需要重启 egress-proxy Deployment 重新解析。
Redis —— 路由元数据的唯一事实源
网关需要记住哪个 Pod 在服务哪个会话。请求到达时,先按 session ID 查 Redis 找到 Pod IP,再把流量路由过去。Redis 开启--appendonly yes持久化到 PVC(1Gi,见 redis.yaml),因此映射能跨网关重启存活。
redis.yaml底部还附带一条redis-ingress-policyNetworkPolicy:只允许app: gateway访问 Redis 6379。理由写得很清楚——agent Pod 虽已被它们自己的 egress 策略挡住,但若命名空间里出现任何"其他"Pod,没有这条规则就可能改写session → pod-IP路由表。而 agent Pod 的 k8s.py 里也做了对称加固:automount_service_account_token=False,连 K8s API 凭据都不挂进运行模型驱动代码的容器里。
Standby Pool —— 预热池消除 10–30 秒冷启动
Pod 启动需要 10–30 秒(拉镜像 + 容器启动)。网关预热一个可配置数量的 standby Pod,新会话到来时可以立即认领而不用等待;Pod 被认领后,池子在后台自动补足。
k8s.py 完整实现了这套生命周期管理,核心函数包括:
_build_pod_manifest():构造 agent Pod 的 manifest,环境变量里把 API key 从anthropic-api-keySecret 通过secretKeyRef注入、把ANTHROPIC_BASE_URL指向https://egress-proxy、通过NODE_EXTRA_CA_CERTS=/certs/ca.crt让 Node.js 信任自签名 CA、设置CLAUDE_CONFIG_DIR=/data,并挂载readiness_probe(HTTP GET/health,initial_delay_seconds=2, period_seconds=2, failure_threshold=15)以确保认领前 uvicorn 真的在监听;_claim_standby_pod():通过原子 PATCH Pod 标签把pool-status: standby改成active并打上session-id——如果两个网关实例竞争认领同一个 Pod,只有一个 PATCH 会成功,失败方自动尝试下一个,全程无需外部锁;_replenish_pool():认领或删除后后台补池,计数时把Pending(仍在拉镜像)的 Pod 也计入,避免慢速拉镜像导致过度预置;循环上限为STANDBY_POOL_SIZE * 2,防止镜像持续拉取失败时无限空转;create_agent_pod()的策略是"先试认领预热 Pod(即时返回);没有可认领对象再按agent-session-{session_id}创建按需 Pod 并轮询等待其 Ready";delete_agent_pod()先按session-id标签查删,再回退到确定性名字,全部幂等处理 404。
值得注意的是,agent Pod 的容器以args=["serve"]启动(k8s.py),配合restart_policy="Never"与termination_grace_period_seconds=5——agent Pod 是按会话存在的临时实体,删了就没了,无需优雅排空。
三、前置条件
| 工具 | 用途 |
|---|---|
| kind | 在 Docker 里跑一个本地 Kubernetes 集群 |
| kubectl | 应用 manifest、检查集群状态 |
| docker | 构建容器镜像 |
openssl | 生成 egress proxy 的 TLS 证书 |
ANTHROPIC_API_KEY | 以环境变量形式提供 |
此外 kind-quickstart.sh 还会检查jq是否已安装。
四、Quickstart:在 kind 上跑通本地端到端
Tier 3 的快速启动脚本位于 kind-quickstart.sh。kind(Kubernetes in Docker)会在你笔记本的 Docker 容器内启动一个真实的 Kubernetes API server,无需云账号;脚本应用的 manifests 与生产集群是同一批,只是镜像仓库不同。
cd hosting/kubernetes export ANTHROPIC_API_KEY=sk-ant-... ./kind-quickstart.sh脚本会完成以下工作:
- 创建 kind 集群——注意它显式
disableDefaultCNI: true并安装Calico,因为 kind 的默认 CNI(kindnet)不执行 NetworkPolicy(详见下文"验证出口封锁"一节的说明);创建后依次等待calico-nodeDaemonSet、calico-kube-controllers及节点就绪; - 构建并装载三个镜像到 kind:agent(复用 Tier 1 的 hosting/Dockerfile,构建上下文是上一级
claude_agent_sdk/,因为镜像需要research_agent/与utils/)、gateway、egress-proxy,再kind load docker-image; - 运行 generate-certs.sh生成 egress proxy 的自签名证书;
- 应用 namespace、三个 Secret 与一个 ConfigMap:
anthropic-api-key、gateway-tenants、egress-proxy-tls,以及agent-config(内含AGENT_IMAGE=local/agent:latest与STANDBY_POOL_SIZE=2);其中 tenant Secret 用openssl rand -hex 16生成两个演示租户 token,映射为alice与bob; - 应用全部 manifests(用
sed "s|REGISTRY_URL|local|g"做占位符替换); - 等待
redis、egress-proxy、gateway三个 Deployment 变为 available; - 把 gateway 端口转发到
localhost:8080并打印两个演示租户的 bearer token。
脚本还会在开头做一次API key 预检:直接请求https://api.anthropic.com/v1/models验证 key 有效,避免一个失效的 key 等到整套集群起来、第一次 curl 时才暴露成难懂的 "Invalid API key"。
脚本结束时输出的两个 token 需要导出后使用:
export ALICE_TOKEN=... # 由 kind-quickstart.sh 打印 export BOB_TOKEN=...五、与 agent 对话
请求路径与形状和 Tier 1/2 一致——只是 base URL 变了,且网关现在要求携带标识调用方租户的 bearer token:
curl -N -X POST http://localhost:8080/sessions/demo/messages \ -H "Authorization: Bearer $ALICE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"prompt": "What tools do you have?"}'会话语义:某个session_id上的第一个请求会认领一个 standby Pod(池子为空时则新建一个);后续同一session_id的请求会路由到同一 Pod,因此 agent 能读到连续的对话历史。
租户隔离语义:会话现在归属alice——网关在 Redis 里记录了创建者租户,并在每一次后续请求上复查归属。同一个调用换成$BOB_TOKEN会返回403 {"detail":"session belongs to another tenant"},不带 token 则返回401。
从 main.py 源码看这层检查的实现细节很完整:
- 租户映射是
gateway-tenantsSecret 里一段静态的token:tenant,token:tenant字符串,启动时解析进_TOKEN_TO_TENANT(空 token 的配对会被丢弃,防止空 Authorization 头成为合法凭据); - 认证函数
authenticate()遍历映射并用secrets.compare_digest逐一比对,避免把查找过程变成时序侧信道;GATEWAY_TENANTS未设置时则放行所有请求并把调用方标记为单租户anonymous(仅限本地调试); _ensure_session_pod()用 Redis 的SET NX加一把带PROVISION_TIMEOUT_S(180 秒)TTL 的 per-session 锁来防并发预置:若客户端重试与正在预置的请求撞车,可能为同一会话认领第二个 Pod 并泄漏到空闲回收器运行;锁竞争失败方会每秒轮询等待赢家的映射出现,且之后仍然走一遍归属检查——两个租户同时抢建同一session_id也不可能共享一个 Pod。
观察整台机器运转(Pod 生命周期可视化):
kubectl -n claude-agent get pods -w # 你会看到 agent-standby-* Pod 出现,随后当你 curl 时其中一个翻转为 active要结束一个会话,请走网关(同样仅限拥有者,与所有会话操作一致),这样 Redis 映射才会被清理:
curl -X DELETE http://localhost:8080/sessions/demo \ -H "Authorization: Bearer $ALICE_TOKEN"kubectl delete pod也能删掉 Pod,但会在 Redis 里留下过期的session → pod-IP条目,直到该会话的下一个请求 502 为止。源码里 main.py 的delete_session处理得非常细致:删除不存在的会话返回 200 no-op 且完全不碰集群——因为 Pod 可能在 Redis 记录写入前就已存在,误删会误杀其他租户仍在预置中的 Pod。
值得一提的是 main.py 对"Pod 已死但映射仍在"的恢复逻辑:当转发收到 502(映射的 Pod 被驱逐 / OOM 杀死 / 节点重启),网关会删除死 Pod 对象、只删掉 Redis hash 里的pod_ip字段(保留其余字段使会话不会变成无主状态、别的租户便无法趁恢复期抢占该 ID),然后重新预置一个 Pod 再转发一次。
六、验证出口封锁:提示注入的 agent 也出不去
Agent 运行的是模型决定要运行的代码。egress proxy + NetworkPolicy 意味着即便 agent 被提示注入,仍然无法访问任意主机。我们来证明这一点:
kind-quickstart.sh之所以安装 Calico,是因为 kind 的默认 CNI(kindnet)不执行 NetworkPolicy。在 GKE / EKS / AKS 或任何 Calico / Cilium 集群上,网络策略默认即被强制执行,本节可以原样运行。
AGENT_POD=$(kubectl -n claude-agent get pods -l role=agent \ -o jsonpath='{.items[0].metadata.name}') # 这一步应当失败——Calico 会丢弃除 egress-proxy 外的一切路由。 # (agent 镜像很精简、没有 curl,所以我们用 Python 的 socket。) kubectl -n claude-agent exec "$AGENT_POD" -- python3 -c \ "import socket; socket.setdefaulttimeout(5); socket.create_connection(('example.com',443)); print('REACHED — policy NOT enforcing')"预期结果是OSError: [Errno 101] Network is unreachable(或超时)并伴随非零退出码。而正向对照——egress-proxy 路径是通的——已被上文那次返回了模型输出的 curl 证明过了。
关于 DNS 残余通道:由于 53 端口保持对所有解析器开放(保证 NodeLocal DNS 缓存可用),DNS 隧道仍是理论上的数据外泄残留通道。network-policy.yaml 的注释给出了收紧建议:如果你的集群直接与 kube-dns/CoreDNS Pod 通信(无节点级 DNS 缓存),可把 DNS 规则收窄为namespaceSelector: kubernetes.io/metadata.name: kube-system,或在解析器层强制 DNS 策略(CoreDNS ACL、Cilium DNS policies)。
七、Standby 预热池的运行时观察
agent-configConfigMap 里的STANDBY_POOL_SIZE控制网关保持多少个热 Pod。查看当前池状态(任意合法租户 token):
curl http://localhost:8080/api/pool -H "Authorization: Bearer $ALICE_TOKEN"对应的实现是 k8s.py 的get_pool_status(),返回{ "target_size": N, "ready_count": n, "standby_pods": [...] },其中ready_count只统计真正可认领的 Pod——要求Running、有 Pod IP、无deletion_timestamp、且全部容器ready(_pod_is_ready())。main.py的/api/pool端点适合接进监控。
八、持久化:会话历史存在哪里,丢了怎么办
hosting/server.py会把会话记录(及其 caller-ID → SDK-ID 映射)持久化到CLAUDE_CONFIG_DIR=/data。在 Tier 3 里,/data是Pod 的临时文件系统(默认以 emptyDir 语义挂在 Pod 本地),因此:
- Pod 存活期间(处于 idle-timeout 窗口内),后续消息能精确续上对话,与 Tier 1/2 行为一致;
- Pod 被回收后,
/data随之消失。该session_id的下一条消息会拿到一个无历史的全新 Pod。
对 cookbook 演示来说这完全够用——会话的寿命超过 curl,但不需要超过集群。生产环境则需要能在 Pod 回收后存活的持久化存储,两种方案:
- 挂载 PersistentVolumeClaim 到
/data,并让网关在会话回归时重新挂载同一 PVC。server.py可原样工作,但每个会话会与某个可用区的卷耦合。 - 把
/data镜像到外部存储,借助 Agent SDK 的SessionStore:本地磁盘写入仍然先行发生,store 只是镜像,mirror_error非致命。这是 notebook 里Making it production-ready一节推荐的做法——需要在server.py里加一个小钩子,cookbook 目前还没有实现。
九、部署到你自己的集群
kind验证的是拓扑;manifests 本身是云无关的。要在 EKS、AKS、GKE、OpenShift 或裸机上运行,只需替换镜像仓库与前端的入口:
REG=your.registry.example.com/claude-agent # ECR、ACR、GHCR、Artifact Registry…… # 1. 构建并推送三个镜像 docker build -t $REG/agent:latest -f ../Dockerfile .. docker build -t $REG/gateway:latest ./gateway docker build -t $REG/egress-proxy:latest ./egress-proxy docker push $REG/agent:latest $REG/gateway:latest $REG/egress-proxy:latest # 2. 为 egress proxy 生成 TLS 证书 ./generate-certs.sh # 3. namespace + secrets + config kubectl apply -f manifests/namespace.yaml kubectl -n claude-agent create secret generic anthropic-api-key \ --from-literal=ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" kubectl -n claude-agent create secret generic gateway-tenants \ --from-literal=GATEWAY_TENANTS="$(openssl rand -hex 16):tenant-a,$(openssl rand -hex 16):tenant-b" kubectl -n claude-agent create secret generic egress-proxy-tls \ --from-file=ca.crt=certs/ca.crt \ --from-file=proxy.crt=certs/proxy.crt \ --from-file=proxy.key=certs/proxy.key kubectl -n claude-agent create configmap agent-config \ --from-literal=AGENT_IMAGE=$REG/agent:latest \ --from-literal=STANDBY_POOL_SIZE=2 # 4. 替换仓库占位符后应用 manifests for f in manifests/*.yaml; do sed "s|REGISTRY_URL|$REG|g" "$f" | kubectl apply -f - done若日后修改了
$REG,必须同时重建agent-configConfigMap——网关派生 agent Pod 时读取的是其中的AGENT_IMAGE,只对 manifests 重跑sed并不会重新指向新镜像。
关于generate-certs.sh需要说明的是它产出的四件套:certs/ca.key(CA 私钥,需保密)、certs/ca.crt(CA 证书,分发给 agent Pod 使其信任代理)、certs/proxy.key与certs/proxy.crt(由自建 CA 签名的代理证书,挂载进代理 Pod)。为什么需要自签名?egress proxy 是内部服务而非公网站点,无法从公共 CA 取证书,因此自行创建 CA 并为egress-proxy、egress-proxy.claude-agent.svc.cluster.local、localhost三个 SAN 签发证书,agent Pod 再通过NODE_EXTRA_CA_CERTS显式信任该 CA(nginx.conf 与 generate-certs.sh 中均有完整解释)。
随后通过集群惯用的方式暴露gatewayService——云 LoadBalancer、Ingress controller 或 service mesh gateway 均可。有三个随环境而变的要点:
- Registry 认证——节点需要
$REG的拉取凭据(imagePullSecrets、IRSA / Workload Identity,或公共镜像仓库); - NetworkPolicy 执行能力——出口封锁只在你的 CNI 执行
NetworkPolicy时才生效(Cilium、Calico、GKE Dataplane V2、启用 VPC CNI policy add-on 的 EKS)。若是忽略策略的 CNI,agent Pod 将能访问互联网; - Gateway 前面的 TLS 与认证——静态
GATEWAY_TENANTStoken 映射只是真实凭据的替身。对外暴露前,务必在网关前放置你的 IdP / API gateway。
RBAC 的划分在 gateway.yaml 中也很值得学习:网关以gateway-sa身份运行,通过gateway-pod-managerRole(verbs 仅限 pods 的create/get/list/patch/delete,限定在claude-agent命名空间)获得 K8s API 权限——恰好是k8s.py会发出的那些调用;而 agent Pod 使用没有任何 Role的agent-sa,配合automount_service_account_token=False,彻底不需要 K8s API 访问。
十、这套方案没给你什么(诚实的边界)
- 真正的身份提供方。网关确实强制了每租户的会话归属——
GATEWAY_TENANTS里每个 bearer token 映射一个租户,创建租户拥有会话,其他租户得到 403——但 token 本身是静态映射,没有签发、轮换、吊销或 per-tenant RBAC。把authenticate()换成你的 IdP 即可,归属检查逻辑无需改变(参见 main.py 的完整注释)。 - 持久的会话存储(见上文"持久化"一节)。
- 网关自动扩缩或多区域路由。
- DNS 级出口控制——53 端口保持对任意解析器开放(见"验证出口封锁"一节)。
- 加固过的支撑服务——gateway、Redis 与 nginx 都以其官方镜像的默认(root)用户运行。加固预算被集中投给了运行模型驱动代码的 agent Pod;其余服务在上生产前应按你所在组织的基线锁定。
- 超出
OTEL_EXPORTER_OTLP_ENDPOINT免费赠送范围之外的可观测性。
十一、清理与目录布局
本地验证完毕后,运行脚本拆除集群与证书:
./teardown.sh # kind delete cluster + remove certs/完整布局如下(文件说明摘自 kubernetes/README.md):
kubernetes/ ├── README.md ├── kind-quickstart.sh # 在 kind 上跑通本地端到端 ├── teardown.sh ├── generate-certs.sh # 为 egress-proxy 生成自签名 CA + 代理证书 ├── gateway/ │ ├── main.py # FastAPI:路由 + 回收(含租户认证与按需建 Pod) │ ├── k8s.py # Pod 生命周期 + standby 预热池 │ ├── proxy.py # SSE 透明中继(不做解析、逐字节转发) │ ├── requirements.txt │ └── Dockerfile ├── egress-proxy/ │ ├── nginx.conf # 出口白名单:只代理 api.anthropic.com │ └── Dockerfile └── manifests/ ├── namespace.yaml # 所有资源隔离在 claude-agent 命名空间 ├── redis.yaml # Redis(含 1Gi PVC 与只许网关访问的入站策略) ├── egress-proxy.yaml ├── gateway.yaml # SA + RBAC + Deployment + Service(含三探针) └── network-policy.yaml # 入站只许网关、出站只许 proxy/DNS 的双向封锁如果还想继续深入,建议配合阅读 07_Hosting_the_agent.ipynb(托管决策的完整叙述:什么时候托管方案更合适、什么时候你才真正需要 Tier 3)、hosting/README.md(三层共用的接口契约与镜像构建方式),以及 gateway/main.py、gateway/k8s.py、egress-proxy/nginx.conf 这些第一手实现——架构图上每一条线,都能在这几个文件里找到对应的代码。
【免费下载链接】claude-cookbooksA collection of notebooks/recipes showcasing some fun and effective ways of using Claude.项目地址: https://gitcode.com/GitHub_Trending/an/claude-cookbooks
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考