AI云原生实战10-K8s HPA撑不住了?KEDA事件驱动让AI推理服务缩容到0,GPU成本砍半

📅 2026/7/26 18:01:31 👁️ 阅读次数 📝 编程学习
AI云原生实战10-K8s HPA撑不住了?KEDA事件驱动让AI推理服务缩容到0,GPU成本砍半

⚠️阅读建议:本文假定你已有基础K8s和GPU调度经验,本文在此基础上深入KEDA事件驱动扩缩容。如果你还没看过本系列的第3、4篇,建议先去补一下GPU调度和MIG共享的基础。


目录

一、凌晨三点,我盯着GPU账单沉默了

二、先搞清楚:HPA为什么搞不定AI场景?

2.1 三个翻车现场

2.2 AI服务的弹性特征

三、KEDA核心概念:三张牌打天下

3.1 ScaledObject:持续运行服务的扩缩容

3.2 ScaledJob:一次性批处理任务

3.3 Scaler:50+种事件源的通用接口

四、AI场景三大Scaler:你的业务该用哪个?

4.1 Prometheus Scaler:为推理服务质量而生

4.2 Kafka Scaler:异步推理和训练的最佳拍档

4.3 Cron Scaler:省钱利器,定时缩容

五、KEDA vs HPA:一张表终结选择困难

六、实战一:Prometheus驱动GPU推理服务自动扩缩

6.1 安装KEDA

6.2 部署vLLM推理服务(带GPU)

6.3 配置Prometheus Scaler的ScaledObject

6.4 验证扩缩容效果

七、实战二:Kafka消息积压触发批处理训练ScaledJob

7.1 场景描述

7.2 Kafka Topic设计

7.3 ScaledJob完整配置

7.4 训练Job的业务逻辑

八、实战三:Cron定时缩容——凌晨没流量的GPU白烧钱

8.1 精细化时间表配置

九、缩容到0与冷启动优化的三大策略

策略一:镜像预热 + 模型缓存

策略二:调整 cooldownPeriod 和 pollingInterval

策略三:预扩缓冲池(Warm Pool)

十、KEDA + GPU节点池联动:省钱闭环

10.1 架构联动全景

10.2 GPU节点池配置

10.3 配好后的效果


一、凌晨三点,我盯着GPU账单沉默了

你是否遇到过这种场景:凌晨三点,线上推理流量几乎为零,但GPU集群的8张A100还在全速运转——风扇嗡嗡响,电费哗哗流,而你的HPA因为CPU/内存指标"看起来正常",稳如泰山地维持着10个Pod在线。

更扎心的是另一头——白天高峰期,推理请求把服务打到响应超时,HPA吭哧吭哧花了3分钟才从10个Pod扩到15个,而流量已经在第2分钟把你所有请求队列塞爆了。

HPA不是不好,是它压根没想过AI服务的特殊性。

网上搜到的HPA教程要么用CPU利用率做例子(对GPU服务基本无效),要么教你在YAML里写一堆metrics-server的配置(KEDA一行配置解决)。本文将从原理到实战,给你一套生产级的AI服务事件驱动扩缩容方案,包含完整YAML、Prometheus/Kafka/Cron三大Scaler配置,以及缩容到0的冷启动优化指南。

💡这篇文章的终极目标:让你白天扛得住流量洪峰,夜里不白烧GPU——一张A100月租一万五,缩容到0省下的电费够你吃一年海底捞。


二、先搞清楚:HPA为什么搞不定AI场景?

2.1 三个翻车现场

翻车一:GPU推理服务的"指标错位"

标准的HPA盯着CPU和内存。但一个vLLM推理服务,CPU可能稳定在15%,而GPU利用率已经95%了——HPA觉得"一切正常",不会扩容。用户那边已经排队排到骂人了。

# 典型HPA配置——对GPU服务形同虚设 apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: inference-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-inference minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu # ❌ GPU跑满了CPU还闲着呢 target: type: Utilization averageUtilization: 60 - type: Resource resource: name: memory # ❌ 推理服务内存基本恒定 target: type: Utilization averageUtilization: 70

翻车二:批处理训练的"完就走"困境

