Kubernetes上AI推理服务部署与弹性伸缩实战指南
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一直PendingGPU资源不足或节点污点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没有Readykubectl 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里挂载PVCvolumes: - 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里的故障千奇百怪但很多问题换个业务场景还会换个马甲再回来留一份“病历”比什么速查手册都管用。