ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多租户K8s集群上的AI Agent部署:隔离、安全与弹性实践

多租户K8s集群上的AI Agent部署:隔离、安全与弹性实践 大概半年前我们团队接手了一个有点特殊的需求公司要把整套AI Agent服务开放给外部客户做成一个多租户的SaaS平台底层跑在一个共享的Kubernetes集群上。当时大家普遍觉得这活儿不算难——K8s的多租户隔离从概念上讲有Namespace把Agent镜像打进去配上配额、配上RBAC不就完了真正动手之后才发现部署这两个字背后的复杂度远超预期。Agent这个东西和普通无状态服务完全不是一个物种它的长连接、LLM调用、工具链编排、突发资源消耗每一条都会和多租户的安全边界发生碰撞。这篇文章我想系统性地整理一下我们在这条路上摸爬滚打出来的完整方案从租户模型怎么设计、隔离边界怎么划到Agent工作负载怎么声明、调度怎么治理再到安全策略怎么落地、大规模怎么弹性伸缩最后是几段实打实的踩坑经历。如果你正在做Agent平台化、或者要在一个共享K8s集群上为多个业务线提供Agent能力这篇文章应该能帮你省掉不少弯路。1. 多租户集群上跑AI Agent先看清三个根本差异1.1 Agent不是普通微服务它是接线员、临时工、还是状态机传统微服务的请求-响应模型非常简单客户端发一个Request服务端算完返回一个Response连接短状态无或很弱。AI Agent完全不是这个套路。你可以把一个Agent想象成一位电话接线员再加一位临时工——它接了任务之后要连续做很多事情先理解用户的自然语言目标然后判断要不要调用工具、查数据库、读文件再拿着工具的结果去调LLM做推理接着可能还要再来一轮工具调用。整个过程是一个感知-推理-行动循环可能持续几十秒甚至几分钟。这就带来三个很具体的工程问题。第一连接是长连接尤其是流式输出场景下Agent会通过SSE或WebSocket持续向客户端推送推理结果连接一旦建起来生命周期很长。第二状态是活的会话上下文、工具执行结果、token计数这些都要放在内存或本地存储里不是随请求结束就能丢的。第三资源消耗是突发的一个复杂任务可能在瞬间发起多个LLM并发请求CPU、内存、网络全部起峰然后又迅速回落。你按普通服务的稳态曲线去做配额和弹性一定会被它打个措手不及。1.2 多租户的真实需求不是各管各的是互相防着如果只是给自己公司内部两三个团队提供Agent能力那多租户其实约等于多Namespace大家自觉一点就行。但一旦Agent能力对外开放租户之间的关系就不是协作而是防备。外部租户之间可能存在恶意扫描、数据窃取、资源抢占甚至攻击集群基础设施的动机。你必须在设计的第一天就把每个租户当作潜在的攻击者来对待。所以多租户设计要同时满足四件事数据隔离——租户A的对话记录、工具调用结果、向量数据库索引不能被租户B看到资源隔离——一个租户的Agent疯狂扩容不能把其他租户的Pod挤死安全隔离——租户Pod不能触达集群的管控面、不能访问其他租户的Service配额治理——每个租户的用量要有上限、可计量、可追溯。这四件事Namespace只解决了极小一部分真正要靠的是一整套叠加机制。1.3 为什么单租户方案照搬过来会翻车我见过不少团队直接把单租户集群的部署方式搬到多租户场景里Ingress规则写得随意Secret在团队里共享网络策略完全不存在RBAC给所有人绑了Cluster-admin。单租户环境下这些无所谓因为都是自己人在用出了问题好说好商量。多租户环境下任何一条宽松的配置都可能成为租户之间互相拉扯的导火索甚至成为外部攻击者的突破口。举一个很常见的例子有人在集群里为了方便把Ingress配成/*全部转发到某个Agent服务再靠路径里的租户参数来区分请求。看起来能用但只要你漏写了一条租户校验租户A的请求就能通过修改参数访问租户B的会话数据。这种问题在代码审查里很难发现因为它是基础设施层的逻辑漏洞。后面我会详细说怎么用网络策略和准入控制给这些漏洞兜底。2. 租户隔离设计Namespace是逻辑边界资源配额才是硬约束2.1 租户模型的三种映射方式租户怎么映射到K8s对象是整个设计的地基。我见过三种主流做法单Namespace单租户适合租户数量不多、每个租户都比较重的场景单Cluster多Namespace也就是一个集群切成很多租户适合标准SaaS场景以及vCluster虚拟集群每个租户一个独立的控制面适合对管控面隔离要求极高的场景。我们最终选的是第二种一个集群每个租户一个Namespace再加一套统一的底层基础设施。原因很现实第一vCluster虽然隔离性好但运维成本和性能损耗都不小2000个Agent不需要那种级别的隔离第二共享集群的利用率更高同一个GPU池可以按租户动态调剂这在虚拟集群里很难做到。但光有Namespace远远不够。Namespace只是给资源打了一个逻辑标签它不限制任何实际使用量。真正让租户跑不出去的是ResourceQuota和LimitRange这两个控制器。2.2 ResourceQuota把租户的资源上限写死ResourceQuota的作用是给Namespace设定资源总量上限。我们的经验是为每个租户配置四类配额计算资源CPU/内存的requests和limits、存储资源PVC数量、存储容量、对象数量Deployment/Service/ConfigMap等、以及GPU等扩展资源的数量。这里有一个特别容易踩的坑requests和limits的配额要分开设置否则会出现配额总量看着很大但实际可调度资源很小的问题。比如你给一个租户配了16核64G的limits但忘了配requestsPod申请的时候如果request被设得很高调度器会因为request不足而拒绝调度但配额却还显示有余量。我建议配成两份数据一份是软限制requests一份是硬限制limits中间留出合理的弹性空间。另外GPU资源的配额一定要显式声明。K8s默认不会帮你把nvidia.com/gpu这种扩展资源计入普通cpu/memory配额你需要在ResourceQuota里单独写hard: nvidia.com/gpu: 4这样的字段。这一条我们后面踩过坑先在这里提醒。2.3 存储隔离和三段式PVC命名规范存储隔离尤其是共享存储卷是Agent场景里非常容易忽略的点。因为Agent经常要把会话上下文、工具缓存、向量索引写到磁盘如果用的还是共享NFS或者分布式文件系统那不同租户之间的文件访问控制就得靠存储层做。我们统一用PVC给每个Agent挂载独立卷并在PVC的命名上做强制规范租户ID-应用名-用途。例如tenant-blue-rag-index。这样做的价值不只是可读性。后面接审计、接网络策略、接LimitRange的PVC数量限制时只要看到名字就能定位归属。我们还把每个租户的PVC数量配额设得比较紧不是抠门而是倒逼开发团队想办法做会话清理和索引压缩不然存储膨胀的速度会把集群拖垮。2.4 网络策略默认拒绝按需放行多租户集群的网络安全第一原则就是默认拒绝。我们在每个租户Namespace里强制部署两条NetworkPolicy一条入站策略只允许来自自己Namespace的和必要的Ingress代理的流量进来一条出站策略只允许访问集群内部的DNS名字CoreDNS、同租户的其他Pod以及外部LLM API的白名单IP。其余的全部丢弃。下面是我们在生产环境用的简化版出站策略示例基于networkpolicy.networking.k8s.io/v1apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress namespace: tenant-blue spec: podSelector: {} policyTypes: - Egress egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - to: - podSelector: {} - to: - ipBlock: cidr: 10.20.0.0/16 - to: - ipBlock: cidr: 203.0.113.0/24 ports: - protocol: TCP port: 443这套策略跑下来最直观的感受是Agent出问题的爆炸半径小了很多。以前一个租户的Agent如果被诱导去访问内部数据库网络策略会直接把它拦在门外数据泄漏自然也就止住了。需要提醒的是NetworkPolicy依赖CNI实现calico和cilium的行为细节略有差异你要在测试环境先验证默认拒绝确实是生效的而不是等上了生产才去试。3. Agent工作负载声明Deployment不是唯一答案3.1 Agent类型决定工作负载类型别搞一刀切我发现很多团队有个惯性任何Agent一律用Deployment。其实Agent的形态差异非常大。至少要区分三种——在线交互式Agent用户在聊天窗口里提问Agent实时推理并流式返回定时触发式Agent每天晚上自动整理报表、巡检系统、生成摘要以及有状态任务式Agent一个任务可能要跑很久中途被打断还要能续。在线交互式Agent适合用Deployment加上HPA因为要应对不确定的并发请求定时触发式Agent适合用CronJob因为它是周期性、短生命周期的批处理任务有状态任务式Agent则适合用StatefulSet因为每个Agent实例需要稳定的网络标识和独立的存储中断之后要能从checkpoint恢复。选错了工作负载类型后面会从弹性到存储全都拧巴。我们第一批部署时就把定时巡检Agent做成了Deployment结果每天一到执行时间点Pod数量飙到几十个任务跑完又都在那里空转浪费了大量资源后来改成CronJob才消停。3.2 PDB、分布约束和反亲和让Agent集群更抗揍一次性把几百个Agent Pod都调度到同一台节点上节点一故障全线飘红。为了把这种风险压下来我们给每个在线服务的Deployment都配置了三样东西PodDisruptionBudgetPDB、topologySpreadConstraints、podAntiAffinity。PDB保证了在集群做节点维护或版本升级时至少有一定数量的Agent Pod是存活的。比如租户有10个副本我们通常会设minAvailable: 5这样即使节点驱逐也不会出现服务中断。topologySpreadConstraints让Pod尽量均匀分布到不同的可用区或节点组。podAntiAffinity则是防止同一个Agent的多个副本挤在同一个节点上。这三样配合Agent平台的可用性一下就上来了。后来有次机房单节点宕机受影响的最多也只有一两个副本用户体验基本无感。3.3 让每个Agent跑在舒适区requests和limits的精调AI Agent当前阶段的资源预测是有点反直觉的。很多早期设计把requests设得很低恨不得Pod尽量小结果LLM一推理CPU和内存直接打满Pod反复被驱逐或OOM。我们的经验是先让Agent跑一周观察它的稳态和峰值然后把requests设成稳态的1.2倍limits设成峰值的1.5倍。听起来很浪费但这是故意为之。因为Agent的长任务不能随便中断一旦OOM整个任务链就断了用户看到的是回答了一半就没了损失远大于那一点资源闲置。给足资源裕量的同时用ResourceQuota把总量限制死不要让资源浪费变成失控。另外如果你的Agent需要跑本地模型推理记得给它的容器配置好GPU的limits和requests保持一致这个调度器才会把它调度到带GPU的节点上。4. 安全部署的四个关键防线认证、授权、策略、密钥4.1 RBAC让租户管理员能管自己的Namespace但仅此而已多租户集群里RBAC的设计直接决定了被攻破一台Pod之后攻击者能横向移动多远。原则是最小权限给租户管理员的是Role和RoleBinding绑定到他的Namespace而不是ClusterRole和ClusterRoleBinding。哪怕是一个只读权限也只用get/list/watch不给delete。这里有个容易被忽略的细节ServiceAccount的权限管理。Agent Pod跑起来之后尽量不要用default的ServiceAccount而是为每个租户创建一个专用的ServiceAccount并绑定最小权限的Role。这样即使某个Agent被攻击者注入恶意指令它能调用的K8s API能力也已经被卡死了。我们还对kubectl auth做了定期审计看看哪些权限其实三个月都没人用过该删就删。4.2 镜像供应链不可变标签和签名构建多租户场景下镜像不再只是能跑就行它必须能证明自己的来源。我们启用了两件事镜像仓库里的tag全部设为不可变immutable防止某个tag被恶意覆盖后旧的Pod被拉取到攻击者篡改的镜像同时用cosign对镜像签名准入控制器要求所有上线镜像必须通过签名校验。第一次推广这个的时候开发团队觉得烦都是内部工具为什么这么严。我们的回应是Agent会执行工具调用它本身就是一个随时可能被prompt注入的高危进程。你无法保证Agent抓取的网页和工具返回的内容完全安全那就至少保证执行环境本身是可验证的。镜像签名在这种场景里不是流程负担是底线。4.3 密钥管理不要相信大多数人都在用的明文SecretSecret对象在K8s里默认只是base64编码不是加密。很多团队把LLM API Key、数据库密码明文写进Secret某个Namespace被攻破后Secret就相当于白送了。我们的做法是把Secret的明文从集群里彻底拿走改用External Secrets OperatorESO从Vault或云厂商的密钥管理服务同步进来。简单说在集群里的Secret只是一个指针真正的密钥存在Vault里。Pod启动时ESO会把Secret注入Pod运行期间也不依赖Vault的可用性避免引入新的单点故障。这里要特别提醒ESO的CRD本身的权限也要加锁否则秘密管理工具本身成为攻击跳板就本末倒置了。我们给ESO单独开了审计日志任何Secret的读写操作都有记录出了问题能回溯到人。4.4 准入控制把坏习惯拦在集群门口准入控制器是可选的最后一道门。我们用的GatekeeperOPA定义了几组硬性策略所有Pod必须有租户标签比如tenant和owner所有Namespace必须配置ResourceQuota不允许hostNetwork、不允许特权容器、不允许挂载宿主机路径所有镜像必须来自白名单仓库。谁踩了规则Pod直接创建不了问题在入门阶段就暴露。这一套刚开始上线的时候几乎每周都有开发群里在喊怎么我的Pod起不来了但两个月之后大家都学会了先看Gatekeeper的报错信息。它把安全底线从靠人自觉变成了强制约束这个转变特别关键。同时我们把Pod Security Standards设成了baseline级别那些需要privileged特例的应用必须单独申请后再开白名单。5. 从200到2000大规模调度与弹性治理5.1 节点池设计把不同类型的Agent放到合适的房间Agent负载不像传统服务那样同质化。有的Agent是CPU密集型的比如大量工具调用和数据处理有的是内存密集型的比如复杂的上下文RAG和向量检索有的是GPU密集型的比如本地跑小型推理模型。把他们都塞进同一个通用节点池会导致任何一个维度的资源成为瓶颈时其他维度大量闲置。我们的节点池划分是这样做的CPU通用池放轻量级Agent和配置中心内存优化池放大上下文、RAG类的AgentGPU池放需要本地推理的Agent。每个节点池打上标签Agent的Deployment通过nodeSelector和nodeAffinity精确选择。这样调度器能更智能地完成资源匹配也方便针对不同节点池做独立扩容。成本上也能算得更清楚哪个租户用的GPU多账单上一目了然。5.2 Agent弹性伸缩的特殊之处别再只看CPU和内存传统HPA看CPU使用率但对于Agent来说CPU和内存是滞后指标。一个更灵敏的信号是请求在队列里的深度、正在处理的活跃会话数、以及LLM token的消耗速率。我们用了KEDA来做事件驱动伸缩它的好处是可以直接监听很多外部指标源比如Kafka的消费堆积量、Redis里的队列长度、甚至Prometheus里自定义的指标。举个例子我们定义了一个HPA规则当租户的Agent服务队列中等待处理的请求数超过某个阈值时自动把副本数从20扩到50当队列清空后再缩回来。这样在业务高峰到来前副本已经先扩容了而不是等到CPU打满才被动扩。不过要提醒一句缩容要下功夫调参。AI Agent的会话有状态缩得太快会把正在执行的任务直接杀掉社区里通用的做法是在HPA里设置behavior的scaleDownstabilizationWindowSeconds给长任务一个缓冲期。我们一般设300秒实测下来长任务的完成率提高了不少。5.3 可观测性三件套日志、指标、链路追踪一个都不能少Agent的调用链比传统服务长得多用户问题 - Agent主流程 - 工具调用 - LLM推理 - 数据处理 - 最终回答。任何一个环节慢了用户只看到转圈圈。没有链路追踪你根本不知道卡在哪。我们引入了OpenTelemetryAgent SDK里直接埋点把整条链路的trace统一收集到Tempo。多租户的可观测性还有一个额外要求所有观测数据必须带租户维度。每条日志、每个指标、每个span都要打上tenant标签。这不只是为了排查问题时定位快更是为了给每个租户提供独立的账单和用量报表。我们是把日志推给Loki指标推给Prometheustrace推给Tempo三个系统按租户ID做二级索引。有一次租户投诉响应慢我们靠一条带tenant标签的trace五分钟就定位到了是他那边向量数据库的瓶颈而不是我们Agent的问题。5.4 多集群单集群真的撑不住时再考虑当你把Agent规模推到几千个副本、几十个租户时可能会想上多集群甚至联邦调度。我的建议是先把单集群的极限压榨干净再谈多集群。多集群本身会引入很多新的问题网络互通、配置同步、调度一致性、故障转移复杂度每一件都够你折腾很久。不过有一种情况下一开始就要考虑多集群多个地理区域部署。比如客户分布在不同的国家或地区要求数据本地化那你天然就要按区域开集群。这种情况下推荐在顶层做一个多集群管理平台上层负责统一策略下发和故障转移底层每个集群仍然保持自治。越是复杂的架构越要保持局部简单。6. 踩坑实录文档不会告诉你的四个事故现场6.1 Ingress共享导致的租户串扰事故第一次事故发生在灰度阶段。当时我们用了一个共享Ingress路由规则是按路径前缀分发给各个租户的Agent服务比如/tenant-a/、/tenant-b/。某天租户A的Agent突然开始收到租户B的对话请求排查了好久才发现租户B的Agent路径里有一个参数和服务发现的逻辑冲突导致请求落到了A的Service后端。这个事故给我们的教训是多租户的入口层必须绝对清晰宁可用不同的域名或不同的Ingress也别用一个路径解析来模糊区分租户。后来我们切到了Gateway API每个租户一条HTTPRoute命名和选择器都显式绑定这类问题再没出现过。6.2 ResourceQuota和GPU配额打架有一次租户反馈GPU资源明明很充足但带GPU的Agent Pod一直在Pending。kubectl describe一看调度器提示无法满足配额。查了半小时才发现我们给租户配的ResourceQuota里写了CPU和内存的limits但没写nvidia.com/gpu的配额。K8s调度器的逻辑是扩展资源也要有配额配额不足就不会调度。修复很简单——在配额里加上GPU数量。但这件事提醒了我们ResourceQuota的覆盖面要完整核查一遍尤其是扩展资源。我们后来写了一个脚本定期扫描所有Namespace的ResourceQuota检查有没有漏掉关键扩展资源。现在任何新的资源类型要接进来第一件事就是问自己这个资源的配额写进ResourceQuota了吗6.3 Agent连接泄漏与Pod内存雪崩第三个坑出在Agent客户端库上。早期我们的Agent用了某个LLM服务的SDKSDK内部维护了一个连接池但连接池的大小没有设上限超时也没有配置。当请求量一上来连接池里的空闲连接越积越多Pod内存一路飙升最后被OOMKilled。解决方法是三层应用层——给所有外部调用加上超时和重试上限连接池设上限中间层——配置RDB/队列的背压不能让Agent无限接收请求基础设施层——Pod内存限制就打在limits上配合HPA的扩容阈值保证在内存打满之前副本数已经顶上来了。这套组合拳之后Agent的稳定性明显上了一个台阶。6.4 升级K8s版本后NetworkPolicy行为异常最后一次事故是关于版本升级的。从K8s 1.24升到1.26之后有一批跨Namespace的Agent调用突然不通了。进一步排查发现这两个版本对NetworkPolicy的处理逻辑有细微变化——某些情况下podSelector和namespaceSelector的组合匹配范围不一样了。我们的策略文件是按旧版本写的结果在升级后一部分流量被默认拒绝拦掉。这件事的教训非常直接K8s版本升级之前必须把NetworkPolicy这一类的策略对象作为重点回归项放到升级验证清单里。我们的做法是写了一个自动化测试在预发集群上模拟几组典型流量升级前后各跑一遍对比结果一致才算通过。这些策略对象不像Deployment那样直观但它们的破坏力往往更大。最后说一点我个人的经验体会吧。多租户集群上部署AI Agent真正的难点从来不是怎么把一个Agent跑起来而是让很多个互不信任的Agent在同一个集群里稳定、安全地跑起来。这个目标没有一蹴而就的银弹它是一系列基础设计的叠加网络默认拒绝、配额硬约束、RBAC最小权限、镜像签名、准入控制再加上对Agent工作负载特征的深刻理解。如果你也在做类似的事情不要急着追求花哨的框架和组件先把这些基础防线一层层搭扎实。你会发现当安全底线足够硬时后面的扩展和演进反而会顺很多。踩过这么多坑之后我最大的感受就是多租户的安全是设计出来的不是补救出来的。
返回列表