Apache YuniKorn技术深潜 封面
DAT401 · Technical Deep Dive · 2026-09
Containers & Data Analytics
Apache YuniKorn 技术深潜
把 YARN 的队列语义搬到 Kubernetes:它解决什么、怎么工作、在 EKS 上怎么协同,以及在什么规模下才值得做。
问题 › 机制 › AWS 协同 › 决策 Why · How · On EKS · Decide
Neo SUN
Solutions Architect Amazon Web Services
Agenda · 本次分享的四条主线

四章分别回答四个问题 为什么 · 怎么工作 · 在 AWS 上怎么用 · 该不该做

为什么需要它

The Problem
  • 批处理 vs 在线服务
  • 资源死锁
  • 它的能与不能

它怎么工作

Mechanism
  • 队列 · Gang · 抢占
  • 调度循环与吞吐模型
  • 三个静默失效的坑

在 AWS 上怎么用

On EKS
  • EMR on EKS 官方路径
  • Karpenter 协同真相
  • 生态集成矩阵

怎么决策

Decide
  • 成本模型与盈亏平衡
  • 选型对比
  • 迁移剧本

How to read this deck · 怎么用这份材料

  • 如果你的问题是「Spark 作业互相等死」,看第 章的 Gang 部分——那是它最硬的价值。
  • 如果你的问题是「多团队抢资源」,看第 章的队列与抢占,以及第 章的身份链路。
  • 如果你要决定做不做,可以直接跳到第 章——那里有盈亏平衡点,而它可能会劝你别做。
Apache YuniKorn 技术深潜 · Agenda04 章 · 46 页 · 50 分钟02
01
The Problem
为什么需要它
这一章不讲 YuniKorn,只讲问题。如果这些问题你没有,后面三章都不用听。
批处理与在线服务的分歧 资源死锁 ResourceQuota 不是配额 它的能与不能
Apache YuniKorn 技术深潜
03
Ch.1 · 为什么需要它 · First Principle

批处理与在线服务对调度器的要求是相反的 这是抽象层次的错配,不是性能问题

维度在线服务(Deployment)批处理(Spark / 训练作业)
调度单位单个 pod,彼此独立一组 pod,必须同生共死
资源不足时扩容或降级,pod 可以一直 Pending必须排队,而且要公平地排
生命周期长期运行,几乎不结束分钟到小时,跑完就走
延迟目标单 pod 调度延迟要低整个作业的完成时间要短,单 pod 慢一点无所谓
资源利用目标留余量应对流量峰值尽可能榨干,别浪费
失败语义重启这个 pod一个成员失败可能整个作业重来
所以问题不是「默认调度器写得不好」——它为左边那一列设计,而且设计得很好。 问题是它的调度单位(pod)与批处理的调度单位(作业)不匹配。这是抽象层次的错配,调参解决不了。
结论:kube-scheduler 的核心循环是「取一个 pod → 过滤节点 → 打分 → 绑定」,这个循环里没有任何「作业」的概念
做法:判断自己要不要换调度器,先问一句:我的负载的最小有意义单位是一个 pod,还是一组 pod?
注意:如果你的批处理是「很多互相独立的单 pod 任务」,那你在左边这一列,不需要 YuniKorn
Ch.1 · 为什么需要它 — 第一性原理04
Ch.1 · 为什么需要它 · Deadlock

失效一:资源死锁 集群 100% 满载,零个作业能推进

默认调度器 · 5 个 Spark 作业进同一个 10 slot 集群
T0:5 个 driver 全部被调度成功 → 占 5/10
T1:每个 driver 各申请 4 个 executor → 需要 20 slot,只剩 5
T2:5 个剩余 slot 被瓜分(各拿 1 个)
T3:每个作业都在等剩下 3 个 executor → 集群满载、零进展、没有任何 pod 会自己退出
YuniKorn Gang 调度 · 同样的 5 个作业
按 taskGroups 声明的 minMember × minResource 先创建 placeholder pod 把坑占满;
占满了才把真实 pod 换进去;占不满就整体排队
⇒ 第 2 个作业根本拿不到 driver 的坑,它会排队
⇒ 第 1 个作业能完整跑完,然后释放全部资源给第 2 个
100%
死锁时的集群占用率
0
死锁时推进的作业数
人工
默认调度器下的唯一解法

这里有一个反直觉的结论,也是整份材料最值得带走的一句

  • 限制并发反而提高吞吐。Gang 让第 2 个作业排队,看起来是「少跑了一个作业」,实际是消除了死锁——这是排队论里的经典结果。
  • Gang 也会引入新的死锁形态:如果队列配额本身就小于 gang 的最小需求,它永远凑不齐。YuniKorn 用「入队前静态可行性检查」来防这个,第二章会给实测证据。
Ch.1 · 为什么需要它 — 资源死锁05
Ch.1 · 为什么需要它 · Quota Semantics

失效二:ResourceQuota 不是配额,是准入拒绝 最被低估的一条差异

维度K8s ResourceQuotaYuniKorn 队列
超额时会怎样 pod 创建失败准入期拒绝,kubectl apply 直接报错 pod 创建成功,在队列里排队等
结构 扁平,一个 namespace 一份 层级树,父队列约束所有子孙之和
由谁执行准入控制器(apiserver)调度器(调度时判断)
有「顺序」概念吗 没有,谁先 apply 谁占 FIFO / Fair / 优先级排序
能借用空闲容量吗 不能 guaranteed 之上、max 之内可借
能保障最低量吗 只有上限,没有下限 guaranteed 是抢占的锚点
「配额」这个词在两个系统里含义不同。K8s 的是硬门禁(超了不让进), YuniKorn 的是调度约束 + 排队(超了就等)。批处理平台需要的是后者——你不会希望半夜的 ETL 因为白天的作业还没跑完就 apply 失败。
YuniKorn 自己的原话:"In most of the cases, YuniKorn queue can be used to replace the namespace resource quota, in order to provide more scheduling features."
注意:两者可以并存,而且并存时 K8s 的 ResourceQuota 先生效(它在准入期)。混用会得到「YuniKorn 说可以排队、apiserver 说不许创建」的矛盾。要么迁移,要么放宽 ResourceQuota。
Ch.1 · 为什么需要它 — ResourceQuota 语义06
Ch.1 · 为什么需要它 · The Other Two & The Answer

另外两个失效,以及 YuniKorn 给出的三个答案 公平性 · 作业级能力

失效三 · 租户之间没有公平性

默认调度器没有「租户」概念,也没有「这个用户已经比别人用得多」这种状态。 一个配错的作业能吃掉整个 namespace,把同 namespace 的其他作业饿死——而这不会触发任何告警,因为从 K8s 视角看一切正常。

失效四 · pod 级视角看不到「作业」

一旦调度器有了 Application 这个一等对象,下面这些能力才成为可能: Gang(要知道这一组 pod 属于同一作业且最少要 N 个)、作业级排队与排序、作业级配额(maxapplications)、 动态优先级、历史用量追踪。这些不是 feature 清单,是同一个抽象带来的推论。

答案 ①
层级队列

解决配额与公平

  • 任意深度的队列树 + guaranteed / max
  • 队列 / 应用 / 用户三层公平共享
  • ACL 多租户 + 放置规则自动路由
答案 ②
Gang 调度

解决资源死锁

  • placeholder 先占位,凑齐再换手
  • 凑不齐则整体排队或整体失败
  • 实测:占位 2.1 秒、换手 0.15 秒
答案 ③
抢占

让保障量真的兑付

  • 七条定律,锚点是 guaranteed
  • 实测:精确停在 guaranteed,不多抢
  • 可用围栏限制在租户子树内
Ch.1 · 为什么需要它 — 另外两个失效与三个答案07
Ch.1 · 为什么需要它 · Honest Boundary

