用竞价实例跑GPU推理:我把成本砍了67%,同时稳住了99.95%的可用性——一份被Spot中断逼出来的架构手记

30秒速览

  • Spot实例不是省钱银弹,是用工程复杂度换成本效率——省了6万美金月费,代价是3000行代码和每周2小时运维
  • 双保险中断检测(实例内通知+外部探活)把检测延迟压到0.6秒,比单纯等通知快了3倍
  • 预热池大小按基础容量的20%设置最划算,我们的3台预热实例月费2100美元,换来了99.96%可用性
  • 客户端指数退避+服务端令牌桶限流,不搞这套的话重试风暴能把P99从400ms干到8秒
  • 不适合5台GPU以下的小规模部署,省的钱不够付开发成本

别被按需实例惯坏了——Spot的中断是家常便饭,不是意外

今年初我在帮一家短视频分析平台(日活大概30万,每天处理2000万条视频片段)做推理架构优化。他们的GPU集群跑的是视频内容审核模型,高峰期需要60张A10G,低峰期也要15张。月账单看着肉疼——光GPU实例就烧掉8万多美金。

我跟他们说,咱们上Spot实例吧。他们CTO第一反应是:「那个不是随时会被回收吗?线上服务用不了吧。」说实话,一年前我也这么想。但现在我可以负责任地说:Spot实例的回收不是黑天鹅,而是确定性事件——AWS会在回收前2分钟发通知,Azure给30秒,GCP给30秒。这跟按需实例的「可能挂」不一样,Spot的挂是「一定挂,而且提前告诉你时间」。

这里有个关键认知差异:不确定性分两种。一种是随机的(比如机房断电),你没法预测;另一种是确定性的(比如Spot回收),你能预测但窗口很短。大多数工程师把Spot归类为前者,所以不敢用。实际上它属于后者——只要你能在2分钟内完成迁移,它就比按需实例更可控。因为按需实例挂了是没有预告的。

先快速过一下三个主流云厂商的Spot(也叫Preemptible VM、竞价实例)基本特性:

特性 AWS Spot Instance GCP Preemptible VM Azure Spot VM
折扣力度 60-90% 60-91% 60-90%
回收通知 2分钟(通过Instance Metadata) 30秒(Preemptive Termination Notice) 30秒(Scheduled Events)
最长存活 无限制(看供需) 最多24小时 无限制(看供需)
回收频率 日均5-10%(历史数据) 必然在24小时内 日均5-10%
中断信号获取方式 curl http://169.254.169.254/latest/meta-data/spot/instance-action ACPI G2 Soft Off 或 maintenance-event 元数据 Azure Metadata Service – Scheduled Events

看到没,GCP的Preemptible VM最狠——不管你愿不愿意,24小时必死。但这也意味着它的行为最可预测。AWS和Azure则是「看市场供需」,可能跑3天,也可能10分钟就被抢走。这种不确定性才是架构设计要面对的真正挑战。

我刚开始做Spot架构的时候犯过一个低级错误:以为回收通知是可靠送达的。结果有一次AWS的网络抖了一下,通知延迟了40秒才到,等我们的handler反应过来,实例已经进入terminating状态了。那3分钟里丢失了大概200多个推理请求。后来我学乖了——不要只依赖事件驱动的中断检测,要加心跳式探活。这个后面会详细写。

多区域自动切换——我为什么选择Route53而不是自研DNS,以及为什么后来后悔了

先说说整体架构的演变。最初我设计的是一个中心化的调度层,所有推理请求先进到一个全局负载均衡器,然后分发到各个区域的Spot实例池。听起来很合理对吧?跑了两周就出问题了。

问题出在跨区域的延迟上。我们的推理请求体很大——一个视频片段平均8MB,从新加坡绕到美西再回来,光是传输就多了200ms。对于实时审核场景,这个延迟是不可接受的。于是我改成了就近接入+异步切换的架构。

现在的方案是这样的:每个区域都有独立的Spot实例池和一个小规模的按需实例缓冲池。客户端通过Latency-based Routing自动连接到最近的区域。当一个区域的Spot容量急剧下降(比如中断率飙升到15%以上),该区域的流量会被自动切换到备用区域。

DNS层我一开始用的是Route53的Latency Routing Policy,配合Health Check来做故障转移。这里有一个关键配置:


# Route53 Health Check 配置(通过AWS CLI)
aws route53 create-health-check 
  --caller-reference "spot-gpu-health-$(date +%s)" 
  --health-check-config '{
    "IPAddress": "10.0.1.100",
    "Port": 8080,
    "Type": "HTTPS",
    "ResourcePath": "/healthz",
    "RequestInterval": 10,
    "FailureThreshold": 3,
    "MeasureLatency": true,
    "Regions": ["us-east-1", "us-west-2", "ap-southeast-1"]
  }'

# 关键:FailureThreshold设为3,意味着连续3次失败才切换
# 我一开始设的1,结果网络抖动导致频繁切换,更不稳定
# 10秒间隔 × 3次 = 30秒检测时间,对Spot的2分钟窗口来说足够了

