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
    # ...
  }
}

抢占流程:

  1. 常规打分找不到可用节点 → 进入 PreemptionIterator;
  2. 遍历节点上优先级更低的 Allocation,逐个"虚拟移除"后重新计算资源;
  3. 凑够资源后,把这些 Allocation 标记为 Preempted,生成"停止 + 新建"的组合 Plan;
  4. 被抢占的 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-peersRaft 法定人数是唯一真相来源
灰度发布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 的全部优雅,来自两个决断:

  1. 把决策(Scheduler)与提交(Applier)分离,用乐观并发换取并行度;
  2. 把调度表达为纯粹的函数计算——同样的输入(状态快照 + Evaluation),在任何节点上重算,都能得到同样的 Plan。

代价也很明显:Nomad 没有 Kubernetes 那样繁荣的 Operator 生态,服务发现、配置分发、Ingress 都要外接 Consul;它的抽象层次更低,很多事情要你自己拼。

如果你的场景是大规模、多租户、混合负载(在线 + 离线 + GPU)的批量调度,Nomad 用五分之一的复杂度交付了更高的调度吞吐;如果你需要的是一整套云原生应用交付生态,那 Kubernetes 仍然更合适。选型的本质不是比功能清单,而是判断"调度"在你的系统里究竟占多大比重——Nomad 赌的是:对很多团队来说,调度就是全部。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部