你提交了100个训练任务到消息队列。每个任务跑8分钟,吃一张GPU。HPA能做到的是:看CPU高了就扩几个Pod。但它不知道怎么"数"队列里的消息还剩多少。结果:要么扩得太多,要么扩得不够。

翻车三:夜间服务还在"空转"

这是最扎心的场景。凌晨2:00到早上8:00,推理流量趋近于零。但服务跑在K8s上,HPA设置minReplicas=2,两张GPU通宵亮着。每张A100每小时5美元(云上价格),6个小时就是60美元。一个月30天,1800美元打了水漂

2.2 AI服务的弹性特征

AI工作负载和传统Web服务在弹性需求上有本质区别:

特征传统Web服务AI推理服务AI批处理训练
流量模式相对平稳,日间高峰高度间歇,突发尖刺事件驱动,队列堆积
单请求耗时毫秒级百毫秒-秒级分钟-小时级
资源瓶颈CPU/内存GPU利用率 + 请求队列GPU显存 + 队列深度
冷启动时间秒级10-60秒(模型加载)分钟级(数据加载)
能否缩容到0可以但很少做强烈需要必须做
扩缩容指标CPU/Mem利用率请求延迟/队列深度消息积压/队列长度

⚠️关键认知:AI服务不适合"以资源定规模",适合"以需求定规模"。有多少请求就启多少Pod,没请求就停掉。KEDA设计哲学就是围绕这一点——事件驱动,而非指标驱动


三、KEDA核心概念:三张牌打天下

KEDA(Kubernetes Event-driven Autoscaling)是一个CNCF毕业项目,本质上是一个轻量级的事件驱动的自动扩缩容组件。它的核心逻辑就一条:

监听外部事件源 → 翻译成扩缩容指令 → 操控HPA或Job完成扩缩

整个KEDA架构可以用一张图看懂:

graph TB subgraph External["外部事件源"] E1[Prometheus<br/>查询延迟指标] E2[Kafka<br/>消费组Lag] E3[Cron<br/>定时触发] E4[RabbitMQ<br/>队列深度] E5[Redis<br/>List长度] end subgraph KEDA["KEDA Core"] K1[Scaler<br/>事件适配器<br/>------------<br/>50+种内置Scaler] K2[Metrics Server<br/>gRPC指标暴露] K3[Operator/Controller<br/>ScaledObject/ScaledJob控制器] end subgraph Target["扩缩目标"] T1[Deployment<br/>StatefulSet<br/>任意Scale子资源] T2[Job<br/>一次性批处理] end E1 --> K1 E2 --> K1 E3 --> K1 E4 --> K1 E5 --> K1 K1 --> K2 K2 --> K3 K3 -->|"操控HPA<br/>增删副本"| T1 K3 -->|"创建/删除Job"| T2 style K1 fill:#339af0,color:#fff style K2 fill:#339af0,color:#fff style K3 fill:#339af0,color:#fff style T1 fill:#51cf66,color:#fff style T2 fill:#fcc419,color:#333

3.1 ScaledObject:持续运行服务的扩缩容

ScaledObject是KEDA最常用的CRD,用于长期运行的服务(Deployment、StatefulSet)。

一句话理解:它定义一个"事件触发器"和"扩缩边界",然后KEDA自动帮你操控HPA

核心字段三要素:

apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: inference-scaler spec: scaleTargetRef: name: llm-inference # 要扩缩的Deployment名称 pollingInterval: 15 # 每15秒检查一次事件源 cooldownPeriod: 300 # 缩容冷静期:5分钟 minReplicaCount: 0 # 可以缩容到0! maxReplicaCount: 20 # 最多20个Pod triggers: # 触发器列表(可以有多个) - type: prometheus # Scaler类型 metadata: # Scaler特定参数 serverAddress: http://prometheus.monitoring.svc:9090 metricName: http_requests_per_second query: | sum(rate( istio_requests_total{ destination_service_name="llm-inference" }[2m] )) threshold: "100" # 每个Pod处理100请求/秒

💡关键设计:注意minReplicaCount: 0。这是HPA做不到的事情——HPA的minReplicas不能设为0,因为metrics-server在Pod不存在时拿不到指标,形成死循环。KEDA通过自己维护指标绕过了这个问题。

3.2 ScaledJob:一次性批处理任务

ScaledJob用于事件驱动的批处理——每个事件创建一个Job实例。