它做不到什么 这一页决定后面三章的可信度

❌ 它不能做(全部有官方文档或源码依据)

  • 拓扑 / NUMA / NVLink 感知调度 —— GPU 大规模训练的关键能力,它没有,路线图上也没有
  • 多集群 —— 一个调度器 = 一个 shim = 一个集群 = 一个 partition,硬编码(K8s shim 只支持 default,YUNIKORN-22)
  • DAG 依赖编排 —— 「A 完成后起 B」是 Argo / Airflow 的职责
  • 任何写操作 API —— 25 条路由里唯一非 GET 的是 POST /ws/v1/validate-conf,只做校验 ⇒ 配额变更只能改 ConfigMap,GitOps 是唯一正规通道
  • 主备高可用 —— 单副本 + 重启后状态重建,没有 leader election
  • 闲置回收 —— notebook 挂着不用照样占配额,那是别的工具的事
  • Red Hat 认证 / OperatorHub 上架 —— YUNIKORN-354「改用 UBI 基础镜像」= Closed / Won't Fix

⚠️ 三条「看起来像 bug 其实是设计」

  • 装上之后所有 pod 的 schedulerName 都被改写 —— mutating webhook 默认作用于全集群所有工作负载类型,包括显式写了 default-scheduler。这是设计行为,第三章会讲唯一的正确出口。
  • 抢占之后有一批 pod 永远 Pending —— 官方设计文档专门分析过 ReplicaSet 场景并预期了这个结局。它防住了抢占循环,代价是永久 Pending。
  • 调度器挂着的时候新 pod 全部 Pending —— 已运行的 pod 不受影响(它们已 bind)。这是单副本模型的必然,不是故障。
对客户的正确表述:它是单副本 + 快速重建模型,不是 active-active。 不要说成「高可用」。
Ch.1 · 为什么需要它 — 诚实边界08
02
Mechanism
它怎么工作
这一章全部有源码或实测支撑。三个「会静默失效」的坑都在这里。
三仓库一条 gRPC 边界 队列模型 Gang 调度 抢占七定律 可观测性
Apache YuniKorn 技术深潜
09
Ch.2 · 它怎么工作 · Architecture

三个仓库,一条 gRPC 边界 这个边界带来的收益与代价

三件套与职责(官方原话摘要)

组件Go 行数职责
yunikorn-core87,925全部调度算法;与平台无关,只通过 SI 通信
yunikorn-k8shim53,063与 K8s 对话:informer、翻译资源与请求、把 pod bind 到节点、admission controller
yunikorn-scheduler-interface3,469抽象层,gRPC / protobuf。只有 Go binding
yunikorn-release2,487Helm chart(values.yaml 仅 166 行)
核心两件合计140,988
口径提醒:官方自称 "light-weight",指的是运行时开销(镜像 35.5 MB、requests 200m CPU), 不是代码规模。核心逻辑 8.8 万行 Go。演讲时别把这两件事混着讲。

SI 这层的收益与代价

  • ✅ 收益 ① core 完全不依赖 K8s 类型,可独立单测
  • ✅ 收益 ② 平台细节(informer / bind / webhook)全在 shim,边界清晰
  • ✅ 收益 ③ 理论上可换平台
  • 🔴 代价 ① 一个改动要跨三个仓库:改 SI protobuf → 重新生成 binding → core 与 shim 各自适配、各自发版
  • 🔴 代价 ② 多一次序列化 / 反序列化
  • 🔴 代价 ③ 排障要跨两个进程边界看日志
「universal」到哪一步了:架构上留了 shim 抽象, 但实际只有一个 shim(Kubernetes)。PMC 成员原话: "We focused purely on the Kubernetes side of things. We left the Yarn thing to the side."
Ch.2 · 它怎么工作 — 架构10
Ch.2 · 它怎么工作 · Scheduling Cycle

每个调度周期只产出一个 allocation 理解吞吐的唯一钥匙 · 源码级

// core/pkg/scheduler/context.go:120 —— 每个 partition 一个 result
func (cc *ClusterContext) schedule() bool {
  for _, psc := range cc.GetPartitionMapClone() {
    // ① 预留优先
    result := psc.tryReservedAllocate()
    if result == nil {
      // ② placeholder 置换(gang swap)
      result = psc.tryPlaceholderAllocate()
      if result == nil {
        // ③ 常规分配
        result = psc.tryAllocate()   // ← 返回单个 result
      }
    }
    ObserveSchedulingLatency(schedulingStart)
    if result != nil { /* 通知 RM */ activity = true }
  }
  ObserveSchedulingCycle(scheduleCycleStart)
  return activity
}

三条推论

  • K8s shim 只支持一个 partition(YUNIKORN-22)⇒ 每周期全集群一个 allocation
  • 吞吐 = 1 / 单周期耗时。官方文档独立印证:周期延迟落在 0.001–0.01 秒 ⇒ 100–1000 allocations/s
  • 官方还指出:predicate 评估占了一个周期的绝大部分,是其他部分的 10 倍
做法:因此最该盯的指标是 yunikorn_scheduler_scheduling_cycle_milliseconds。它从 ~2ms 涨到 ~20ms,吞吐就从 ~600/s 掉到 ~50/s。
顺序有讲究:placeholder 置换的优先级高于新的常规分配—— 已凑齐的 gang 应尽快换手,不该让新来的普通 pod 抢走它占好的坑。
Ch.2 · 它怎么工作 — 调度主循环11
Ch.2 · 它怎么工作 · Queue Model

guaranteedmax:两个数字定义整个多租户 90% 的实际价值来自把这棵树设计对

语义(官方原话)

