← 返回资讯
林远舟
技术编辑
已审核

HPA看CPU是死路,异构GPU集群得从排队深度和P99切入

上周三,不对,是周四凌晨——等等,让我想想,应该是周三,因为第二天有个站会我记得特别清楚。反正就是凌晨3点12分,手机震得床头柜嗡嗡响,一看是PagerDuty。生产环境GPU集群又挂了。

HPA看CPU是死路,异构GPU集群得从排队深度和P99切入

HPA看CPU是死路,异构GPU集群得从排队深度和P99切入


上周三,不对,是周四凌晨——等等,让我想想,应该是周三,因为第二天有个站会我记得特别清楚。反正就是凌晨3点12分,手机震得床头柜嗡嗡响,一看是PagerDuty。生产环境GPU集群又挂了。

第三次了。那个月。

每次都是某个模型API突然被流量打爆,然后雪崩一样拖垮其他节点。我们当时全靠手动扩缩容救火,半夜爬起来改HPA配置、调replica数量。说真的,那种凌晨三点盯着终端等Pod Ready的日子,我真是一秒都不想再过了。


异构模型调度的坑

先交代下背景。我们团队托管了7个AI模型——LLaMA 3.1 70B做文本生成,ResNet-152跑图像识别,Whisper large-v3搞语音转文字,还有几个微调过的专用模型。硬件也是五花八门:4台A100 80GB,一堆T4,外加两块昇腾910B——对,就是去年公司被安利国产化的时候买的,现在想想都是泪。

问题核心在哪?

每种模型吃资源的方式完全不一样。

举个栗子。我们那个Stable Diffusion XL模型,平时QPS也就20出头,但用户一上传图片触发推理,瞬间飙到200+,持续大概30秒然后回落。文本模型呢,是那种温温吞吞的中等负载,但偶尔来个几万token的长文本,显存占用直接翻倍,OOM Kill分分钟的事。

传统HPA看CPU和内存指标触发扩缩容,对我们这种情况基本是瞎的。GPU利用率跟业务负载之间的关系特别不线性——有时候利用率90%但推理延迟正常得一批,有时候才60%就开始排队超时了。嗯...这个其实跟模型的计算密度有关,但当时我没想那么深。

我最开始的骚操作是把所有模型扔一个大的GPU池子里,用Cluster Autoscaler管。结果呢?A100节点经常被T4就能跑的小模型占着,真正需要大显存的模型调度不上去。钱烧了不说,性能还拉胯。我记得有次看监控,一块A100在跑个破文本分类模型,显存用了不到8GB,旁边一个需要40GB的图像模型在那排队等了15分钟。

浪费。


负载感知方案怎么搞的

踩了一圈坑之后我意识到,必须从业务指标出发做扩缩容决策。不能死盯着底层资源指标。

我们定了三个核心监控维度:

1. 请求排队深度:比QPS更能反映真实压力,因为异构模型处理时间差太多了——最短30ms,最长能到15秒

2. 预测延迟P99:不是平均值。P99能更早预警性能恶化,平均值太容易被长尾拉偏了

3. GPU显存水位:有些模型瓶颈在显存不在计算,超过85%就得准备扩容

基于这些指标,我们用KEDA写了ScaledObject配置。KEDA这玩意儿确实好用,能从Prometheus拉自定义指标驱动扩缩容。我们用的版本是2.14,当时最新的。

YAML
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
 name: image-gen-model-scaler
spec:
 scaleTargetRef:
 name: image-gen-deployment
 advanced:
 horizontalPodAutoscalerConfig:
 behavior:
 scaleDown:
 stabilizationWindowSeconds: 120
 scalingStrategy:
 customScalingQueueLength:
 targetValue: 5
 triggers:
 - type: prometheus
 metadata:
 serverAddress: http://prometheus:9090
 metricName: request_queue_depth
 query: |
 sum(rate(model_queue_depth{model="image-gen"}[1m]))
 threshold: "5"

这个配置的意思是,当image-gen模型的请求排队深度超过5时触发扩容。我特意在scaleDown加了120秒稳定窗口,防止抖动——之前没加这个,扩容缩容来回横跳,节点资源被反复折腾,P99反而更差了。这个教训大概值我两天加班。

部署上去第一周,确实比手动强多了。但很快发现一个更头疼的问题。

冷启动。


那次故障,我现在想起来还冒冷汗

去年12月18号,下午3点07分——我记得这么清楚是因为那天是我妈生日,我本来打算早点走。结果文本模型突然收到一堆长文档翻译请求,排队深度从3跳到50只用了大概20秒。

KEDA立刻触发扩容。新Pod开始拉镜像。

模型镜像15GB。拉镜像花了将近4分钟。

这4分钟里,老Pod已经被打爆了,新Pod还没Ready。用户开始看到大量504超时。更惨的是,因为所有请求都路由到这个模型的端点,其他正常模型也被拖累,整个API网关开始疯狂返回503。

我当时在工位上,眼看着Grafana的报警一个个弹出来,手心全是汗,心里就一个念头:完犊子了。

事后复盘,三个致命问题:

修的时候我是这么搞的:

1. 预热Pod池