一句话理解:Kafka来一条消息就创建一个Job去处理,处理完就销毁,不占资源

apiVersion: keda.sh/v1alpha1 kind: ScaledJob metadata: name: training-job-scaler spec: jobTargetRef: parallelism: 1 completions: 1 template: spec: containers: - name: trainer image: myrepo/training:latest resources: limits: nvidia.com/gpu: 1 restartPolicy: Never pollingInterval: 10 successfulJobsHistoryLimit: 5 failedJobsHistoryLimit: 10 maxReplicaCount: 8 # 最多同时跑8个训练Job triggers: - type: kafka metadata: bootstrapServers: kafka-broker:9092 consumerGroup: training-consumer topic: training-requests lagThreshold: "1" # 每积压1条消息就启动一个Job

⚠️ScaledObject vs ScaledJob 选择决策

  • 你的任务是长期运行的服务(API、推理)→ 用ScaledObject
  • 你的任务是跑完就结束的批处理(训练、数据清洗)→ 用ScaledJob
  • 搞反了会出事故:ScaledJob在任务完成后不会自动缩容"副本",它会不停创建新Job直到队列清空

3.3 Scaler:50+种事件源的通用接口

KEDA的Scaler体系是它最强大的地方。截止2026年,KEDA支持50+种内置Scaler,覆盖了几乎所有你能想到的外部事件源。

对于AI场景,以下是最重要的几个:

Scaler触发条件AI适用场景
Prometheus任意PromQL查询结果推理延迟、GPU利用率、请求QPS
Kafka消费组Lag异步推理队列、训练任务队列
Cron定时规则(cron表达式)夜间缩容、预扩预热
RedisList/Stream长度轻量推理缓存、预处理
RabbitMQ队列深度企业内部异步推理
NATS Streaming订阅堆积事件驱动推理管道

四、AI场景三大Scaler:你的业务该用哪个?

用一张图串联起AI推理和训练场景下的KEDA决策路径:

graph TD START{"你的AI负载<br/>是什么类型?"} START -->|"在线推理<br/>同步API"| A1["Prometheus Scaler<br/>+ ScaledObject"] START -->|"异步推理<br/>消息队列"| A2["Kafka/RabbitMQ Scaler<br/>+ ScaledObject"] START -->|"批量训练<br/>离线任务"| A3["Kafka Scaler<br/>+ ScaledJob"] START -->|"定时预热<br/>夜间缩容"| A4["Cron Scaler<br/>+ ScaledObject"] A1 --> B1["监控指标:请求延迟P99"] A1 --> B2["监控指标:GPU利用率"] A1 --> B3["监控指标:请求QPS"] A2 --> C1["监控指标:队列深度"] A2 --> C2["监控指标:消息积压数量"] A3 --> D1["每积压N条消息"] A3 --> D2["启动1个训练Job"] A3 --> D3["训练完成自动销毁"] A4 --> E1["工作日8:00预热"] A4 --> E2["凌晨2:00缩容到0"] B1 --> F["扩缩决策:<br/>P99>500ms → 扩容<br/>P99<100ms → 缩容"] B2 --> F B3 --> F style START fill:#ff6b6b,color:#fff style A1 fill:#339af0,color:#fff style A2 fill:#339af0,color:#fff style A3 fill:#fcc419,color:#333 style A4 fill:#fcc419,color:#333 style F fill:#51cf66,color:#fff

4.1 Prometheus Scaler:为推理服务质量而生

这是AI推理场景下最强大的Scaler,因为PromQL可以表达任何你想要的扩缩容逻辑

一个实用PromQL示例——按GPU利用率加权扩缩

# 每个Pod实际使用的GPU利用率(百分比) ( sum by (pod) ( DCGM_FI_DEV_GPU_UTIL{pod=~"llm-inference-.*"} ) ) / 100

另一个实用PromQL——按请求队列深度扩缩

# vLLM/Pod内积压的请求数 sum( vllm:num_requests_waiting{pod=~"llm-inference-.*"} )

💡实战建议:不要用单一的GPU利用率指标。推荐用"请求队列深度 + GPU利用率"做复合指标——任何一个超过阈值都触发扩容。KEDA的ScaledObject支持多个trigger,它们之间是OR关系(任一触发即扩容),但不能做AND。如果需要AND关系,你需要把逻辑写到PromQL里。

