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,当时最新的。
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的报警一个个弹出来,手心全是汗,心里就一个念头:完犊子了。
事后复盘,三个致命问题:
- **没有预热机制**:新节点拉起来是"冷"的,模型加载到GPU显存还要30-50秒,加起来快5分钟了
- **熔断器缺失**:某个模型后端压力大时,应该快速失败而不是让请求堆积成山
- **单点故障**:所有模型共享一个API网关,没做故障隔离
修的时候我是这么搞的:
1. 预热Pod池
我们预留了2个"温热"的Pod,已经加载好模型但不接流量。一旦检测到要扩容,立刻把这些Pod加入Service后端,同时后台再拉新Pod补充预热池。这个改动把扩容响应时间从4分钟压到了10秒以内。
# 预热管理器的核心逻辑,写得很糙,但能用
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:
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:
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看完数据说了句"这省下来的钱够再招个人了"——虽然最后也没招,但起码预算批了。
还在纠结的问题
有些问题我到现在也没完全解决:
- **成本分摊**:多模型混部之后,很难精确算每个模型该摊多少硬件成本。财务那边每个月都要跟我对账,我都是大概估一个比例,心虚得很
- **跨Zone流量费用**:两个Zone之间数据传输的费用比我想的高不少,特别是大模型推理产生的中间数据。上个月账单出来我吓了一跳,跨AZ流量居然占了总费用的12%
- **昇腾芯片调度**:那两块昇腾910B至今只能跑特定模型,跟NVIDIA的调度策略完全不兼容,基本还是半手动管理。昇腾的CANN版本升级到7.0.RC1之后有些算子还是不兼容,我都在考虑要不要把它们退库了
最近在调研Kuberay和Volcano,看看能不能用更智能的批调度替代现在的KEDA方案。Kuberay的v1.1.0版本好像支持了模型预热的特性,还没试。有结果了再写一篇分享。
你们那边的AI服务怎么搞扩缩容的?也是半夜爬起来改YAML吗?还是有什么更好的方案?特别是用国产芯片的朋友,昇腾和寒武纪的调度兼容性怎么样,我很好奇——毕竟我们那两块昇腾还躺在机房里吃灰呢。评论区聊聊呗,或者你有类似的血泪史也可以倒倒苦水,咱们互相取暖(不是)。
#AI部署 #Kubernetes #自动扩缩容 #模型推理 #容灾设计 #GPU调度
读者评论 3