KServe 源码拆解系列 · 第 7 篇
第 6 篇你追着请求走完了整条路:VirtualService → ksvc → 推理 Pod。但有个问题一直悬着:Pod 的数量是谁定的?流量翻倍时谁来加 Pod?夜里没人调用时谁来把 GPU 腾出来?------这篇拆自动扩缩容的控制面:YAML 里的几个字段,怎么变成 HPA、KEDA ScaledObject 和 Knative 的注解。
为什么推理服务的扩缩容更难
普通 Web 服务扩一个 Pod 是秒级的事。推理服务多出两条链:
scss
普通服务: 拉镜像(秒) ── 进程启动(秒) ── 接流量
推理服务: 拉镜像 ── 下载模型(GB级) ── 加载进 GPU ── 接流量
链条长带来两个后果:扩得太慢 (流量涌进来了,Pod 还在下载模型);缩得太贵(GPU 按张计费,缩容晚一分钟都烧钱)。KServe 的对策是给你三条路径各管一摊:
| 路径 | 所在模式 | 指标 | Scale to Zero | 适合 |
|---|---|---|---|---|
| KPA(Knative) | Knative 模式(集群默认) | concurrency / rps | 支持(minReplicas: 0) | 请求驱动,追求极致省 GPU |
| HPA | Standard 模式(默认) | cpu / memory | 不支持(下限 1) | 有常驻负载,不想引入 Knative |
| KEDA | Standard 模式 | 任意事件源(Prometheus、OTel...) | 支持 | 按队列深度/自定义指标扩缩 |
第一道分叉是"模式"。控制器每次调和先调 isvcutils.GetDeploymentMode(isvc.Status.DeploymentMode, annotations, deployConfig)(pkg/controller/v1beta1/inferenceservice/controller.go)算 deploymentMode,优先级:status 记录值 → serving.kserve.io/deploymentMode 注解 → 集群 ConfigMap 的 defaultDeploymentMode(生产默认 "Serverless")。components/predictor.go 据此分岔:Standard 走 reconcileRawDeployment,否则走 reconcileKnativeDeployment(KsvcReconciler)。
Standard 模式的原生 Deployment 配哪种扩缩器,由 serving.kserve.io/autoscalerClass 注解决定(pkg/constants/constants.go):可取值 hpa / external(扩缩器由我自己另装)/ keda / none(完全不用),不写默认 DefaultAutoscalerClass = AutoscalerClassHPA。注意 kpa 不在允许列表里------KPA 只能靠 Knative 模式获得,Standard 模式选不了。
配置传递链:同一个 Spec,两条落地路径
所有扩缩容字段都住在第 2 篇见过的 ComponentExtensionSpec(pkg/apis/serving/v1beta1/component.go),predictor、transformer 各持一份:MinReplicas(默认 1,设 0 才能缩到零)、MaxReplicas、ScaleTarget、ScaleMetric(concurrency/rps/cpu/memory)、AutoScaling(HPA/KEDA 的新式指标列表)、ContainerConcurrency。
路径 A:Knative 模式 → 注解。 KsvcReconciler(reconcilers/knative/ksvc_reconciler.go)建 Service 前调 knutils.SetAutoScalingAnnotations(pkg/controller/v1alpha1/utils/utils.go)翻译字段:
go
if _, ok := annotations[autoscaling.ClassAnnotationKey]; !ok {
annotations[autoscaling.ClassAnnotationKey] = autoscaling.KPA // ← kpa.autoscaling.knative.dev
}
if scaleTarget != nil {
annotations[autoscaling.TargetAnnotationKey] = strconv.Itoa(int(*scaleTarget))
}
// scaleMetric 同理写 autoscaling.knative.dev/metric
min-scale 没写时填 constants.DefaultMinReplicas(即 1);max-scale 只在 maxReplicas != 0 时写------0 表示不限,交给 Knative 全局上限。
KPA 本体(PodAutoscaler、queue-proxy、Activator)全是 Knative 组件,KServe 只负责把这些注解贴到 Revision 模板上;ContainerConcurrency 也原样进 RevisionSpec------单 Pod 的硬并发上限(queue-proxy 执行),scaleTarget 软目标之下的最后一道保险。
Webhook 负责拦错(pkg/apis/serving/v1beta1/inference_service_validation.go):KPA 只收 concurrency/rps,HPA 只收 cpu/memory,KEDA 直接拒绝 scaleMetric 字段------配置错误拦在 apply 时,而不是等扩缩器在集群里报错。
路径 B:Standard 模式 → HPA/KEDA 对象。 RawKubeReconciler(reconcilers/raw/raw_kube_reconciler.go)调 autoscaler.NewAutoscalerReconciler,autoscaler_reconciler.go 里按注解分派:
go
ac := getAutoscalerClass(componentMeta) // ← 读 serving.kserve.io/autoscalerClass,默认 hpa
switch ac {
case constants.AutoscalerClassHPA, constants.AutoscalerClassExternal, constants.AutoscalerClassNone:
return hpa.NewHPAReconciler(...)
case constants.AutoscalerClassKeda:
return keda.NewKedaReconciler(...)
default:
return nil, fmt.Errorf("unknown autoscaler class type: %v", ac)
}
external 和 none 也进 HPAReconciler,由 shouldCreateHPA/shouldDeleteHPA 控制:external/none 时跳过创建、删掉已存在的 HPA。none 的配合在 deployment_reconciler.go:把 deployment.Spec.Replicas 设为 MinReplicas,让 Deployment 自己管副本数。有 HPA 时,比较期望/实际 Deployment 用 cmpopts.IgnoreFields(appsv1.DeploymentSpec{}, "Replicas") 忽略 Replicas------否则 HPA 每调一次 replicas,KServe 就跟着调和一次,无限循环。
HPA 对象在 hpa_reconciler.go 的 createHPA 里生成,注意钳制:minReplicas 为空或小于 constants.DefaultMinReplicas 时取默认值 1------HPA 路径没有缩到零 ;maxReplicas < minReplicas 时直接取 minReplicas。指标由 getHPAMetrics 生成:新式 autoScaling.metrics(仅 Resource 类)优先,旧式 scaleMetric: cpu 没写 scaleTarget 时兜底 DefaultCPUUtilization = 80(constants.go)。
Scale to Zero:注解背后是谁在干活
概述文讲过体验:没请求 0 个 Pod,请求来了 Activator 兜住、拉起、转发。控制面做的只有三件:
- 写 min-scale 注解 。
DefaultMinReplicas = 1------KServe 默认不缩零,显式写minReplicas: 0才启用(ComponentExtensionSpec注释原话:"defaults to 1 but can be set to 0 to enable scale-to-zero")。 - 把关 initial-scale 注解 。
ValidateInitialScaleAnnotation(pkg/controller/v1alpha1/utils/utils.go)校验autoscaling.knative.dev/initial-scale:非整数删掉;设为 0 但集群config-autoscaler里allow-zero-initial-scale是 false 也删掉(CheckZeroInitialScaleAllowed读这个 ConfigMap)。 - 传 ContainerConcurrency。Activator 上报的并发数除以目标值,就是从零要拉起的 Pod 数;ContainerConcurrency 封顶单 Pod 能接的并发------它直接参与这次扩容的数学。
真正接住冷启动请求的 Activator 是 Knative 数据面组件,不在 KServe 仓库里。KServe 角度看到的代价就是第 5 篇那条链:Pod 调度 → storage-initializer 下载模型 → 加载进 GPU------LLM 场景下整条链可能是分钟级,请求只能在 Activator 缓冲区里等着。所以三条路径只有 KPA 和 KEDA 支持缩零;即便支持,KServe 也默认 minReplicas=1。
最后提一个容易混的:serving.kserve.io/stop: "true" 注解(constants.StopAnnotationKey)。它也能让副本清零,但语义是手动停机 不是自动扩缩容------各 reconciler 看到它(utils.GetForceStopRuntime)直接删除 Deployment/HPA/ScaledObject/Ingress。
KEDA:把扩缩容交给事件源
HPA 只认 cpu/memory,KEDA 认任何事件源。KEDA 路径的产出物是 ScaledObject------KEDA operator 盯着这个 CRD,在背后生成真正的 HPA。触发器在 keda_reconciler.go 的 getKedaMetrics 里生成,对应 autoScaling.metrics 的三种类型:Resource (cpu/memory)直接映射成 trigger;External (Prometheus 等)把 query、serverAddress、threshold 塞进 metadata;PodMetric(OpenTelemetry)针对 per-Pod 的推理指标,关键在这一行:
go
metricQuery := fmt.Sprintf("sum(%s{namespace=\"%s\", deployment=\"%s\"})", query, componentMeta.Namespace, componentMeta.Name)
你写的查询被包进 sum(...) 并强制带上 namespace/deployment 选择器------防止一个 InferenceService 的扩缩容读到别的服务的指标。这类指标由 KServe 部署的 OTel Collector 收集:raw_kube_reconciler.go 检测到 PodMetric + opentelemetry 后端就调 otel.NewOtelReconciler,scalerAddress 来自集群 ConfigMap 的 metricScalerEndpoint。
createKedaScaledObject 还有两个细节。一是稳定窗口(防抖动):优先取 autoScaling.behavior,没写则回落集群 ConfigMap 的 autoscaler 段------生产默认 "scaleUpStabilizationWindowSeconds": "0"、"scaleDownStabilizationWindowSeconds": "300"(config/configmap/inferenceservice.yaml)。扩要快、缩要稳,正是"GPU 省着用但服务别抖动"的取舍;它们写进 Advanced.HorizontalPodAutoscalerConfig.Behavior,透传给 KEDA 底层那个 HPA。顺带说个发现:HPA 路径的 createHPA 里 Behavior 是硬编码的空结构------autoScaling.behavior 目前只有 KEDA 路径生效。
二是调和防抖:更新前先 r.client.Update(ctx, desired, client.DryRunAll) 做一次 dry-run(keda_reconciler.go 注释明说不要删)------让 API server 把 KEDA webhook 补的默认值填回本地对象再比较,否则会陷入无限 update。控制器层面还有一层配合(controller.go):探测到 keda.sh/v1/ScaledObject CRD 才 Owns(...) 并挂 predicate------只放行 spec 变化,KEDA 高频的 status 更新被过滤掉。
一句话总结
扩缩容的控制面核心是一个分派:deploymentMode 决定走 Knative(KPA,KServe 只写 autoscaling.knative.dev/* 注解)还是 Standard(HPA/KEDA,由 serving.kserve.io/autoscalerClass 注解选);scaleMetric/scaleTarget/minReplicas/maxReplicas 在 Knative 路径被翻译成注解,在 Standard 路径被填进 HPA Spec 或 KEDA ScaledObject;Scale to Zero 由 Knative 的 Activator 实现,KServe 只负责写 min-scale、校验 initial-scale。
下一篇预告:第 8 篇 多模型管理:TrainedModel 与 ServingRuntime。一个 Deployment 一个模型太浪费 GPU------几百个模型怎么塞进同一个推理 Pod?ServingRuntime 与 ModelMesh 的换入换出机制,源码里长什么样?