4.2 Kafka Scaler:异步推理和训练的最佳拍档

Kafka在AI场景有两个经典模式:

模式一:异步推理——用户提交推理请求到Kafka,消费者从队列中取请求、跑推理、返回结果。KEDA根据消费组Lag自动扩缩消费者Pod。

模式二:训练任务调度——训练请求入队,ScaledJob每积压N条消息起一个训练Job,Job跑完自动销毁。

Kafka Scaler的核心参数:

triggers: - type: kafka metadata: bootstrapServers: kafka-broker.default:9092 consumerGroup: inference-workers # 消费组名 topic: inference-queue # 监控的Topic lagThreshold: "5" # 每5条积压扩1个Pod activationLagThreshold: "2" # 积压≥2时从0扩到1 offsetResetPolicy: latest

⚠️注意activationLagThreshold:这是KEDA从0扩到1的触发阈值,必须小于lagThreshold。如果设成一样,可能出现"一直在0和1之间震荡"的问题。

4.3 Cron Scaler:省钱利器,定时缩容

Cron Scaler的用法最直观但最容易被低估:

triggers: - type: cron metadata: timezone: Asia/Shanghai start: 0 8 * * 1-5 # 工作日早8点预热 end: 0 2 * * 1-5 # 工作日凌晨2点缩容 desiredReplicas: "4"

⚠️Cron Scaler的一个坑:它会"叠加"在其它trigger之上。如果你同时配了Cron和Prometheus trigger,Cron的逻辑是把目标副本数设为特定值(不是上下限),而Prometheus trigger是基于指标动态计算。两者同时生效时,KEDA取较大的那个值


五、KEDA vs HPA:一张表终结选择困难

对比维度HPA (HorizontalPodAutoscaler)KEDA (ScaledObject)
扩缩依据CPU/内存利用率(内置)任意外部事件(PromQL、Kafka Lag、Redis长度…)
指标来源metrics-server(有限)50+ Scaler + 自定义
缩容到0❌ 不支持✅ 原生支持
冷启动N/A支持activation机制
扩缩速度依赖metrics-server采集间隔(15s-60s)可自定义pollingInterval(最小1s)
GPU感知❌ 无法直接使用GPU指标✅ PromQL查询DCGM指标
批处理Job❌ 不支持✅ ScaledJob原生支持
多触发器支持(多个metrics取max)支持(多个triggers取max)
复杂度低(内置)中(需安装KEDA Operator)
适用场景传统Web服务AI推理、流式处理、批处理、GPU服务

💡一句话结论:如果你只跑无状态Web API,HPA够用。如果你跑AI推理、GPU训练、流式处理——别纠结,直接上KEDA。


六、实战一:Prometheus驱动GPU推理服务自动扩缩

6.1 安装KEDA

# Helm安装(推荐) helm repo add kedacore https://kedacore.github.io/charts helm repo update helm install keda kedacore/keda \ --namespace keda \ --create-namespace \ --set prometheus.enabled=true # 启用Prometheus Scaler支持 # 验证安装 kubectl get pods -n keda # NAME READY STATUS RESTARTS AGE # keda-operator-6d8f9c7b4-xxxxx 1/1 Running 0 30s # keda-metrics-apiserver-5d8f9c7b4-xxxxx 1/1 Running 0 30s

6.2 部署vLLM推理服务(带GPU)

先上推理Deployment——以vLLM部署Llama-3-8B为例:

apiVersion: apps/v1 kind: Deployment metadata: name: llm-inference labels: app: llm-inference spec: replicas: 1 selector: matchLabels: app: llm-inference template: metadata: labels: app: llm-inference annotations: prometheus.io/scrape: "true" prometheus.io/port: "8000" spec: # GPU节点亲和性(配合上一篇文章的GPU调度策略) nodeSelector: nvidia.com/gpu.product: NVIDIA-A100-SXM4-40GB tolerations: - key: nvidia.com/gpu operator: Equal value: "true" effect: NoSchedule containers: - name: vllm image: vllm/vllm-openai:v0.5.4 args: - "--model" - "meta-llama/Meta-Llama-3-8B-Instruct" - "--tensor-parallel-size" - "1" - "--max-model-len" - "8192" - "--gpu-memory-utilization" - "0.90" ports: - containerPort: 8000 name: http resources: limits: nvidia.com/gpu: 1 # 每Pod一张A100 memory: "32Gi" cpu: "8" requests: nvidia.com/gpu: 1 memory: "16Gi" cpu: "4" readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 60 # ⚠️ 模型加载至少需要30-60秒 periodSeconds: 10 --- apiVersion: v1 kind: Service metadata: name: llm-inference spec: selector: app: llm-inference ports: - port: 8000 targetPort: 8000