guaranteed(min)max(quota)
作用参与份额计算与分配决策该队列任意时刻所有分配的硬上限
与抢占抢占的锚点:队列不可跌破它与抢占无关
设成 0无保障禁止该队列使用该资源(如 nvidia.com/gpu: 0
root 队列root 不能设任何资源限制,设了 parsing error。它的 max 自动等于集群大小
实测原文:给 root 设 resourcesPOST /ws/v1/validate-conf 直接返回 {"allowed":false,"reason":"root queue must not have resource limits set"}

一条最容易配错的设计原则

≤ 容量
Σ guaranteed
> 容量
Σ max

guaranteed 之和应 ≤ 集群容量(否则保障是空头承诺); max 之和可以、且通常应该 > 集群容量——这正是「允许借用空闲容量」的表达方式。

反面:如果你把所有队列的 max 加起来正好等于集群容量, 你就退化成了静态切分,丢掉了 YuniKorn 相对「多个专用集群」的主要好处——而那个好处占成本节省的约 70%(第四章)。
做法:max 通常安全(只放宽借用上限); 调 guaranteed改变抢占行为。优先调 max,谨慎调 guaranteed。
Ch.2 · 它怎么工作 — 队列模型12
Ch.2 · 它怎么工作 · Trap #1

坑一:同一套系统里有两套资源命名 写错不报错、不生效

写在哪CPU 该写什么依据写错的后果
队列配置 resources.guaranteed / .max / limits.maxresources vcore 官方队列配置页全部示例都是 vcore: 10;core 内部常量 CPU = "vcore" cpu ⇒ 被当成「一个名叫 cpu 的自定义资源」⇒ 对真实 CPU 完全不限流
Pod 注解 task-groupsminResource cpu 官方 gang 页示例是 "cpu": "100m"(这里是 K8s 资源名,由 shim 翻译) vcore ⇒ 同样不报错、同样不生效
Pod 的 resources.requestscpuK8s 原生规范
 官方原话(这是唯一的例外说明):"YuniKorn tracks CPU resources internally as the vcore resource type. This maps to the Kubernetes resource type cpu. All other resource types have consistent naming."
单位后缀 k/M/G/T/P/E(10 的幂)与 Ki/Mi/Gi/Ti/Pi/Ei(2 的幂); vcore 可用 m 表示 millicorememory 默认单位是字节
🔴 1.0 起的行为变更 1.0 之前 memory 按 100 万字节解释、vcore 按 millicore 解释 ⇒ 从 0.x 迁上来的旧配置,数值含义会整体漂移,必须重算
为什么这个坑特别危险:不报错。队列看起来配好了、API 里能读回来、pod 正常 Running—— 只有当某天一个作业吃掉整个集群时你才会发现那个配额从来没生效过。 ⚠️ 而且不能简化成「一律用 vcore」——那样讲会让听众把 gang 注解写错。必须讲清是分场景的。
Ch.2 · 它怎么工作 — 坑一:两套资源命名13
Ch.2 · 它怎么工作 · Trap #2 · 头号陷阱

坑二:放置规则短路 ⇒ 你的整套配额被静默绕过 而这就是官方示例的默认写法

# 官方文档给 tag 规则的示例,一字未改
placementrules:
  - name: tag
    value: namespace
    create: true

# 官方原话:
# "The order that the rules are defined in is the
#  order in which they are executed. If a rule
#  matches the policy will STOP executing the
#  remaining rules."
修法(三道防线全上):provided 规则放最前 + create: false; ② root.childtemplate 兜底给动态队列一个上限; ③ 巡检 isManaged==false

✅ 实测:它做了什么

配好 root.{prod,dev,streaming}root.devmax_vcore=3000。提交一个 minMember=8 × 500m = 4000m 的 gang, pod 上明确打了 queue: root.dev预期被配额拦住。实际 8 个 pod 全部 Running。

队列isManagedmax_vcorealloc
root.prodtrue80000
root.devtrue30000 ← 指定的队列,一点没用上
root.streamingtrue40000
root.gang-hardfalseunlimited4000

根因四步:① 规则链只有 tag,它第一个就匹配 ⇒ ② pod 上的 queue label 从未被查看 ⇒ ③ create:true 用 namespace 名自动创建root.gang-hard ⇒ ④ 该动态队列 isManaged=false、max 为 unlimited,继承 root ⇒ 等于没有配额

诚实划界(必须说清,否则是冤枉文档):官方文档对机制的描述完全正确——短路求值、create 语义、动态队列都写了。 问题是它从未说明后果:不会说这让 queue label 失效,也不会说自动创建的队列无配额。而它给的示例恰恰就是这个危险组合。 ⇒ 正确说法是:「文档没写错,但它没告诉你后果。」
Ch.2 · 它怎么工作 — 坑二:放置规则14
Ch.2 · 它怎么工作 · Gang Scheduling

Gang:先用 placeholder 占坑,再换成真实 pod 三个阶段 · 三个注解

① PREPARE
创建 placeholder

按每个 taskGroup 的 minMember 创建等量 pause pod,资源精确等于 minResource

② RESERVE
占位

placeholder 是真实 K8s pod,只跑 pause,被 kubelet 真正启动,占住节点上的坑

③ SWAP
换手

凑齐 minMember ⇒ 发 RunApplication ⇒ 真实 pod 换进 placeholder 占好的位置

✗ TIMEOUT
凑不齐

Hard ⇒ 整个应用失败;Soft(默认) ⇒ 退化成普通逐 pod 调度

三个注解(缺一不可)

注解放在哪
yunikorn.apache.org/task-groups只放应用的第一个 pod(Spark 场景 = driver);若无「第一个」概念则所有 pod 都带同样内容
.../task-group-name每个 pod 都要,声明自己属于哪个组
.../schedulingPolicyParameterstask-groups 同一个 pod。空格分隔的 k=v
没有集群级开关:官方原话 "There is no cluster-wide configuration needed to enable Gang Scheduling." 调度器看到有效的 taskGroups 就走 gang 路径。

怎么划分 task group

判据(官方):「同资源规格 + 同放置约束」为一组—— 不是按角色分组。如果 executor 分大小两档,那就是两个 task group。

框架分组minMember
Sparkdriver + executor1 + 最小 executor 数
FlinkJobManager + TaskManager1 + 最小并行度
Rayhead + worker1 + 最小 worker 数
MPIlauncher + worker1 + 全部(最典型的 all-or-nothing)
Ch.2 · 它怎么工作 — Gang 机制15
Ch.2 · 它怎么工作 · Measured

✅ 实测:placeholder → 真实 pod 的完整时序 0.5 秒间隔紧密轮询 · 本地 kind 集群

时刻Δ(T0)观测到什么
19:32:00.15T0kubectl apply 发出(minMember=4 × 500m = 2000m,队列 max 4000m 空闲)
19:32:00.52+0.37s4 个真实 pod 创建完毕,全部 Pending
19:32:02.68+2.53s🔵 4 个 placeholder 出现tg-gang-fit-app-worker-group-*),全部 Pending
19:32:04.04+3.89s第 1 个 placeholder 变 Running
19:32:04.74+4.59s🔴 4 个 placeholder 全部消失,第 1 个真实 pod Runningswap 完成
19:32:05.44+5.29s2 个真实 pod Running
19:32:06.15+6.00s4 个真实 pod 全部 Running
6.0 s
提交 → 全部 Running
2.1 s
placeholder 存活时长
~0.15 s
swap 耗时
🔴 一个必须知道的观测陷阱:placeholder 只活 2.1 秒。 我们第一次用 8 秒间隔看 kubectl get pods完全没看到 placeholder,一度误判为「没有创建 placeholder」。 ⇒ 要看到它,必须用 kubectl get pods -w 或亚秒轮询,或者故意让 gang 装不下。 ⇒ swap 能这么快的原因是 placeholder 的 terminationGracePeriodSeconds: 0——删除时不等优雅退出。
Ch.2 · 它怎么工作 — 实测 swap 时序16
Ch.2 · 它怎么工作 · Trap #3 · Hard vs Soft

坑三:默认是 Soft,而 Soft 会给你「半个 gang」 ✅ 实测 A/B:只改一个参数

行为HardSoft(默认)
入队时 gang 需求 > 队列 max立即 ApplicationRejected,不建 placeholder相同(该检查与 style 无关)
超时后应用状态Failed,从队列消失Running
超时后pod状态全部 Failed装得下的 Running,其余 Pending
pod 的 .status.reasonResourceReservationTimeout无(走正常调度)
关键 event(实测原文)ApplicationFailedresuming as non-gang application (SOFT)
语义全有或全无,宁可失败尽力而为,允许部分启动
适合Spark / MPI / 分布式训练——凑不齐就没意义的作业可弹性伸缩、能容忍部分副本的负载
🔴 需要真 gang 的作业必须显式写 gangSchedulingStyle=Hard 默认值 Soft 对「真 gang」场景是错的默认值——它会产生「2 个 Running + 2 个 Pending」: 占着资源、干不了活、还不报错(应用状态是 Running,监控上一切正常)。
实测时序(Hard):T+48s 仍在等 → T+63s 全部 Failed。 设定 60 秒,吻合。超时后队列立刻归还全部资源。
另一个坑:参数名是 placeholderTimeoutInSeconds(有 In)。 键写错会被静默忽略——零日志、零告警,然后套用默认值 15 分钟。第三章有一个真实受害者。
Ch.2 · 它怎么工作 — 坑三:Hard vs Soft17
Ch.2 · 它怎么工作 · Preemption

抢占:七条定律 设计来自 Apache YARN 的经验 · 锚点是 guaranteed