但我后来发现Route53有个硬伤:DNS缓存。客户端和中间DNS服务器会缓存解析结果,TTL设得太短会增加DNS查询开销,设得太长又影响切换速度。我们的安卓客户端甚至有自己的DNS缓存层,完全不理会TTL。有一回美东区域挂了,安卓端的流量还傻傻地往那边跑,等了快5分钟才切过来。

我折腾了两天,最后在客户端加了一个轻量级的连接池探活机制——不是依赖DNS切换,而是TCP连接失败时立即切换到备用域名。这相当于在应用层又做了一层路由。代码大概长这样:


import asyncio
import aiohttp
from typing import List, Optional
import random

class MultiRegionClient:
    """客户端多区域自动切换——别只依赖DNS,那玩意儿不可靠"""
    
    def __init__(self, region_endpoints: List[str]):
        # region_endpoints: ['us-east.api.example.com', 'ap-se.api.example.com']
        self.endpoints = region_endpoints
        self.active_endpoint = random.choice(region_endpoints)  # 初始随机选
        self.failed_endpoints = set()  # 熔断池
        
        # 关键参数:探测间隔30秒,太久会影响切换速度
        self.probe_interval = 30
        self.connection_timeout = 3  # 3秒连不上就认为挂了
        
    async def infer(self, video_data: bytes) -> dict:
        # 主路径:走当前活跃endpoint
        for attempt in range(3):  # 最多重试3个不同区域
            try:
                result = await self._do_infer(self.active_endpoint, video_data)
                return result
            except (aiohttp.ClientConnectorError, asyncio.TimeoutError):
                # 连不上,标记为失败,换下一个
                self.failed_endpoints.add(self.active_endpoint)
                available = [ep for ep in self.endpoints 
                           if ep not in self.failed_endpoints]
                if not available:
                    # 全挂了,重置熔断,硬着头皮上
                    self.failed_endpoints.clear()
                    available = self.endpoints
                self.active_endpoint = random.choice(available)
                continue
        raise Exception("所有区域都连不上,建议检查是不是自己断网了")
    
    async def _do_infer(self, endpoint: str, data: bytes) -> dict:
        url = f"https://{endpoint}/v1/infer"
        timeout = aiohttp.ClientTimeout(total=15)  # 推理超时15秒
        async with aiohttp.ClientSession(timeout=timeout) as session:
            async with session.post(url, data=data) as resp:
                return await resp.json()

这个客户端设计的核心思路是:DNS给了一个初始方向,但真正的容错在应用层。而且熔断池用的是内存结构,不打持久化——挂了就挂了,重启后重新学习就行,没必要搞那么复杂。

多区域切换还有一个隐藏成本:跨区域数据传输费。AWS的跨AZ流量是0.01美元/GB,跨Region是0.02美元/GB。如果大量推理请求需要跨区域冗余,这部分成本会吃掉Spot省下的钱。我的做法是只在切换期间走跨区域,正常情况下100%就近服务。实际观测下来,跨区域流量占比不到0.5%。

实例预热池设计——我在Lambda和EventBridge之间反复横跳的两周

这是整个架构里最让我头疼的部分。Spot实例从启动到能接收推理请求,中间有个「预热」过程——拉取模型权重(5-8GB)、加载到GPU显存、初始化推理引擎。用A10G的话,整个过程大约需要3-4分钟。如果你等到收到中断通知才开始启动新实例,2分钟根本不够。

所以需要一个预热池(Warm Pool)——始终保持一定数量的备用实例处于「Ready」状态,随时可以接管流量。这听起来简单,但池子多大?什么时候补充?怎么快速把模型加载进去?我踩了一堆坑。

踩坑记录:Auto Scaling Group的Warm Pool功能

AWS在2021年推出了ASG Warm Pool功能,我听名字以为就是干这个的,兴冲冲地配上了。结果发现它解决的问题跟我要的完全不一样——ASG Warm Pool是把实例保持在Stopped状态,需要时快速启动。但Stopped状态的GPU实例不保留显存内容,启动后还得重新加载模型。而且它不支持Spot实例的热替换逻辑。

更坑的是,Warm Pool里的实例在Stopped状态下仍然会计费(只是不算EC2费用,但EBS卷、弹性IP等照收)。我跑了三天账单反而涨了。看了文档才发现这个功能是为CPU场景设计的,对GPU推理完全是鸡肋。关掉。

最终方案:基于ASG Lifecycle Hook的预热管理器

我自己写了一个预热管理器,核心逻辑放在EC2实例的UserData脚本里,配合ASG的Lifecycle Hook来控制实例的就绪状态。

架构是这样的:

  • 每个区域维护一个ASG,由Spot和On-Demand混合组成(比例大约9:1)
  • Spot实例的desired capacity始终比实际需要多2-3台
  • 新实例启动后自动执行预热脚本:下载模型→加载到GPU→自检→注册到负载均衡
  • 预热完成后才通过Lifecycle Hook通知ASG该实例已就绪
  • 收到中断信号时,实例会立即从LB摘除,但保持存活30秒排空请求

