结果:已合并(2026-11-09 17:44, kai-scheduler/KAI-Scheduler#2532),Issue #2531 也一并关闭。 事实上,本文内容取自 GitHub 的 PR、Issue、Review、时间线、检查记录,KAI-Scheduler 源码(PR 的基础 Commit
0243c4e9)以及 API 模块(标签 v0.2.0), 贡献账本的实测原始数据与初稿,以及 TT Lab 仓库的课程 Commit。时间均为韩国时间。
目录
- 一句话总结
- 1. 问题所在:示例集全军覆没,什么都接收不到
- 2. 根本原因:仅跳过类型中 0 和 -1 的检查
- 3. 修改内容:diff 要点
- 4. 反应与应对:一个提问与被清除的批准
- 5. 经验教训:开源贡献要领
- 链接
一句话总结
| PR | kai-scheduler/KAI-Scheduler#2532 docs(queues): explain that an omitted limit is 0 and fix the example queues |
| 修改内容 | docs/queues/README.md 一个文件。特殊值表格的一行、说明的一个段落,示例队列集中缺失的 limit: -1 和 memory 块。新增 16 行,删除 2 行 |
| 反应 | 其他贡献者确认代码路径并提出一个问题、协作者权限的审阅者批准(后自动解除)、维护者批准以及 skip-changelog 标签 |
| 结果 | PR 公开 10-27 14:34 → 合并 11-09 17:44,历时约 13 天 3 小时 10 分钟。经合并队列执行的 Squash 合并 |
1. 问题所在:示例集全军覆没,什么都接收不到
KAI-Scheduler 是一个面向 GPU 工作负载的 Kubernetes 调度器。如果在 Pod 中写入 schedulerName: kai-scheduler ,该调度器就会代替默认调度器
接管部署,并根据 Pod 所属队列(Queue)的份额来分配资源。队列中针对每种资源(cpu、memory、gpu)都设有保证量 quota 和上限 limit 。
在 10-27 凌晨制作 TT Lab 学习网站的“Kubernetes GPU 调度与 AI 部署”课程时,我首次踩到了这个坑。在实验 VM 上运行一台 k3s,
部署了 4 张虚拟 GPU(fake-gpu-operator 0.2.1)和 KAI-Scheduler v0.18.6,然后创建了一个未在 gpu quota: 1 中写入 limit 的队列,并放入了一个单 GPU 的 Pod。由于配额(Quota)为 1,本以为能接收 1 张,但 Pod 却停留在 Pending 状态,PodGroup 上还附带了这样的条件:
OverLimit: nolimit quota has reached the allowable limit of GPUs. Limit is 0 GPUs,
currently 0 GPUs allocated and workload requested 1 GPUs.
limit 将 -1 改为 vfy-kai.log后,一直等待的同一个 Pod 随即完成调度(实操验证记录4cdcc0b3)。我没有计算具体几秒钟启动。这件事成为了课程的两个实操(“limit 留空的队列陷阱”与
“将 limit 写入 -1 来修复”),并在当天的课程 Commit( -1 )的阅读材料中写道:“请务必将 limit 写作
或具体数字”。 真正的问题在于文档。队列文档的特殊值表格将留空的 limit 解释为“无额外资源(默认值)”,最后一个示例的标题则是
“Unlimited Queue - default value is -1”。阅读文档的人自然会期待:留空的话要么能用到配额上限,要么干脆就是无限制。
于是,我们将文档中的示例队列集 原封不动地requests: {cpu: 100m, memory: 32Mi}, limits: {nvidia.com/gpu: 1}应用,并逐个放入相同的 Pod( kai-docs-test.log·kai-docs-after.log )。原始数据取自贡献账本的
| ,每个 Pod 等待 15 秒后读取状态。 | 示例队列 | 修改前(照搬文档) |
|---|---|---|
修改后(PR 初版)memory Basic Queue(无 |
) Limit is 0 GB Pending, |
(内存) |
Running limit Hierarchical Queue(cpu·gpu 无 memory ,无 |
) Limit is 0 cores Pending, |
(CPU) |
Runningmemory Unlimited Queue(无 |
) Limit is 0 GB Pending, |
(内存) |
Running memory: {quota: -1, limit: -1} 仅在 Basic Queue 中加入
,等待中的 Pod 便转为了 Running。无论是设定了 GPU 上限的示例,还是名为“Unlimited”的示例,都因为缺少内存块,而无法接收哪怕请求一丝内存的 Pod。在 Kubernetes 中,不请求内存的 Pod 凤毛麟角,因此初次参考该文档创建队列的人几乎必然会在此处碰壁。
环境为单台实验 VM(4 vCPU·4GiB)、k3s v1.36.5、KAI-Scheduler v0.18.6,且 GPU 为虚拟设备。未在真实的 GPU 节点或多节点环境中进行测量(未实测)。不过,由于该判定是由调度器仅根据队列数字完成的,因此似乎与 GPU 是否真实无关。
2. 根本原因:仅跳过类型中 0 和 -1 的检查 0243c4e9 行号基于 PR 的基础 Commit kai-scheduler/api 及该 Commit 使用的 API 模块
- v0.2.0。
docs/queues/README.md文档。 第 61–67 行limit的特殊值表格将0 or unset的 描述为“No additional resources allowed (default)”。 第 112 行memory的示例标题为 “Unlimited Queue - default value is -1”。第 76–126 行的示例集中没有一个 - 块。
scheduling/v2/resources.go类型。 第 17–24 行QueueResource.Limit的float64是一个没有默认值标识的 。如果不填写,就会变成 Go 的零值 0。 图表中的 Queue CRDlimit也只将type: number写为default且缺少 - ,因此 API Server 并不会自动填充 -1。
proportion.go值迁移。 第 414–427 行Limit直接将队列的math.Max(-1, limit*mebibytes)传递作为上限。只有内存会将单位转换为 - ,但无论向 0 乘以什么,结果都依然是 0。
max_allowed_check.go检查。 第 51–66 行isOverLimit的
会针对每种资源分别遍历,只有当上限精确等于 -1 时才会跳过(第 54 行)。除此之外,它都会检查“已用量 + 请求量”是否小于等于上限。如果上限为 0,那么只要请求哪怕一点该资源就会超出限制。由于请求量为 0 的资源会被跳过(第 58–60 行),因此如果是未请求内存的 Pod,即使没有内存块也能通过。 总结来说,代码本身逻辑前后一致:“留空则为 0,为 0 则阻断,仅 -1 代表无限制”。出问题的是文档的说明与示例。 因此,我在 Issue 中同时提出了两种方向:要么将文档修改为符合当前实际行为,要么如果预期的默认值是 -1,则在 CRD 默认值或准入阶段进行填充。 由于究竟是否该改变行为应由维护者决定,我只制作了能立即合并的文档侧 PR,并备注道:“如果选择修改默认值的方向,可以关闭此 PR”。
3. 修改内容:diff 要点
-| `limit` | `0` or unset | No additional resources allowed (default) |
+| `limit` | `0` or unset | No resources of this type can be allocated to the queue, including its quota (default) |
+
+Each resource (`cpu`, `memory`, `gpu`) is checked on its own. If a queue omits a resource or its `limit`,
+the limit for that resource is `0`, and workloads that request it stay pending with an `OverLimit` condition
+such as `Limit is 0 GB`. Set `limit: -1` for every resource you do not want to cap.
# Basic Queue, Hierarchical Queue
+ memory:
+ quota: 0
+ limit: -1
# Hierarchical Queue 의 cpu·gpu 에도
+ limit: -1
-### Unlimited Queue - default value is -1
+### Unlimited Queue
+`-1` is not filled in for you: set it on every resource.
# Unlimited Queue
+ memory:
+ quota: -1 # Unlimited quota
+ limit: -1 # No limit
(说明段落原本只有长长的一行,为了便于阅读我将其拆分成了三行。)
PR 正文中包含了第 1 节的修改前后表格、实测环境、“仅修改文档,代码与 Chart 保持不变”的声明、 skip-changelog 标签请求、若修改同一文件其他部分的开放 PR #2275(MIG 计算说明)率先合并则会进行 Rebase 的说明,以及在 AI 辅助下发现不一致、进行实测并撰写初稿的声明。
该仓库的 CONTRIBUTING 要求满足 DCO(Signed-off-by)、Conventional Commits 风格的 PR 标题、会改变行为的变更对应的变更日志片段(如果是纯文档则为
skip-changelog 标签),并且要求外部贡献者的 PR 须获得“两名受信审阅者的批准”。由于仓库没有关于使用 AI 的单独规则,我阅读了上级基金会的生成式 AI 指南,并做出了如实申报。外部贡献者无权添加标签,因此我在正文中进行了请求。
4. 反应与应对:一个提问与被清除的批准
| 时间 | 日期 |
|---|---|
| 10-27 凌晨 | 在实验 VM 中发现陷阱,测量文档示例集修改前后的情况(原始数据文件时间 01:46·02:30·02:38) |
| 10-27 14:33–14:34 | 提交 Issue #2531,42 秒后公开 PR(初版 c871f61b,带 SSH 签名) |
| 10-27 19:28 | 其他贡献者的 Review(意见):确认代码路径与新说明相符并提出一个问题 |
| 10-28 21:39 | 具有协作者权限的审阅者批准了初版 |
| 10-29 00:47 | 推送反映了提问的 Commit 47bd30b5 ,批准随即自动解除。回复评论 |
| 10-29 00:54 | 将两个 Commit 合并为一个带有 SSH 签名的 Commit(2c23833a)并进行强制更新(Force Push) |
| 10-29 06:17 | 提问的贡献者留下“2c23833a reads right” |
| 11-01 01:21 | 发表请求重新审阅已被解除的批准的评论 |
| 11-09 16:04–16:05 | 维护者批准,附带 skip-changelog 标签 |
| 11-09 17:43–17:44 | 进入合并队列通过四项检查,17:44:50 合并(e5f0f298),Issue #2531 也随之关闭 |
提问。 另一位贡献者直接追踪了 proportion.go·CRD·isOverLimit ,确认“代码与新语句相符”后,指出这并非要求而是提问:由于新的 memory 块是 quota: -1 ,连内存 保证量也变成了无限制,但 GPU 示例将“无保证”写作了 quota: 0 ,因此如果意图仅仅是解除上限,使用 quota: 0·limit: -1 是否更加贴切?这是一个非常中肯的指出。我们想要的本来是“不要将上限设为 0”,而不是修改保证量。因此,我将设定了上限的两个示例(Basic·Hierarchical)的内存改为
quota: 0·limit: -1 ,仅保留所有资源皆无限制的 Unlimited 示例使用 quota: -1 。上方的 diff 即为最终版本。
并没有在实验 VM 中重新运行此最终版本的记录(未实测)。第 1 节的“修改后”是内存为 quota: -1 的初版结果。由于上限检查只看
limit ,推测结果应无二致,且同一实测中 Unlimited 示例的 gpu 以 quota: 0·limit: -1 获得了 1 张 GPU 即为依据,但并未单独测量内存 quota: 0 本身。
被清除的批准。 具有协作者权限的审阅者批准初版三小时后,我提交了反映提问的 Commit,GitHub 的时间线中随即便留下了因该推送而导致批准被解除(dismissed)的记录。引发解除的 Commit 是 47bd30b5 。。该仓库似乎使用了当有新提交上传时便会作废之前批准的设置(仓库设置无权查看,故未确认)。在 7 分钟后,我们将两个提交合并为一个带签名的提交并重新上传,而在 11-01 留下的请求重新批准的评论中,写了「squash push 清除了批准」。再次查看事件记录,发现并不是 squash,而是它前方的 push。无论哪种,批准后的 push 都会清除批准这一点是相同的,但在说明原因时,比起凭记忆,更应该先查看时间线。
等待与合并。 在 10-29 凌晨的问题整理完毕后,有 11 天没有新的审查。11-09 16:04,维护者进行了批准,1分钟后贴上了标签,并在 17:43 放入了合并队列。在合并队列中,PR 工作流、变更记录检查、PR 标题检查以及 DCO 都在 14 秒内全部通过,而由于是仅修改文档的 PR,构建和 E2E 检查则以「不需要」为由被跳过。合并提交的作者是我们的账号,消息是 PR 标题末尾带上了 (#2532) 以及 DCO 签名。
在 PR 页面上,两个红色的检查依然保留着。变更记录检查和计算批准数的检查(Approval Gatekeeper)在 10-29 强制刷新时运行并失败了,虽然在 11-09 16:03 再次运行,但由于是在批准和标签贴上之前的 1~2 分钟,因此又失败了。谁重新运行的,无法从公开记录中确认。Approval Gatekeeper 要求外部贡献者的 PR 必须获得组织成员或协作者两人的批准,但在合并时依然有效的受信任批准只有维护者的一票(协作者的批准处于解除状态,提问的贡献者只留下了意见)。该检查并未在合并队列中运行,因此它似乎并不是阻止合并的必选检查。规则的应用由维护者判断,我们无需单独进行应对。
截至 11-10,没有包含此提交的发布标签。合并后发布的 v0.16.16 和 v0.14.12 是旧版本分支的补丁发布,此文档修改仅存在于 main 分支中。该文档可直接在 GitHub 的 main 分支中读取。
5. 学到的经验:开源贡献要点
- 「留空时是什么」不通过文档,而是通过类型和 CRD 来确认。 Go 구조체의 영값, CRD 의
default,如果在 Webhook 的默认值填充中也没有找到 -1,那么留空的值就是 0。至于 0 究竟是「无」还是「阻止」,必须一直追踪读取该值的检查函数到最后才能知道。 - 文档中的示例应当逐字逐句地实际应用一下。 三个示例全部失败这一事实,是通过复制粘贴而不是通过阅读代码得知的。如果有「修改前与修改后」的表格,维护者和审查者就都不需要在脑海中重新做一遍相同的实验了。
- 是否更改行为通过 Issue 询问,而文档 PR 则只提供其中一种答案。 将默认值更改为 -1 会改变现有用户的队列行为,这不是外部贡献者可以决定的事情。如果在 Issue 中写出两条路径,并在文档 PR 中写上「如果选择了另一边,将其关闭即可」,维护者只需做选择就行了。这次 Issue 没有收到回复,文档 PR 合并后,Issue 也随之关闭了。
- 提问也具有被采纳的价值。 即使是被评价为「这是请求而非提问」的审查意见,如果它能使示例的含义更加准确,也应当予以采纳。quota 和 limit 听起来都像是「直到多少」,但一个是保证,一个是上限。文档示例应当原封不动地展现出这种差异。
- 批准后的 push 会清除批准。 如果有需要修改的地方,去修改固然没错,但在需要两个批准的仓库中失去一个批准会导致等待时间变长。审查问题应当在批准前处理好,合并提交的工作也应一次性完成。究竟是什么清除了批准,可以通过时间线 API(
issues/<번호>/timeline) 的review_dismissed事件和dismissal_commit_id来确认。
Titi 的话。 学校小卖部的卡上有一个「每日限额」栏,我以为留空就是无限呢。但是小卖部阿姨把空白处读作了 0 元。所以连一瓶牛奶都买不到!说明上明明只写着「空白处为默认值」。当我建议在说明上加上「空白处是 0 元,如果是无限请务必填写 -1」之后,13 天后贴出了新的说明。中间因为修改了一个涂鸦而好不容易获得的盖章被清除了,觉得稍微有点可惜。
链接
所有资料均查阅于 2026-11-10。
- PR: kai-scheduler/KAI-Scheduler#2532 (合并提交
e5f0f298) - Issue: kai-scheduler/KAI-Scheduler#2531
- 修改的文档(基准提交):
docs/queues/README.md - 行为依据(基准提交):
proportion.go,max_allowed_check.go, Queue CRD,kai-scheduler/apiv0.2.0resources.go - 贡献规则: CONTRIBUTING, Approval Gatekeeper 工作流
- 同文件的其他 PR: kai-scheduler/KAI-Scheduler#2275 MIG GPU 计算说明(截至 11-10 开放)
- GitHub 时间线 API: https://docs.github.com/en/rest/issues/timeline