七条定律(官方原文编号)

  1. 抢占策略是强建议,不是保证(DaemonSet 场景会破例)
  2. 抢占不能把受害队列压到 guaranteed 以下
  3. 任务不能抢占同一应用内的任务(目前只做队列间抢占
  4. 队列不低于 guaranteed不能触发抢占
  5. 队列不高于 guaranteed不能被抢
  6. 只能抢占优先级更低或相等的任务(K8s 只允许更低
  7. 不能抢占围栏之外的任务(单向围栏)
目标定调(官方):"Preemption is used to get the usage of a queue to at least its guaranteed resource capacity. … Preemption is not the tool to get a queue to its full maximum."
🔴 定律 4 的直接后果: 如果你不设 guaranteed,抢占基本什么都不会做——因为「队列低于 guaranteed」这个前提永远不成立。 这是「抢占配了没反应」的第一大原因。
🔴 定律 2 的反直觉后果: 队列虽然超了 guaranteed,但如果它上面每个 pod 都很大(杀掉任何一个都会跌破 guaranteed), 那它一个 pod 都不会被抢。⇒ pod 粒度越粗,抢占越难生效。
🔴 定律 3 的配置含义: 同队列内高优抢不了低优。想让高优作业压过低优,必须放在不同队列(用 priority.offset 区分), 不是靠同队列内的 PriorityClass。这是很常见的配置错误。
方向别搞反: preemption.policy: fence = 我不能往外抢disabled = 我不能被抢想保护在线服务,用 disabled,不是 fence
Ch.2 · 它怎么工作 — 抢占七定律18
Ch.2 · 它怎么工作 · Measured

✅ 实测:抢占真实发生,且精确停在 guaranteed 4 个 pod 被抢 · 完整取证

布置:怎么在「不缺资源」的集群上造出稀缺

难点:集群有 89 core,随便提交都能满足,永远不会触发抢占。 而 root 队列不允许设 resources,所以「给全集群设总闸」这条路堵死。
解法:插一个带 max 的中间父队列。

root
└── tenant          max = 4 vcore  ← 稀缺点
    ├── hi   guar 2, offset +1000, delay 10s
    └── lo   guar 1, offset −1000

先让 lo 提交 8 × 500m = 4000m,正好吃满 tenant
lo alloc=4000 / guar=1000 ⇒ 超出,可被抢
hi alloc=0 / guar=2000 ⇒ 低于,可触发
这正是定律 4 + 5 的教科书式配置。

结果

时刻lohi
T+10s8 Running4 Pending(pending=2000)
T+25s4 Running / 4 Pendingalloc 4000→20004 Runningalloc 0→2000
T+45s ~ T+130s状态稳定,不再变化
🔵 关键观察:hi 拿到恰好 2000m(= 它的 guaranteed)就停了, 没有继续往 max=4000 抢完美印证「抢占只回到 guaranteed,不冲 max」。
🔴 连带问题:被抢占的 4 个 pod 被删后, ReplicaSet 立刻补了 4 个新 pod,而它们永远 Pending(持续到实验结束)。 ⇒ 抢占在 Deployment 语义下不是「缩容」,是「制造永久 Pending」。
Ch.2 · 它怎么工作 — 抢占实测19
Ch.2 · 它怎么工作 · Observability Gaps

抢占的两个观测缺口 都是实测发现的 · 都影响审计

缺口一 · K8s event 会丢,YuniKorn 事件流不丢

1 / 4
kubectl get events 剩下
4 / 4
/ws/v1/events/batch

4 个 pod 被抢占,kubectl get events只剩 1 条 Preempted by … (其余随 pod 删除 / event TTL 消失),而 YuniKorn 的事件 API 里 4 条全在

做法:抢占审计 / 计费 / 事后复盘必须/ws/v1/events/batch,不能依赖 kubectl get events。 事件四要素齐全:被抢的 allocation UUID、受害应用、抢占方的 request UUID + 应用名 + 队列名

缺口二 · 抢占方不知道自己抢了别人

# 抢占方 pod 的完整 event(实测)
Normal  Scheduling         is queued and waiting…
Normal  Informational      Request '…' does not fit
                           in queue 'root.tenant.hi'
                           ← 中间「我抢占了别人」完全没有记录
Normal  Scheduled          Successfully assigned…
Normal  PodBindSuccessful  is successfully bound…
后果:只有受害者有 Preempted by 事件。 能查「谁杀了我」,查不到「我杀了谁」。排查「这个作业抢了谁」只能从受害方事件反查 (按抢占方的 request UUID 在事件流里搜)。
还有一条相关的:「配额不足在排队」这个事件的级别是 Normal 而不是 Warning只报 Warning 的告警规则会漏报配额不足。
Ch.2 · 它怎么工作 — 观测缺口20
Ch.2 · 它怎么工作 · Trap #4 · 当前 GA 上的活跃 bug

坑四:v1.9.0 的 /ws/v1/metrics双重 gzip 默认的 Prometheus 抓取全部失败,且静默

版本gzip.goContent-Encoding 出现次数
v1.7.0不存在无中间件,不受影响
v1.8.0不存在不受影响
v1.9.0(当前 GA)存在1 只 Set,从不读 ⇒ 无保护
master(→1.10.0)存在7 含穿透保护 ⇒ 已修
因果链:/ws/v1/metricspromhttp.Handler() 提供, 它自己已经 gzip 并设了 Content-Encoding: gzip;1.9.0 的中间件不检查就再压一次, 而 header 仍只宣告一层编码 ⇒ 客户端按一层解压 ⇒ 拿到 gzip 帧不是指标文本。 Prometheus 默认发 Accept-Encoding: gzip ⇒ 指标静默消失、target 掉线。
// master 的修复注释(它自己把失败模式讲清了)
// A handler is allowed to encode the response body
// itself … Compressing again would produce a doubly
// encoded body advertised by a single Content-Encoding,
// which NO CLIENT CAN DECODE. Hand the response over
// untouched instead.
if !d.decided && d.Header().Get("Content-Encoding") != "" {
  d.passThrough()
}

# 缓解 ①:原生 Prometheus
- job_name: yunikorn
  enable_compression: false

# 缓解 ②:prometheus-operator
spec:
  endpoints:
    - port: yunikorn-core
      path: /ws/v1/metrics
      enableCompression: false
影响面:只有这一个端点。 只有它的 handler 预先编码过;/ws/v1/events/stream 被中间件按精确路径排除; 其余路由主动加 --compressed 反而有益(因为 API 没有分页)。
Ch.2 · 它怎么工作 — 坑四:metrics 双 gzip21
03
On Amazon EKS
在 AWS 上怎么用
AWS 是唯一把 YuniKorn 写进产品文档的公有云。但它是「官方记录、自行运维」——而与 Karpenter 的协同靠的是一个未成文的魔法字符串。
AWS 的官方立场 EKS 版本矩阵 Karpenter 协同 生态集成矩阵
Apache YuniKorn 技术深潜
22
Ch.3 · 在 AWS 上怎么用 · Vendor Position

AWS 是唯一把它写进产品文档的公有云 但是「官方记录、自行运维」

云 / 平台官方推荐的批调度方案官方文档提及 YuniKorn托管组件
AWS EKS / EMR on EKSYuniKorn 与 Volcano 并列,各有一篇官方 tutorial✅ 有专门教程页❌ 非 add-on,仍装社区 Helm chart
阿里云 ACK自研增强 kube-scheduler(内建 Gang + Capacity)⚠️ 仅「计算巢」货架,不在 ACK 组件中心⚠️ 中立货架,非产品团队背书
腾讯云 TKE自研 Extender 系插件❌ 未找到
华为云 CCEVolcano(一等公民;xGPU 虚拟化硬依赖 Volcano ≥1.10.5)❌ 未找到
火山引擎 VKE自研增强调度器 + Kueue❌ 未找到
Google GKEKueue(拓扑感知调度 TAS 直接建在其 CRD 上)site:cloud.google.com yunikorn 返回 0 条
Azure AKSKueue(≥4 篇官方文档)❌ 未找到
Red Hat OpenShift无支持声明⚠️ 仅 YuniKorn 自己的本地开发指南无 Operator、无 UBI 镜像
Cloudera CDP / CDEYuniKorn(唯一把它做成商业产品核心的厂商)✅ CDE 由 YuniKorn 驱动
AWS 给了什么:EMR on EKS 官方教程(Spark Operator + spark-submit 两条路径)、 AWS Big Data 博客、aws-samples 示例仓库、awslabs/data-on-eks 参考架构(无条件安装 YuniKorn)。
AWS 没给什么:❌ 不是 EKS add-on ❌ 无托管版本 ❌ 无支持 SLA ❌ 从未发布任何 YuniKorn 的性能数字 ❌ 官方教程未覆盖 StartJobRun API 路径。
三阵营看清谁在推: YuniKorn = Cloudera(娘家)+ AWS;Volcano = 华为 + CNCF + Spark 主干内建; Kueue = Google + Microsoft + 字节 + K8s SIG 官方子项目(动能最强)。
Ch.3 · 在 AWS 上怎么用 — AWS 的官方立场23
Ch.3 · 在 AWS 上怎么用 · Version Matrix

EKS 版本 × YuniKorn 版本:有一个现存缺口 而且它会在每个 EKS 小版本上重演

EKS K8sEKS 支持状态最低 YuniKorn(官方矩阵)结论
1.36标准支持无 —— 没有任何 GA 版本声明支持 1.36🔴 缺口。只能:① 非官方地跑 1.9.0(官方说它已通过 e2e 测试,只是不在矩阵里)② 跑 1.10.0-beta
1.35标准支持1.9.0✅ 完美匹配,跑 1.9.0
1.34标准支持1.8.0✅ 推荐 1.9.0
1.33扩展支持1.8.0🟡 可跑,但没有「精确匹配」的构建
1.32扩展支持1.7.0🟡 1.7.0 精确匹配,但已落后两个小版本、不再收补丁
1.31扩展支持(2026-11 到期1.6.0🔴 精确匹配的 1.6.3 已 EOL;且 AWS 会自动升级该集群
 YuniKorn 的支持窗口极宽:K8s 1.24 至今未终止支持。所以问题从来不是「太老不支持」,而是「太新还没声明」。
为什么会重演:EKS 大约在上游发布后 ~4 个月跟进一个新 minor; YuniKorn 大约 一年 2 个 minor(实算:近三次间隔 10.4 / 6.2 / 5.8 个月) ⇒ EKS 总会先于 YuniKorn 声明支持某个 K8s 版本。
做法:不要用 EKS 的最新 minor 跑 YuniKorn,等矩阵覆盖它; ② 升级顺序:先升 YuniKorn,再升 EKS(它向后兼容窗口宽,反过来会出现空档)。
本地也踩过同一个坑:我们实测时 kind v0.33.0 默认给的是 K8s v1.37.0, 超出支持上限,必须显式 pin kindest/node:v1.33.4「自动升级把你推到未测试版本上」这个模式在托管 K8s 上同样成立。
Ch.3 · 在 AWS 上怎么用 — 版本矩阵24
Ch.3 · 在 AWS 上怎么用 · Karpenter · 机制真相

整个协同就是一个魔法字符串 我 clone 了 Karpenter 源码复核过

// kubernetes-sigs/karpenter/pkg 下 grep schedulerName
// (排除测试)⇒ 命中数 0。它结构上不认调度器。

// pkg/utils/pod/scheduling.go —— 唯一的门
func IsProvisionable(pod *corev1.Pod) bool {
  return FailedToSchedule(pod) &&
    !IsScheduled(pod) && !IsPreempting(pod) &&
    !IsOwnedByDaemonSet(pod) && !IsOwnedByNode(pod)
}

func FailedToSchedule(pod *corev1.Pod) bool {
  for _, c := range pod.Status.Conditions {
    if c.Type == corev1.PodScheduled &&
       c.Reason == corev1.PodReasonUnschedulable {
      return true   // ← 就这一个字符串
    }
  }
  return false
}

🔵 而 YuniKorn 刻意写两种 Reason

pod 为什么 pendingYuniKorn 写的 ReasonKarpenter 扩容?
队列配额超了SchedulingSkipped❌ 不会门匹配不上
集群容量真的不够Unschedulable✅ 会

源码注释原文——意图写得一字不差
· 配额超了:// we do not trigger the auto-scaling
· 容量不够:// set pod condition to Unschedulable in order to trigger auto-scaling

⇒ 结论要说准:这不是「两个系统互不知情、碰巧能用」, 而是一个未成文但设计正确的隐式契约。「队列限额」与「节点弹性」两套限流器被正确衔接了—— 不会为一个队列本来就不让跑的作业去买节点。
但它仍然脆弱:无 API 定义、双方文档都没写、无跨项目集成测试。 Volcano 就曾把这个 Reason 字符串写错,导致 Karpenter 完全不为它扩容,直到 volcano-sh/volcano#2602 修好。这是发生过的真事。
Ch.3 · 在 AWS 上怎么用 — Karpenter 机制真相25
Ch.3 · 在 AWS 上怎么用 · Karpenter · Consolidation

Karpenter 的默认配置会驱逐运行中 gang 的成员 五种保护机制,按强度排序

为什么会发生

# Karpenter 的默认值(kubebuilder 默认)
spec:
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 0s   # 零宽限期

官方对该策略的定义:"Any node that can be removed or replaced to reduce cost / … willing to accept the pod disruption."
失败模式:Karpenter 驱逐一个 executor → gang 已经 Running、placeholder 已消失 → YuniKorn 把它当普通 ask 重新调度 → 不会重新走 all-or-nothing → 若无 headroom,作业永远卡在 N−1

选谁下手:官方文档说优先终止 「pod 少的节点」「即将过期的节点」「低优先级 pod 的节点」—— 而「低优先级」看的是 K8s PriorityClass,不是 YuniKorn 队列优先级。 ⚠️ 且「pod 少的节点优先」对一节点一 executor 的 GPU gang 来说,意味着 gang 里每个节点都是首选目标。
#机制强度 / 代价
1NodePool spec.replicas(静态 NodePool)最强:设了它 consolidation 相关字段被忽略。🔴 需 feature gate StaticCapacity=trueAlpha,默认关
2consolidateAfter: Never强且 GA,一行搞定。但不阻止 Drift 与 Expiration ⇒ 要配 expireAfter: Never
3karpenter.sh/do-not-disrupt中。官方原话「可当成单 pod 的 PDB」。🔴 必须也打在 placeholder 上
4PodDisruptionBudget🔴 对 Karpenter 有效,对 YuniKorn 抢占无效(见下)
5独立 NodePool + taints隔离爆炸半径,代价是容量碎片
🔵 独家:怎么给 placeholder 打注解。 真实 pod 上的注解不会传给 placeholder。必须写进 task group 的 annotations 字段——而这个字段官方文档从未提及(源码里 TaskGroup 有 9 个字段,文档只展示 7 个):
{"name":"exec","minMember":4,"annotations":{"karpenter.sh/do-not-disrupt":"true"}}
Ch.3 · 在 AWS 上怎么用 — consolidation 与保护26
Ch.3 · 在 AWS 上怎么用 · Two Surprises

两个反直觉:PDB 保护不了你,而 CA 在这件事上比 Karpenter 好 都有源码依据

🔴 反直觉一 · PDB 被 YuniKorn 抢占静默绕过

谁在回收 pod走什么 APIPDB 生效?
Karpenter consolidationEviction API(pods/eviction✅ 生效
YuniKorn 抢占Pods().Delete() —— 裸 DELETE❌ 不生效
// k8shim/pkg/client/kubeclient.go —— 全文件搜 Evict:0 命中
nc.clientSet.CoreV1().Pods(pod.Namespace).
    Delete(context.Background(), pod.Name, apis.DeleteOptions{})

PDB 只由 eviction 子资源的准入检查执行;直接 DELETE 不受 PDB 约束
同一个集群里,两种「回收 pod」的机制对 PDB 的态度完全不同。
⇒ 保护负载不被抢占,唯一有效手段是队列的 preemption.policy: disabled 或 PriorityClass 上的 allow-preemption: "false"

🔵 反直觉二 · Cluster Autoscaler 在这件事上更好

能力KarpenterCluster Autoscaler
认自定义调度器隐式(靠魔法字符串)✅ 显式开关
--bypassed-scheduler-names=yunikorn
用什么做装箱模拟自己重写的语义✅ 真正的 kube-scheduler framework
--scheduler-config-file 可配插件)
可观测性scheduler_unprocessed 标签可证明生效
 推荐配置:--bypassed-scheduler-names=yunikorn --allowed-scheduler-names=default-scheduler,yunikorn
要说准:Karpenter 在速度、实例选型、consolidation 上更强。 但在「认自定义调度器」这一项上,Karpenter 是隐式契约,CA 是显式开关。 ⇒ 选型时应把这一点摆出来,而不是默认「Karpenter 一定更好」
共同边界:两者都不理解 gang。 可能出现「开了 2 台节点里 1 台成功、另一台容量不足失败」⇒ gang 仍凑不齐,而你已付了 1 台的钱。 这是 gang 在云上的结构性缺口,两家都没解决。
Ch.3 · 在 AWS 上怎么用 — 两个反直觉27
Ch.3 · 在 AWS 上怎么用 · Documentation Defects

两处需要自己核实的地方 都在 AWS 官方材料里 · 都有完整证据链

① EMR on EKS 教程的参数名 (两处都是)

# AWS 官方教程原文(tutorial-yunikorn.html)
yunikorn.apache.org/schedulingPolicyParameters:
  "placeholderTimeoutSeconds=30 gangSchedulingStyle=Hard"
#  ↑ 漏了 In

// k8shim 唯一接受的键(constants.go:83)
const SchedulingPolicyTimeoutParam =
      "placeholderTimeoutInSeconds"
// 全仓搜无 In 的拼写:零命中
你以为实际倍数
超时 30 秒超时 15 分钟30×

为什么静默:解析函数的 switch 只有两个 case、没有 default 分支写错是合法的 k=v,不触发 malformed 告警,但匹配不上任何 case ⇒ timeout 保持初值 0 ⇒ core 套用默认 15 分钟。零日志、零告警、零事件。 叠加同文档里的 Hard:凑不齐的 gang 会占着 placeholder 15 分钟, 而 placeholder 在 Karpenter 下已经把节点开出来并计费了

data-on-eksvolumeBindTimeout

# awslabs/data-on-eks · helm-values/yunikorn.yaml(main 分支原文)
yunikornDefaults:
  # The default volume bind timeout value of
  # 10 SECONDS may be too short for EBS
  service.volumeBindTimeout: "60s"
注释声称的默认值10
✅ 源码 + 官方文档的实际默认值10 分钟Default: 10m,官方示例调整方向是调大到 15m)
注释的意图「对 EBS 太短」⇒ 想调大
实际效果10 分钟 → 60 秒,调小了 10 倍

做的事与注释的意图正好相反。EKS 上 EBS attach 本就可能慢;把 bind 超时压到 60 秒更容易中止分配, 而对 gang 来说一次 bind 中止意味着整个 gang 重试
同一份参考架构另外三点:chart 钉在 1.7.0(落后两个小版本);namespace 用 yunikorn-system 而 AWS 自己的 EMR 教程用 yunikorn两份官方材料不一致); 用 ArgoCD 且 selfHeal: true手改 ConfigMap 会被回滚

态度:目的不是挑 AWS 的错,而是说明照抄任何参考架构都需要自己核实关键参数。两处都有完整证据链,也都该作为 issue 反馈。 诚实标注:性能后果未在 AWS 实测(无凭证),标为推论;「值缩小 10 倍」与「参数名不被接受」是确定的。
Ch.3 · 在 AWS 上怎么用 — 两处文档缺陷28
Ch.3 · 在 AWS 上怎么用 · Ecosystem

它到底支持哪些大数据组件 官方覆盖 5 类,其余靠通用机制

三档判据(先看这个)

  • ✅ 原生支持 —— 对方有为 YuniKorn 写的代码或官方文档页
  • 🟡 通用机制可用 —— 对方完全不知道 YuniKorn 存在,但它的 CR 暴露了完整 PodSpec,你能塞 schedulerName + label
  • ❌ 不可用 —— 对方 CRD 是手写字段白名单,没有 schedulerName、没有 podTemplate ⇒ 只能靠 admission controller 按 namespace 强行接管
🔵 理解的钥匙:YuniKorn 的设计取向是不引入新 CRD它不需要对方为它写代码,只需要对方肯让你设 schedulerName。 ⇒ 所以支持面的边界不在 YuniKorn 这边,在对方的 CRD 有没有留口子
对比 Volcano:它有自己的 PodGroup CRD,所以对方必须为它写代码(如 Spark 的 VolcanoFeatureStep)。 门槛更高,但一旦集成就是一等公民。
组件判定关键事实
 官方文档覆盖的全部 5 类/docs/user_guide/workloads/ 只有 9 页)
