1. 先聊几句:AI推理服务为什么非得放在Kubernetes上
我最早接触AI推理服务部署的时候,团队还在用裸机加定时脚本的方式管理GPU服务器。模型每次更新,都要手动停容器、换镜像、改端口,运气好十分钟搞定,运气不好撞上依赖冲突能折腾一晚上。后来我们把推理服务逐渐迁移到Kubernetes上,实际运维成本确实降了一大截。这篇文章就把我在这个过程中踩过的坑、验证过的方案,以及最终沉淀下来的“Kubernetes + AI推理服务”最佳实践完整整理出来。
先说清楚这篇文章适合谁看。如果你正准备把AI模型从测试环境搬到线上,或者已经跑在Kubernetes上但经常出现GPU资源分配不灵、扩容不及时、模型加载慢这类问题,那这篇文章对你有直接参考价值。如果你是纯后端开发,对Kubernetes不太熟,也不用担心,我会把很多基础概念用生活化的方式解释一遍,你只需要照着步骤操作,也能搭出一套能用的推理服务底座。
我在这里说的“AI推理服务”,指的是模型训练完成之后,部署到线上接收请求并返回预测结果的服务。常见形态有通过HTTP接口调用的大模型对话服务、图像分类服务、OCR识别服务,也有通过gRPC协议调用的推荐排序模型。这类服务的共同特点是:无状态、对延迟敏感、依赖GPU资源、流量有较明显的波峰波谷。这几个特点恰好和Kubernetes最擅长的容器编排、弹性伸缩、故障自愈对上了频道。
我在下文会围绕五个环节展开:架构设计、资源调度、弹性伸缩、故障排查、容量规划。每个环节我都会给出实际的配置示例和操作步骤,不是我凭空编的,都是我在项目里反复调整后确认能用的方案。
2. 部署链路与资源规划,先把地基打牢
2.1 一个可以直接抄的推理服务架构
在没有专门推理平台的情况下,最稳妥的部署方式不是一上来就上Kserve这类复杂框架,而是用Kubernetes原生资源先跑通:Deployment管理副本,Service提供稳定访问入口,HPA负责水平伸缩。等业务量起来之后,再平滑过渡到更高级的推理服务框架,这样每一步出现问题时都容易定位。
下面这个Deployment配置我已经用了很久,模型推理服务的通用参数都在里面了:
apiVersion: apps/v1 kind: Deployment metadata: name: inference-server namespace: ai-serving labels: app: inference spec: replicas: 2 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: inference template: metadata: labels: app: inference spec: containers: - name: inference-container image: registry.internal/ai-server:v2.1.3 ports: - containerPort: 8080 name: http resources: requests: cpu: "4" memory: "8Gi" nvidia.com/gpu: "1" limits: cpu: "8" memory: "12Gi" nvidia.com/gpu: "1" readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 5 successThreshold: 1 failureThreshold: 6 startupProbe: httpGet: path: /health/startup port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 30有几个参数我要特别说明。maxUnavailable: 0加上maxSurge: 1意味着滚动更新时是先起一个新Pod再接流量,再杀掉旧Pod,不会出现服务瞬间没有副本可用的情况。startupProbe是我强烈建议加的,因为很多模型启动时要加载权重文件,耗时可能长达几十秒,如果没有这个探针,readinessProbe会不断失败,Kubernetes会一直重启容器,形成CrashLoopBackOff。
这个阶段我用了一个实际项目中的数据来验证:模型从加载到可以接收请求大约需要40秒,如果不配startupProbe,容器会在启动后20秒左右被触发的readinessProbe判断为不健康,然后在重启一次后进入指数退避状态,服务始终起不来。加上startupProbe之后,Kubernetes会给容器最多150秒的启动时间,问题彻底解决。
2.2 GPU资源调度:哪些坑是必踩的
Kubernetes本身不认识GPU,要让它能调度GPU资源,必须先在集群中安装NVIDIA的设备插件。最简单的安装方式是通过DaemonSet部署,官方仓库里就有现成的清单:
kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/main/nvidia-device-plugin.yml装完之后,节点上会多出一种可调度的资源,叫nvidia.com/gpu。你在Pod里申请多少张卡,Kubernetes就会把Pod调度到有足够空闲卡片的节点上。这个机制特别像你在餐厅订位,一张桌子能坐多少人,餐厅前台心里有数,你来了直接带到有空位的区域。
但是这里的坑在于:默认情况下,Kubernetes不知道一张物理GPU卡上的显存被分成几份。你申请nvidia.com/gpu: 1,它就把整张卡给你,哪怕你只需要显存的四分之一,剩下四分之三就这么闲着了。如果你希望多个推理服务共享同一张卡,有两个方案可选:一是开启NVIDIA MIG功能,把物理GPU切分成多个独立实例;二是靠推理框架自己支持“卡上并发”,比如Triton的多模型并发能力,配合每个请求的显存动态管理来复用显存。
我实测下来,中小规模推理集群优先考虑MIG。下面是一个开启MIG后给Pod分配GPU实例的示例:
resources: requests: nvidia.com/gpu: "1" nvidia.com/mig-1g.5gb: "1" limits: nvidia.com/gpu: "1" nvidia.com/mig-1g.5gb: "1"注意,MIG模式不是所有GPU型号都支持,A100和A800支持得比较好,RTX 3090这类消费级显卡就不支持。而且开启MIG需要在物理机层面操作,操作前一定要确认业务允许重启GPU节点。这个改动属于影响面比较大的操作,我建议先在测试集群验证。
还有一类问题是显存溢出导致的服务崩溃。模型加载到GPU上之后,如果输入数据异常大,或者峰值请求超过预设的batch size,显存可能瞬间被占满,然后容器直接OOM。在Kubernetes层面我们能做的是把内存和显存都设置成合理上限,更深层的防护要靠推理框架自己实现,比如对输入做尺寸校验、限制并发请求数。
2.3 网络入口和流量治理:别忽略连接超时
推理服务部署好之后,下一步就是建立一个稳定的访问入口。Kubernetes的Service负责把Pod的IP变化对外屏蔽,但如果请求量上来了,或者需要按比例切流量做灰度,Service就不太够用了,需要用Ingress来承担七层流量管理。
我在项目里用的是Ingress Nginx加上一段自定义配置。推理请求有两点特殊:第一,部分模型单次推理时间很长,需要调大代理超时;第二,请求体里可能带图片或长文本,需要调整body大小限制。下图是我线上在用的Ingress配置片段:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: inference-ingress namespace: ai-serving annotations: nginx.ingress.kubernetes.io/proxy-connect-timeout: "10" nginx.ingress.kubernetes.io/proxy-read-timeout: "300" nginx.ingress.kubernetes.io/proxy-send-timeout: "300" nginx.ingress.kubernetes.io/proxy-body-size: "20m" spec: rules: - host: inference.example.com http: paths: - path: /v1/models pathType: Prefix backend: service: name: inference-service port: number: 8080proxy-read-timeout: 300是我特别强调的。默认60秒的超时对普通Web服务够用,但大模型在GPU上生成一次回答很可能超过60秒,一旦超时,客户端只看到网关返524,根本猜不到到底是模型还在推理还是服务已经挂了。
关于使用gRPC还是HTTP,我个人的建议是:如果推理服务内部有大量结构化数据的传递,比如推荐系统要传用户特征、物品特征,优先用gRPC;如果对外暴露的是标准REST API,比如给前端页面提供对话能力,HTTP就够了。gRPC在Kubernetes上通过Ingress Nginx也可以代理,需要在后端服务配置里加上对应的protocol,但复杂度会高一截,建议先从HTTP开始。
3. 弹性伸缩:自动扩缩容的调参实操
3.1 为什么默认HPA在推理场景下不够用
Kubernetes自带的HorizontalPodAutoscaler默认支持CPU和内存作为扩缩容指标,但对AI推理服务来说,这两个指标往往不能真实反映负载压力。我见过一个典型的例子:模型服务把大量输入数据放在GPU上并行计算,CPU占用率始终只有不到30%,但GPU利用率已经跑到95%,这时候如果只按CPU扩容,系统会一直认为“很闲”,等到请求堆积到一定程度,服务才开始大面积超时。
要让Kubernetes根据GPU利用率或每秒查询数来扩缩容,有两个比较成熟的路线:一是配合Prometheus Adapter把自定义指标暴露成外部指标,HPA通过type: External引用;二是直接用Knative Serving的KPA,因为它支持并发数驱动的自动扩缩容。两者我都试过,如果集群里已经有一套Prometheus监控,优先选Prometheus Adapter,因为它不引入新的组件,和现有监控体系是同一个数据源。
3.2 关键参数设置与计算过程
一个完整的HPA配置需要覆盖三个部分:指标数据来源、目标值、扩缩容策略。下面是我线上使用的配置示例:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: inference-hpa namespace: ai-serving spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: inference-server minReplicas: 2 maxReplicas: 10 behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 periodSeconds: 30 scaleDown: stabilizationWindowSeconds: 300 policies: - type: Pods value: 1 periodSeconds: 60 metrics: - type: External external: metric: name: gpu_utilization_ratio selector: matchLabels: resource: "inference-server" target: type: AverageValue averageValue: "0.6"在这个配置里,目标值averageValue: "0.6"的含义是所有Pod的平均GPU利用率达到60%时开始扩容。为什么定60%而不是80%?因为GPU利用率到了80%以上,如果流量继续增长,单个Pod很快进入排队状态,新增Pod从启动到完成模型加载至少需要30秒到1分钟,这段时间内旧的Pod很可能已经出现超时。留出20个百分点的余量,相当于给扩容争取了缓冲时间。
扩缩容行为参数我用的是:扩容时每30秒最多增加2个Pod,缩容时每60秒最多减少1个Pod,稳定窗口期分别为60秒和300秒。这样设置的目的很简单:扩容要快,缩容要慢。推理服务最怕抖动,如果缩容太激进,流量稍微波动一下就把多余副本杀掉,等流量再次上来,又要重新等Pod启动,反而比保持原样更糟。
3.3 应对流量突刺和冷启动
流量突刺是推理服务最常见的场景之一。比如运营活动开始、突发热点事件,流量可能在几分钟内翻几倍。HPA采集指标本身有周期,Prometheus Adapter默认30秒拉一次指标,HPA默认15秒评估一次,加上扩缩容稳定窗口,从流量上涨到Pod扩容完成,最快也需要1到2分钟。如果模型加载又耗时,用户看到的可能就是一长串的504。
我的处理办法是在业务侧再加一道“预热”逻辑:用自定义指标in_queue_size表示排队中的请求数,一旦超过阈值就立即扩容,而不是等GPU利用率攒上去。举个例子,如果平均每个请求耗时200毫秒,单实例能承载5个并发请求,那QPS超过25时,queue里面必然开始堆积。我就把in_queue_size > 1作为另一个扩容触发条件,这样比等GPU指标更早发现问题。
冷启动的问题则需要更前置的手段。一个常规操作是把模型的minReplicas保持在2到3个,避免频繁从0开始;另一个是让模型容器加载完成后主动上报健康,保证流量只进到“真的能干活”的Pod。Kubernetes的startupProbe就是为这个设计的,我在前面的Deployment配置里已经加过了,线上一定要保留,不要删。
4. 上线之后的故障排查实录
4.1 常见故障速查表
推理服务跑在Kubernetes上,日常运维里我最常遇到的故障主要集中在这几类。我整理成一张速查表,方便你直接对着排查:
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| Pod一直Pending | GPU资源不足或节点污点 | kubectl describe pod <pod-name> | 看Events里是否有Insufficient nvidia.com/gpu,增加GPU节点或清理已占用卡 |
| CrashLoopBackOff | 启动时模型加载失败 | kubectl logs <pod-name> --previous | 检查模型路径和镜像权限,确认显存是否足够 |
| ImagePullBackOff | 镜像地址错误或仓库鉴权失败 | kubectl describe pod <pod-name> | 检查imagePullSecrets,确认仓库地址可访问 |
| OOMKilled | 容器内存或显存达到上限 | kubectl logs <pod-name> | 调大limits,或优化推理框架的显存管理 |
| 服务503 | 后端Pod没有Ready | kubectl get endpoints <service-name> | 确认Pod是否通过就绪探针,检查健康检查路径 |
| HPA不扩缩容 | 自定义指标没有采集到 | kubectl get hpa -oyaml | 检查Prometheus Adapter的指标查询语句,确认命名空间选择器 |
| 流量灰度不生效 | Ingress配置了错误的权重 | kubectl get ingress -oyaml | 检查注解格式,确认目标服务名和端口正确 |
4.2 两个印象深刻的实战案例
第一个案例发生在一次大版本升级中。我把新模型镜像发上去之后,滚动更新一开始很顺利,新Pod启动后readinessProbe一直返回未就绪状态,旧Pod迟迟没有主动摘除,结果Kubernetes等了一段时间后直接把旧Pod杀掉了,流量全部打到还没准备好的新Pod上,线上请求开始大面积超时。
排查过程是这样:我先用kubectl get events --sort-by=.lastTimestamp看了事件流,发现新Pod一直处于未Ready状态。接着看日志,模型加载倒是正常,但健康检查返回的不是200,而是一个302重定向。原因是我在新版本里把健康检查路径的handler加了一层登录认证拦截,Kubernetes的探针请求被重定向走了,探针认为服务不健康。最后我把健康检查路径跳过认证,同时调整了滚动更新配置,确保新Pod确认Ready后旧Pod才下线。这次事故让我养成了一个习惯:每次改代码后,先在本地点一下健康检查接口,确认返回状态码符合预期再发版。
第二个案例是GPU显存泄漏导致的服务不稳定。服务运行约24小时后,某个Pod的GPU利用率正常,但显存持续增长,直到触发OOM后重启。一开始我以为是镜像里某个库的已知问题,后来对比同一批Pod的显存占用曲线,发现所有Pod都在以相近速率增长,这更像是推理框架在长连接场景下没有及时释放显存。处理方案是:在推理服务外层加一个定时任务,当进程持续运行超过12小时后自动重启Pod,同时推动算法团队定位显存泄漏点。这种“临时规避+根治跟进”的组合拳,在生产环境中很实用,毕竟线上业务不能等根因完全查清再恢复。
5. 镜像、模型文件与容量规划的经验沉淀
5.1 大模型镜像的构建与拉取优化
AI推理服务的镜像和普通Web服务不太一样,动辄好几个GB。我遇到最夸张的一次,镜像里打包了多个版本的模型权重,Docker镜像直接膨胀到10GB以上,每次发布都要等好几分钟拉镜像,非常影响发布效率。
后面我做的优化是将模型权重从镜像里剥离,不打包进Docker镜像,而是挂载到PVC或直接放到对象存储里,推理服务启动时通过网络接口拉取模型。这样镜像体积缩减到几百MB,发布速度和稳定性都显著提升。具体操作是在Deployment里挂载PVC:
volumes: - name: model-storage persistentVolumeClaim: claimName: model-pvc containers: - name: inference-container volumeMounts: - name: model-storage mountPath: /models模型文件放在PVC的好处是:多个副本共享同一份模型文件,不需要每个Pod各自复制一份,节省存储空间;同时更新模型时只需要更新PVC里的模型文件,然后重启Pod加载即可,不用重新构建镜像。
如果你没有共享存储,也可以把模型放在节点本地目录,用hostPath挂载进去,但这样Pod被调度到其他节点时就找不到模型了,所以生产环境我建议至少配置一个NFS或云厂商的NAS作为持久化存储。
5.2 用QPS和时延估算GPU卡数和副本数
容量规划是推理服务上线前的核心工作。不做容量规划的直接后果是:活动流量一上来,GPU节点不够用,服务排队超时;而如果节点买太多,日常跑到不到20%利用率,成本白白浪费。
我用的估算方法很简单,核心是“单实例能承受的最大QPS × 副本数 ≥ 预估峰值QPS”。单实例最大QPS不是拍脑袋定的,要通过压测实际得出。具体操作:先用wrk或locust对单个推理Pod发起持续压测,观察GPU利用率稳定在70%左右时对应的QPS值。假设单实例在GPU利用率70%时能支撑30 QPS,那么要支撑300 QPS的峰值流量,就需要10个实例。
给公式的话是这样的:
实例数 = 预估峰值QPS / 单实例压测QPS × 冗余系数冗余系数我建议取1.5到2。比如上面那个例子,300 QPS峰值,单实例30 QPS,理论需要10个实例,加上1.5倍冗余就是15个实例。多出来的这5个实例容量,是为了应对冷启动期间的流量堆积和突发的暴力请求。
GPU卡数也类似,如果你用的是单卡单实例,那需要的GPU卡数等于实例数。如果你开了MIG,一张物理卡分成多个实例,那需要的物理卡数就是实例数除以每张卡的实例数。这个估算方法虽然粗略,但足够作为采购和预算依据,比靠感觉靠谱得多。
5.3 多模型共享与租户隔离的落地细节
业务发展到一定阶段,一个Kubernetes集群里会同时跑很多个推理服务,有的模型是给推荐用的,有的是给内容审核用的,权限和资源需求都不一样。这时候如果不做隔离,一个模型把GPU打满,其他模型的延迟就会跟着遭殃。
我用的隔离方案是:按业务或团队划分Namespace,并在每个Namespace下设置ResourceQuota和LimitRange。
apiVersion: v1 kind: ResourceQuota metadata: name: ai-serving-quota namespace: ai-serving spec: hard: requests.nvidia.com/gpu: "8" requests.cpu: "32" requests.memory: "64Gi" limits.cpu: "64" limits.memory: "128Gi"上面这个ResourceQuota的意思是:ai-serving这个Namespace总共最多申请8张GPU卡、32核CPU和64Gi内存。这样即使某个模型逻辑异常疯狂扩容,也不会扩展到其他团队的资源。
LimitRange则用来约束单个Pod的资源上下限,防止有人申请了超大资源导致其他Pod没有可用配额。这个层面的隔离属于运维防御性配置,平时看不出来作用,但等真出问题的时候,它能帮你隔离出“爆炸半径”。我特别建议在共享集群里加上这一层,成本极低、收益很高。
最后再分享一个小技巧。我每次排查完一个问题,都会顺手把kubectl describe的输出和日志片段保存到一个固定的本地目录里,按日期命名。这看起来是个很小的习惯,但积累了半年之后,很多问题根本不用重新排查,直接在历史记录里搜关键词就能找到当时的结论和处理方式。Kubernetes里的故障千奇百怪,但很多问题换个业务场景还会换个马甲再回来,留一份“病历”,比什么速查手册都管用。