ARTICLE DETAIL

资讯详情

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

在Kubernetes上构建Agentic工作负载的运行时编排层实践

在Kubernetes上构建Agentic工作负载的运行时编排层实践 1. 从“ax”这个标题说起一个被低估的运行时编排切口“ax”这个标题乍看像是一个缩写、一个代号甚至像是随手敲下的两个字母。但把热搜词摊开来看——agentic、orchestration、runtime、Kubernetes——这几个词凑在一起指向的其实是一个非常具体的工程命题在 Kubernetes 之上如何为 agentic 工作负载提供一个可编排、可观测、可复现的运行时层。这不是又一个“AI 应用框架”而是更底层的东西是让多个 agent 能像微服务一样被调度、被治理、被追踪的那层基础设施。我自己第一次接触这类需求是在一个内部工具链项目里。当时团队想把几个独立的自动化任务串成一条流水线一个负责抓取和清洗数据一个负责调用模型做摘要一个负责把结果写回工单系统。最开始大家用脚本硬串cron 加 shell跑起来能用但一旦某个环节失败排查成本极高日志散落在三台机器上重跑还得手动清状态。后来我们把每个环节封装成独立容器用 Kubernetes 做编排问题才收敛。但新的问题又来了agent 之间的上下文传递、中间状态的持久化、失败重试的幂等性这些都不是原生 K8s 能直接解决的。这就是“ax”这类运行时层要填的坑。所以这篇内容适合谁看如果你正在做多 agent 协作、任务编排、或者想把现有的自动化脚本升级成可治理的运行时系统那这里的思路和踩坑记录对你有用。如果你只是听说过 agentic 这个词但还没动手也可以把它当作一个落地视角的参考至少能知道从脚本到运行时之间中间缺了哪些东西。提示本文讨论的“ax”是一个运行时编排层的代称不指向任何特定商业产品。所有方案均为基于常见工程实践的合理推演具体实现需结合你的实际环境调整。2. 核心设计思路为什么要在 K8s 上再包一层运行时2.1 原生 Kubernetes 编排 agent 的三个缺口Kubernetes 擅长的是无状态服务的调度和自愈但 agentic 工作负载有几个特性它原生支持得并不好。第一个缺口是上下文传递。一个 agent 的输出往往是下一个 agent 的输入而且这个输入可能很大——比如一段几千字的文本、一个结构化的 JSON、甚至一个文件路径。K8s 的 Pod 之间没有原生的“数据管道”概念你得自己搞 PVC、ConfigMap、或者外部存储。每次传递都要序列化和反序列化还要处理生命周期很容易出错。第二个缺口是状态机语义。Agent 任务通常是有状态的等待中、运行中、等待人工确认、失败可重试、失败不可重试。K8s 的 Pod 状态只有 Pending、Running、Succeeded、Failed 这几种表达不了业务层面的状态流转。你当然可以用 CRD 自定义资源但那就意味着你要自己写 controller工作量不小。第三个缺口是可观测性粒度。K8s 的日志和事件是 Pod 级别的但一个 agent 任务可能跨多个 Pod甚至跨多个命名空间。你想追踪“这个任务从触发到完成中间经过了哪些 agent、每个 agent 花了多久、哪一步失败了”原生工具给不了你这种视图。这三个缺口就是“ax”这类运行时层存在的理由。它不是在重复造 K8s而是在 K8s 之上补一层面向 agent 的抽象。2.2 为什么选 Kubernetes 作为底座而不是其他方案有人会问既然 K8s 有这么多不顺手的地方为什么不直接用 Nomad、Docker Swarm、或者干脆用云函数我的判断是K8s 的生态成熟度目前没有替代品。你需要的东西——服务发现、配置管理、密钥管理、网络策略、资源配额、多租户隔离——K8s 都有现成的。自己搭一套同等能力的底座成本远高于在 K8s 上做适配。而且 agentic 工作负载的规模通常不会大到需要自研调度器K8s 的调度能力绰绰有余。另一个现实原因是人才。会写 K8s YAML 的人比会写 Nomad Job 的人多得多招人、培训、社区支持都更容易。技术选型不能只看技术本身还要看围绕它的生态和人力池。所以“ax”的设计思路很明确K8s 负责基础设施层的编排ax 负责 agent 层的编排。两层各司其职通过 CRD 和 controller 做桥接。2.3 运行时层的核心抽象Task、Agent、Flow在具体实现之前先定义清楚抽象。我试过几种不同的建模方式最后收敛到三个核心概念。Task是最小执行单元。一个 Task 对应一个容器镜像、一组输入参数、一个超时时间、一个重试策略。Task 是无状态的同样的输入应该产生同样的输出。这是幂等性的基础。Agent是 Task 的封装但增加了“决策”能力。一个 Agent 可以包含多个 Task根据上一步的输出决定下一步走哪个分支。Agent 是有状态的它的状态存在外部存储里而不是容器内部。Flow是 Agent 的编排。一个 Flow 定义了多个 Agent 之间的依赖关系、数据流向、失败处理策略。Flow 可以嵌套一个 Flow 可以作为另一个 Flow 的节点。这三个概念对应到 K8s 资源上Task 对应 JobAgent 对应一个自定义 CRDFlow 对应另一个 CRD。Controller 监听这些 CRD 的变化驱动实际的工作负载。注意抽象层级不是越多越好。我见过有人把 Task 再拆成 Step结果 YAML 写了三百行还没进入正题。三个层级对大多数场景够用了再多就是过度设计。3. 核心细节解析从 CRD 定义到 Controller 逻辑3.1 CRD 设计中的关键字段与取舍先看 Agent 这个 CRD 的核心字段。我把它简化成几个必填项和几个可选项。必填项包括spec.image容器镜像、spec.command启动命令、spec.inputs输入参数、spec.outputs输出声明。可选项包括spec.retryPolicy、spec.timeoutSeconds、spec.resources、spec.envFrom。这里有个设计决策值得展开输入输出为什么用声明式而不是命令式。命令式的话你可以在 command 里直接写--input/data/step1.json简单直接。但声明式的好处是controller 可以在启动容器之前做校验——检查输入文件是否存在、格式是否合法、大小是否超限。这能把很多错误提前到调度阶段而不是等容器跑起来才报错。输出声明也是类似的逻辑。Agent 跑完之后controller 根据spec.outputs的声明去收集结果而不是让 Agent 自己往某个地方写。这样 controller 可以统一做结果校验、格式转换、存储归档。另一个关键字段是spec.concurrencyPolicy。默认是Forbid即同一个 Agent 的多个实例不能同时运行。这对有状态 Agent 很重要避免并发写冲突。如果是无状态 Agent可以设成Allow提高吞吐。3.2 Controller 的调谐循环从期望状态到实际状态Controller 的核心是一个无限循环观察当前状态对比期望状态执行动作缩小差距。听起来简单但细节很多。第一步是获取 Agent 对象。从 informer 的本地缓存里拿而不是直接打 API Server。本地缓存有延迟但性能好得多。对于大多数场景几百毫秒的延迟可以接受。第二步是检查当前阶段。Agent 的status.phase可能有这几个值Pending、Running、Succeeded、Failed、Retrying。根据 phase 决定下一步动作。比如 Pending 就创建 JobRunning 就检查 Job 状态Failed 就根据重试策略决定是否创建新 Job。第三步是创建或更新 Job。这里有个坑Job 的命名要带唯一后缀否则重复创建会冲突。我一般用agent-name-加上一个基于时间戳和随机数的后缀。同时要给 Job 打上 ownerReference这样 Agent 被删除时 Job 会被级联删除。第四步是收集结果。Job 完成后从 Pod 日志或者挂载的存储里读取输出。如果输出格式不符合声明把 Agent 标记为 Failed 并记录原因。第五步是更新 Agent 状态。把 phase、开始时间、结束时间、输出位置、错误信息写回status字段。注意status是子资源更新它不会触发 spec 的变更避免无限循环。整个循环用 workqueue 做限速和重试。失败的任务会以指数退避的方式重新入队避免打爆 API Server。3.3 数据传递的三种方式与选型建议Agent 之间的数据传递是运行时层最核心的功能之一。我实践过三种方式各有适用场景。方式一共享 PVC。所有 Agent 挂载同一个 PVC通过文件路径传递数据。优点是简单、大文件友好、不需要额外组件。缺点是并发写需要加锁PVC 的访问模式受限于底层存储ReadWriteOnce 还是 ReadWriteMany跨节点调度可能有问题。方式二对象存储。Agent 把输出写到 S3 兼容的存储把 URL 传给下一个 Agent。优点是无状态、可扩展、天然支持跨集群。缺点是需要额外的存储服务小文件场景下延迟比 PVC 高。方式三消息队列。Agent 把输出发到 Kafka 或 NATS下一个 Agent 订阅。优点是解耦彻底、支持扇出和缓冲。缺点是运维复杂度高而且消息队列的持久化语义和 Agent 的重试语义要对齐否则会出现重复消费或消息丢失。我的选型建议是小规模、单集群用 PVC中等规模、多集群用对象存储大规模、事件驱动用消息队列。不要一上来就上 Kafka运维成本会吃掉你所有的开发时间。4. 实操过程从零搭建一个最小可用的 ax 运行时4.1 环境准备与依赖检查先确认你的 K8s 集群版本。我用的测试环境是 v1.26.0这是比较稳定的一个版本。太老的版本1.20 以下CRD 的 apiextensions 行为有差异太新的版本1.29 以上有些 API 被废弃了需要调整。检查集群状态kubectl cluster-info kubectl get nodes -o wide kubectl get apiservices | grep apiextensions如果 apiextensions 的 APIService 不是 Available 状态CRD 创建会失败。这种情况通常是 kube-apiserver 和 extension-apiserver 之间的网络问题需要检查 Service 和 Endpoint。还需要一个容器镜像仓库。我用的是本地 registry地址是registry.local:5000。如果你用公有云把镜像地址换成对应的即可。提示本地 registry 需要配置 insecure-registries否则 kubelet 拉镜像会报 TLS 错误。具体配置在/etc/docker/daemon.json或 containerd 的 config.toml 里取决于你的容器运行时。4.2 定义并创建 Agent CRD先写 CRD 的 YAML。我把它拆成两个文件一个定义 Agent一个定义 Flow。这里先看 Agent。apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: agents.ax.example.com spec: group: ax.example.com versions: - name: v1alpha1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object required: [image, command] properties: image: type: string command: type: array items: type: string inputs: type: object x-kubernetes-preserve-unknown-fields: true outputs: type: array items: type: string retryPolicy: type: object properties: maxRetries: type: integer default: 3 backoffSeconds: type: integer default: 10 timeoutSeconds: type: integer default: 600 status: type: object properties: phase: type: string startTime: type: string completionTime: type: string message: type: string subresources: status: {} scope: Namespaced names: plural: agents singular: agent kind: Agent shortNames: [ag]几个细节x-kubernetes-preserve-unknown-fields: true让 inputs 可以接受任意结构不用提前定义 schema。subresources.status启用 status 子资源避免 spec 和 status 互相触发。shortNames让kubectl get ag也能用。创建 CRDkubectl apply -f agent-crd.yaml kubectl get crd agents.ax.example.com如果创建成功kubectl api-resources | grep agents应该能看到这个资源。4.3 编写 Controller 的核心逻辑Controller 我用 Go 写基于 client-go 和 controller-runtime。核心逻辑在一个 Reconcile 函数里。func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var agent axv1alpha1.Agent if err : r.Get(ctx, req.NamespacedName, agent); err ! nil { return ctrl.Result{}, client.IgnoreNotFound(err) } switch agent.Status.Phase { case : return r.handlePending(ctx, agent) case Running: return r.handleRunning(ctx, agent) case Failed: return r.handleFailed(ctx, agent) default: return ctrl.Result{}, nil } }handlePending负责创建 Job。Job 的 spec 里容器镜像从agent.Spec.Image取命令从agent.Spec.Command取环境变量里注入AGENT_INPUTS和AGENT_OUTPUTS值是 JSON 序列化后的字符串。handleRunning检查 Job 的状态。如果 Job 的status.succeeded大于 0说明成功收集输出并更新 Agent 状态为 Succeeded。如果status.failed大于 0根据重试策略决定是重试还是标记 Failed。handleFailed检查重试次数。如果没超过maxRetries创建一个新的 Job把 Agent 状态改回 Running。如果超过了保持 Failed 并记录最终错误信息。这里有个容易忽略的点Job 的 backoffLimit 要设成 0。因为重试逻辑由 Controller 控制如果 Job 自己也重试两层重试会叠加实际重试次数变成乘积。我一开始没注意结果一个任务重试了 27 次才发现问题。4.4 部署与验证跑通第一个 AgentController 编译成镜像用 Deployment 部署到集群里。需要给它配 RBAC允许它读写 agents、jobs、pods、configmaps 等资源。apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: ax-controller rules: - apiGroups: [ax.example.com] resources: [agents, flows] verbs: [get, list, watch, update, patch] - apiGroups: [ax.example.com] resources: [agents/status, flows/status] verbs: [update, patch] - apiGroups: [batch] resources: [jobs] verbs: [get, list, watch, create, delete] - apiGroups: [] resources: [pods, pods/log] verbs: [get, list, watch]部署完成后创建一个测试 AgentapiVersion: ax.example.com/v1alpha1 kind: Agent metadata: name: hello-agent spec: image: busybox:latest command: [sh, -c, echo hello from agent sleep 5] timeoutSeconds: 60 retryPolicy: maxRetries: 2 backoffSeconds: 5kubectl apply之后观察 Agent 状态kubectl get agents -w kubectl describe agent hello-agent kubectl get jobs kubectl logs job/hello-agent-xxxxx如果一切正常Agent 的 phase 会从空变成 Running再变成 Succeeded。Job 的日志里能看到hello from agent。注意如果 Agent 一直卡在 Pending先检查 Controller 的日志。常见原因是 RBAC 权限不足或者 Job 创建失败比如镜像拉不下来。kubectl describe agent的 Events 部分通常会有线索。5. 常见问题与排查技巧实录5.1 Agent 卡在 Running 不结束这是最常见的问题。原因通常有三种容器进程没退出、超时时间设得太长、Controller 没收到 Job 完成事件。排查顺序先看 Pod 状态。kubectl get pods如果 Pod 是 Running进容器看进程在干什么。kubectl exec -it pod-name -- ps aux。如果进程已经僵死检查是不是有死锁或者等待外部资源。如果 Pod 已经 Completed 但 Agent 还是 Running说明 Controller 的 watch 出了问题。检查 Controller 的日志有没有watch closed或者too many requests之类的错误。这种情况通常是 informer 的 resync 周期太长或者 API Server 限流了。超时时间我一般设成预期执行时间的 2 到 3 倍。设太短会导致正常任务被误杀设太长会导致真正卡住的任务占用资源太久。如果任务执行时间波动很大可以用activeDeadlineSeconds在 Job 层面做硬超时Controller 层面做软超时。5.2 重试导致的状态不一致前面提到 Job 的 backoffLimit 要设成 0这是避免双重退避的关键。但即使设了 0还有一种情况会导致状态不一致Controller 在创建新 Job 之后、更新 Agent 状态之前崩溃了。重启后 Controller 看到 Agent 还是 Failed又创建了一个 Job结果两个 Job 同时在跑。解决办法是引入一个status.lastJobName字段记录最近一次创建的 Job 名字。Controller 在创建新 Job 之前先检查这个 Job 是否已经存在。如果存在说明上次创建成功了但状态没更新直接复用即可。另一个办法是用乐观锁。更新 Agent 状态时带上 resourceVersion如果冲突就重新获取再更新。controller-runtime 的Update方法默认会做这个检查但如果你用的是Patch需要自己处理。5.3 输出收集失败的几种典型场景输出收集失败通常表现为Agent 标记为 Succeeded但下游 Agent 拿不到输入。第一种场景是输出路径不对。Agent 声明的输出是/output/result.json但容器里实际写到了/tmp/result.json。这种错误在容器内看不出来因为容器有自己的文件系统视图。解决办法是在 Agent 启动脚本里加一个校验确认输出文件存在再退出。第二种场景是输出格式不对。声明的是 JSON实际写的是纯文本。Controller 解析失败但 Agent 本身是成功的。这种情况我建议在 Controller 里做宽松解析先尝试 JSON失败就按纯文本处理并在 status 里记录格式警告。第三种场景是输出太大。Controller 从 Pod 日志里读输出但日志有大小限制默认 10MB 左右。超过限制的部分会被截断。大输出应该走 PVC 或对象存储而不是日志。5.4 常见问题速查表现象可能原因排查动作解决方式Agent 卡在 PendingRBAC 不足、镜像拉取失败、资源配额不足kubectl describe agent看 Events补权限、修镜像地址、调配额Agent 卡在 Running进程未退出、超时太长、watch 断连进 Pod 看进程、查 Controller 日志修脚本、调超时、重启 Controller重试次数异常Job backoffLimit 非 0、状态更新冲突看 Job 的status.failed设 backoffLimit0、加乐观锁输出收集失败路径不对、格式不对、输出太大进 Pod 看文件、查 Controller 日志加校验、宽松解析、改用 PVCController 频繁重启内存泄漏、OOM、panickubectl logs --previous加内存限制、修 panic、调 resync提示这张表是我自己踩坑之后整理的不一定覆盖所有情况。遇到新问题先看 Events 和 Controller 日志八成能定位到原因。6. 从单机脚本到运行时一个真实迁移案例的复盘6.1 迁移前的状态与痛点去年我帮一个团队把他们的数据处理流水线从脚本迁移到 ax 运行时。迁移前他们的流程是这样的一个 Python 脚本从数据库拉数据调模型做分类把结果写到 CSV然后另一个脚本读 CSV 做汇总。两个脚本用 cron 串起来中间靠文件系统传递数据。痛点很明确第一失败没有重试一次失败就要人工介入。第二没有状态记录想知道昨天跑了多少条、失败了多少条得去翻日志。第三扩展性差数据量涨了只能换更大的机器不能水平扩展。第四环境不一致开发机和生产机的 Python 版本、依赖版本经常对不上。6.2 迁移过程中的关键决策迁移的第一步是容器化。把两个脚本分别打成镜像依赖锁死环境变量注入配置。这一步花了大概两天主要是处理依赖冲突和路径问题。第二步是定义 Agent。第一个 Agent 负责拉数据和分类输出是一个 JSON 文件。第二个 Agent 负责汇总输入是第一个 Agent 的输出。两个 Agent 通过 PVC 传递数据。第三步是定义 Flow。Flow 里声明两个 Agent 的依赖关系第一个成功后才启动第二个。失败策略是重试三次每次间隔 30 秒。超时时间设成 30 分钟因为模型推理有时候比较慢。这里有个决策点要不要把两个 Agent 合并成一个。合并的话部署简单但失去了独立重试的能力。如果汇总逻辑失败整个流程都要重跑包括耗时的模型推理。分开的话汇总失败只需要重跑汇总。我们最后选择分开因为模型推理的成本远高于汇总。6.3 迁移后的效果与遗留问题迁移后最直观的变化是可观测性。kubectl get agents能看到每个 Agent 的状态、开始时间、结束时间。kubectl describe agent能看到详细的 Events。出问题的时候不用再 SSH 到机器上翻日志直接看 K8s 的资源状态就行。第二个变化是重试自动化。以前失败要人工发现、人工重跑现在 Controller 自动重试。重试三次都失败才会告警告警量下降了大概七成。第三个变化是扩展性。数据量涨了之后把 Agent 的副本数调大就行。虽然我们的 Agent 是有状态的不能简单加副本但可以把数据分片每个分片一个 Agent 实例。这个改造花了些时间但比换机器划算。遗留问题也有。PVC 的访问模式是 ReadWriteOnce导致两个 Agent 必须调度到同一个节点。如果节点故障两个 Agent 都会挂。后来我们改成了对象存储这个问题才解决。另外Controller 本身是单副本的有单点故障风险。我们后来改成了 leader election 模式跑两个副本一个主一个备。7. 运行时层的扩展方向从能跑到好用7.1 可观测性增强指标、追踪与日志聚合基础的运行时能跑任务但要好用可观测性必须跟上。我建议至少做三件事。第一暴露 Prometheus 指标。Controller 里埋点记录 Agent 的创建数、成功数、失败数、执行时长分布。这些指标能帮你发现趋势性问题比如某个 Agent 的失败率突然上升。第二接入分布式追踪。每个 Agent 启动时生成一个 trace ID传给下游 Agent。这样你可以在追踪系统里看到整个 Flow 的调用链每个 Agent 花了多久、在哪一步卡住。OpenTelemetry 的 SDK 已经比较成熟接入成本不高。第三日志聚合。Pod 日志默认是临时的Pod 删了就没了。用 Fluent Bit 或者 Loki 把日志收集起来按 Agent 名字和 Flow ID 索引。排查问题的时候直接搜 Flow ID 就能看到所有相关日志。7.2 多租户与资源隔离如果运行时层要给多个团队用多租户是绕不开的。K8s 原生的 Namespace 可以做逻辑隔离但资源隔离需要额外配置。我一般用 ResourceQuota 限制每个 Namespace 的 CPU、内存、Pod 数量。用 LimitRange 设置默认的 requests 和 limits避免用户忘记写。用 NetworkPolicy 限制跨 Namespace 的流量防止一个租户的 Agent 访问另一个租户的数据。还有一个容易被忽略的点镜像仓库的隔离。如果所有租户共用一个仓库一个租户推了一个恶意镜像可能影响其他租户。建议每个租户有自己的仓库命名空间配合 ImagePullSecret 做认证。7.3 与现有 CI/CD 的集成运行时层不是孤立的它需要和现有的 CI/CD 流程对接。我的做法是Agent 的镜像由 CI 构建和推送Agent 的 CRD 由 CD 部署。CI 里加一步构建完镜像后自动更新 CRD 里的 image 字段。CD 里加一步部署完 CRD 后触发一次冒烟测试。这样开发人员只需要提交代码剩下的构建、推送、部署、测试全自动。他们不需要懂 K8s只需要懂自己的业务逻辑。这才是运行时层最大的价值把基础设施的复杂度封装起来让业务开发聚焦在业务上。注意集成的时候要注意权限边界。CI/CD 的 ServiceAccount 不应该有集群管理员权限只给它必要的命名空间和资源权限。最小权限原则在这里同样适用。8. 一些踩坑之后的个人体会做运行时层这件事最大的坑不是技术本身而是抽象层级的把握。抽象太少用户要写一堆 YAML体验差抽象太多灵活性丧失稍微特殊一点的需求就做不了。我试过把 Task 和 Agent 合并结果发现有些场景需要单独重试 Task合并之后就做不到了。也试过把 Flow 拆成更细的 Step结果 YAML 复杂度爆炸用户怨声载道。最后的平衡点是Task 和 Agent 分开Flow 保持粗粒度。Task 负责执行Agent 负责决策Flow 负责编排。三层各司其职用户按需使用。简单的场景只用 Agent复杂的场景才引入 Flow。另一个体会是不要试图解决所有问题。运行时层不是银弹它解决的是编排和治理的问题不解决业务逻辑的问题。有些团队想把业务规则也塞进 CRD 里结果 CRD 变成了一个四不像的 DSL维护成本极高。我的建议是业务逻辑留在容器里CRD 只描述编排相关的元数据。最后一个建议从最小可用版本开始。不要一上来就设计一个支持多集群、多租户、自动扩缩容的完整系统。先做一个能在单集群跑通 Agent 的版本用起来收集反馈再迭代。我见过太多项目死在过度设计上第一个版本还没上线架构已经改了五版。先跑起来比什么都重要。
返回列表