1. 多租户集群跑 AI Agent,真正的难点不在模型
把 AI Agent 塞进 Kubernetes 这件事,2024 年之后已经不算新鲜了。真正让一线运维和平台团队头疼的,是"多租户"这三个字。单租户集群里跑一个 Agent,你随便给它一个 Deployment、挂个 ServiceAccount、开个 NodePort,能跑起来就算成功。可一旦这个集群要同时服务十几个业务线、几十个团队,每个团队都有自己的 Agent 要跑,问题就全冒出来了:A 团队的 Agent 能不能读到 B 团队的 Secret?某个 Agent 跑飞了把节点 CPU 打满,会不会拖垮隔壁的在线服务?Agent 要执行 shell、要装依赖、要访问外部 API,这些权限怎么收口?
我所在的平台团队在过去大半年里,把一套多租户 AI Agent 运行平台从零搭到了生产可用,中间踩的坑足够写一本小册子。这篇就把整个思路和关键实现拆开讲,重点放在隔离、调度、权限、可观测这四件事上,而不是模型本身——模型选型是业务团队的事,平台要解决的是"怎么让几十个 Agent 安全地共存在一个集群里"。
先明确一下本文说的 AI Agent 是什么。它不是一个单纯的推理服务,而是一个能自主规划、调用工具、执行代码、多轮循环的程序。典型形态是:一个主进程接收任务,调用 LLM 做规划,然后通过工具调用去执行 shell 命令、读写文件、访问数据库或外部 API,根据结果再决定下一步。这意味着它比普通微服务危险得多——普通微服务的行为是确定的,Agent 的行为是运行时才决定的,它可能执行任意命令、访问任意网络地址。这就是为什么多租户场景下,隔离必须做到内核级别,光靠 namespace 和 RBAC 远远不够。
关键词里的Kata VM和Sandbox正是解决这个问题的核心手段。下面我会从架构选型一路讲到落地细节,包括为什么最终选了 Kata Containers 而不是 gVisor,为什么调度器要改造,以及那些文档里不会写的坑。
2. 隔离层级的选择:为什么 namespace 和 RBAC 挡不住 Agent
2.1 从"信任边界"重新理解多租户
很多人对多租户的理解停留在"用 Namespace 隔开就行"。这在传统微服务场景下勉强成立,因为微服务的行为是开发时确定的,你 review 过代码,知道它只会访问哪些资源。但 Agent 不一样,它的工具调用是 LLM 在运行时生成的,你今天给它一个"执行 Python 脚本"的工具,明天它可能就写出os.system("curl ...")这样的代码。你没法在开发阶段穷举它的行为。
所以多租户隔离的第一原则是:把每个租户的 Agent 当成不可信代码来对待。这个心态转变很关键。一旦你接受了"Agent 可能执行任意代码"这个前提,Namespace、RBAC、NetworkPolicy 这些 K8s 原生隔离手段的定位就清楚了——它们是管理面隔离,管的是"谁能创建什么资源",而不是运行面隔离,管不了"进程能干什么"。
我见过有团队用 Pod Security Admission 的 restricted 策略就以为万事大吉,结果 Agent 通过一个允许的 syscall 组合做了容器逃逸。restricted 策略能挡住 privileged、hostPath 这些明显危险项,但挡不住内核漏洞利用。容器共享内核这个事实,决定了它的隔离强度上限。
2.2 Kata Containers、gVisor、Firecracker 的取舍
运行面隔离的主流方案有三个方向,我们逐个评估过:
| 方案 | 隔离原理 | 隔离强度 | 启动开销 | 兼容性 | 运维复杂度 |
|---|---|---|---|---|---|
| runc(普通容器) | Namespace + Cgroup | 低 | 极低 | 完美 | 低 |
| gVisor | 用户态内核拦截 syscall | 中高 | 中 | 部分 syscall 不支持 | 中 |
| Kata Containers | 轻量 VM + 独立内核 | 高 | 较高 | 好(真内核) | 中高 |
| Firecracker | 微 VM | 高 | 低 | 需专门适配 | 高 |
我们最终选 Kata Containers,核心理由是兼容性。Agent 要执行各种用户代码,Python、Node、Go 编译出来的二进制都可能跑,gVisor 对某些 syscall 的支持不完整,实测中遇到过 numpy 的某些操作直接报错、某些 JIT 编译场景崩溃。这类问题排查起来极其痛苦,因为报错信息往往指向底层 syscall 而不是业务代码。Kata 用的是真实内核,跑在轻量 VM 里,兼容性和普通容器几乎一致,代价是启动慢一点、内存开销大一点。
Firecracker 隔离强度也很好,启动快,但它对设备模型做了大量裁剪,网络和存储的适配工作量大,而且它本身是给函数计算场景设计的,长驻的 Agent 进程用起来并不顺手。如果你的 Agent 是短任务型、秒级生命周期,Firecracker 值得考虑;如果是长驻的、要维持会话状态的,Kata 更合适。
提示:Kata 的启动开销在 200ms 到 1s 之间,取决于镜像和节点配置。如果你的 Agent 需要频繁冷启动,这个开销要提前评估,必要时用预热池(warm pool)来摊薄。
2.3 RuntimeClass 的落地配置
选定 Kata 之后,落地其实很简单,K8s 用 RuntimeClass 来声明。先在节点上装好 Kata 的 containerd shim,然后定义一个 RuntimeClass:
apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: kata-agent handler: kata overhead: podFixed: cpu: "250m" memory: "160Mi" scheduling: nodeSelector: katacontainers.io/kata-runtime: "true"overhead这个字段很多人会忽略,但它很重要。Kata 每个 Pod 都要起一个 VM,VM 本身要占 CPU 和内存,如果你不声明 overhead,调度器会按容器声明的资源来算,结果就是节点实际负载远超预期,出现 OOM 或者 CPU 争抢。我们一开始没配,节点上明明还有 2 核空闲,调度器却认为满了,或者反过来,调度上去了但节点直接被打爆。加上 overhead 之后,调度器的账才算得准。
然后在 Agent 的 Pod spec 里引用:
spec: runtimeClassName: kata-agent containers: - name: agent-runtime image: registry.internal/agent-runtime:1.4.2 resources: requests: cpu: "500m" memory: "512Mi" limits: cpu: "2" memory: "2Gi"这里有个经验:Agent 的 resources 一定要设 limits,而且 requests 和 limits 不要差太多。Agent 的行为不可预测,一个死循环就能把节点吃干。我们给所有 Agent 强制加了 LimitRange,禁止不设 limits 的 Pod 进入 Agent 命名空间。同时 CPU limit 不要设得太死,Agent 做推理和数据处理时对 CPU 敏感,设太低会导致任务超时,反而引发重试风暴。
3. 调度与资源治理:让几十个 Agent 不互相踩踏
3.1 为什么默认调度器不够用
K8s 默认调度器是给无状态微服务设计的,它假设 Pod 之间是同质的、可互换的。但 Agent 场景有几个特殊之处:第一,Agent 有状态,一个会话可能持续几十分钟甚至几小时,中途不能被随意驱逐;第二,Agent 的资源需求波动极大,规划阶段几乎不占资源,执行代码阶段可能瞬间吃满;第三,Agent 之间有亲和性,同一个租户的 Agent 可能共享缓存、共享向量库连接,放太散会放大延迟。
我们试过纯默认调度器,问题很快暴露:高峰期节点负载严重不均,有的节点 90% 利用率,有的只有 20%,因为默认调度器只看 requests,而 Agent 的实际用量和 requests 偏差很大。后来我们引入了两级调度:默认调度器负责粗粒度放置,再叠加一个自定义的调度扩展(Scheduler Plugin)做二次打分。
3.2 用 Scheduler Plugin 做租户感知打分
K8s 1.19 之后调度框架(Scheduling Framework)已经稳定,写一个自定义 Plugin 比早年改调度器源码优雅得多。我们实现了一个TenantSpread插件,核心逻辑是:同一租户的 Agent 尽量分散到不同节点,不同租户的 Agent 尽量混布以提高资源利用率。
func (p *TenantSpread) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) { tenant := pod.Labels["tenant-id"] nodeInfo, err := p.handle.SnapshotSharedLister().NodeInfos().Get(nodeName) if err != nil { return 0, framework.AsStatus(err) } count := 0 for _, p := range nodeInfo.Pods { if p.Pod.Labels["tenant-id"] == tenant { count++ } } // 同租户 Pod 越多,得分越低 score := int64(100 - count*20) if score < 0 { score = 0 } return score, nil }这个插件配合PodTopologySpread使用效果更好。PodTopologySpread 能保证同租户的 Pod 在拓扑域(比如可用区、节点)上均匀分布,但它是硬约束,配置不当会导致 Pod 一直 Pending。我们的做法是:用 PodTopologySpread 做软约束(whenUnsatisfiable: ScheduleAnyway),用自定义 Plugin 做精细打分,两者互补。
3.3 资源超卖与 QoS 分级
Agent 的资源画像很特殊:大部分时间低负载,偶尔尖峰。如果按峰值来分配,集群利用率会低得可怜;如果按均值分配,尖峰时又会互相踩踏。我们的方案是分级 QoS + 有限超卖。
把 Agent 分成三档:
- 交互式 Agent:用户实时等待结果,延迟敏感,requests 接近 limits,QoS 为 Guaranteed。
- 批处理 Agent:后台跑任务,可以容忍排队和降级,requests 远小于 limits,QoS 为 Burstable。
- 实验性 Agent:内部测试用,BestEffort,随时可被驱逐。
节点上通过 kubelet 的--system-reserved和--kube-reserved预留系统资源,再配合 cgroup v2 的cpu.weight做软限流。实测下来,交互式 Agent 的 P99 延迟在混布场景下能稳定在 800ms 以内,批处理 Agent 的吞吐则提升了近 40%,因为批处理任务填满了交互式 Agent 留下的资源空隙。
注意:超卖一定要配合驱逐策略。我们给批处理和实验性 Agent 的命名空间配了 PriorityClass,交互式 Agent 优先级最高。节点资源紧张时,kubelet 会先驱逐低优先级的 Pod,保证交互式 Agent 不受影响。
4. 权限收口:Agent 的"最小权限"到底怎么落地
4.1 ServiceAccount 不是权限边界
很多团队给每个租户建一个 ServiceAccount,就以为权限隔离做完了。这是个危险的误解。ServiceAccount 管的是Agent 访问 K8s API 的权限,但 Agent 真正危险的地方在于它能访问集群外的东西——数据库、对象存储、第三方 API、内网服务。这些都不在 K8s RBAC 的管辖范围内。
我们的做法是三层权限模型:
第一层是 K8s API 权限,用 RBAC 严格限制,每个租户的 ServiceAccount 只能操作自己命名空间内的资源,而且只给必要的 verb。Agent 通常只需要读 ConfigMap、写自己的 Job,其他一律不给。
第二层是网络权限,用 NetworkPolicy 做默认拒绝。每个租户命名空间默认拒绝所有入站和出站流量,然后按需放行。Agent 要访问外部 API,必须显式声明 egress 规则,并且经过 DNS 白名单。
第三层是凭证权限,这是最容易被忽略的。Agent 要访问数据库、要调外部 API,就需要凭证。我们禁止把长期凭证直接挂进 Pod,而是用 Vault 的 sidecar 注入短期 token,token 有效期 15 分钟,自动轮换。这样即使 Agent 被攻破,攻击者拿到的凭证也很快失效。
4.2 NetworkPolicy 的默认拒绝与 DNS 陷阱
默认拒绝的 NetworkPolicy 写起来简单,但有个经典陷阱:拒绝所有 egress 之后,DNS 也断了。Agent 解析不了域名,所有外部调用全挂。所以必须显式放行 DNS:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress namespace: tenant-a spec: podSelector: {} policyTypes: - Egress egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53放行 DNS 之后,还要注意 DNS 本身可能被滥用做数据外泄。我们额外部署了 DNS 审计,记录所有查询,对异常域名(比如随机字符串、已知的隧道域名)做告警。这个手段在真实攻防里救过我们一次——某个租户的 Agent 被注入恶意代码后试图通过 DNS 查询外泄数据,被审计规则拦下了。
4.3 工具调用的权限代理
Agent 的工具调用是权限收口的关键点。我们不让 Agent 直接持有数据库密码或 API key,而是通过一个**权限代理(Tool Proxy)**来中转。Agent 调用工具时,请求先到代理,代理校验租户身份、校验工具白名单、注入真实凭证,再转发到目标服务。
这个代理的价值在于:第一,凭证不落地到 Agent 进程,泄露风险大幅降低;第二,所有工具调用都有审计日志,谁在什么时候调了什么工具、传了什么参数,一清二楚;第三,可以做细粒度的限流和配额,防止某个 Agent 疯狂调用外部 API 把配额打爆。
代理本身用 mTLS 双向认证,Agent 侧持有的是短期证书,由集群内的 CA 签发。证书轮换周期 24 小时,配合 cert-manager 自动管理。
5. 可观测性:Agent 的"黑盒"怎么打开
5.1 Agent 的可观测性和微服务不一样
普通微服务的可观测性三板斧——Metrics、Logging、Tracing——在 Agent 场景下都要改造。Metrics 方面,Agent 的指标不只是 CPU、内存、QPS,更重要的是任务维度的指标:任务成功率、平均轮次、工具调用次数、LLM token 消耗、单任务成本。这些指标业务团队关心,平台团队也关心,因为它们是容量规划和成本分摊的依据。
Logging 方面,Agent 的日志量极大,一次任务可能产生几万行日志,因为每一轮 LLM 交互、每一次工具调用都要记录。全量采集成本太高,我们的策略是分级采样:错误日志全采,正常日志按 1% 采样,关键节点(任务开始、任务结束、工具调用)全采。这样既控制了成本,又保证了问题可追溯。
Tracing 方面,Agent 的调用链是动态的、可能循环的,传统的 trace 模型(一个请求一条链)不完全适用。我们用 OpenTelemetry 的 span 来表达每一轮交互,用 trace 来表达整个任务,循环通过 span 的父子关系体现。这样在 Jaeger 里能看到一个任务完整的执行树,包括每一轮 LLM 调用和工具调用的耗时。
5.2 用 eBPF 做运行面监控
Agent 跑在 Kata VM 里,传统的 sidecar 采集方式有局限,因为 VM 内部对宿主机是半透明的。我们引入了 eBPF 做运行面监控,在宿主机层面采集 Agent 的系统调用、网络连接、文件访问。这样即使 Agent 在 VM 里做了什么可疑操作,宿主机也能看到。
eBPF 的另一个用途是异常行为检测。我们定义了一组规则:Agent 进程如果尝试连接非白名单地址、尝试访问敏感文件路径、尝试执行危险 syscall,就触发告警并可选地阻断。这套机制和 Kata 的隔离形成纵深防御——Kata 保证即使逃逸也逃不出 VM,eBPF 保证在逃逸之前就能发现异常。
提示:eBPF 程序要针对内核版本做兼容性测试。我们线上节点内核版本不统一,踩过 eBPF 程序在旧内核上加载失败的坑。建议用 libbpf 的 CO-RE 特性,一次编译到处运行,减少维护成本。
5.3 成本分摊与配额
多租户平台绕不开成本分摊。Agent 的成本主要来自三块:计算资源、LLM token、外部 API 调用。计算资源可以通过 namespace 的 ResourceQuota 统计,LLM token 和外部 API 调用则要通过工具代理的日志来统计。
我们做了一个成本看板,按租户、按项目、按天聚合,数据来源是 Prometheus 加工具代理的审计日志。每个租户有月度预算,超预算自动降级——批处理 Agent 排队,交互式 Agent 限流。这个机制上线后,整体成本下降了约 25%,因为很多团队发现自己有大量低效的 Agent 在空跑。
6. 那些文档里不会写的坑
6.1 Kata 和 GPU 的兼容性问题
如果 Agent 要用 GPU 做推理,Kata 的配置会复杂不少。Kata 支持 GPU 直通,但需要节点上正确配置 VFIO、正确绑定设备,而且不同厂商的 GPU 配置方式不一样。我们用的是 NVIDIA,需要装nvidia-container-toolkit的 Kata 适配版本,并且在 Kata 配置里声明 GPU 设备。踩过的坑是:GPU 直通后,VM 内的驱动版本必须和宿主机匹配,否则会出现设备可见但无法使用的情况。排查这个问题花了整整两天,因为报错信息非常隐晦。
6.2 镜像拉取的性能瓶颈
Kata 每个 Pod 都要起 VM,VM 启动时要挂载 rootfs,如果镜像很大,启动会非常慢。我们一开始用普通镜像,一个 Agent 镜像 2GB,冷启动要 8 秒以上。后来做了两件事:一是镜像分层优化,把不常变的基础层和常变的业务层分开,基础层预拉到节点;二是引入镜像加速,用 Dragonfly 做 P2P 分发,大镜像的拉取时间从分钟级降到秒级。
6.3 租户误删资源的防护
多租户平台最怕的是租户误操作把别人的资源删了。RBAC 能防住跨命名空间的操作,但防不住租户在自己命名空间里误删关键资源。我们加了删除保护:关键资源(比如租户的 Agent 配置、凭证)打上protection.finalizer标签,删除时先走审批流程。同时用 OPA Gatekeeper 做准入控制,禁止删除带有特定标签的资源。
6.4 升级 K8s 版本时的 RuntimeClass 兼容性
K8s 升级是个大工程,Kata 的 RuntimeClass 在跨版本升级时出过问题。1.24 到 1.25 升级时,containerd 的配置格式有变化,Kata shim 的路径变了,导致所有 Agent Pod 起不来。教训是:升级前一定要在预发环境完整验证 Kata 的运行时链路,包括 Pod 创建、VM 启动、网络连通、存储挂载。我们后来把这条加进了升级 checklist,再没出过类似问题。
7. 从能跑到好用,中间隔着工程化
回头看这套平台的建设过程,最大的体会是:多租户 AI Agent 平台的难点从来不是"能不能跑起来",而是"能不能安全、稳定、可计量地跑起来"。Kata 解决了隔离,调度插件解决了资源分配,权限代理解决了凭证安全,eBPF 和 OpenTelemetry 解决了可观测性,成本看板解决了计量。每一块单独看都不复杂,但组合起来就是一个完整的工程体系。
如果让我给正在做类似事情的团队一个建议,那就是先把隔离和权限做扎实,再考虑性能和成本优化。我见过太多团队一上来就追求高密度、低成本,结果隔离没做好,一个租户的 Agent 把整个集群搞挂,或者出了数据泄露,后面补窟窿的成本远高于一开始就做对。隔离是地基,地基不牢,上面盖什么都是危房。
最后分享一个实操小技巧:给每个租户的 Agent 加一个"熔断器"。当某个租户的 Agent 在短时间内触发大量异常(工具调用失败、资源超限、网络拒绝),自动把该租户的新任务排队,同时告警。这个机制能防止单个租户的问题扩散到整个集群,我们在一次真实故障中靠它把影响范围控制在了单个租户内,其他租户完全无感。