6.3 配置Prometheus Scaler的ScaledObject

接下来是核心——让KEDA根据GPU利用率+请求QPS自动扩缩这个推理服务:

apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: llm-inference-scaler namespace: default spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-inference pollingInterval: 15 # 每15秒查询一次Prometheus cooldownPeriod: 300 # 缩容冷静期5分钟 minReplicaCount: 0 # 0流量时缩容到0 maxReplicaCount: 10 # 最多10个GPU Pod triggers: # 触发器1:GPU利用率(如果利用率>70%,扩容) - type: prometheus metadata: serverAddress: http://prometheus-k8s.monitoring.svc:9090 metricName: gpu_utilization threshold: "70" query: | avg( DCGM_FI_DEV_GPU_UTIL{ pod=~"llm-inference-.*", exported_namespace="default" } ) # 触发器2:vLLM等待队列中的请求数 - type: prometheus metadata: serverAddress: http://prometheus-k8s.monitoring.svc:9090 metricName: vllm_waiting_requests threshold: "5" query: | sum( vllm:num_requests_waiting{ pod=~"llm-inference-.*" } ) # 触发器3:请求速率(QPS) - type: prometheus metadata: serverAddress: http://prometheus-k8s.monitoring.svc:9090 metricName: http_qps_per_pod threshold: "50" query: | sum( rate( istio_requests_total{ destination_service_name="llm-inference", response_code!~"5.*" }[2m] ) ) / count( count by (pod) ( kube_pod_info{ pod=~"llm-inference-.*" } ) ) # 🌙 触发器4:Cron定时——凌晨2点强制缩容到0 - type: cron metadata: timezone: Asia/Shanghai start: 0 8 * * 1-5 # 工作日8:00恢复 end: 30 1 * * * # 每天1:30缩到0 desiredReplicas: "0" # ⚡ 触发器5:Cron定时——工作日高峰期预热到4个实例 - type: cron metadata: timezone: Asia/Shanghai start: 30 7 * * 1-5 # 7:30预热 end: 59 18 * * 1-5 # 18:59结束 desiredReplicas: "4"

6.4 验证扩缩容效果

# 查看ScaledObject状态 kubectl get scaledobject llm-inference-scaler # NAME SCALETARGETKIND SCALETARGETNAME MIN MAX TRIGGERS READY # llm-inference-scaler apps/v1.Deployment llm-inference 0 10 5 True # 查看HPA(KEDA自动创建的) kubectl get hpa # NAME REFERENCE TARGETS MINPODS MAXPODS # keda-hpa-llm-inference Deployment/llm-inference 35/70, 2/5, ... 0 10 # 模拟流量——用hey压测 hey -z 60s -c 100 -m POST \ -H "Content-Type: application/json" \ -d '{"model":"llama-3-8b","messages":[{"role":"user","content":"Hello"}]}' \ http://llm-inference.default:8000/v1/chat/completions # 观察自动扩容 kubectl get pods -l app=llm-inference -w # llm-inference-7d8f9c-xxxxx 0/1 Pending 0 0s # llm-inference-7d8f9c-xxxxx 0/1 ContainerCreating 0 5s # llm-inference-7d8f9c-xxxxx 1/1 Running 0 65s ← 注意加载时间

⚠️首次冷启动延迟:从0到1的过程不是瞬间的。Pod创建(5秒)+ 镜像拉取(已有缓存秒级)+ 模型加载到GPU显存(30-60秒) = 首次启动约45-70秒。后面会讲如何优化。


七、实战二:Kafka消息积压触发批处理训练ScaledJob

7.1 场景描述