Apache Sparkspark.kubernetes.scheduler.name(Spark 3.3.0 起);Spark 官方文档有专节且钉 1.9.0;Spark 仓库里有 YuniKorn 集成测试
Kubeflow Spark Operator一等公民,有官方 YuniKorn 集成文档页
Ray / KubeRay🔵 整个生态里唯一会自动生成 YuniKorn task-groups 的 operator
Apache Flink有官方页,经 podTemplate 打注解(⚠️ 该页已陈旧)
TF / MPI(Kubeflow)✅ 页面⚠️ 官方那两页只演示队列路由,零 gang scheduling
 其余全部靠通用机制或不可用
Argo Workflows / Airflow / Tekton / Dagster🟡都有 schedulerName 旋钮。🔴 共同大坑:YuniKorn 的 ACL 看到的是「编排器的 SA」,不是 DAG 所有者 ⇒ 队列 ACL 无法按最终用户区分
Trino / Presto / StarRocks / Doris / ClickHouse / Druid / Pinot🟡/❌🔴 零个查询引擎有 YuniKorn 集成。但它们是长运行服务——队列配额与抢占保护适用,gang 与公平共享不适用
Kubeflow Trainer / LeaderWorkerSet🔴 gang API 是 coscheduling|volcano 的封闭联合,没有 YuniKorn。LWS 的 KEP 点了 YuniKorn 的名而代码只支持 Volcano。NVIDIA NeMo 让你用 KAI Scheduler
Ch.3 · 在 AWS 上怎么用 — 生态集成矩阵29
04
Decide
怎么决策
这一章会算一笔账,而这笔账可能会劝你别做。这是我们能放在 CFO 面前最可信的一件事。
成本模型 盈亏平衡点 选型对比 迁移剧本
Apache YuniKorn 技术深潜
30
Ch.4 · 怎么决策 · Cost Model

