| 维度 | 在线服务(Deployment) | 批处理(Spark / 训练作业) |
|---|---|---|
| 调度单位 | 单个 pod,彼此独立 | 一组 pod,必须同生共死 |
| 资源不足时 | 扩容或降级,pod 可以一直 Pending | 必须排队,而且要公平地排 |
| 生命周期 | 长期运行,几乎不结束 | 分钟到小时,跑完就走 |
| 延迟目标 | 单 pod 调度延迟要低 | 整个作业的完成时间要短,单 pod 慢一点无所谓 |
| 资源利用目标 | 留余量应对流量峰值 | 尽可能榨干,别浪费 |
| 失败语义 | 重启这个 pod | 一个成员失败可能整个作业重来 |
| 所以问题不是「默认调度器写得不好」——它为左边那一列设计,而且设计得很好。 问题是它的调度单位(pod)与批处理的调度单位(作业)不匹配。这是抽象层次的错配,调参解决不了。 | ||
minMember × minResource 先创建 placeholder pod 把坑占满;ResourceQuota 不是配额,是准入拒绝 最被低估的一条差异| 维度 | K8s ResourceQuota | YuniKorn 队列 |
|---|---|---|
| 超额时会怎样 | pod 创建失败准入期拒绝,kubectl apply 直接报错 | pod 创建成功,在队列里排队等 |
| 结构 | 扁平,一个 namespace 一份 | 层级树,父队列约束所有子孙之和 |
| 由谁执行 | 准入控制器(apiserver) | 调度器(调度时判断) |
| 有「顺序」概念吗 | 没有,谁先 apply 谁占 | FIFO / Fair / 优先级排序 |
| 能借用空闲容量吗 | 不能 | guaranteed 之上、max 之内可借 |
| 能保障最低量吗 | 只有上限,没有下限 | guaranteed 是抢占的锚点 |
「配额」这个词在两个系统里含义不同。K8s 的是硬门禁(超了不让进),
YuniKorn 的是调度约束 + 排队(超了就等)。批处理平台需要的是后者——你不会希望半夜的 ETL 因为白天的作业还没跑完就 apply 失败。 | ||
默认调度器没有「租户」概念,也没有「这个用户已经比别人用得多」这种状态。 一个配错的作业能吃掉整个 namespace,把同 namespace 的其他作业饿死——而这不会触发任何告警,因为从 K8s 视角看一切正常。
一旦调度器有了 Application 这个一等对象,下面这些能力才成为可能:
Gang(要知道这一组 pod 属于同一作业且最少要 N 个)、作业级排队与排序、作业级配额(maxapplications)、
动态优先级、历史用量追踪。这些不是 feature 清单,是同一个抽象带来的推论。
guaranteed / maxguaranteeddefault,YUNIKORN-22)POST /ws/v1/validate-conf,只做校验 ⇒ 配额变更只能改 ConfigMap,GitOps 是唯一正规通道schedulerName 都被改写 —— mutating webhook 默认作用于全集群所有工作负载类型,包括显式写了 default-scheduler 的。这是设计行为,第三章会讲唯一的正确出口。| 组件 | Go 行数 | 职责 |
|---|---|---|
| yunikorn-core | 87,925 | 全部调度算法;与平台无关,只通过 SI 通信 |
| yunikorn-k8shim | 53,063 | 与 K8s 对话:informer、翻译资源与请求、把 pod bind 到节点、admission controller |
| yunikorn-scheduler-interface | 3,469 | 抽象层,gRPC / protobuf。只有 Go binding |
| yunikorn-release | 2,487 | Helm chart(values.yaml 仅 166 行) |
| 核心两件合计 | 140,988 | — |
// 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 }
yunikorn_scheduler_scheduling_cycle_milliseconds。它从 ~2ms 涨到 ~20ms,吞吐就从 ~600/s 掉到 ~50/s。guaranteed 与 max:两个数字定义整个多租户 90% 的实际价值来自把这棵树设计对guaranteed(min) | max(quota) | |
|---|---|---|
| 作用 | 参与份额计算与分配决策 | 该队列任意时刻所有分配的硬上限 |
| 与抢占 | 抢占的锚点:队列不可跌破它 | 与抢占无关 |
| 设成 0 | 无保障 | 禁止该队列使用该资源(如 nvidia.com/gpu: 0) |
| root 队列 | root 不能设任何资源限制,设了 parsing error。它的 max 自动等于集群大小 | |
resources 时
POST /ws/v1/validate-conf 直接返回
{"allowed":false,"reason":"root queue must not have resource limits set"}
guaranteed 之和应 ≤ 集群容量(否则保障是空头承诺);
max 之和可以、且通常应该 > 集群容量——这正是「允许借用空闲容量」的表达方式。
max 加起来正好等于集群容量,
你就退化成了静态切分,丢掉了 YuniKorn 相对「多个专用集群」的主要好处——而那个好处占成本节省的约 70%(第四章)。max 通常安全(只放宽借用上限);
调 guaranteed 会改变抢占行为。优先调 max,谨慎调 guaranteed。| 写在哪 | CPU 该写什么 | 依据 | 写错的后果 |
|---|---|---|---|
队列配置 resources.guaranteed / .max / limits.maxresources |
vcore |
官方队列配置页全部示例都是 vcore: 10;core 内部常量 CPU = "vcore" |
写 cpu ⇒ 被当成「一个名叫 cpu 的自定义资源」⇒ 对真实 CPU 完全不限流 |
Pod 注解 task-groups 的 minResource |
cpu |
官方 gang 页示例是 "cpu": "100m"(这里是 K8s 资源名,由 shim 翻译) |
写 vcore ⇒ 同样不报错、同样不生效 |
Pod 的 resources.requests | cpu | K8s 原生规范 | — |
官方原话(这是唯一的例外说明):"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 表示 millicore;memory 默认单位是字节 | ||
| 🔴 1.0 起的行为变更 | 1.0 之前 memory 按 100 万字节解释、vcore 按 millicore 解释
⇒ 从 0.x 迁上来的旧配置,数值含义会整体漂移,必须重算 | ||
# 官方文档给 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.dev 的 max_vcore=3000。提交一个 minMember=8 × 500m = 4000m 的 gang,
pod 上明确打了 queue: root.dev。预期被配额拦住。实际 8 个 pod 全部 Running。
| 队列 | isManaged | max_vcore | alloc |
|---|---|---|---|
| root.prod | true | 8000 | 0 |
| root.dev | true | 3000 | 0 ← 指定的队列,一点没用上 |
| root.streaming | true | 4000 | 0 |
| root.gang-hard | false | unlimited | 4000 |
根因四步:① 规则链只有 tag,它第一个就匹配 ⇒ ② pod 上的 queue label 从未被查看
⇒ ③ create:true 用 namespace 名自动创建了 root.gang-hard
⇒ ④ 该动态队列 isManaged=false、max 为 unlimited,继承 root ⇒ 等于没有配额。
create 语义、动态队列都写了。
问题是它从未说明后果:不会说这让 queue label 失效,也不会说自动创建的队列无配额。而它给的示例恰恰就是这个危险组合。
⇒ 正确说法是:「文档没写错,但它没告诉你后果。」按每个 taskGroup 的 minMember 创建等量 pause pod,资源精确等于 minResource
placeholder 是真实 K8s pod,只跑 pause,被 kubelet 真正启动,占住节点上的坑
凑齐 minMember ⇒ 发 RunApplication ⇒ 真实 pod 换进 placeholder 占好的位置
Hard ⇒ 整个应用失败;Soft(默认) ⇒ 退化成普通逐 pod 调度
| 注解 | 放在哪 |
|---|---|
yunikorn.apache.org/task-groups | 只放应用的第一个 pod(Spark 场景 = driver);若无「第一个」概念则所有 pod 都带同样内容 |
.../task-group-name | 每个 pod 都要,声明自己属于哪个组 |
.../schedulingPolicyParameters | 与 task-groups 同一个 pod。空格分隔的 k=v |
taskGroups 就走 gang 路径。判据(官方):「同资源规格 + 同放置约束」为一组—— 不是按角色分组。如果 executor 分大小两档,那就是两个 task group。
| 框架 | 分组 | minMember |
|---|---|---|
| Spark | driver + executor | 1 + 最小 executor 数 |
| Flink | JobManager + TaskManager | 1 + 最小并行度 |
| Ray | head + worker | 1 + 最小 worker 数 |
| MPI | launcher + worker | 1 + 全部(最典型的 all-or-nothing) |
| 时刻 | Δ(T0) | 观测到什么 |
|---|---|---|
| 19:32:00.15 | T0 | kubectl apply 发出(minMember=4 × 500m = 2000m,队列 max 4000m 空闲) |
| 19:32:00.52 | +0.37s | 4 个真实 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 Running(swap 完成) |
| 19:32:05.44 | +5.29s | 2 个真实 pod Running |
| 19:32:06.15 | +6.00s | ✅ 4 个真实 pod 全部 Running |
kubectl get pods,完全没看到 placeholder,一度误判为「没有创建 placeholder」。
⇒ 要看到它,必须用 kubectl get pods -w 或亚秒轮询,或者故意让 gang 装不下。
⇒ swap 能这么快的原因是 placeholder 的 terminationGracePeriodSeconds: 0——删除时不等优雅退出。Soft,而 Soft 会给你「半个 gang」 ✅ 实测 A/B:只改一个参数| 行为 | Hard | Soft(默认) |
|---|---|---|
入队时 gang 需求 > 队列 max | 立即 ApplicationRejected,不建 placeholder | 相同(该检查与 style 无关) |
| 超时后应用状态 | Failed,从队列消失 | Running |
| 超时后pod状态 | 全部 Failed | 装得下的 Running,其余 Pending |
pod 的 .status.reason | ResourceReservationTimeout | 无(走正常调度) |
| 关键 event(实测原文) | ApplicationFailed | resuming as non-gang application (SOFT) |
| 语义 | 全有或全无,宁可失败 | 尽力而为,允许部分启动 |
| 适合 | Spark / MPI / 分布式训练——凑不齐就没意义的作业 | 可弹性伸缩、能容忍部分副本的负载 |
🔴 需要真 gang 的作业必须显式写 gangSchedulingStyle=Hard。
默认值 Soft 对「真 gang」场景是错的默认值——它会产生「2 个 Running + 2 个 Pending」:
占着资源、干不了活、还不报错(应用状态是 Running,监控上一切正常)。 | ||
Failed。
设定 60 秒,吻合。超时后队列立刻归还全部资源。placeholderTimeoutInSeconds(有 In)。
键写错会被静默忽略——零日志、零告警,然后套用默认值 15 分钟。第三章有一个真实受害者。guaranteedguaranteed 以下guaranteed ⇒ 不能触发抢占guaranteed ⇒ 不能被抢guaranteed,抢占基本什么都不会做——因为「队列低于 guaranteed」这个前提永远不成立。
这是「抢占配了没反应」的第一大原因。priority.offset 区分),
不是靠同队列内的 PriorityClass。这是很常见的配置错误。preemption.policy: fence = 我不能往外抢;
disabled = 我不能被抢。
想保护在线服务,用 disabled,不是 fence。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 的教科书式配置。
| 时刻 | lo | hi |
|---|---|---|
| T+10s | 8 Running | 4 Pending(pending=2000) |
| T+25s | 4 Running / 4 Pendingalloc 4000→2000 | 4 Runningalloc 0→2000 |
| T+45s ~ T+130s | 状态稳定,不再变化 | |
hi 拿到恰好 2000m(= 它的 guaranteed)就停了,
没有继续往 max=4000 抢 ⇒ 完美印证「抢占只回到 guaranteed,不冲 max」。kubectl get events 剩下/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 的告警规则会漏报配额不足。/ws/v1/metrics 被双重 gzip 默认的 Prometheus 抓取全部失败,且静默| 版本 | gzip.go | Content-Encoding 出现次数 |
|---|---|---|
| v1.7.0 | 不存在 | — 无中间件,不受影响 |
| v1.8.0 | 不存在 | — 不受影响 |
| v1.9.0(当前 GA) | 存在 | 1 只 Set,从不读 ⇒ 无保护 |
| master(→1.10.0) | 存在 | 7 含穿透保护 ⇒ 已修 |
/ws/v1/metrics 由 promhttp.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
/ws/v1/events/stream 被中间件按精确路径排除;
其余路由主动加 --compressed 反而有益(因为 API 没有分页)。| 云 / 平台 | 官方推荐的批调度方案 | 官方文档提及 YuniKorn | 托管组件 |
|---|---|---|---|
| AWS EKS / EMR on EKS | YuniKorn 与 Volcano 并列,各有一篇官方 tutorial | ✅ 有专门教程页 | ❌ 非 add-on,仍装社区 Helm chart |
| 阿里云 ACK | 自研增强 kube-scheduler(内建 Gang + Capacity) | ⚠️ 仅「计算巢」货架,不在 ACK 组件中心 | ⚠️ 中立货架,非产品团队背书 |
| 腾讯云 TKE | 自研 Extender 系插件 | ❌ 未找到 | ❌ |
| 华为云 CCE | Volcano(一等公民;xGPU 虚拟化硬依赖 Volcano ≥1.10.5) | ❌ 未找到 | ❌ |
| 火山引擎 VKE | 自研增强调度器 + Kueue | ❌ 未找到 | ❌ |
| Google GKE | Kueue(拓扑感知调度 TAS 直接建在其 CRD 上) | ❌ site:cloud.google.com yunikorn 返回 0 条 | ❌ |
| Azure AKS | Kueue(≥4 篇官方文档) | ❌ 未找到 | ❌ |
| Red Hat OpenShift | 无支持声明 | ⚠️ 仅 YuniKorn 自己的本地开发指南 | ❌ 无 Operator、无 UBI 镜像 |
| Cloudera CDP / CDE | YuniKorn(唯一把它做成商业产品核心的厂商) | ✅ | ✅ CDE 由 YuniKorn 驱动 |
aws-samples 示例仓库、awslabs/data-on-eks 参考架构(无条件安装 YuniKorn)。StartJobRun API 路径。| EKS K8s | EKS 支持状态 | 最低 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 至今未终止支持。所以问题从来不是「太老不支持」,而是「太新还没声明」。 | |||
kind v0.33.0 默认给的是 K8s v1.37.0,
超出支持上限,必须显式 pin kindest/node:v1.33.4。「自动升级把你推到未测试版本上」这个模式在托管 K8s 上同样成立。// 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 }
| pod 为什么 pending | YuniKorn 写的 Reason | Karpenter 扩容? |
|---|---|---|
| 队列配额超了 | SchedulingSkipped | ❌ 不会门匹配不上 |
| 集群容量真的不够 | Unschedulable | ✅ 会 |
源码注释原文——意图写得一字不差:
· 配额超了:// we do not trigger the auto-scaling
· 容量不够:// set pod condition to Unschedulable in order to trigger auto-scaling
volcano-sh/volcano#2602 修好。这是发生过的真事。# 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。
| # | 机制 | 强度 / 代价 |
|---|---|---|
| 1 | NodePool spec.replicas(静态 NodePool) | 最强:设了它 consolidation 相关字段被忽略。🔴 需 feature gate StaticCapacity=true(Alpha,默认关) |
| 2 | consolidateAfter: Never | 强且 GA,一行搞定。但不阻止 Drift 与 Expiration ⇒ 要配 expireAfter: Never |
| 3 | karpenter.sh/do-not-disrupt | 中。官方原话「可当成单 pod 的 PDB」。🔴 必须也打在 placeholder 上 |
| 4 | PodDisruptionBudget | 🔴 对 Karpenter 有效,对 YuniKorn 抢占无效(见下) |
| 5 | 独立 NodePool + taints | 隔离爆炸半径,代价是容量碎片 |
annotations 字段——而这个字段官方文档从未提及(源码里 TaskGroup 有 9 个字段,文档只展示 7 个):
{"name":"exec","minMember":4,"annotations":{"karpenter.sh/do-not-disrupt":"true"}}| 谁在回收 pod | 走什么 API | PDB 生效? |
|---|---|---|
| Karpenter consolidation | Eviction 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"。
| 能力 | Karpenter | Cluster Autoscaler |
|---|---|---|
| 认自定义调度器 | 隐式(靠魔法字符串) | ✅ 显式开关--bypassed-scheduler-names=yunikorn |
| 用什么做装箱模拟 | 自己重写的语义 | ✅ 真正的 kube-scheduler framework ( --scheduler-config-file 可配插件) |
| 可观测性 | — | ✅ scheduler_unprocessed 标签可证明生效 |
推荐配置:--bypassed-scheduler-names=yunikorn --allowed-scheduler-names=default-scheduler,yunikorn | ||
# 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-eks 的 volumeBindTimeout# 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 会被回滚。
schedulerName + labelschedulerName、没有 podTemplate ⇒ 只能靠 admission controller 按 namespace 强行接管schedulerName。
⇒ 所以支持面的边界不在 YuniKorn 这边,在对方的 CRD 有没有留口子。
PodGroup CRD,所以对方必须为它写代码(如 Spark 的 VolcanoFeatureStep)。
门槛更高,但一旦集成就是一等公民。| 组件 | 判定 | 关键事实 |
|---|---|---|
官方文档覆盖的全部 5 类(/docs/user_guide/workloads/ 只有 9 页) | ||
| Apache Spark | ✅ | spark.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 |
| # | 机制 | 占节省份额 | 说明与算例 |
|---|---|---|---|
| 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) |
| 3 | EKS 控制面合并 | 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 的账是最常见的夸大。 |
"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 |
| 工作项 | 工程师·周 | 金额 |
|---|---|---|
| 试点:1 队列 1 团队非生产,验证 binpacking + 指标 | 3 | $12,766 |
| 生产铺开:3 团队、队列树、放置规则、ACL、配额定量 | 6 | $25,532 |
| 给所有 Spark 作业上 gang(taskGroup、AZ 固定、超时调优) | 4 | $17,021 |
| 计费链路:日志 → S3 → Athena,关联 CUR split cost | 5 | $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 | $631 | 11% |
| 120 节点($54.4k) | $12,893 | $7,077 | 122% |
| 300 节点($136k) | $32,232 | $26,416 | 454% |
| 600 节点($272k) | $64,464 | $58,648 | 1008% |
| 维度 | YuniKorn | Volcano | Kueue |
|---|---|---|---|
| 架构定位 | 替换 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 基金会 TLP | CNCF | kubernetes-sigsK8s 官方子项目 |
| 云厂商背书 | 仅 AWS + Cloudera | 华为云 CCE 一等公民 | GKE / AKS / 火山引擎 |
| 选型的真正问题不是「哪个 feature 多」,而是「你要的是配额准入还是原子放置」。 Kueue 放行 Job 之后把放置权交回 kube-scheduler ⇒ 它保证的是「这批 pod 的配额够了」, 不是「这批 pod 一定同时落到节点上」。这是三者之间最实质的技术差异。 | |||
| YARN(Capacity Scheduler) | YuniKorn | 迁移时的注意点 |
|---|---|---|
队列(root.a.b) | 队列(root.a.b) | 概念与命名几乎 1:1,这是它的护城河 |
capacity(百分比) | resources.guaranteed(绝对量) | 🔴 YARN 用百分比会随集群自动伸缩,YuniKorn 用绝对量不会。集群扩容后必须回来改配置,否则保障比例被稀释 |
maximum-capacity | resources.max | 同上,绝对量 |
acl_submit_applications | submitacl | 语法不同(「用户列表 空格 组列表」),语义相同 |
acl_administer_queue | adminacl | ⚠️ YuniKorn 的 adminacl 同时授予 submit 权限 |
| ApplicationMaster | Spark driver | K8s 上没有 AM 这一层 |
maximum-applications | maxapplications | 子队列必须 ≤ 父队列 |
| 队列内 FIFO / Fair · DRF | application.sort.policy | 🔴 跑 gang 的队列必须是 fifo;配成 fair 报 unsupported 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 拓扑)的天然融合。 | ||
schedulerName: yunikorn,其余保持 default-scheduler」——
这行不通。mutating webhook 默认作用于全集群所有工作负载类型,
会把显式写着 default-scheduler 的也改写成 yunikorn,而且不报错。
⇒ 唯一正确的出口是 namespace 级开关。embedAdmissionController=false,或 processNamespaces 指向空 ns。判据:能读 config、能抓指标
1 ns + 1 leaf 队列 + binpacking;不开 gang、不开抢占。判据:跑一次超配额实验,看到 SchedulingSkipped
3 团队;provided 规则在前;组 ACL。判据:isManaged==false 巡检为空;组 ACL 真的匹配上了
加 taskGroups,显式 Hard,超时按节点冷启动 p99 取值。判据:placeholder 能正常 swap,无长期 Accepted
设 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
schedulerName: yunikorn 的 pod 在卸载后永远不会被调度,回退前必须重建;
② 正在跑的 gang 的 placeholder 命运我们未实测,回退演练要专门验证。ApplicationRejectedkindest/node 版本binpacking| 动作 | 负责人 | 截止 | 验收标准 |
|---|---|---|---|
| 算出当前规模与盈亏平衡线的差距 | 平台负责人 | 本周三 | 一个数字:距离平衡点还差多少节点 |
| 本地 kind 跑通配额与 gang 两个实验 | SRE | 本周五 | 能复述「配额撞上」与「gang 被拒」两种 event 原文 |
| 过一遍四个坑,确认自己不会踩 | SRE + 数据平台 | 下周一 | 输出一份「我们的配置里有没有这四个坑」的检查结果 |
| 决定做 / 不做,并写下理由 | 平台负责人 | 下周五 | 不做也是一个合格的结论,但要有理由 |
| # | 坑 | 症状 | 页 |
|---|---|---|---|
| 1 | mutating webhook 改写全集群所有工作负载的 schedulerName | 无法用 schedulerName 灰度;A/B 测出「两边一样」 | P08 · P36 |
| 2 | 放置规则 tag+create:true 在前 ⇒ 造出无配额队列 | 配额完全不生效,pod 全 Running | P14 |
| 3 | 队列配置里写 cpu 而非 vcore | 配额不生效,不报错 | P13 |
| 4 | schedulingPolicyParameters 键写错 ⇒ 零日志静默忽略 | 超时是 15 分钟而不是你设的值 | P17 · P28 |
| 5 | v1.9.0 /ws/v1/metrics 双重 gzip | Prometheus target 掉线,无指标 | P21 |
| 6 | 抢占走裸 DELETE ⇒ PDB 被绕过 | PDB 保护不了服务 | P27 |
| 7 | 节点排序默认 fair(摊平)⇒ 云上多开节点 | 实测 46/50 vs 30/50 个节点 | P31 |
| 8 | gang 队列必须 fifo;fair 报 unsupported sort type | 报错完全不提原因;properties 会从 root 继承 | P35 |
| 9 | 默认 gangSchedulingStyle=Soft ⇒「半个 gang」 | 应用 Running 但一半 pod Pending,监控看不出 | P17 |
| 10 | 零 placeholder 落地 ⇒ 超时计时器从不启动 | 应用无限期停在 Accepted | P15 |
| 11 | 抢占 + Deployment ⇒ 永久 Pending | ReplicaSet 补的 pod 永远调度不上 | P19 |
| 12 | 抢占方没有 event 记录;K8s event 会丢 | 查不到「我抢了谁」;审计缺记录 | P20 |
| 13 | Fargate 与 YuniKorn 互斥 | Fargate pod 永久 Pending | — |
| 14 | placeholder 吃 VPC CNI 的 IP 与 max-pods | 瞬时需 2× pod 槽位;IP 耗尽表现为 gang 超时 | — |
| 15 | kubectl delete cm yunikorn-configs ⇒ 瞬时重置为默认 | 队列树消失,无确认 | — |