场景:一个AI平台,用户提交模型微调请求到Kafka队列。每个请求包含训练数据路径、超参数、模型类型。KEDA监听队列积压,自动为每个请求创建一个训练Job。

7.2 Kafka Topic设计

# 创建训练任务Topic kafka-topics.sh --create \ --bootstrap-server kafka-broker:9092 \ --topic training-tasks \ --partitions 10 \ --replication-factor 3 # 训练任务消息格式 # {"taskId":"uuid","modelType":"llama-7b","dataPath":"s3://data/finetune/2026","epochs":3,"lora":true}

7.3 ScaledJob完整配置

apiVersion: keda.sh/v1alpha1 kind: ScaledJob metadata: name: training-job-scaler namespace: ai-training spec: pollingInterval: 10 successfulJobsHistoryLimit: 5 # 保留5个成功Job历史 failedJobsHistoryLimit: 10 # 保留10个失败Job历史(方便排查) maxReplicaCount: 8 # 最多同时8个训练Job(对应8张GPU) rolloutStrategy: gradual # 逐步创建Job,避免GPU节点瞬间被打满 jobTargetRef: parallelism: 1 # 每个Job只用1个GPU completions: 1 backoffLimit: 2 # 失败重试2次 ttlSecondsAfterFinished: 3600 # 完成后1小时自动清理Job template: metadata: labels: type: finetune-job spec: # GPU亲和性 nodeSelector: nvidia.com/gpu.product: NVIDIA-A100-SXM4-40GB tolerations: - key: nvidia.com/gpu operator: Equal value: "true" effect: NoSchedule containers: - name: trainer image: myrepo/llm-finetune:v2.1 env: - name: KAFKA_MESSAGE valueFrom: fieldRef: fieldPath: metadata.annotations['keda.sh/kafkaMessage'] # ↑ KEDA自动把触发Job的Kafka消息内容注入环境变量 resources: limits: nvidia.com/gpu: 1 memory: "80Gi" cpu: "16" requests: nvidia.com/gpu: 1 memory: "64Gi" cpu: "8" volumeMounts: - name: training-data mountPath: /data - name: model-output mountPath: /output volumes: - name: training-data persistentVolumeClaim: claimName: training-data-pvc - name: model-output persistentVolumeClaim: claimName: model-output-pvc restartPolicy: Never triggers: - type: kafka metadata: bootstrapServers: kafka-broker.ai-training:9092 consumerGroup: training-consumers topic: training-tasks lagThreshold: "1" # 积压1条消息就启动1个Job activationLagThreshold: "1" offsetResetPolicy: latest # ⚠️ ScaledJob场景中,KEDA为每个Job创建独立的消费者,不共享consumer group

7.4 训练Job的业务逻辑

训练容器需要做三件事:

# trainer.py - 训练Job入口伪代码 import os, json, subprocess def main(): # 1. 从环境变量读取Kafka消息内容 kafka_msg = json.loads(os.environ["KAFKA_MESSAGE"]) task_id = kafka_msg["taskId"] data_path = kafka_msg["dataPath"] epochs = kafka_msg.get("epochs", 3) # 2. 下载训练数据 subprocess.run(["aws", "s3", "sync", data_path, "/data/"]) # 3. 执行训练 subprocess.run([ "torchrun", "--nproc_per_node=1", "finetune.py", "--model", kafka_msg["modelType"], "--data", "/data/", "--output", f"/output/{task_id}", "--epochs", str(epochs), "--lora" if kafka_msg.get("lora") else "" ]) # 4. 上传模型产物 subprocess.run(["aws", "s3", "sync", f"/output/{task_id}/", f"s3://models/output/{task_id}/"]) if __name__ == "__main__": main()

💡关于rolloutStrategy:KEDA支持default(一口气创建所有Job)和gradual(逐步创建,每秒最多创建N个)。对于GPU训练场景,强烈建议用gradual——同时拉起8个训练Job会让GPU节点瞬间OOM,而且kubelet可能一次分配不了那么多GPU设备。


八、实战三:Cron定时缩容——凌晨没流量的GPU白烧钱

前面ScaledObject里已经内嵌了Cron trigger,这里单独拿出来做一个更精细的配置示例。

8.1 精细化时间表配置

apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: inference-cron-schedule spec: scaleTargetRef: name: llm-inference minReplicaCount: 0 maxReplicaCount: 10 triggers: # 🌅 工作日早晨预扩——提前30分钟,避开冷启动延迟 - type: cron metadata: timezone: Asia/Shanghai start: 30 7 * * 1-5 end: 0 9 * * 1-5 desiredReplicas: "3" # 🔥 午间流量高峰——午餐时间刷AI的人最多 - type: cron metadata: timezone: Asia/Shanghai start: 0 11 * * 1-5 end: 30 13 * * 1-5 desiredReplicas: "6" # 🌙 晚间省钱模式 - type: cron metadata: timezone: Asia/Shanghai start: 0 22 * * * end: 0 2 * * * desiredReplicas: "1" # 💤 深度睡眠——凌晨2点到早上7点 - type: cron metadata: timezone: Asia/Shanghai start: 0 2 * * * end: 30 7 * * 1-5 desiredReplicas: "0" # 🎉 周末——大部分人不上班,只保留最低保障 - type: cron metadata: timezone: Asia/Shanghai start: 0 9 * * 0,6 end: 0 22 * * 0,6 desiredReplicas: "1"

⚠️Cron Scaler的优先级规则:如果某个时间段有多个Cron trigger同时生效,KEDA取desiredReplicas最大值。这意味着如果你在午间同时有desiredReplicas: "6"和 Prometheus trigger算出需要8个——最终会扩到8个(Prometheus的值更大)。


九、缩容到0与冷启动优化的三大策略

缩容到0是KEDA的杀手级特性,但"从0到1"的冷启动延迟是个现实问题。以下是生产级的三层优化方案。

策略一:镜像预热 + 模型缓存

# 在所有GPU节点上预先拉取模型镜像 apiVersion: apps/v1 kind: DaemonSet metadata: name: image-prewarmer spec: selector: matchLabels: app: image-prewarmer template: metadata: labels: app: image-prewarmer spec: nodeSelector: nvidia.com/gpu.present: "true" initContainers: - name: pull-images image: busybox command: ["sh", "-c", "echo 'Image prewarming DaemonSet ensures images are cached on GPU nodes'"] containers: - name: sleep image: busybox command: ["sleep", "infinity"]

模型本身可以挂载PVC或利用节点本地SSD缓存,避免每次启动都重新下载几十GB的模型文件:

# 使用hostPath将模型缓存到节点本地NVMe volumes: - name: model-cache hostPath: path: /mnt/nvme/models/llama-3-8b type: DirectoryOrCreate

策略二:调整cooldownPeriodpollingInterval

spec: pollingInterval: 10 # 更快响应流量变化(默认30s) cooldownPeriod: 180 # 缩容冷静期降到3分钟(默认300s) # 高级配置:防止频繁震荡 advanced: horizontalPodAutoscalerConfig: behavior: scaleDown: stabilizationWindowSeconds: 120 # 缩容稳定窗口2分钟 policies: - type: Percent value: 50 # 每次最多缩50% periodSeconds: 60 scaleUp: stabilizationWindowSeconds: 0 # 扩容窗口0秒(立即响应) policies: - type: Percent value: 100 # 每次最多翻倍 periodSeconds: 15 - type: Pods value: 4 # 或每次最多加4个Pod periodSeconds: 15 selectPolicy: Max # 取两者中较大的

策略三:预扩缓冲池(Warm Pool)

对于延迟敏感的推理服务,完全缩容到0可能不现实。可以保持1个"温Pod"兜底:

spec: minReplicaCount: 1 # 永远保持1个Pod在线 # 但这个Pod不需要是最小规格的——可以是一个低配模型 idleReplicaCount: 1 # KEDA 2.14+ 支持

💡终极方案:分层冷启动——高配A100跑热门模型常驻1个Pod,低配T4跑冷门模型按需从0起。通过Istio/Gateway API做流量路由,根据模型ID转发到不同backend。


十、KEDA + GPU节点池联动:省钱闭环

KEDA负责"Pod层级"的扩缩容,但如果Pod扩了、节点没地方放,还是白搭。这时候需要节点层弹性——让KEDA和Cluster Autoscaler(或Karpenter)联动。

10.1 架构联动全景