四个机制,只有两个真值钱 参考单价 m6g.4xlarge us-east-1 = $449.68 / 节点 / 月

#机制占节省份额说明与算例
1池化
用配额替代专用集群
~70% 机制是统计复用,不是调度。三个团队各按自己峰值买 = Σ(mean+z·σ);合池后买 mean_total + z·σ_total,而独立需求下 σ_total = √3·σ_i。
算例:每队 mean 12 / peak 20 ⇒ 3×20 = 60 节点 → 36 + 8√3 ≈ 50 节点
YuniKorn 在这里的作用只是「提供一个团队信得过的配额,让共享变得可接受」。
2装箱
节点排序 binpacking
~28% 减少碎片 ⇒ Karpenter 更晚才扩容。
🔶 公开材料里唯一的一手量化数字(Halodoc,EMR on EKS,2026-02):节点内存利用率 >90%、CPU ~88%、单节点扩容前可达「96% and above」,"approximately a 10% reduction in EC2 costs"
保守取 8% 节点数下降:50 → 46 节点。(✅ 我们实测也看到了这个效果:默认 fair 排序用了 46/50 个节点,而 default-scheduler 只用 30/50)
3EKS 控制面合并2% 3 集群 × $0.10/集群·小时 × 730 = $219/月 → $73/月,省 $146/月这是零头,别拿它当卖点。
4减少失败 / 死锁作业建模为 $0 这是延迟 / SLO 收益,不是成本收益。算例:一个 4 vCPU / 16 GB 的 driver 白等 30 分钟,烧掉的 EMR uplift 是 $0.029
即使 50 个 driver 同时卡 1 小时也只有 ~$4。放可靠性页,别放 ROI 页。
Spot 故意不在这个列表里 它与 YuniKorn 正交(用不用 YuniKorn 都能用 Spot)。把 Spot 的节省算进 YuniKorn 的账是最常见的夸大。
Ch.4 · 怎么决策 — 成本模型31
Ch.4 · 怎么决策 · The Overclaim

