K8s 1.37 新特性:原生 Gang Scheduling 与 HPA Scale to Zero

K8s 1.37 新特性:原生 Gang Scheduling 与 HPA Scale to Zero

Kubernetes 1.37 已于 2026 年 8 月 26 日发布,今天分享其中两项值得关注的新特性。 原生 Gang Scheduling 和 HPA Scale to Zero 都进入 Beta 了。前者让调度器能够按一组 Pod 来判断资源是否够用,后者让空闲的 Pod 自动缩到零,新任务来了再启动。

这篇主要分享这两个特性,顺便搭一个集群实际跑一下,看看用起来是什么效果。

1.K8s 1.37 主要新特性

Kubernetes 1.37 更新主要在资源管理和调度方面:

  • 原生 Gang Scheduling、HPA Scale to Zero 进入 Beta
  • DRA 的扩展资源支持和设备污点等能力正式 GA
  • Memory QoS 也进入 Beta
  • 同时 Metrics API 也正式稳定
  • PVC 新增闲置状态追踪,Pod 证书与信任包进入 GA。

更多新特性可以参考 Kubernetes v1.37 官方发布公告。

这篇主要挑 Gang Scheduling 和 HPA Scale to Zero 展开,看看实际怎么用。

2.重点特性介绍

2.1 Gang Scheduling

Gang Scheduling 并不是什么新概念,在 k8s 生态中,早在 2018 年左右就受到关注了,大致发展分为以下几个阶段:

  • 2018-2020:早期探索,主要解决 TensorFlow、MPI、Spark 等分布式任务的调度问题,代表项目 kube-batch 和 Volcano。
  • 2021-2024:逐步普及,随着越来越多的大数据、AI 训练等分布式任务切换到 K8s 底座,Gang Scheduling 逐渐成为了重要调度能力,Volcano 等项目持续发展。
  • 2025-2026:走向原生,Kubernetes 将 Gang Scheduling 纳入原生调度体系,先后在 v1.35、v1.36、v1.37 中实现和完善。

1.37 除了将核心 API 提升至 Beta,还让 PodGroup 直接作为调度队列中的单元,允许动态修改 minCount,并将 Workload-Aware Preemption 合并到 GenericWorkload 开关中。

核心对象

原生 Gang Scheduling 引入了两个核心对象:Workload 和 PodGroup。

Workload 保存调度策略模板。 比如一个任务需要两个 Worker 一起调度,可以这样定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: scheduling.k8s.io/v1beta1
kind: Workload
metadata:
  name: training
spec:
  podGroupTemplates:
    - name: workers
      schedulingPolicy:
        gang:
          minCount: 2

这里定义了一个名为 workers 的模板,要求至少两个成员满足调度条件。Workload 本身不会创建 Pod,也不会因为创建了模板就自动生成 PodGroup;这些工作需要相应的控制器完成。

PodGroup 表示实际参与调度的一组 Pod,保存这组的调度要求和状态。 根据上面的模板创建的 PodGroup 可以写成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: scheduling.k8s.io/v1beta1
kind: PodGroup
metadata:
  name: training-workers
spec:
  workloadRef:
    workloadName: training
    templateName: workers
  schedulingPolicy:
    gang:
      minCount: 2

workloadRef 标明使用了哪个模板,实际调度规则则保存在 PodGroup 自己的 schedulingPolicy 中。Pod 通过下面的字段加入这个组:

1
2
3
spec:
  schedulingGroup:
    podGroupName: training-workers

这样,同一个 Workload 模板可以用于创建多个 PodGroup,每个组分别记录自己的调度状态。Pod 仍由 Job 等控制器或用户创建,PodGroup 负责表达组调度要求。

PodGroup 也可以独立使用,不需要先创建 Workload。 后面的 Demo 就采用这种方式,直接创建 PodGroup 和 Pod,观察资源不足时的调度行为。

原生支持之后,Volcano 还有必要吗

K8s 原生支持 Gang Scheduling 之后还有必要使用 Volcano 吗?

提到 Gang Scheduling 大家就会想到 Volcano,但是 Volcano 的功能却不止于此。 Volcano 还负责批处理作业管理,以及多个团队、多个作业之间的资源分配。这些需求并没有因为原生支持 Gang 而消失。

  • 如果只需要简单的 Gang Scheduling,那可以优先考虑 K8s 原生能力,没必要专门引入 Volcano 之类的项目
  • 需要多租户 GPU 队列、资源借用与回收、公平调度,那么 Volcano 仍然有明显价值。

