Nomad 调度器深度实战:从两阶段调度、Bin Packing 打分到 Plan Applier 与抢占驱逐的工程全解
关键词:Nomad、调度器、Bin Packing、Plan Applier、Evaluation、抢占、Drain
大多数人对调度器的认知来自 Kubernetes:一个控制循环不断比对 desired/actual,把 Pod 绑定到 Node。HashiCorp Nomad 走的是另一条路——它没有声明式 reconcile 循环,而是一条 Evaluation → Plan → Applier 的流水线:先算出一份"状态变更提案",再由 Leader 串行地校验、落地。这条设计让 Nomad 的单集群规模可以轻松推到 5000+ 节点、十万级 Allocation,而调度延迟仍保持在毫秒级。本文拆开这条流水线,看看它到底做对了什么。
一、数据模型:从 Job 到 Allocation 的四层下推
job "web" {
datacenters = ["dc1"]
type = "service"
group "web" { # Task Group = 调度单元
count = 10
network {
port "http" {}
}
spread { # 打散:跨机架/可用区
attribute = "${node.datacenter}"
weight = 100
}
constraint { # 过滤:硬性约束
attribute = "${attr.kernel.name}"
value = "linux"
}
task "server" { # Task = 最小执行单元
driver = "docker"
config {
image = "nginx:1.25"
ports = ["http"]
}
resources {
cpu = 500 # MHz
memory = 256 # MB
}
}
}
}
Nomad 的调度粒度是 Task Group,不是单个容器。一个 Group 内的多个 Task 共享网络命名空间、共享卷,被当作一个整体打包到同一节点上——这就是 Nomad 版的 Pod,但比 Pod 更早出现。
层级关系:Job → Task Group (count=N) → Task → Allocation。Allocation 是"Group 实例在某个 Node 上的一次落地",是调度器唯一真正写出的产物。理解这一点很关键:调度器的全部工作,就是决定哪些 Node 上应该新增/停止哪些 Allocation。
二、流水线:Evaluation → Plan → Plan Applier
Job 注册 / 节点变更 / Allocation 失败
│
▼
写入 Raft 状态机(memdb + BoltDB)
│
▼
生成 Evaluation(一次"需要重新决策"的令牌)
│
▼
Broker 按调度类型分发到 Scheduler 队列
│
▼
Scheduler 计算 → 产出 Plan(变更提案)
│
▼
Plan Applier(仅 Leader)校验 plan.Index 连续性
│
├── NACK(序号冲突)→ Evaluation 重新入队
└── ACK → 事务提交到状态机 → 下发 Allocation
三个组件各司其职:
- Broker:维护 Evaluation 队列,按
service / batch / system / sysbatch / _core分桶,每种 Job 类型一个独立 worker pool,避免一个巨型 batch Job 饿死在线服务。 - Scheduler:无状态计算,只读状态快照,输出 Plan。它不直接写状态。
- Plan Applier:只在 Leader 上运行的核心串行点。所有 follower 的 Plan 都要通过 RPC 转发给 Leader 执行。
乐观并发:为什么 Plan Applier 是性能瓶颈的解药
Plan 带一个单调递增的 Index。Applier 要求 plan.Index == lastAppliedIndex + 1,否则直接 NACK,让 Evaluation 重新入队重算。
// scheduler/plan_applier.go 简化示意
func (a *PlanApplier) SubmitPlan(p *structs.Plan) (*PlanResult, error) {
a.l.Lock()
defer a.l.Unlock()
if p.Index != a.latestIndex+1 {
// 有人抢先改了状态,本次提案作废
return nil, errors.New("plan rejected: stale index")
}
// 以事务方式落地:要么全成功,要么全回滚
txn := a.snap.StateStore.WriteTxn(p.Index)
for _, alloc := range p.NodeAllocation {
if err := txn.Insert("allocs", alloc); err != nil {
txn.Abort()
return nil, err
}
}
if err := txn.Commit(); err != nil {
return nil, err
}
a.latestIndex = p.Index
return &PlanResult{RefreshIndex: p.Index}, nil
}
这是经典的乐观并发控制。调度计算(慢、可并行、可重入)与状态提交(快、串行、必须有序)被彻底解耦。多个 Scheduler 可以并行算,只有最后那一步写入需要排队——而这步只是一次内存事务提交,代价极低。相比之下,把整个调度逻辑塞进一个全局锁里,才是让调度器在集群变大时雪崩的根本原因。
工程推论:集群调度吞吐的上限 ≈ 1 / 单次 Plan Apply 耗时。生产上这个值通常在亚毫秒级,所以瓶颈几乎总出现在 Scheduler 的过滤/打分计算,或者 Raft 复制延迟上,而不是 Applier。
三、两阶段调度:Feasibility 过滤 + Ranking 打分
Nomad 为每个待放置的 Allocation 遍历候选节点,采用迭代器栈(iterator stack)的写法,本质是先硬过滤、后软打分。
// scheduler/generic_sched.go 简化示意
func (s *GenericScheduler) selectNode(...) *RankedNode {
// 1) 随机打散节点,避免热点与惊群
iter := NewRandomIterator(nodes)
// 2) 可行性过滤:不满足即出局
iter = NewFeasibilityWrapper(iter, s.ctx, s.evalID,
NewDriverChecker(), // 是否装了 docker / exec / java
NewConstraintChecker(), // constraint / affinity 硬规则
NewResourceChecker(), // CPU/Memory/磁盘 是否放得下
NewDistinctHostsChecker(),// 同名 Task 是否已占该节点
NewNetworkChecker(), // 端口是否冲突
NewCSIVolumeChecker(), // 卷拓扑是否满足
)
// 3) 打分排序:多个 Scorer 叠加
iter = NewBinpackIterator(iter, ...) // 装箱紧密度
iter = NewJobAntiAffinityIterator(iter, ...)
iter = NewNodeAffinityIterator(iter, ...)
iter = NewSpreadIterator(iter, ...) // 跨属性打散
iter = NewPreemptionIterator(iter, ...) // 抢占兜底
return iter.Next()
}
Bin Packing:Nomad 的默认装箱哲学
Nomad 默认倾向于把节点塞满而不是摊平,这与 Kubernetes 的默认 LeastAllocated 策略相反。理由很实际:装箱能减少碎片、提高单节点利用率,从而让集群缩容省下真金白银。
打分逻辑大致是:计算放置后节点的资源占用率,占用率越高得分越高(在合理范围内),再叠加各维度的权重:
// scheduler/binpack.go 简化示意
func (iter *BinpackIterator) scoreNode(node *structs.Node) float64 {
// 归一化资源占比(0~1)
cpuUsed := float64(node.Resources.Cpu) / float64(node.NodeResources.Cpu.CpuShares)
memUsed := float64(node.Resources.Memory) / float64(node.NodeResources.Memory.MemoryMB)
// 核心:占用率越高越"合意",但要避免超过水位线
fit := iter.binPackFit(node)
if fit > 0 {
// 装箱奖励:接近满配的节点得分更高
return fit
}
// fit < 0 表示放不下或超限,直接淘汰
return -1
}
如果你希望"摊平"而不是"塞满",可以在 server 配置里切策略:
# server.hcl
server {
enabled_schedulers = ["service", "batch", "system", "_core"]
# 把装箱算法换成"最空闲优先",适合延迟敏感型在线服务
default_scheduler_config {
scheduler_algorithm = "spread"
}
}
实战建议:离线/批处理集群用 binpack(省成本),在线交易集群用 spread(抗突发流量、降低单机故障半径)。混合型集群的做法是拆成两个 datacenter,各自设不同算法,用 datacenters 字段路由。
Spread 与 Anti-Affinity 的区别
很多人搞混这两个。一句话概括:
constraint+distinct_hosts:硬约束,放不下就 pending。spread:软打分,只影响排序,永远能放下,但可能堆在一起。affinity:带权重的软偏好(weight = 100强偏好,-100强排斥)。
生产上的正确姿势是:可用性用 spread 表达,安全性用 constraint 表达。比如"同机架最多放 3 个副本"这种硬要求,用 distinct_property 或 constraint 表达;"尽量分散"才用 spread。
四、抢占:优先级驱动的资源回收
当高优先级 Job 无节点可用时,Nomad 不会一直挂着,而是尝试抢占低优先级 Allocation。
job "critical-api" {
priority = 100 # 默认 50,范围 1~100
group "api" {
count = 20
# ...
}
}
抢占流程:
- 常规打分找不到可用节点 → 进入
PreemptionIterator; - 遍历节点上优先级更低的 Allocation,逐个"虚拟移除"后重新计算资源;
- 凑够资源后,把这些 Allocation 标记为
Preempted,生成"停止 + 新建"的组合 Plan; - 被抢占的 Allocation 所属 Job 会收到一个新的 Evaluation,尝试重新调度。
# 观测抢占行为
nomad job status -verbose critical-api
# 查看被抢占的 allocation
nomad alloc status <alloc-id> | grep -i -A3 "Placement Metrics"
踩坑提醒:抢占是级联的。A 抢 B、B 又去抢 C,在资源紧张的大集群里可能形成抢占风暴。因此 Nomad 默认只在 system 和 sysbatch 之外的调度器上开启抢占,且建议给关键 Job 配 constraint 划出专属节点池(用 node.class 或自定义 meta),从根上避免抢占。
五、Drain 与迁移:有状态调度的必修课
节点下线是调度器最容易被低估的能力。Nomad 的 drain 不是简单"杀掉重建",而是一套带超时与并行度控制的迁移状态机:
# 优雅下线:给 1 小时迁移窗口,每批最多迁 2 个
nomad node drain \
-enable \
-deadline 1h \
-max-parallel 2 \
-min-healthy-time 30s \
-healthy-deadline 5m \
<node-id>
对应的 HCL 迁移策略:
job "db" {
migrate {
max_parallel = 1 # 有状态服务必须串行迁移
health_check = "checks"
min_healthy_time = "30s"
healthy_deadline = "5m"
}
group "db" {
count = 3
update {
max_parallel = 1
canary = 1 # 先灰度 1 个
min_healthy_time = "30s"
healthy_deadline = "5m"
auto_revert = true # 失败自动回滚
auto_promote = false # 人工确认后再放量
}
}
}
auto_revert = true 是被低估的一个开关:灰度实例健康检查失败时自动回滚到上一版本,配合 auto_promote = false,就得到了一套"半自动金丝雀"——比在外部再搭 Argo Rollouts 轻量得多。
节点 drain 期间,deadline 到期仍未迁完的 Allocation 会被强制停止。对于跑长任务的 batch Job,务必把 deadline 设得大于单个任务的最大运行时长,否则会出现"迁到一半被砍"的诡异状态。
六、生产调优清单
结合上面的机制,给出几条经过验证的调优项:
| 场景 | 手段 | 说明 |
|---|---|---|
| 调度延迟高 | 调大 num_schedulers | 每种类型独立 worker,默认 1,大集群建议 4~8 |
| 大批量 Job 饿死在线服务 | 拆分 datacenter 或调 scheduler_config | 不同类型 Job 走独立队列与并发度 |
| 节点资源碎片严重 | 保持 binpack 算法 + 统一实例规格 | 规格越杂,碎片率越高 |
| 抢占风暴 | 用 constraint 划专属节点池 | 优先级只是兜底,不是常态手段 |
| 状态不一致 | 只信任 nomad operator raft list-peers | Raft 法定人数是唯一真相来源 |
| 灰度发布 | update.canary + auto_revert | 无需外部组件 |
调试调度决策时,这两个命令是救命稻草:
# 查看某个 Job 为什么没被调度起来(会打印每个节点的失败原因)
nomad job status -verbose <job-id>
# 干跑:只算 Plan 不落地,先看 diff
nomad plan <job.hcl>
nomad plan 是 Nomad 最有价值的运维特性之一:它把"提交即变更"变成"先预览 diff 再决定",配合 CI 可以在合并前拦截掉 80% 的误操作。
七、结语:Nomad 的设计取舍
Nomad 的全部优雅,来自两个决断:
- 把决策(Scheduler)与提交(Applier)分离,用乐观并发换取并行度;
- 把调度表达为纯粹的函数计算——同样的输入(状态快照 + Evaluation),在任何节点上重算,都能得到同样的 Plan。
代价也很明显:Nomad 没有 Kubernetes 那样繁荣的 Operator 生态,服务发现、配置分发、Ingress 都要外接 Consul;它的抽象层次更低,很多事情要你自己拼。
如果你的场景是大规模、多租户、混合负载(在线 + 离线 + GPU)的批量调度,Nomad 用五分之一的复杂度交付了更高的调度吞吐;如果你需要的是一整套云原生应用交付生态,那 Kubernetes 仍然更合适。选型的本质不是比功能清单,而是判断"调度"在你的系统里究竟占多大比重——Nomad 赌的是:对很多团队来说,调度就是全部。

发表评论 取消回复