为什么 LLM 比普通服务更怕 Spot 抢占?
Spot / Preemptible GPU 实例通常能够提供相比按需(On-Demand)实例 60%~80% 的成本折扣,是 LLM 推理集群降本的核心抓手。然而,直接将通用无状态 Web 应用的 Spot 运维策略照搬到 LLM Serving(如 vLLM、TGI、TensorRT-LLM)往往会导致灾难性的可用性滑坡:
- 长耗时请求驻留(High Execution Time):长 Prompt 的 Prefill 阶段与数百至数千 Token 的 Decode 过程可能持续数十秒甚至数分钟。当抢占信号到达时,节点上大概率堆积着大量处于中途的请求。
- 运行状态深嵌于 GPU 显存(In-Memory KV Cache):自注意力机制生成的 KV Cache 分布在显存内。实例被硬终止即意味着所有上下文计算成果丢失,难以跨节点无缝“断点续传”。
- 流式传输不可逆(Irreversible Streaming Tokens):对于 Server-Sent Events (SSE) 或 WebSocket 模式,前序 Token 已送达终端。此时若请求被中断并在新节点盲目从头重试,极易造成内容重复、分叉或语义漂移;若包含 Tool Call / Function Calling,还会引发外部系统副作用重复执行。
因此,Spot GPU 治理的核心逻辑不是祈祷不被抢占,而是建立确定性的控制链路:信号感知(Signal Ingestion)→ 停止准入(Admission Close)→ 连接排空(Drain Budget)→ 替代拉起(Warm Replacement)→ 幂等重试(Idempotent Retry)。
原理一:将 Notice Window 转化为 Drain Budget
各大云厂商提供的抢占预警机制和留存窗口差异显著,且均为“尽力而为(Best-effort)”保障:
| 云厂商 / 平台 | 预警信号类型 | 典型窗口期 | 行为特性 |
|---|---|---|---|
| AWS EC2 Spot | Interruption Notice / Rebalance Recommendation | 约 120 秒(通知)/ 更早(推荐) | Rebalance 信号无保底;120 秒到期直接硬终止 |
| Google Cloud (GCP) | Metadata Preemption Notice | 约 30 秒(默认)/ 可配更长(Preview) | 默认仅给 30 秒 ACPI 关机信号,窗口极窄 |
| Kubernetes (Karpenter) | Node Interruption Event | 取决于底层云事件 | 自动添加 Taint 并发起优雅排空流程 |
生产环境中不能假设拥有完整的预警时间,必须扣除全链路开销后计算出真实的 Drain Budget:
$$\text{DrainBudget} = \text{NoticeWindow} - \text{DetectionLatency} - \text{IngressRemovalLatency} - \text{SafetyMargin}$$
- DetectionLatency:DaemonSet 轮询元数据或云端 EventBridge 推送的延迟(建议保持在 1~3 秒以内)。
- IngressRemovalLatency:EndpointSlice 更新、Kube-Proxy 同步以及网关从负载均衡拓扑摘除实例的收敛耗时(通常为 2~8 秒)。
- SafetyMargin:安全保留垫片(建议预留 10~15 秒),用于处理网络波动和容器强制销毁开销。
若云厂商提供 120 秒窗口,实际用于跑完请求的 Drain Budget 往往仅剩 85~95 秒;而在 GCP 的 30 秒窗口下,真正可用的 Drain 预算仅有不足 15 秒。
原理二:Warm Replacement 与 Drain 并行,拒绝串行等待
传统的集群运维常见反模式是“收到终止 → 优雅排空旧 Pod → 旧 Pod 退出 → 触发扩容 → 拉取新镜像与权重 → 接流”。在 LLM 场景下,大模型冷启动动辄数分钟,串行逻辑会导致可用容量瞬间塌陷。
正确的架构是:中断通知一旦确认,立即分叉为两条并行管道。
┌── 1. 停止准入 + 按 Drain Budget 抢跑未完成请求 (旧节点)
[中断信号触发 / Taint] ──┤
└── 2. 并行申请替代实例 + 启动 Warm Replacement (新节点)
借助 Karpenter 的中断处理机制或自定义 Autoscaler Controller,在对被抢占节点打上 cloud.google.com/gke-preemptible=true 或 karpenter.sh/disruption=interrupted 污点的同时,即刻调度替代容量,使“新节点就绪”与“旧节点注销”最大程度重叠。
原理三:In-flight 请求分类分流,及早止损
进入 Draining 阶段后,绝不能让实例被动等待所有请求自然结束。需要通过剩余 Token 估算算法评估在预算内成功完成的概率:
def evaluate_inflight_request(req, drain_budget, recent_tpot, safety_factor=0.85):
"""
根据预估剩余时间决定是否在当前节点继续执行
"""
estimated_remaining_tokens = req.max_tokens - req.generated_tokens
estimated_remaining_time = estimated_remaining_tokens * recent_tpot
# 仅当剩余时间落在折扣后的 Drain Budget 内才继续
if estimated_remaining_time <= (drain_budget * safety_factor):
return "CONTINUE_DRAIN"
else:
return "ABORT_AND_FAILOVER"
针对不同属性的请求,采用差异化分流表:
| 请求特征 | 判断条件 | 处理动作 |
|---|---|---|
| 短文本 / 尾部 Decode | 预估完成时间 $\le$ 剩余 Drain Budget | 允许继续生成并在终止前返回 |
| 长文本 / 刚进入 Prefill | 预估完成时间 $>$ 剩余 Drain Budget | 立即主动中止(Abort),不再浪费算力 |
| 只读幂等生成 | 客户端支持重传或网关层托管 | 网关拦截并无缝重路由至健康备用节点 |
| 复合型 / 具外部副作用 | 涉及 DB 写入、转账扣费、Agent Tool Call | 记录 Ledger 状态,禁止自动重放,向客户端抛出特定状态码 |
原理四:流式生成的 Retry Contract 与网关 Ledger
非流式 JSON 请求的重试只需检查幂等性,而流式请求一旦中断,客户端极可能已渲染了部分内容。若直接从 Prompt 起点完全重算,用户界面会出现跳变或内容混乱。
推荐在 API Gateway(如基于 Envoy、Kong 或自研网关)维护一个带有上下文感知的中继账本(Request Ledger):
{
"request_id": "req_llm_9837421",
"attempt": 2,
"served_prefix_tokens": 214,
"model_revision": "qwen2.5-72b-instruct@v1",
"prompt_fingerprint": "sha256_e3b0c44...",
"retry_safe": true,
"interruption_reason": "spot_eviction"
}
重试协议设计选择:
- 客户端可见断裂协议(Clean Break):网关向客户端发送标准的 SSE 事件
event: error, data: {"code": "SPOT_PREEMPTION", "recoverable": true},客户端捕获后根据业务逻辑选择提示用户“生成中断,点击续写”,并自动将历史已确认文本作为 Prefix 提交。 - 服务端静默接续(Server-side Prefix Continuation):若模型服务支持 Prefix Caching 且上游具备严格的确定性采样配置(
temperature=0,固定 Seed),新节点可将用户已收到的 Token 数组直接附加到输入,仅返回增量部分。但这需要精细的版本一致性保证。
架构实战:端到端 Spot 抢占生命周期状态机
+-------------+ Interruption Warning +--------------+
| READY | ------------------------> | DRAINING |
+-------------+ +--------------+
^ | |
| | | (Parallel Actions)
| v v
+-------------+ Model Gate Passed [Stop Ingress] [Provision Replacement]
| RECOVERED | <------------------ | |
+-------------+ [Run or Abort] [Warm Weights & GPU]
| |
+----+-----+
|
v
+--------------+
| TERMINATING |
+--------------+
关键状态流转逻辑:
- 信号捕获器(Node-Problem-Detector / Spot Handler):实时轮询云厂商元数据。一旦命中通知,立刻将 Kubernetes Node 打上
NoSchedule污点,并将对应 Serving Pod 的 Readiness 状态置为false。 - 应用层 Drain Loop:
async def handle_spot_eviction(deadline_ts):
serving_engine.stop_accepting_new_requests()
while get_current_time() < (deadline_ts - SAFETY_MARGIN_SECONDS):
if serving_engine.get_active_request_count() == 0:
break
await asyncio.sleep(0.5)
# 强制终止剩余无法跑完的长请求
await serving_engine.abort_all_inflight(
reason="drain_budget_exhausted_spot_cleanup"
)
- 严苛的 Replacement Readiness Gate:替代容量在接流前,严禁仅依赖
PodScheduled或ContainersReady,必须执行模型层探针:- GPU 驱动及显存分配自检通过;
- 模型参数加载并完成反序列化;
- 完成至少一次真实 Dummy Prompt 推理(完成 CUDA Kernel / Graph Warmup)。
算力拓扑:混合池与 Failure Domain 防御
千万不要将整个 Serving 集群全部押注在单一可用区的单一规格 Spot 实例上。一旦该规格被云厂商集中回收,容易出现雪崩式的容量穿透。
┌── On-Demand 容量基线(承担 20%~40% 核心恒定水位)
│
总 Serving 集群 ──┼── Spot 资源池 A(主力可用区,例如 A100-80G / AZ-a)
├── Spot 资源池 B(备用机型/可用区,例如 L40S / AZ-b)
└── Fallback 限流降级通道(极端抢占时代币退避或切换量化模型)
- 基线保底:On-Demand 算力池负责维系核心 SLO,保障即便在 Spot 全部中断时核心业务依然具备最小可用性。
- 实例异构性(Instance Diversification):配置 Karpenter 节点池选择器时,放宽实例族约束,支持从同等算力的多种 GPU 规格中弹性选优。
核心监控指标与演练清单
黄金观测指标(Prometheus)
llm_spot_notice_to_draining_seconds:从底层发出信号到应用停止接收新流量的延迟。llm_spot_inflight_aborted_ratio:因 Drain 预算耗尽而被强制掐断的请求比例。llm_spot_replacement_ready_latency_seconds:替代实例申请、镜像拉取、显存载入到通过健康探针的全程耗时。llm_spot_wasted_decode_tokens_total:因抢占中断而被废弃计算的 Decode Token 总量。
混沌工程演练矩阵(Chaos Drill)
生产上线前,务必通过类似 AWS Fault Injection Service (FIS) 或在节点模拟注入 ACPI 信号进行压测演练:
- 演练场景 1:节点在 80% 显存占用、高并发短文本推理时突发抢占,验证 Drain 成功率。
- 演练场景 2:正在进行 4K 超长上下文 Prefill 时收到中断,验证快速 Abort 与降级重发逻辑。
- 演练场景 3:模拟全可用区 Spot 售罄场景,验证 Autoscaler 能否在预定时间内降级拉起 On-Demand 备用池。
- 演练场景 4:验证网关层在流式输出中途断开时,客户端是否能正确解析重试错误码而不产生死循环重试。