我们预留了2个"温热"的Pod,已经加载好模型但不接流量。一旦检测到要扩容,立刻把这些Pod加入Service后端,同时后台再拉新Pod补充预热池。这个改动把扩容响应时间从4分钟压到了10秒以内。

PYTHON
# 预热管理器的核心逻辑,写得很糙,但能用
class WarmPoolManager:
 def maintain_warm_pool(self, model_name):
 warm_pods = self.get_warm_pods(model_name)
 if len(warm_pods) < self.min_warm_pods:
 pod = self.provision_pod(model_name)
 self.wait_model_loaded(pod) # 等模型加载完
 self.mark_as_warm(pod) # 标记温热但不接流量

等等,这里我要更正一下——实际代码里wait_model_loaded不是简单的轮询,是调了模型的/health端点,确认返回200且model_loaded: true才算Ready。我一开始用time.sleep(60)硬等的,被同事在code review里喷惨了,说我这是"面向运气编程"。确实该喷。

2. 模型级别的熔断器

我们用Istio的DestinationRule给每个模型端点配了熔断策略。Istio版本是1.20.2:

YAML
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
 name: text-model-circuit-breaker
spec:
 host: text-model-service
 trafficPolicy:
 connectionPool:
 http:
 http1MaxPendingRequests: 10
 maxRequestsPerConnection: 5
 outlierDetection:
 consecutive5xxErrors: 3
 interval: 30s
 baseEjectionTime: 60s

效果立竿见影。文本模型后端开始超时时,Istio直接熔断,不再往那转发请求,其他模型的服务完全不受影响。用户可能会看到某个功能不可用,但至少不会全局崩。这个方案我是在CNCF的Slack里跟人讨论出来的,有个老哥还提醒我注意baseEjectionTime别设太短,否则会频繁弹来弹去。

3. 多区域容灾

说实话,多区域部署这事儿我之前一直觉得是大厂才搞的。但经历了那次全站503之后,我咬着牙把核心模型部署到了两个可用区。成本涨了大概30%,但跟一次事故造成的用户流失比,这钱花得值。

具体做法是用Pod反亲和性,确保同一个模型的Pod分布在不同Zone:

YAML
affinity:
 podAntiAffinity:
 requiredDuringSchedulingIgnoredDuringExecution:
 - labelSelector:
 matchExpressions:
 - key: model
 operator: In
 values:
 - text-gen
 topologyKey: topology.kubernetes.io/zone

这里有个小坑——requiredDuringSchedulingIgnoredDuringExecution太硬了,如果某个Zone资源不够,Pod会一直Pending。后来改成preferredDuringSchedulingIgnoredDuringExecution,加了个权重。嗯...这个权衡其实挺纠结的,严格反亲和和可用性之间怎么选,我到现在也没完全想清楚。


数据说话

优化前后我拉了一组对比数据,说实话差距比我想象的大:

| 指标 | 优化前 | 优化后 |

|------|--------|--------|

| 扩容响应时间 | 3-5分钟 | 8-12秒 |

| P99延迟波动 | 200ms-3000ms | 180ms-450ms |

| 月均故障次数 | 4.7次 | 0.3次 |

| GPU资源闲置率 | 42% | 18% |

| 单次故障影响范围 | 全站 | 单模型 |

GPU闲置率从42%降到18%这个数据,直接帮我说服了老板继续投钱做优化。之前大家觉得多租几块GPU备着就行,现在知道精细化调度才是正解。我们CTO看完数据说了句"这省下来的钱够再招个人了"——虽然最后也没招,但起码预算批了。


还在纠结的问题

有些问题我到现在也没完全解决:

最近在调研Kuberay和Volcano,看看能不能用更智能的批调度替代现在的KEDA方案。Kuberay的v1.1.0版本好像支持了模型预热的特性,还没试。有结果了再写一篇分享。


你们那边的AI服务怎么搞扩缩容的?也是半夜爬起来改YAML吗?还是有什么更好的方案?特别是用国产芯片的朋友,昇腾和寒武纪的调度兼容性怎么样,我很好奇——毕竟我们那两块昇腾还躺在机房里吃灰呢。评论区聊聊呗,或者你有类似的血泪史也可以倒倒苦水,咱们互相取暖(不是)。

#AI部署 #Kubernetes #自动扩缩容 #模型推理 #容灾设计 #GPU调度

592
8469 阅读
3 评论
分享
链接已复制
编辑说明

本文由 MakeSense 编辑团队撰写并审核。文中引用的数据和观点均经过交叉验证,如有疏漏欢迎在评论区指正。最后更新:2026年06月27日 18:32

林远舟

技术编辑

全栈工程师出身,做过 5 年技术社区运营。对 AI 编程工具、开发者生态有深入研究,喜欢用实测数据说话。

读者评论 3

运营小陈 1周前
转发到团队群了,大家都觉得有参考价值。
回复 点赞 (4)
数据分析师 昨天
数据引用很扎实,建议补充一下近三个月的最新数据。
回复 点赞 (9)
产品经理阿杰 4天前
从产品角度看,这个方向确实有机会,但商业化路径还需要验证。
回复 点赞 (15)