关键在于:K8s 原生 Gang Scheduling 解决的是“一组 Pod 如何一起调度”,Volcano 解决的则是“多组作业如何公平、高效地共享整个集群的资源”。

2.2 HPA Scale to Zero

早在 Serverless 兴起时期,K8s 就在探索工作负载的零副本弹性伸缩。同样可以大致分为三个阶段:

  • 2018-2020:随着 Serverless 和事件驱动架构的发展,Knative、KEDA 等项目相继出现,实现了工作负载从 0 到 N 的弹性伸缩。K8s 也早在 v1.16 就引入了 HPA Scale to Zero 的 Alpha 实现。
  • 2021-2025:生态发展。 KEDA、Knative 等项目持续完善零副本伸缩能力,逐渐应用于事件驱动服务、消息队列消费者和 Serverless 工作负载,但 Kubernetes 原生 HPA 的相关能力仍长期停留在 Alpha 阶段。
  • 2026:原生能力完善,K8s v1.36 改进了 HPA Scale to Zero 的状态管理与扩缩容逻辑,v1.37 则正式将其提升至 Beta 并默认启用,使基于 Object、External Metrics 的工作负载可以通过原生 HPA 实现从 0 到 N 的自动伸缩。

零副本靠什么唤醒?

Scale to Zero 并不难,最大的问题其实是零副本靠什么唤醒。

传统 HPA 常用 CPU、内存指标扩缩容,但副本缩到零之后,Pod 指标也没了,拿什么发现新任务?

所以零副本伸缩需要独立存在的 Object 或 External 指标。一般是通过额外的队列长度来解决这个问题,零副本之后新任务仍可以入队。HPA 持续读取这个独立存在的指标,就能知道什么时候该启动消费者。

minReplicas: 0 至少需要一个 Object 或 External 指标,只有 CPU、内存指标的配置会被 API Server 拒绝。

1.37 中 HPAScaleToZero 在 kube-apiserver 和 kube-controller-manager 上默认开启,这里直接配置 HPA。后面会用 ScaledToZero Condition 区分自动归零和手动暂停。

与 KEDA、Knative 的分工

KEDA、Knative 早就能缩到零,原生 HPA 补上这项能力后,它们仍各有用途:

方案主要解决什么零副本时如何恢复
原生 HPA根据指标调整工作负载副本数持续读取 Object、External 指标,恢复副本;指标适配和请求缓冲需要另外提供
KEDA对接消息队列、数据库等事件源,提供 Scaler、认证和触发器配置在这里参考的 KEDA 2.18 架构中,由 Operator 处理 0↔1,生成的 HPA 处理 1↔N
Knative Serving(KPA)提供面向 HTTP 服务的自动伸缩和流量接入Activator 暂存请求并配合 Autoscaler 唤醒服务,就绪后转发请求

如果已经有可靠的 External Metrics 链路,只需要根据队列长度伸缩消费者,可以直接使用原生 HPA。需要方便地接入多种事件源时,KEDA 的 Scaler 和配置能力仍然有价值;需要 HTTP 请求触发唤醒、等待冷启动和转发流量时,Knative Serving 提供的是更完整的服务链路。

3. Demo

环境准备

这里使用 KubeClipper v1.7.0 部署 Kubernetes v1.37.0 单节点集群,安装和创建集群的步骤参考之前的 KubeClipper 1.7.0 发布:Operation 优化与 Kubernetes 1.37 支持。

1
2
3
4
kubectl get node

NAME     STATUS   ROLES           AGE     VERSION
node-1   Ready    control-plane   9m20s   v1.37.0

本次节点 Allocatable CPU 为 8 核,未设置控制平面 NoSchedule 污点。其他规格的环境需要相应调整示例中的 CPU requests。

开启 Gang Scheduling

Gang Scheduling 在 1.37 中进入 Beta,但默认没有开启。需要在控制平面组件中打开 GenericWorkload,同时启用对应的 API。

先备份静态 Pod 配置:

