我们的 AI 接口平时 QPS 稳定在 3 左右,某天晚上被爬虫批量调用,10 分钟内冲到 180。
那晚我在监控大屏前盯了半小时,脑子里只有一个念头:扩容救不了这十分钟。
后来我认真算了一遍弹性扩容的经济账,才发现很多时候它并不是最优解——尤其在"模型要重新加载"这件事上。
先分清三种"突发"
脉冲型突发。 几秒冲到峰值立刻回落,来源通常是批量任务、健康检查风暴、爬虫。特点是持续时间短于扩容启动时间——机器还没就绪,流量已经过去。
阶梯型突发。 上一个台阶并保持住,比如定时任务触发。这是最值得投入弹性扩容的场景,扩出来的实例一直有用。
持续型高位。 上台阶之后再不回来。这不是突发,是容量规划问题,弹性方案不如直接加固定容量。
我那次是第一种,正确做法是限流 + 队列。
扩容的启动延迟,比你想的久
这是最反直觉的一点,也是我踩过最贵的坑。
大多数人以为"扩容"就是点一下按钮多一台机器。但对 AI 推理服务来说,新实例要经历:
调度 → 拉镜像 → 启动进程 → 下载/读取模型权重 → 加载进显存 → 预热 → 才是就绪
各环节实测耗时(量级示意,跟镜像大小和存储速度相关):
| 环节 | 量级 |
|---|---|
| 调度到 Pod 运行 | 几秒到几十秒 |
| 拉镜像(几个 GB) | 几十秒到几分钟 |
| 读模型权重(几十 GB) | 几分钟到几十分钟 |
| 加载进显存 | 十几秒到几分钟 |
| 预热(跑几个请求) | 十几秒 |
加起来最快也要几分钟。权重没做成镜像的一部分,每次扩容都要重新从对象存储拉几十 GB——这就是我那次扩了 6 个副本,5 分钟内一个都没就绪的原因。
让扩容真正管用的三个前提
权重必须在镜像里,或者在节点本地盘
这是最关键的一条。我现在把量化后的模型直接打进镜像:
FROM vllm/vllm-openai:latest
COPY ./models/qwen2.5-7b-int4/ /models/qwen2.5-7b-int4/
ENV VLLM_MODEL_PATH=/models/qwen2.5-7b-int4
镜像变大了(几 GB 到十几 GB),拉镜像时间变长,但省掉了"从对象存储拉几十 GB"这一步。总时间通常更短——镜像走仓库的优化分发和节点缓存,模型文件往往从对象存储裸下载。
预热必须在就绪检查之前
新实例加载完模型不代表能服务,得跑几个真实请求把 CUDA graph、内存池、算子初始化好。不预热的话第一个用户要等十几秒,还容易被判定不健康踢掉,再进来又冷启动——恶性循环。
@app.on_event("startup")
async def warmup():
"""模拟真实负载做预热,完成后才置为就绪。"""
await load_model()
for prompt in ["你好", "写一句关于秋天的短文"]:
async for _ in generate(prompt, max_tokens=32):
pass
MODEL_READY = True
就绪检查必须区分"活着"和"能干活":
readinessProbe:
httpGet:
path: /ready
port: 8000
periodSeconds: 5
failureThreshold: 3
initialDelaySeconds: 30
扩容指标不能只看 CPU
用 HPA 按 CPU 扩容有个前提——CPU 利用率能反映负载。对推理服务这个前提不成立:decode 是访存密集的,GPU 算力可能只用一半,CPU 更空。CPU 30% 的时候服务可能已经排队了。
按请求队列长度扩才是对的,用 KEDA 配上 Prometheus 适配器:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: llm-autoscaler
spec:
scaleTargetRef:
name: llm-api
minReplicaCount: 2
maxReplicaCount: 20
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: llm_request_queue_length
threshold: "5"
query: "sum(llm_request_queue_length{app='llm-api'})"
minReplicaCount: 2 这个下限别设成 1 或 0。设 0 的话,流量退去后实例全销毁,下次流量来又要等几分钟冷启动——弹性省下的钱全赔在冷启动上了。
三种应对方式的成本结构
假设峰值持续 30 分钟,峰值 QPS 是平时的 10 倍。
| 应对方式 | 成本结构 | 代价 |
|---|---|---|
| 固定容量 | 峰值所需的常驻机器乘全时段 | 那台大机器 23.5 小时空转 |
| 弹性扩容 | 平时 2 个 + 峰值多出来的 10 个乘 0.5 小时 | 前提是镜像和权重预置做好了 |
| 限流 + 队列 | 最低 | 用户要等,或降级到小模型 |
我现在的策略是三者组合:基础容量扛日常波动,超出的靠弹性扩容兜底,再上面是限流。
从固定配置切到按量付费,什么时候做
固定配置和按量付费是同一台机器的两种计费方式,切换是平滑的。要决策的是"要不要为弹性改变部署形态"。
| 情况 | 选择 |
|---|---|
| 负载全天平稳,利用率长期 > 40% | 包年包月,别折腾弹性 |
| 有明显峰谷,谷时利用率 < 10% | 按量付费 + 自动停机 |
| 峰谷比超过 5 倍且峰值持续 | 按量付费 + 弹性扩容 |
| 突发极短(分钟级) | 按量付费 + 限流,别扩容 |
利用率 10% 和 40% 这两条线是经验值不是标准,背后逻辑通用:按量付费的单价通常比包年包月贵出明显比例,溢价要靠"闲置省下的钱"覆盖。
决定切换点的还有两个问题:扩容启动时间 vs 突发持续时间,启动 5 分钟而突发只有 3 分钟的话弹性扩容无用;流量的可延迟性,聊天只能加机器,批处理能排队就限流。
弹性方案的账单不只有机器时长,几项容易漏算:
冷启动期间的空转。 扩出来的实例在预热阶段就在计费了。预热 3 分钟而突发只有 5 分钟,大部分扩容出来的机器是纯浪费。
镜像存储。 打进镜像的权重意味着仓库要存几十 GB,每个节点都要拉一份。
调参是持续工作。 阈值设太低会反复抖动(扩了又缩,缩了又扩),太高则起不到作用。
缩容时机。 缩容前要等正在处理的请求做完,否则会杀掉用户请求。
收尾
那次 QPS 冲到 180 的晚上,我最后做的事是加了限流和队列,不是扩容。
扩容解决的是"持续性的容量不足",限流解决的是"瞬时的异常流量"。 搞混了,你会花几十分钟的扩容时间,去解决一个本该在 30 秒内结束的问题。
各家的按量单价、包年折扣变动频繁,文章里给的只是量级和判断框架,具体以官方页面为准。