Kubernetes 调度器插件开发实战:NetQoS 详解
什么是 Kubernetes 调度器?
Scheduler 的本质
Kubernetes 调度器(Scheduler)是一个独立的二进制文件,它是集群的资源调度员:
- 独立的服务器:在 K8s 中通常使用 Static Pod 来部署,在 master 节点运行
- 不是嵌入式组件:和 APIServer 是平级的独立服务
- 本质上是超级客户端:通过 API 与集群交互
调度器的核心职责
它只做唯一的一件事情:
1 2 3
| 监听 (发现一个 pod 的 spec.nodeName 是空的) → 思考 (根据算法算出哪个 Node 最合适) → 填空 (把 Node 的名字填进 spec.nodeName)
|
这就是调度的全部奥秘。
K8s 的拓扑结构关系
理解调度器在整个集群中的位置至关重要:
Master Node / Control Plane(控制平面):
- APIServer:指挥官,唯一对外窗口
- Scheduler:调度员
- Etcd:档案室
- Controller Manager:自动化运维
Worker Node / Data Plane(数据平面):
- Kubelet:地勤队长,负责接收指挥官的命令
- Container Runtime:容器运行时
- Kube-proxy:路牌,网络向导
NetQoS 插件实现详解
第一步:定义插件骨架
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
| package netqos
import ( "context" "fmt"
v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" framework "k8s.io/kubernetes/pkg/scheduler/framework" )
const Name = "NetQoSPlugin"
type NetQoS struct { handle framework.Handle }
var _ framework.ScorePlugin = &NetQoS{}
func New(_ context.Context, _ runtime.Object, h framework.Handle) (framework.Plugin, error) { return &NetQoS{ handle: h, }, nil }
func (pl *NetQoS) Name() string { return Name }
|
第二步:实现核心打分逻辑
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 62 63 64 65 66 67
|
func (pl *NetQoS) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) { nodeInfo, err := pl.handle.SnapshotSharedLister().NodeInfos().Get(nodeName) if err != nil { return 0, framework.NewStatus(framework.Error, fmt.Sprintf("getting node %q from snapshot: %v", nodeName, err)) } node := nodeInfo.Node() if node == nil { return 0, framework.NewStatus(framework.Error, fmt.Sprintf("node %q not found", nodeName)) }
podQoS := pod.Labels["qos"]
nodeNet := node.Labels["net"]
var score int64 = 10
if podQoS != "latency-sensitive" { score = 0 }
if nodeNet == "high" { score = framework.MaxNodeScore }
if nodeNet == "low" { score = 0 }
fmt.Printf("🕵️ [NetQoSPlugin] Pod=%s, Node=%s, NodeNet=%s, -> Score=%d\n", pod.Name, nodeName, nodeNet, score)
return score, nil }
|
Snapshot 机制的重要性
代码中的关键注释揭示了分布式系统中的核心概念:
“在调度周期开始时,调度器会把所有 Node 的状态’拍照’存下来。在这一轮调度中,即使 Etcd 里的 Node 变了,你查到的 Node 信息也不会变。这保证了调度逻辑的原子性。”
这意味着:
- 周期性快照:在每个调度周期开始时对所有节点状态拍一张照片
- 只读访问:整个调度过程中只能看到这张老照片
- 牺牲时效性换取确定性:宁可稍微滞后几秒也要保证决策的一致性
Label 匹配的业务语义
NetQoS 插件的核心逻辑非常简单:通过比对 Pod 和 Node 的 Label 来决定是否匹配。
约定的协议:
- Pod 如果有
qos=latency-sensitive标签,说明它对网络延迟敏感
- Node 如果有
net=high 或 net=low 标签,表明其网络带宽等级
打分矩阵:
| Pod 需求 |
Node 能力 |
得分 |
含义 |
| 无特殊要求 |
任意 |
0 |
中立态度 |
| latency-sensitive |
high |
100 |
完美匹配 |
| latency-sensitive |
low |
0 |
勉强接受 |
| latency-sensitive |
未标记 |
10 |
保守估计 |
这里体现了Soft Constraint(软约束)的设计哲学:
正如注释所说:**”Score 返回 0 分 = 可以去,但我不喜欢。如果所有高网速节点都满员了,Pod 依然会被调度到这个 0 分的低网速节点。”**
这与 Filter 阶段的硬约束形成对比:
- **硬约束 **(Filter):一票否决。比如”CPU 不够”、”有污点 (Taint)”、”端口冲突”
- **软约束 **(Score):择优录取。比如”CPU 最空闲”、”镜像已存在”、”网速快”
调试技巧:暴力打印
代码中的这段注释很有意思:
1 2 3
|
fmt.Printf("🕵️ [NetQoSPlugin] Pod=%s, Node=%s, NodeNet=%s, -> Score=%d\n", ...)
|
使用原始的 fmt.Printf 而不是正规日志库的原因很简单:无论日志级别配置得多高,stdout 总会输出,配合 kubectl logs 可以直接看到带 emoji 的关键信息。
第三步:分数归一化扩展
1 2 3 4 5
|
func (pl *NetQoS) ScoreExtensions() framework.ScoreExtensions { return nil }
|
开发与测试流程
创建 Fake Node
由于 K8s 调度有一个优化机制——当集群只有一个节点时,会跳过复杂的打分流程直接调度,因此我们必须至少有两个节点才能触发 Score 逻辑。
按照注释中的步骤操作:
1 2 3
| su - minikube-user kubectl apply -f fake-node.yaml kubectl get nodes
|
预期输出:
1 2 3
| NAME STATUS ROLES AGE VERSION minikube Ready control-plane 10d v1.25.0 fake-node Ready worker 1m v1.25.0
|
编译和启动插件
按照注释中的指导:
1 2 3 4 5
| go build -o bin/kube-scheduler cmd/scheduler/main.go
./bin/kube-scheduler --config=my-scheduler-config.yaml --v=3
|
注释特别说明:”这种方式只是便于我们本地调试,因为不用打包镜像再部署,而生产环境需要我们写 Dockerfile 再部署为一个独立的 Deployment”
给节点打标签
1 2
| kubectl label node minikube net=low --overwrite kubectl label node fake-node-1 net=high --overwrite
|
部署测试 Pod
1
| kubectl apply -f test-pod.yaml
|
观察插件输出
第一次尝试可能会发现问题:
1
| I1202 09:46:46.080884 340712 schedule_one.go:302] "Successfully bound pod to node" pod="default/qos-demo-pod" node="minikube" evaluatedNodes=2 feasibleNodes=1
|
这说明调度的时候发现了两个节点,但是只有一个可行节点(minikube)。这是因为 fake node 是个假的,在调度器看来它是不可行的节点。
解决方法是给 test-pod.yaml 加上一个 toleration 容忍污点,然后重新部署:
1 2
| kubectl delete pod qos-demo-pod --force --grace-period=0 kubectl apply -f test-pod.yaml
|
再次查看输出,可以看到打分机制生效了:
1 2 3 4
| 🕵️ [NetQoSPlugin] Pod=qos-demo-pod, Node=fake-node-1, NodeNet=high, -> Score=100 🕵️ [NetQoSPlugin] Pod=qos-demo-pod, Node=minikube, NodeNet=low, -> Score=0 I1202 09:58:38.594357 340712 default_binder.go:53] "Attempting to bind pod to node" pod="default/qos-demo-pod" node="fake-node-1" I1202 09:58:38.628905 340712 schedule_one.go:302] "Successfully bound pod to node" pod="default/qos-demo-pod" node="fake-node-1" evaluatedNodes=2 feasibleNodes=2
|
从输出可以清楚看到:
- fake-node-1 因为是高速网络获得了 100 分
- minikube 因为是低速网络得到了 0 分
- 最终 Pod 被成功调度到了高分节点 fake-node-1
分布式资源调度系统的核心设计哲学
通过分析 NetQoS 插件的实现,我们可以总结出一些通用的系统设计原则。
1. 多阶段决策模型
先过滤(Filter),再打分(Score)。
**Filter (硬约束)**:一票否决。比如”CPU 不够”、”有污点 (Taint)”、”端口冲突”。这是布尔逻辑 (True/False)。
**Score (软约束)**:择优录取。比如”CPU 最空闲”、”镜像已存在”、”网速快”。这是加权逻辑 (Weighted Ranking)。
短路优化原则也在这里生效:如果 filter 已经筛选掉了一个节点,就没必要再花时间去 score 它了。
架构启示:当你设计任何一个资源分配系统时,永远要先做减法(过滤掉不可用的),再做排序(选出最好的)。不要试图在一个步骤里完成这两件事,那是性能噩梦。
2. 一致性快照和无锁设计
在每一轮调度周期(Scheduling Cycle)开始时,K8s 会把当前所有 Node 的状态”拍一张照片”存在内存里。
在接下来的几毫秒内,你的 Filter 和 Score 逻辑全都是基于这张照片算的。不需要加锁,也不需要访问 API Server。
架构启示:在读多写少的场景下,Copy-On-Write (COW) 或周期性快照是提升性能的神器。
3. 框架与插件模式
深层原理:控制反转 (Inversion of Control)。
现在的 Scheduling Framework 定义了 10+ 个扩展点(QueueSort, PreFilter, Filter, PostFilter, Score, NormalizeScore, Reserve, Permit, PreBind, Bind…)。
它把流程固定下来,把逻辑开放出去。
架构启示:优秀的架构设计是”对修改封闭,对扩展开放”(Open-Closed Principle)。如果你要设计一个复杂的业务系统(比如交易流程),试着把它拆解成”核心流程”+”插件挂载点”。
4. 乐观并发控制
**Assume (假设)**:当调度器选好 Node-A 后,它不会先去 Etcd 写数据(太慢)。它会直接在内存里把 Node-A 的资源扣掉(Assume),然后立刻开始调度下一个 Pod。
**Async Bind (异步绑定)**:真正的写入操作(Bind)是异步发送给 APIServer 的。
**Conflict (冲突)**:如果后面发现写入失败(比如 Node-A 突然挂了),调度器会执行”回滚”,把刚才扣掉的资源还回去。
架构启示:为了极致的吞吐量(Throughput),我们可以先斩后奏。只要回滚机制设计得好,乐观锁比悲观锁(数据库事务)快得多。
总结
通过对 NetQoS 插件源码的分析,我们不仅学会了一个具体的调度器插件如何实现,更重要的是理解了背后蕴含的系统设计思想:
- 多阶段决策:先减后加的决策顺序
- 快照隔离:以时间换空间的无锁设计
- 插件架构:控制反转带来的可扩展性
- 乐观并发:先斩后奏的高吞吐策略
这些原则不仅适用于 K8s 调度器,也可以应用到其他分布式系统和资源管理系统的设计中。