1
2
mkdir -p /root/k8s-137-manifests-backup
cp -a /etc/kubernetes/manifests/*.yaml /root/k8s-137-manifests-backup/

编辑 /etc/kubernetes/manifests/ 下的三个文件,在各自的 spec.containers[].command 参数列表中增加:

文件增加的参数
kube-apiserver.yaml--feature-gates=GenericWorkload=true、--runtime-config=scheduling.k8s.io/v1beta1=true
kube-controller-manager.yaml--feature-gates=GenericWorkload=true
kube-scheduler.yaml--feature-gates=GenericWorkload=true

例如,kube-apiserver 中的配置如下,其他已有参数保持原样:

1
2
3
4
5
6
7
spec:
  containers:
  - command:
    - kube-apiserver
    - --feature-gates=GenericWorkload=true
    - --runtime-config=scheduling.k8s.io/v1beta1=true
    # 其他已有参数省略

保存后 kubelet 会自动重建对应的静态 Pod。等待控制平面组件恢复为 1/1 Running,再次确认实际运行进程的启动参数:

1
2
kubectl get pods -n kube-system -l tier=control-plane
ps -ef | grep -E '[k]ube-(apiserver|controller-manager|scheduler)'

下面只保留进程名和相关参数,其余输出省略:

1
2
3
kube-apiserver --feature-gates=GenericWorkload=true --runtime-config=scheduling.k8s.io/v1beta1=true ...
kube-controller-manager --feature-gates=GenericWorkload=true ...
kube-scheduler --feature-gates=GenericWorkload=true ...

三个组件的实际运行进程都带有 --feature-gates=GenericWorkload=true,说明开关已在运行的组件中启用。kube-apiserver 还应带有 --runtime-config=scheduling.k8s.io/v1beta1=true。

然后确认对应的 API 也已开放:

1
kubectl api-resources --api-group=scheduling.k8s.io
1
2
3
4
NAME              SHORTNAMES   APIVERSION                  NAMESPACED   KIND
podgroups                      scheduling.k8s.io/v1beta1   true         PodGroup
priorityclasses   pc           scheduling.k8s.io/v1        false        PriorityClass
workloads                      scheduling.k8s.io/v1beta1   true         Workload

资源列表中应能看到 podgroups 和 workloads,API 版本为 scheduling.k8s.io/v1beta1。至此,组件开关和 API 均已确认启用,接下来通过 Demo 验证实际调度效果。

HPA Scale to Zero 在 1.37 中默认开启,无需额外修改 HPAScaleToZero。

Gang Scheduling

下面通过一个简单示例,对比资源不足时普通调度和 Gang 调度的表现。

普通调度与 Gang Scheduling 在资源不足时的行为对比

普通调度:先启动一部分

为了方便测试调度 Pending 的情况,这里先创建一个占位 Pod,这样剩余资源只能创建一个 Pod 了,便于测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
kubectl create namespace gang-demo

kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: blocker
  namespace: gang-demo
spec:
  containers:
    - name: blocker
      image: busybox:1.37.0
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: "2"
EOF

kubectl get pods -n gang-demo

确认 blocker 为 Running 后,再创建两个普通 Pod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
for i in 0 1; do
  kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: plain-${i}
  namespace: gang-demo
spec:
  containers:
    - name: worker
      image: busybox:1.37.0
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: "3"
EOF
done

稍等后再次查看,可以看到一个 Worker 运行,另一个 Pending:

1
2
3
4
5
6
kubectl get pods -n gang-demo

NAME      READY   STATUS    RESTARTS   AGE
blocker   1/1     Running   0          ...
plain-0   1/1     Running   0          ...
plain-1   0/1     Pending   0          ...

普通调度会逐个处理 Pod,能放下的先运行,不会等待其他成员。

Gang 调度:资源不足,整组等待

删除刚才的普通 Pod,保留占位 Pod,保证两次实验的资源条件一致:

1
kubectl delete pod -n gang-demo plain-0 plain-1

创建独立的 PodGroup,minCount 要求至少两个成员满足调度条件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
kubectl apply -f - <<'EOF'
apiVersion: scheduling.k8s.io/v1beta1
kind: PodGroup
metadata:
  name: workers
  namespace: gang-demo
spec:
  schedulingPolicy:
    gang:
      minCount: 2
EOF

再创建两个 Pod,通过 schedulingGroup.podGroupName 加入这个组。其他配置与普通 Pod 一致:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
for i in 0 1; do
  kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: gang-${i}
  namespace: gang-demo
spec:
  schedulingGroup:
    podGroupName: workers
  containers:
    - name: worker
      image: busybox:1.37.0
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: "3"
EOF
done

稍等一会儿再次查看,两个成员都保持 Pending,且都没有绑定节点:

1
2
3
4
5
6
7
kubectl get pods -n gang-demo \
  -o custom-columns=NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName

NAME      PHASE     NODE
blocker   Running   node-1
gang-0    Pending   <none>
gang-1    Pending   <none>

这次成员已经齐全,等待的原因是资源不足。查看 PodGroup 的状态和事件:

1
2
3
4
5
kubectl describe podgroup workers -n gang-demo

Type:    PodGroupInitiallyScheduled
Status:  False
Reason:  Unschedulable

PodGroup 明确报告无法调度。即使节点还能容纳一个 Worker,调度器也不会先调度,因为无法满足 minCount: 2,这也正是 Gang 调度的语义。

释放资源,整组调度

删除占位 Pod,再查看两个成员的状态:

1
2
3
kubectl delete pod blocker -n gang-demo

kubectl get pods -n gang-demo

稍等后,两个成员都变为 Running:

1
2
3
NAME     READY   STATUS    RESTARTS   AGE
gang-0   1/1     Running   0          ...
gang-1   1/1     Running   0          ...

两次实验的区别就很直观了:普通调度允许部分 Pod 先运行;Gang 调度在无法满足最小成员数时整组等待,资源满足后才推进调度。 这里保证的是初始调度门槛,容器启动仍有先后;成员后续被删除或驱逐时,也不保证运行数量始终满足 minCount。

实验结束后清理:

1
kubectl delete namespace gang-demo

HPA Scale to Zero

本次 HPA 实验已在 hpa-zero 命名空间预先部署 Redis、Redis Exporter、Prometheus 和 Prometheus Adapter。下面展示关键配置和运行结果,省略基础组件的安装过程。

获取队列指标

简单起见,我们用 Redis List 来保存任务,重点看队列长度怎样传给 HPA。

HPA Scale to Zero 指标链路与零副本唤醒过程

先把队列长度暴露为 Prometheus 指标。 Redis Exporter 通过 --script 加载下面的 Lua 脚本,读取 jobs 队列长度:

1
return {"queue_depth", tostring(redis.call("LLEN", "jobs"))}

这里用 LLEN 直接读取长度,空队列返回 0,避免 List 清空、key 消失后指标也跟着消失。

Prometheus 采集 Exporter,并通过采集配置和 relabel 补上命名空间、队列标签。采集后的指标类似这样,其他标签省略:

1
redis_script_values{key="queue_depth",namespace="hpa-zero",queue="jobs"} 40

再把指标提供给 HPA。 Prometheus Adapter 将这条时间序列映射为 External Metric queue_depth,对应的 Helm Chart values 配置片段如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
rules:
  default: false
  external:
    - seriesQuery: 'redis_script_values{key="queue_depth",namespace!=""}'
      resources:
        overrides:
          namespace: {resource: namespace}
      name:
        matches: ^redis_script_values$
        as: queue_depth
      metricsQuery: >-
        sum(<<.Series>>{key="queue_depth",<<.LabelMatchers>>}
          and on(job,instance) (up{job="redis"} == 1)
          and on(job,instance) (redis_up == 1)) by (namespace,queue)

其中 namespace 对应 Kubernetes 命名空间,queue 用来选择队列。查询还检查采集状态和 redis_up,上游失败时不返回有效负载值,不能把“指标读不到”补成零。

最后确认 Kubernetes 能读到指标:

1
2
kubectl get --raw \
  '/apis/external.metrics.k8s.io/v1beta1/namespaces/hpa-zero/queue_depth?labelSelector=queue%3Djobs'

空队列时应返回 value: "0"。如果这条指标不可用,HPA 可能报告 ScalingActive=False / FailedGetExternalMetric,已经归零的消费者也无法自动唤醒。

配置消费者和 HPA

消费者从 Redis 的 jobs 列表取任务,处理后写入 completed 列表,方便核对完成数量。直接创建 Deployment 和 HPA,Deployment 初始为 1 个副本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: queue-worker
  namespace: hpa-zero
spec:
  replicas: 1
  selector:
    matchLabels: {app: queue-worker}
  template:
    metadata:
      labels: {app: queue-worker}
    spec:
      terminationGracePeriodSeconds: 5
      containers:
        - name: worker
          image: redis:7.4.2-alpine
          command: ["sh", "-ec"]
          args:
            - |
              while true; do
                task=$(redis-cli -h redis --raw LPOP jobs)
                if [ -n "$task" ]; then
                  echo "$(date -u +%FT%TZ) processing $task"
                  sleep 2
                  redis-cli -h redis RPUSH completed "$task" >/dev/null
                  echo "$(date -u +%FT%TZ) completed $task"
                else
                  sleep 1
                fi
              done
          resources:
            requests: {cpu: 50m, memory: 32Mi}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: queue-worker
  namespace: hpa-zero
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: queue-worker
  minReplicas: 0
  maxReplicas: 4
  metrics:
    - type: External
      external:
        metric:
          name: queue_depth
          selector:
            matchLabels: {queue: jobs}
        target:
          type: Value
          value: "10"
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 30
EOF

这里用 LPOP 模拟消费,仅验证伸缩效果。任务出队后,若 Pod 在处理完成前退出,任务可能丢失;实际业务需要消息确认、失败重投和优雅退出,队列长度也不包含正在执行的任务。

本例使用 Value: 10 验证从零恢复:指标为 40 时,计算出的期望副本数为 4。这不表示始终按“每 10 个任务一个 Pod”伸缩,非零副本时还会考虑 Ready Pod 数量。按每副本平均积压量设置目标,应使用 AverageValue。

为了少等一会儿,把缩容稳定窗口改成了 30 秒,默认是 5 分钟。实际缩容还受指标刷新和 HPA 同步周期影响,不会刚好卡着队列清空后的第 30 秒发生。

从零副本到自动唤醒

先不放任务,看下 HPA:

1
kubectl get hpa -n hpa-zero -w

另一个终端观察消费者:

1
kubectl get deploy,pods -n hpa-zero

HPA 会把初始的 1 个消费者缩成 0,等 Pod 全部退出后,查看 Condition:

1
kubectl describe hpa queue-worker -n hpa-zero

关键状态如下:

1
2
3
Type:    ScaledToZero
Status:  True
Reason:  ScaledToZero

ScaledToZero=True 表示 HPA 自己将副本缩到了零,后续会继续评估指标。手动将 Deployment 缩到零则保留暂停语义,不等同于这次自动归零。

接下来直接放 40 个任务,不用手动启动消费者:

1
2
kubectl exec -n hpa-zero deploy/redis -- redis-cli EVAL \
  'for i=1,40 do redis.call("RPUSH","jobs","task-"..i) end return redis.call("LLEN","jobs")' 0

这次 HPA 把期望副本数从 0 调成了 4,消费者启动后开始处理任务。唤醒之后,ScaledToZero 变成 False,Reason 是 NotScaledToZero。

等消费完成,核对一下队列和完成数量:

1
2
3
4
$ kubectl exec -n hpa-zero deploy/redis -- redis-cli LLEN jobs
0
$ kubectl exec -n hpa-zero deploy/redis -- redis-cli LLEN completed
40

40 个任务都处理完了。再等缩容稳定窗口过去,消费者重新缩成 0,Pod 全部退出,ScaledToZero 也重新变成 True。

完整过程就是 1 → 0 → 4 → 0:

阶段消费者任务状态
空闲自动缩到 0队列为空
放入 40 个任务起初为 040 个任务等待处理
HPA 读到新指标扩到 4,随后全部就绪开始消费
消费完成暂时保留副本队列为 0,完成数为 40
稳定窗口过后再次自动归零消费者 Pod 全部退出

4. 使用上的取舍

Gang Scheduling 和 HPA Scale to Zero 这次两个特性,都是把生态里使用较多的能力进一步补进 Kubernetes 原生体系。简单场景有了更直接的选择,而 Volcano 的队列和资源管理、KEDA 的事件源适配、Knative 的请求接入仍各有用途。是否替换现有方案,还是要看实际需要哪些能力。

0%