最常见的夸大:EMR on EKS 的 uplift 按申请量计费 装箱省不下它一分钱

AWS 定价页原文(us-east-1,2026-09 读)

"Pricing is based on requested vCPU and memory resources for the Task or Pod"
每 vCPU·小时 $0.01012 · 每 GB·小时 $0.00111125

AWS 自己的算例金额
100 vCPU × $0.01012 × 0.5 h$0.506
300 GB × $0.00111125 × 0.5 h$0.1667
uplift 合计$0.6727
🔴 关键推论: YuniKorn 改变的是 pod 落在哪个节点,从不改变 pod 申请多少。
EMR uplift 这一行在用 YuniKorn 前后是逐位相同的。
装箱省下的 EMR uplift 是 $0。
能降低 uplift 的只有两件事:跑得更快(运行时 / SQL 优化)② 申请更少(rightsizing)。
换调度器不在这两条里。
📌 这一条必须主动在 PPT 上说出来。 一个 FinOps 审阅者一定会发现它。
你先说,是专业;被他发现,是可信度归零。
顺带纠正另一个混淆:那些著名的 「5.37 倍更快、成本降 61%」是 EMR Spark 运行时 vs 开源 Spark 的数字 (TPC-DS 派生、3 TB、104 条查询、6 × c5d.9xlarge),与调度器无关。 AWS 从未发布任何 YuniKorn 的性能数字
正确口径:EMR 运行时买的是查询速度;YuniKorn 买的是排队、原子性和公平性——第二样你必须自己测。
Ch.4 · 怎么决策 — 最常见的夸大32
Ch.4 · 怎么决策 · Break-even

盈亏平衡点:55 节点(us-east-1)/ 40 节点(香港) 低于这个规模,我的建议是别做

落地成本(按 $4,255 / 工程师·周)

工作项工程师·周金额
试点:1 队列 1 团队非生产,验证 binpacking + 指标3$12,766
生产铺开:3 团队、队列树、放置规则、ACL、配额定量6$25,532
给所有 Spark 作业上 gang(taskGroup、AZ 固定、超时调优)4$17,021
计费链路:日志 → S3 → Athena,关联 CUR split cost5$21,277
runbook、告警、升级自动化、调度器容灾3$12,766
一次性合计21$89,362
稳态运维0.2 FTE$3,333 / 月

TCO = $89,362 / 36 月 + $3,333 = $5,816 / 月
毛节省按 23.7% 计(池化 + 装箱 + 控制面)⇒ 平衡点 = $24,538 / 月的 EC2+EKS 支出 ⇒ us-east-1 约 55 台 m6g.4xlarge;香港 ap-east-1 约 40 台(单价更高,门槛低 27%)。

改造前规模毛节省/月净/月ROI
60 节点($27.2k)$6,446$63111%
120 节点($54.4k)$12,893$7,077122%
300 节点($136k)$32,232$26,416454%
600 节点($272k)$64,464$58,6481008%

必须说出来的一句话

  • 教科书式的「3 个团队各 20 节点」这个例子,过不了盈亏平衡线。一次性投入的简单回收期是 13.9 个月,扣掉稳态运维后,在 $34.6k/月 的账单上只净赚 $631/月
  • YuniKorn 是一个 >100 节点规模才成立的决定。回收期 3.8 个月(如果你在合并多个专用集群); ~124 个月,也就是永远不回本(如果你本来就把 Karpenter 用得很好)。
Ch.4 · 怎么决策 — 盈亏平衡点33
Ch.4 · 怎么决策 · Comparison

YuniKorn vs Volcano vs Kueue 不要从 feature 列表开始比——先看架构定位

维度YuniKornVolcanoKueue
架构定位替换 kube-scheduler与 kube-scheduler 并存的独立调度器不调度 pod,只做 Job 级准入门控
用户手上写什么注解 + label不引入新 CRD自己的 CRDQueue / PodGroup / VolcanoJob原生 Job + label + 配套 CRD
层级队列 强项任意深度 + guaranteed/max + ACL有 Queue CRD,层级较弱cohort / borrowing,形态不同
真 gang(原子放置) placeholder 占位 PodGroup + minAvailable招牌能力 不做放置 ⇒ 无真 gang
抢占 七条定律,锚点 guaranteed preempt / reclaim 基于优先级
拓扑感知(NUMA / 网络) 无,路线图上也没有 有相关能力 有 TASGKE 的拓扑调度建在其上
多集群 无,且硬编码单集群 volcano-global(alpha) MultiKueue
AI 训练框架原生支持 Kubeflow Trainer 不支持它 Trainer / LWS / MPI 都支持 JobSet / Trainer / RayJob
Spark 生态 强项Spark 官方文档有专节且钉 1.9.0 Spark 主干内建 feature step
治理归属Apache 基金会 TLPCNCFkubernetes-sigsK8s 官方子项目
云厂商背书仅 AWS + Cloudera华为云 CCE 一等公民GKE / AKS / 火山引擎
选型的真正问题不是「哪个 feature 多」,而是「你要的是配额准入还是原子放置」。 Kueue 放行 Job 之后把放置权交回 kube-scheduler ⇒ 它保证的是「这批 pod 的配额够了」, 不是「这批 pod 一定同时落到节点上」。这是三者之间最实质的技术差异。
Ch.4 · 怎么决策 — 三者对比34
Ch.4 · 怎么决策 · YARN Migration

YARN → YuniKorn 概念对照 这是它的护城河,也是最容易配错的地方