# 1. KEDA ScaledObject:Pod从0扩到10 # 2. Pending Pod触发Cluster Autoscaler:节点从2台扩到5台 # 3. 新节点加入集群后,Pending Pod调度成功 # 4. 流量过后,KEDA缩容Pod到0 # 5. 空节点被Cluster Autoscaler回收

10.2 GPU节点池配置

# Karpenter NodePool(推荐,比Cluster Autoscaler更快) apiVersion: karpenter.sh/v1beta1 kind: NodePool metadata: name: gpu-inference-pool spec: template: spec: requirements: - key: karpenter.sh/capacity-type operator: In values: ["spot"] # 推理用Spot实例省钱 - key: node.kubernetes.io/instance-type operator: In values: - "g5.2xlarge" # 1x A10G - "g5.4xlarge" # 1x A10G + 更多CPU - "p3.2xlarge" # 1x V100 - key: nvidia.com/gpu.present operator: In values: ["true"] # GPU节点专用Taint——防止CPU Pod抢占 taints: - key: nvidia.com/gpu value: "true" effect: NoSchedule # 节点启动后自动打标签 startupTaints: - key: nvidia.com/gpu-startup value: "true" effect: NoSchedule # 等Device Plugin上报GPU后再接受Pod nodeClassRef: name: gpu-default # KEDA缩容后节点自动回收 consolidation: enabled: true disruption: consolidationPolicy: WhenUnderutilized consolidateAfter: 5m # 空节点5分钟后回收 budgets: - nodes: "20%" # 最多同时回收20%的节点

10.3 配好后的效果

# 凌晨2:00:0个推理Pod,0个GPU节点 kubectl get nodes -l nvidia.com/gpu.present=true # No resources found # 早上8:00:Cron预热触发,KEDA扩到4个Pod # → Pending触发Karpenter启动2台g5.4xlarge # → 60秒后Pod全部Running # 中午12:00:Prometheus检测到GPU利用率飙升 # → KEDA扩到8个Pod # → Karpenter再启动2台g5.4xlarge # 凌晨2:00:Cron缩容到0 # → 节点空闲5分钟后被Karpenter回收 # → 0个GPU Pod,0个GPU节点,0美元开销

💡这是KEDA+GPU节点池的内核Pod扩缩容(KEDA)→ Pending触发节点扩缩容(Karpenter)→ Pod缩到0后节点自动回收。三个组件各司其职,形成一个完整的"按需付费"闭环。


十一、总结与系列预告

核心要点回顾

KEDA解决的不是"怎么扩缩容"的问题——HPA也能。KEDA解决的是"该不该扩缩"的问题——当你的决策依据是PromQL查询结果、Kafka消息积压、GPU利用率这些外部信号时,HPA无能为力。

三个必须记住的数字

  • 50+:KEDA内置Scaler数量,覆盖几乎所有事件源
  • 0:KEDA独有的缩容到0能力,AI场景的省钱根基
  • 60秒:GPU推理服务冷启动的典型延迟,通过预热和缓存可降到15秒

三个最容易踩的坑

  1. ❌ 把GPU推理服务配成CPU/内存指标的HPA → 永远扩不上去
  2. ❌ ScaledJob的lagThreshold设太大 → 队列越积越长,永远追不上
  3. ❌ 冷启动时没有设置足够的initialDelaySeconds → Pod被kubelet反复kill

📦 三件套

【源码获取】:本文所有YAML配置的完整仓库,关注本系列后在公众号后台回复「KEDA实战」获取一键部署脚本。

【思考题】:如果你的推理服务支撑100个不同模型,Prometheus Scaler需要监控100个模型的延迟指标。如何设计一个通用的扩缩容策略,避免为每个模型创建单独的ScaledObject?欢迎在评论区留下你的方案——我会在下篇文章中分析最佳实践。

【系列文章预告】:下一篇《NVIDIA MIG深度实战——A100/H100的物理分区与K8s集成》将深入:

  • MIG 7种profile的物理切分配置
  • K8s Device Plugin MIG策略
  • MIG vs Time Slicing vs HAMi 终极对比
  • 生产环境MIG监控与故障排查

觉得有用的话,三连支持一下!🔥


标签:KEDA、事件驱动、弹性伸缩、AI推理、GPU、Kubernetes、HPA