预热脚本的核心部分:


#!/bin/bash
# EC2 UserData 预热脚本——注意这里每行都有坑

set -e

# 1. 拉取模型——用S3 Transfer Acceleration,比直连快40%
# 别用aws s3 cp,大文件断点续传用aws s3 sync配合--no-sign-request会快很多
MODEL_PATH="/models/video_review_v3.onnx"
aws s3 cp s3://models-bucket/video_review_v3.onnx $MODEL_PATH 
  --endpoint-url https://s3-accelerate.amazonaws.com 
  --cli-read-timeout 300

# 2. 启动推理服务——这里用systemd管理,保证挂了能自动拉起
cat > /etc/systemd/system/inference.service << 'EOF'
[Unit]
Description=Video Inference Service
After=network.target

[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/inference
ExecStart=/opt/inference/start_server.sh
Restart=always
RestartSec=5
# 关键:给GPU足够的初始化时间
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable inference
systemctl start inference

# 3. 等待服务就绪——轮询health endpoint,最多等5分钟
MAX_WAIT=300
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
  if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/healthz | grep -q 200; then
    echo "Inference service ready after ${WAITED}s"
    break
  fi
  sleep 5
  WAITED=$((WAITED + 5))
done

if [ $WAITED -ge $MAX_WAIT ]; then
  echo "Inference service failed to start, terminating instance"
  # 别浪费钱,起不来就自杀,让ASG重试
  aws autoscaling terminate-instance-in-auto-scaling-group 
    --instance-id $(curl -s http://169.254.169.254/latest/meta-data/instance-id) 
    --should-decrement-desired-capacity
fi

# 4. 完成Lifecycle Hook——通知ASG这个实例已就绪
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
ASG_NAME=$(aws autoscaling describe-auto-scaling-instances 
  --instance-ids $INSTANCE_ID 
  --query 'AutoScalingInstances[0].AutoScalingGroupName' 
  --output text)

aws autoscaling complete-lifecycle-action 
  --lifecycle-hook-name "instance-ready" 
  --auto-scaling-group-name "$ASG_NAME" 
  --instance-id "$INSTANCE_ID" 
  --lifecycle-action-result "CONTINUE"

这里有个细节很多人会忽略:预热脚本里一定要有超时自杀逻辑。Spot实例是按秒计费的(至少AWS和GCP是),一台实例启动后5分钟还没就绪,直接terminate,别让它继续烧钱。我在初期没加这个,结果有一次模型下载卡住了(S3限流),实例空跑了2小时,白白浪费了4美元。虽然单台不多,但一个池子十几台,一个月下来也很可观。

预热池的大小怎么定?这需要根据中断率和启动时间来计算。我写了个简单的仿真工具来模拟:


import numpy as np
from collections import deque

class SpotPoolSimulator:
    """模拟Spot实例池的行为,计算不同预热池大小下的可用性"""
    
    def __init__(self, 
                 base_capacity: int,      # 基础需要的实例数
                 warm_pool_size: int,     # 额外预热的实例数
                 interruption_rate: float, # 每小时中断概率
                 startup_minutes: int,     # 新实例启动到就绪的时间
                 simulation_hours: int = 720):  # 模拟30天
        self.base = base_capacity
        self.warm = warm_pool_size
        self.ir = interruption_rate
        self.startup = startup_minutes
        self.hours = simulation_hours
        
        # 追踪每分钟的capacity
        self.capacity_history = []
        self.interruption_events = 0
        
    def run(self):
        total_capacity = self.base + self.warm
        recovering_until = 0  # 恢复到满容量之前的时间戳
        available = total_capacity
        
        for minute in range(self.hours * 60):
            # 每分钟检查是否有中断
            # 简化模型:每个实例独立以ir/60的概率中断
            active_instances = min(available, total_capacity)
            interruptions = np.random.binomial(active_instances, self.ir / 60)
            
            if interruptions > 0:
                self.interruption_events += 1
                available -= interruptions
                # 被中断的实例需要startup分钟来替换
                # 简化:假设同一分钟内的中断都同时开始恢复
                recovery_time = minute + self.startup
                recovering_until = max(recovering_until, recovery_time)
            
            # 检查是否有恢复完成的
            if minute >= recovering_until and available  0)
        availability = 1 - (shortfall_minutes / (self.hours * 60))
        return availability, self.interruption_events

# 实际运行:我们的场景,base=15, warm=3, 中断率每小时5%
sim = SpotPoolSimulator(base_capacity=15, warm_pool_size=3, 
                        interruption_rate=0.05, startup_minutes=4)
avail, events = sim.run()
print(f"可用性: {avail:.4f}, 中断事件: {events}次/月")
# 输出:可用性: 0.9996, 中断事件: 520次/月
# 也就是说,15台spot实例,一个月会经历520次中断,但预热池能把可用性撑到99.96%

这个仿真帮我们确定了的预热池大小:基础容量的20%作为预热缓冲,在我们的场景下是3台。加上多区域冗余,实际可用性达到了99.95%以上。

中断处理与自动迁移——2分钟能干什么?我测量了每一毫秒

有了预热池,中断处理的核心问题就变成了一个时间竞赛:在2分钟内完成流量排空、请求迁移、实例优雅关闭。我来拆解每一步的时间消耗。

首先是中断信号的检测。AWS的Spot Instance Termination Notice通过Instance Metadata提供,轮询间隔越短越好。但官方文档建议的5秒轮询还不够,我降到2秒:


import requests
import time
import threading
from datetime import datetime

class SpotInterruptionDetector:
    """检测Spot中断信号——轮询版,别指望回调,不可靠"""
    
    def __init__(self, callback, poll_interval=2):
        self.callback = callback
        self.poll_interval = poll_interval
        self.interrupted = False
        self.metadata_url = "http://169.254.169.254/latest/meta-data/spot/instance-action"
        self.thread = threading.Thread(target=self._poll_loop, daemon=True)
        
    def start(self):
        self.thread.start()
    
    def _poll_loop(self):
        while not self.interrupted:
            try:
                resp = requests.get(self.metadata_url, timeout=1)
                if resp.status_code == 200 and not self.interrupted:
                    # 响应内容类似:{"action": "terminate", "time": "2024-01-15T10:30:00Z"}
                    action = resp.json()
                    terminate_time = datetime.fromisoformat(action['time'].replace('Z', '+00:00'))
                    remaining = (terminate_time - datetime.utcnow()).total_seconds()
                    
                    self.interrupted = True
                    self.callback(remaining_seconds=remaining)
                    break
                elif resp.status_code == 404:
                    # 404 = 没有中断通知,正常
                    pass
            except requests.RequestException:
                # 网络问题,不能误判为中断
                # 但也要处理连续多次失败的情况——可能是实例已经在挂了
                pass
            
            time.sleep(self.poll_interval)
    
    def get_interruption_remaining(self):
        """备用方案:主动查询剩余时间"""
        try:
            resp = requests.get(self.metadata_url, timeout=1)
            if resp.status_code == 200:
                action = resp.json()
                terminate_time = datetime.fromisoformat(action['time'].replace('Z', '+00:00'))
                return (terminate_time - datetime.utcnow()).total_seconds()
        except:
            pass
        return None

# 实际的回调处理
def on_interruption_detected(remaining_seconds):
    logger.warning(f"收到中断通知,剩余{remaining_seconds}秒")
    
    # 阶段1:立即从负载均衡摘除(0-5秒)
    deregister_from_lb()
    
    # 阶段2:排空进行中的请求(最多10秒)
    # 设置一个Graceful Shutdown周期
    graceful_timeout = min(remaining_seconds * 0.3, 10)  # 用30%时间排空
    drain_requests(timeout=graceful_timeout)
    
    # 阶段3:通知调度器迁移流量(5-10秒)
    notify_global_scheduler()
    
    # 阶段4:保存状态,优雅关闭(剩余时间)
    save_checkpoint()
    
# 启动检测
detector = SpotInterruptionDetector(
    callback=on_interruption_detected, 
    poll_interval=2  # 2秒轮询,在2分钟窗口下足够快
)
detector.start()

但后来我发现一个问题:在极少数情况下(大约0.3%),中断通知会延迟送达。等我们的检测器收到信号时,只剩下不到30秒。这种情况下,上面的流程就走不完了。所以我加了一个心跳式的外部探活——不从实例内部检测,而是从负载均衡器侧检测。

具体做法是:每个推理实例每秒向Redis写一个心跳,负载均衡器侧的健康检查会读这个心跳。如果连续3秒没心跳,即使没收到中断通知,也主动将该实例摘除。这比被动等通知快了至少10秒。


# 心跳写入(推理服务内部)
import redis
import threading
import time

class HeartbeatSender:
    """向Redis写心跳——外部探活的关键一环"""
    
    def __init__(self, redis_client, instance_id, interval=1):
        self.redis = redis_client
        self.instance_id = instance_id
        self.interval = interval
        self.running = False
        # key过期时间设3倍间隔,防止key堆积
        self.ttl = interval * 3
        
    def start(self):
        self.running = True
        self.thread = threading.Thread(target=self._loop, daemon=True)
        self.thread.start()
    
    def stop(self):
        self.running = False
    
    def _loop(self):
        while self.running:
            try:
                key = f"heartbeat:gpu:{self.instance_id}"
                self.redis.setex(key, self.ttl, str(time.time()))
            except redis.RedisError:
                # Redis挂了不能影响推理——但外部探活会检测到并摘除
                pass
            time.sleep(self.interval)

# 负载均衡器侧的健康检查(在调度器里跑)
def check_all_instances_health():
    """外部探活:检查所有实例的心跳,超时3秒就摘除"""
    redis_conn = redis.Redis(host='redis.internal', port=6379)
    now = time.time()
    unhealthy_instances = []
    
    # 扫描所有心跳key
    cursor = 0
    while True:
        cursor, keys = redis_conn.scan(cursor, match='heartbeat:gpu:*', count=100)
        for key in keys:
            instance_id = key.decode('utf-8').split(':')[-1]
            last_heartbeat = float(redis_conn.get(key) or 0)
            if now - last_heartbeat > 3:  # 3秒没心跳,判定为挂
                unhealthy_instances.append(instance_id)
        if cursor == 0:
            break
    
    # 批量摘除不健康的实例
    for instance_id in unhealthy_instances:
        deregister_unhealthy_instance(instance_id)
        logger.warning(f"外部探活发现{instance_id}无心跳,已从LB摘除")
    
    return unhealthy_instances

这套双保险机制(实例内部的通知检测 + 外部的探活)把中断检测的中位延迟从1.8秒降到了0.6秒。最差情况(通知延迟)下,也能在3-4秒内检测到并触发摘除。

接下来是请求迁移。我们的推理服务设计成无状态的——每个请求独立,不依赖之前的结果。这意味着迁移很简单:把请求重新分配到健康的实例就行。但这里有个坑:已经被中断实例接收但还没处理完的请求,怎么处理?

第一版方案简单粗暴:直接丢弃,让客户端重试。结果发现有些请求被重试了3次,因为恰好碰到了多个实例同时中断(同一批Spot实例经常会被同时回收)。客户端超时累积,用户端等了快10秒才返回结果。

改进方案是请求级别的幂等和去重:每个请求带唯一ID,调度器缓存最近2分钟内的请求结果。如果请求被重新分配到新实例,调度器先检查缓存,命中就直接返回,不重新推理。


# 调度器层的请求幂等缓存
import hashlib
from functools import lru_cache
import time

class IdempotentScheduler:
    """带幂等缓存的请求调度器——避免Spot中断时重复推理"""
    
    def __init__(self, redis_client, ttl=120):
        self.redis = redis_client
        self.ttl = ttl  # 缓存2分钟,覆盖绝大部分中断窗口
        self.request_count = 0
        self.cache_hit_count = 0
    
    def dispatch(self, request_id: str, video_data: bytes, 
                 available_instances: list) -> dict:
        self.request_count += 1
        
        # 先查缓存:这个请求之前被处理过吗?
        cache_key = f"infer_result:{request_id}"
        cached = self.redis.get(cache_key)
        if cached:
            self.cache_hit_count += 1
            return json.loads(cached)
        
        # 缓存未命中,选择实例并执行推理
        instance = self._select_healthy_instance(available_instances)
        try:
            result = self._execute_inference(instance, video_data)
            # 写缓存——注意用SETEX而非SET+EXPIRE,保证原子性
            self.redis.setex(cache_key, self.ttl, json.dumps(result))
            return result
        except InferenceTimeoutError:
            # 推理超时(可能是实例在中断边缘),不写缓存
            # 让客户端重试时走另一个实例
            raise
        except Exception as e:
            # 其他异常也不缓存,避免缓存了错误结果
            raise
    
    def get_cache_hit_rate(self):
        if self.request_count == 0:
            return 0
        return self.cache_hit_count / self.request_count

# 实际效果:缓存命中率稳定在6-8%
# 这部分请求在实例中断期间被重试时,直接命中缓存,零额外延迟

这个幂等缓存的命中率大约在6-8%,看起来不高,但在实例大批量中断的场景下(比如一个区域突然大批回收),这6-8%就是雪中送炭——减少了二次风暴。

成本-可用性权衡模型——67%的节省背后,我付出了什么代价

终于到了最实在的部分:省了多少钱,以及为此牺牲了什么。

先看成本对比。我们的基准场景:日均需要15-60张A10G(对应p4d.24xlarge的1/8或g5.12xlarge),按需实例价格$3.912/小时(us-east-1,2024年价格)。月均按需费用:


# 按需实例成本计算
# 假设加权平均实例数 = (15 * 12小时低谷 + 60 * 12小时高峰) / 24 = 37.5台
# 按需月费 = 37.5 × $3.912 × 730小时 = $107,091

# Spot实例成本(实际3个月平均)
# Spot价格波动较大,平均折扣67%
# Spot月费 = 37.5 × ($3.912 × 0.33) × 730 = $35,340

# 但还要加上额外的按需缓冲区(保证可用性)
# 我们维持了3台常驻按需实例(不中断保障)
# 按需缓冲 = 3 × $3.912 × 730 = $8,567

# 总计 = $35,340 + $8,567 = $43,907
# 相比纯按需节省 = $107,091 - $43,907 = $63,184
# 节省比例 = 59%

# 三个月实际账单:
# 第一个月(调优期):$51,200(节省52%)
# 第二个月(稳定后):$41,800(节省61%)
# 第三个月(加入多区域优化):$38,900(节省64%)

这组数据来自我们实际的AWS账单,不是纸上算的。注意第一个月浪费了不少——频繁的实例churn导致预热池反复建设,多花了钱。后面稳定下来才达到预期的60%+节省。

但成本并不是唯一指标。Spot架构引入了额外的复杂度,这些复杂度带来了隐性成本

隐性成本项 描述 量化估算
运维复杂度 需要维护预热管理器、中断检测、多区域切换逻辑,代码量约2000行 初期2人月开发,后续每月10小时维护
延迟抖动 Spot中断时的请求重试带来P99延迟增加 P99从450ms增加到680ms(增加51%)
跨区域流量费 切换期间的跨区域数据传输 约$180/月(占总流量0.5%)
资源浪费 预热池中的备用实例不服务流量但会计费 约$2,100/月(3台预热实例)
调试难度 分布式系统的问题定位比单机复杂 平均故障定位时间从15分钟增加到40分钟

重点说一下延迟抖动。这是Spot架构最让人纠结的地方。按需实例的P99延迟相对稳定,但Spot实例在中断时的请求重试会把P99推高。我们从监控里拉了数据:


# Prometheus查询:P99推理延迟(按实例类型分组)
# Spot实例组:
histogram_quantile(0.99, rate(inference_duration_seconds_bucket{instance_type="spot"}[5m]))
# 结果:0.68秒(中位数0.32秒)

# 按需实例组:
histogram_quantile(0.99, rate(inference_duration_seconds_bucket{instance_type="ondemand"}[5m]))
# 结果:0.45秒(中位数0.31秒)

# 差距在P99上确实存在——0.68 vs 0.45,多了51%
# 但P50几乎没区别(0.32 vs 0.31)
# 说明大多数请求不受影响,只有极少数碰上了中断重试

这个P99延迟的增加,对于我们的业务是可接受的——视频审核允许1-2秒的延迟波动。但对于真正实时场景(比如自动驾驶、高频交易),这51%的P99劣化可能就是致命的。

可用性方面,我们设定了99.9%的SLA目标(每月停机不超过43分钟)。实际运行数据:

  • 第一个月:99.87%(故障时间56分钟)——未达标。原因是一次大规模的Spot回收影响了整个us-east-1区域,我们的预热池被瞬间击穿。
  • 第二个月:99.94%(25分钟)——达标。多区域切换机制开始生效。
  • 第三个月:99.96%(17分钟)——超额完成。进一步优化了预热池补充速度。

第一个月的不达标让我重新审视了区域选择策略。原先我只用us-east-1(因为最便宜),后来改成了双区域部署(us-east-1 + us-west-2),虽然跨区域流量费增加了,但可用性得到了质的提升。

这里有一个容易被忽视的成本:Spot价格本身是波动的。我设的最高出价是按需价格的80%,大部分时间市场价远低于这个,但偶尔会飙升。我们的账单里有几条记录显示Spot价格短暂突破了按需价格(通常持续几分钟),这时候系统会自动切换到按需缓冲池,避免成本失控。


# Spot价格监控与切换逻辑
def check_spot_price_and_adjust():
    """监控Spot市场价格,价格超过按需80%时切换到按需池"""
    
    # 查询当前Spot价格
    spot_price = get_current_spot_price(instance_type='g5.12xlarge', 
                                         az='us-east-1a')
    ondemand_price = 3.912  # 固定按需价格
    
    threshold = ondemand_price * 0.8  # 80%阈值
    
    if spot_price > threshold:
        logger.warning(f"Spot价格飙升: {spot_price} > {threshold},触发切换")
        # 增加按需实例数量
        set_ondemand_percentage(0.5)  # 50%流量走按需
        # 减少Spot请求量
        set_spot_percentage(0.3)
        # 剩余20%走预热池(保持在线但不接收新请求)
        set_warm_pool_active(True)
    else:
        # 价格正常,恢复正常比例
        set_ondemand_percentage(0.1)
        set_spot_percentage(0.9)
        set_warm_pool_active(True, size=3)
    
    # 记录价格用于后续分析
    record_metric('spot_price', spot_price)

三个月下来,真正触发价格切换的事件只有4次,每次持续不超过15分钟。说明Spot价格大部分时间是稳定的,但这个保护机制必须要有——就像保险一样,你希望永远用不上,但不能没有。

真实场景复盘:从99.87%到99.96%的三个关键修复

我把第一个月没达标的原因做了个详细复盘,发现三个关键问题,分别做了修复。这些修复加起来让可用性提升了0.09个百分点——看起来很少,但对于30万日活的平台,这意味着每月多了2.6万用户不受故障影响。

修复一:预热池补充的「雪崩效应」

第一个月碰到的最大问题是:当一批Spot实例被同时回收(比如一次回收5台),预热池里的3台立即顶上,但ASG需要启动新实例来补充预热池。问题是ASG默认是串行启动的(Max Batch Size默认1),5台新实例要排队启动。加上预热时间,整个预热池需要15-20分钟才能恢复满血状态。在这个窗口期,再来一波中断就直接击穿了。

修复方案是调整ASG的启动参数:


# ASG配置调整(通过CloudFormation)
Resources:
  SpotInferenceASG:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      # 关键参数调整
      MinSize: '15'
      MaxSize: '70'  # 给足上限,避免触及
      DesiredCapacity: '18'  # 15台服务 + 3台预热
      
      # 实例预热时间——告诉ASG新实例需要多久才能接收流量
      DefaultInstanceWarmup: 240  # 4分钟,与我们的预热脚本匹配
      
      MixedInstancesPolicy:
        InstancesDistribution:
          OnDemandBaseCapacity: 0
          OnDemandPercentageAboveBaseCapacity: 10  # 10%按需兜底
          SpotAllocationStrategy: "capacity-optimized"  
          # ↑ 关键:优先选择中断概率最低的容量池
          
      # 启动步长——修复「串行启动慢」的问题
      # 这一步在ASG的UpdatePolicy里配,不在这里
      
  ASGUpdatePolicy:
    Type: UpdatePolicy
    Properties:
      AutoScalingRollingUpdate:
        MinInstancesInService: 15
        MaxBatchSize: 5  # 允许同时启动5台,而不是默认1台
        PauseTime: PT10M  # 暂停10分钟观察

光改ASG配置还不够。我还在预热管理器中加了「紧急补充模式」——当可用实例数低于基础容量的90%时,跳过常规预热流程,直接从模型缓存节点拉权重(S3太慢),并且允许实例在模型加载到80%时就开始接收低优先级请求。

修复二:中断信号漏检的补偿机制

前面提到过,大约0.3%的中断通知会延迟送达或完全丢失。第一个月我们有两次故障就是这种情况——实例突然没了,预热池还没来得及补上,导致部分请求失败。

我加了一个外部探活+预言式检测的机制。不仅检查当前存活状态,还通过历史中断模式预测哪些实例「可能即将被回收」,提前进行预迁移。


# 预言式检测:基于AWS Spot Instance Advisor数据
# 虽然无法100%预测个体实例的中断,但可以监控区域级别的中断率
# 当区域中断率飙升时,主动迁移流量

class PredictiveSpotManager:
    """预言式Spot中断管理——通过区域中断率预判风险"""
    
    def __init__(self):
        self.region_interruption_threshold = 0.2  # 20%中断率阈值
        self.interruption_history = deque(maxlen=60)  # 最近60分钟的记录
        
    def record_interruption(self, instance_id, region):
        """记录每次中断事件"""
        self.interruption_history.append({
            'timestamp': time.time(),
            'instance_id': instance_id,
            'region': region
        })
    
    def get_region_risk(self, region: str) -> float:
        """计算某个区域当前的中断风险(0-1)"""
        now = time.time()
        recent = [e for e in self.interruption_history 
                  if e['region'] == region and (now - e['timestamp'])  self.region_interruption_threshold:
                logger.warning(f"区域{region}中断风险{risk:.1%},触发主动迁移")
                # 将该区域40%的流量平滑迁移到备用区域
                migrate_traffic(source=region, percentage=0.4, gradual=True)
                
                # 同时扩容备用区域的预热池
                expand_warm_pool(target_region=get_backup_region(region), 
                               additional=5)

这个预言式检测让我们在第二次大规模中断之前就完成了流量迁移,避免了第一个月的那种全区域雪崩。

修复三:客户端重试的「雷霆风暴」

这是一个隐蔽的问题。当服务端出现间歇性故障(比如某个实例中途挂了),客户端会自动重试。但如果重试策略太激进,会在瞬间制造大量请求,把原本健康的实例也压垮——形成「客户端重试风暴」。

第一个月有一次就是这样:一个Spot实例中断,导致约200个请求失败,客户端在50ms内同时重试这200个请求,加上新增的正常流量,瞬间把剩余实例的请求队列打满了。P99延迟从400ms飙到8秒,更多请求超时,更多重试……正反馈循环。

修复方案是客户端指数的退避 + 服务端限流


# 客户端:指数退避重试(替换原来的固定间隔重试)
class ExponentialBackoffRetrier:
    """指数退避重试器——防止客户端重试风暴"""
    
    def __init__(self, base_delay=0.1, max_delay=5, jitter=True):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.jitter = jitter
        
    def get_delay(self, attempt: int) -> float:
        # 指数退避:0.1s, 0.2s, 0.4s, 0.8s...
        delay = min(self.base_delay * (2 ** attempt), self.max_delay)
        
        if self.jitter:
            # 加随机抖动,避免所有客户端同时重试(thundering herd)
            delay = delay * (0.5 + random.random())  # 50%-150%的随机范围
        
        return delay

# 服务端:令牌桶限流(防止过载)
class TokenBucketRateLimiter:
    """令牌桶限流器——服务端最后一道防线"""
    
    def __init__(self, rate: int, burst: int):
        """
        rate: 每秒允许的请求数
        burst: 突发允许的最大请求数
        """
        self.rate = rate
        self.burst = burst
        self.tokens = burst  # 当前可用令牌
        self.last_update = time.monotonic()
        self.lock = threading.Lock()
    
    def acquire(self) -> bool:
        """尝试获取一个令牌,成功返回True"""
        with self.lock:
            now = time.monotonic()
            # 补充令牌
            elapsed = now - self.last_update
            self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
            self.last_update = now
            
            if self.tokens >= 1:
                self.tokens -= 1
                return True
            return False

# 在推理API入口使用限流器
rate_limiter = TokenBucketRateLimiter(rate=100, burst=150)  # 每秒100个,突发150

@app.post('/v1/infer')
async def infer(video: UploadFile):
    if not rate_limiter.acquire():
        raise HTTPException(status_code=429, detail="Too many requests, back off")
    
    # 正常推理逻辑...

这三个修复叠加后,系统的可用性从第一个月的99.87%提升到了99.96%。它们看起来都是细节,但在大规模Spot部署中,细节就是一切。

这个方案不适合所有人——别让成本优化变成架构事故

写到这儿我得说一句掏心窝子的话:Spot实例做线上推理,不适合所有团队。我见过一些团队听说能省60%成本就盲目上,结果运维成本翻倍,还不如用按需实例省心。

先说说哪些场景适合Spot推理:

  • 弹性的、可延迟的工作负载:批处理推理、离线视频分析、数据预处理。请求延迟允许在1-2秒波动。
  • 多区域部署:至少两个区域的Spot容量足够支撑流量。只有一个区域的话,大规模回收就傻眼了。
  • 中等以上的实例规模:15台以上的实例池,才值得为预热池和多区域切换投入开发成本。如果只有3-5台GPU,直接按需就好,省下来的钱不够付你的加班费。
  • 有一定基础设施能力的团队:能搞定多区域部署、DNS配置、监控告警。小团队可能Hold不住这些。

哪些场景不建议:

  • 严格实时场景:P99延迟增加50%不能接受。
  • 小规模部署:5台以下实例,Spot省不了多少,运维成本反而更高。
  • 需要长时GPU内存状态:比如需要缓存大量中间计算结果在显存里,实例中断后重建成本太高。
  • 监管要求严格的金融/医疗系统:实例的频繁变动可能触发合规审计。

我还想强调一个被忽视的问题:Spot实例的GPU型号和驱动版本一致性。不同Spot容量池可能提供不同批次的GPU(比如有的是A10G,有的是A100的部分分区),驱动版本也可能不同。我们的TensorRT推理引擎对CUDA版本敏感,出现过因为驱动版本不一致导致推理结果差异的情况。

解决方案是在预热脚本里加了一致性检查:


# GPU型号和驱动版本检查
GPU_REQUIRED = "NVIDIA A10G"
CUDA_REQUIRED = "12.1"

def verify_gpu_environment():
    """验证GPU环境是否符合要求——不一致则终止并报告"""
    try:
        import pynvml
        pynvml.nvmlInit()
        
        gpu_name = pynvml.nvmlDeviceGetName(pynvml.nvmlDeviceGetHandleByIndex(0))
        if GPU_REQUIRED not in gpu_name.decode('utf-8'):
            logger.error(f"GPU型号不匹配:需要{GPU_REQUIRED},实际{GPU_REQUIRED}")
            return False
        
        cuda_version = subprocess.check_output(['nvcc', '--version']).decode('utf-8')
        if CUDA_REQUIRED not in cuda_version:
            logger.error(f"CUDA版本不匹配:需要{CUDA_REQUIRED},实际{cuda_version}")
            return False
        
        return True
    except Exception as e:
        logger.error(f"GPU环境检查失败: {e}")
        return False

# 在预热脚本中调用
if not verify_gpu_environment():
    # 环境不符合,终止实例,让ASG重新分配
    aws autoscaling terminate-instance-in-auto-scaling-group ...

最后说一下长期维护。我们这套Spot架构跑了三个月,代码库膨胀了大约3000行(包括调度器、预热管理器、中断检测、监控面板),而且每个月还需要花大约10个小时处理各种边缘情况:

  • 某个区域Spot容量突然枯竭(一般是AWS内部调整)
  • 预热池的模型版本跟线上不一致(模型更新时没同步到预热池)
  • 跨区域DNS解析偶尔卡住(CDN节点故障)
  • Redis心跳key泄漏(实例不正常退出导致key残留)

这些都是常规运维工作,不是架构缺陷。但如果你打算上Spot,请把这些维护成本算进ROI里。我们的真实ROI是:每月节省6.3万美金,但投入了1.5人月的开发和每周2小时的运维。换算下来,年化净节省大约70万美金。对于一个30万日活的产品,这笔钱足够再雇两个高级工程师了。

所以,Spot GPU推理不是一个「便宜」的方案——它是用工程复杂度成本效率。你有这个工程能力,它就是金矿;你没有,它就是个坑。我折腾这三个月最大的感受是:省下来的每一块钱,都写着代码。

发表评论