YARN(Capacity Scheduler)YuniKorn迁移时的注意点
队列(root.a.b队列root.a.b概念与命名几乎 1:1,这是它的护城河
capacity百分比resources.guaranteed绝对量🔴 YARN 用百分比会随集群自动伸缩,YuniKorn 用绝对量不会。集群扩容后必须回来改配置,否则保障比例被稀释
maximum-capacityresources.max同上,绝对量
acl_submit_applicationssubmitacl语法不同(「用户列表 空格 组列表」),语义相同
acl_administer_queueadminacl⚠️ YuniKorn 的 adminacl 同时授予 submit 权限
ApplicationMasterSpark driverK8s 上没有 AM 这一层
maximum-applicationsmaxapplications子队列必须 ≤ 父队列
队列内 FIFO / Fair · DRFapplication.sort.policy🔴 跑 gang 的队列必须是 fifo;配成 fairunsupported sort type,而这个报错不提原因。且 properties 会从父队列继承
node label / partition🟡 只能靠 nodeSelector + taint 近似🔴 最大的语义缺口:没有「队列绑定节点组」这个一等公民概念(K8s shim 只支持 1 个 partition)
reservation system(预约未来容量)❌ 无对应物YARN 能预约「明天 2 点给我 100 核」,这里不能
opportunistic containers · Federation❌ 无对应物单集群,见第 1 章的边界页
 🔵 反过来 YuniKorn 有 YARN 没有的:gang scheduling(YARN 靠 AM 自己协调)、 以及与 K8s 原生调度约束(affinity / topologySpread / PVC 拓扑)的天然融合。
Ch.4 · 怎么决策 — YARN 概念对照35
Ch.4 · 怎么决策 · Rollout

迁移剧本:五个阶段 而最直觉的那个方案行不通

🔴 先破除一个错误方案: 「给要迁移的负载加 schedulerName: yunikorn,其余保持 default-scheduler」—— 这行不通。mutating webhook 默认作用于全集群所有工作负载类型会把显式写着 default-scheduler 的也改写成 yunikorn,而且不报错。 ⇒ 唯一正确的出口是 namespace 级开关
阶段 0
装但不接管

embedAdmissionController=false,或 processNamespaces 指向空 ns。判据:能读 config、能抓指标

阶段 1
单队列单团队

1 ns + 1 leaf 队列 + binpacking不开 gang、不开抢占。判据:跑一次超配额实验,看到 SchedulingSkipped

阶段 2
队列树 + ACL

3 团队;provided 规则在前;组 ACL。判据:isManaged==false 巡检为空;组 ACL 真的匹配上了

阶段 3
Gang

加 taskGroups,显式 Hard,超时按节点冷启动 p99 取值。判据:placeholder 能正常 swap,无长期 Accepted

阶段 4
抢占

guaranteed;关键队列设 preemption.policy: disabled。判据:能触发,且无永久 Pending 的 Deployment

✅ 正确的灰度开关(两套,可组合)

# A · admission controller 的 namespace 过滤
#   ✅ 实测:支持逗号分隔正则,亚秒热生效
admissionController.filtering.processNamespaces:
  '^data-eng-dev$,^data-eng-staging$'

# B · namespace 注解(覆盖正则,优先判断)
#   🔵 灰度的最佳抓手:不改全局配置
kubectl annotate ns team-a \
  yunikorn.apache.org/namespace.enableYuniKorn=true
为什么抢占放最后、且独立成一次变更: 它是唯一会主动杀掉正在运行的 pod 的能力,而且 PDB 保护不了它
可逆性(客户最关心的)——PMC 成员原话: "Since it is a drop-in replacementStopping YuniKorn will revert the cluster back to its old state."
但要补两条实测限定:① 已带 schedulerName: yunikorn 的 pod 在卸载后永远不会被调度,回退前必须重建; ② 正在跑的 gang 的 placeholder 命运我们未实测,回退演练要专门验证。
诚实表述:新负载的回退是干净的(摘注解,几秒生效); 已在运行的负载需要重建。
Ch.4 · 怎么决策 — 迁移剧本36
Ch.4 · 怎么决策 · Call to Action

下周就能做的三件事 按「投入从低到高」排序 · 第一件今天就能做完

今天 · 30 分钟
0 成本

先算规模,再谈技术

  • 拉出当前 EC2 + EKS 月度支出
  • 对照盈亏平衡线:香港 ~40 台 / 美东 ~55 台
  • 不到线就先别做,把这页发给决策者
本周 · 1 天
仅本地

在 kind 上跑一遍

  • 17 秒装完,验证配额真的生效
  • 跑一次装不下的 gang,看 ApplicationRejected
  • 记住 pin kindest/node 版本
下周 · 3 工程师·周
需排期

非生产试点

  • 1 namespace + 1 leaf 队列 + binpacking
  • 不开 gang、不开抢占
  • 把指标接进 Prometheus(记得关压缩
动作负责人截止验收标准
算出当前规模与盈亏平衡线的差距平台负责人本周三一个数字:距离平衡点还差多少节点
本地 kind 跑通配额与 gang 两个实验SRE本周五能复述「配额撞上」与「gang 被拒」两种 event 原文
过一遍四个坑,确认自己不会踩SRE + 数据平台下周一输出一份「我们的配置里有没有这四个坑」的检查结果
决定做 / 不做,并写下理由平台负责人下周五不做也是一个合格的结论,但要有理由

如果只带走三句话

  • 它解决的问题是真的(资源死锁、配额不能排队),但它是一个 >100 节点规模才成立的决定
  • 四个坑都是静默的:资源命名、放置规则、gang 参数名、1.9.0 的 metrics gzip。不报错不等于生效。
  • 在 AWS 上它有官方文档背书,但没有托管版本;与 Karpenter 的协同靠一个未成文的字符串——能用,但要知道它为什么能用。
Ch.4 · 怎么决策 — 行动清单37
Appendix · Reference

附录:坑清单速查 每条都有本场对应页码

#症状
1mutating webhook 改写全集群所有工作负载的 schedulerName无法用 schedulerName 灰度;A/B 测出「两边一样」P08 · P36
2放置规则 tag+create:true 在前 ⇒ 造出无配额队列配额完全不生效,pod 全 RunningP14
3队列配置里写 cpu 而非 vcore配额不生效,不报错P13
4schedulingPolicyParameters 写错 ⇒ 零日志静默忽略超时是 15 分钟而不是你设的值P17 · P28
5v1.9.0 /ws/v1/metrics 双重 gzipPrometheus target 掉线,无指标P21
6抢占走裸 DELETE ⇒ PDB 被绕过PDB 保护不了服务P27
7节点排序默认 fair(摊平)⇒ 云上多开节点实测 46/50 vs 30/50 个节点P31
8gang 队列必须 fifofairunsupported sort type报错完全不提原因;properties 会从 root 继承P35
9默认 gangSchedulingStyle=Soft ⇒「半个 gang」应用 Running 但一半 pod Pending,监控看不出P17
10零 placeholder 落地 ⇒ 超时计时器从不启动应用无限期停在 AcceptedP15
11抢占 + Deployment ⇒ 永久 PendingReplicaSet 补的 pod 永远调度不上P19
12抢占方没有 event 记录;K8s event 会丢查不到「我抢了谁」;审计缺记录P20
13Fargate 与 YuniKorn 互斥Fargate pod 永久 Pending
14placeholder 吃 VPC CNI 的 IP 与 max-pods瞬时需 2× pod 槽位;IP 耗尽表现为 gang 超时
15kubectl delete cm yunikorn-configs瞬时重置为默认队列树消失,无确认
完整版见配套《Apache YuniKorn 精通指南》(21 章 + 7 附录,6,400 行):28 条坑清单、25 条 REST 路由全表、Label/Annotation 全表,以及推翻或修正的 7 处流行说法(含 9 处我们自己的更正)。
Appendix — 坑清单速查38
Thank you!
Apache YuniKorn 技术深潜 · v1.9.0
Neo SUN
Solutions Architect Amazon Web Services
带走这三样:
· 配额体系完整性巡检命令(P14)
· 四个静默失效的坑(P38)
· 盈亏平衡线:香港 ~40 台(P33)
配套材料:
· 《精通指南》21 章 + 7 附录
· 可复现实验室(kind + KWOK)
· 2,500 行实测记录(含失败过程)