从云原生走向 AI 原生:一套面向未来的架构方法论 → 阅读《AI 原生基础设施》

调度器

已发行

HAMi Scheduler Extender 是 GPU 资源控制平面的决策引擎,通过 Filter、Score、Bind 三段式流程为每个 Pod 选择最优的 GPU 节点和设备。

调度器架构

Scheduler Extender 模式

Kubernetes 原生调度器不理解 GPU 资源的切分与共享模型。HAMi 通过 Scheduler Extender 模式扩展调度能力:kube-scheduler 在调度过程中通过 HTTP 调用 HAMi 的 Extender 端点,将 GPU 相关的决策委托给 HAMi。

图 1: Scheduler Extender 架构
图 1: Scheduler Extender 架构

HTTP 路由

HAMi Scheduler Extender 暴露以下 HTTP 端点:

路由方法调用时机功能
/filterPOSTkube-scheduler 过滤阶段筛选满足 GPU 资源需求的节点
/scorePOSTkube-scheduler 评分阶段对可行节点进行 GPU 相关评分
/bindPOSTkube-scheduler 绑定阶段执行 Pod 到节点的绑定
/webhookPOST准入控制阶段辅助 Webhook 处理
/healthzGET健康检查返回组件健康状态
/readyzGET就绪检查返回组件就绪状态

节点缓存与 Informer 同步

调度器维护一个内存中的节点资源缓存,通过 Kubernetes Informer 实时同步:

// 伪代码:节点缓存结构
type NodeCache struct {
    sync.RWMutex
    nodes map[string]*NodeInfo
}

type NodeInfo struct {
    Name       string
    Devices    []DeviceInfo    // 节点上的 GPU 设备列表
    Allocatable DeviceUsage    // 可分配资源总量
    Used       DeviceUsage     // 已分配资源量
    Pods       []PodAllocation // 已调度到该节点的 Pod
}

// Informer 事件处理
func (c *NodeCache) OnNodeUpdate(oldNode, newNode *v1.Node) {
    c.Lock()
    defer c.Unlock()

    info := parseNodeDevices(newNode)
    c.nodes[newNode.Name] = info
}

func (c *NodeCache) OnPodUpdate(oldPod, newPod *v1.Pod) {
    c.Lock()
    defer c.Unlock()

    if newPod.Spec.NodeName != "" {
        node := c.nodes[newPod.Spec.NodeName]
        node.updateAllocation(newPod)
    }
}

Informer 通过 Watch 机制监听 Node 和 Pod 的变化事件,确保缓存与集群实际状态保持一致,避免每次调度请求都查询 API Server。

Filter 阶段

Filter 阶段的核心任务是:从候选节点列表中筛选出满足 Pod GPU 资源需求的节点。

收集 Pod 资源请求

// 解析 Pod 的 GPU 资源请求
func parsePodGPURequest(pod *v1.Pod) *GPURequest {
    req := &GPURequest{}
    for _, container := range pod.Spec.Containers {
        limits := container.Resources.Limits
        if val, ok := limits[HamivGPU]; ok {
            req.Count += int(val.Value())
        }
        if val, ok := limits[HamivGPUMem]; ok {
            req.Memory += int(val.Value())
        }
        if val, ok := limits[HamivGPUCores]; ok {
            req.Cores += int(val.Value())
        }
    }
    return req
}

遍历候选节点

对每个候选节点,调度器执行一系列检查:

// Filter 阶段伪代码
func Filter(args *extender.ExtenderArgs) (*extender.ExtenderFilterResult, error) {
    pod := args.Pod
    nodes := args.Nodes.Items

    gpuReq := parsePodGPURequest(pod)
    // 无 GPU 请求,跳过过滤
    if gpuReq.Count == 0 {
        return &extender.ExtenderFilterResult{Nodes: args.Nodes}, nil
    }

    feasibleNodes := []v1.Node{}
    failedNodes := map[string]string{}

    for _, node := range nodes {
        nodeInfo := cache.GetNode(node.Name)
        if nodeInfo == nil {
            failedNodes[node.Name] = "节点缓存未找到"
            continue
        }

        // 检查 1:设备类型匹配
        if !matchDeviceType(pod, nodeInfo) {
            failedNodes[node.Name] = "设备类型不匹配"
            continue
        }

        // 检查 2:GPU 资源余量
        if !checkGPUFit(nodeInfo, gpuReq) {
            failedNodes[node.Name] = "GPU 资源不足"
            continue
        }

        // 检查 3:NUMA 亲和性
        if !checkNUMAAffinity(pod, nodeInfo) {
            failedNodes[node.Name] = "NUMA 亲和性不满足"
            continue
        }

        // 检查 4:UUID 白黑名单
        if !checkUUIDFilter(pod, nodeInfo) {
            failedNodes[node.Name] = "UUID 过滤不通过"
            continue
        }

        // 检查 5:设备健康状态
        if !checkDeviceHealth(nodeInfo) {
            failedNodes[node.Name] = "设备不健康"
            continue
        }

        // 检查 6:配额检查
        if !checkQuota(pod.Namespace, gpuReq) {
            failedNodes[node.Name] = "命名空间配额超限"
            continue
        }

        feasibleNodes = append(feasibleNodes, node)
    }

    return &extender.ExtenderFilterResult{
        Nodes:        &v1.NodeList{Items: feasibleNodes},
        FailedNodes:  failedNodes,
    }, nil
}

设备类型匹配

Pod 可以通过注解指定需要的 GPU 型号:

metadata:
  annotations:
    hami.io/gpu-type: "NVIDIA-A100-SXM4-40GB"

调度器在 Filter 阶段比对注解中的设备类型与节点实际设备,排除不匹配的节点。

NUMA 检查

对于需要多 GPU 的工作负载(如分布式训练),NUMA 拓扑影响数据传输性能:

func checkNUMAAffinity(pod *v1.Pod, node *NodeInfo) bool {
    numaHint := pod.Annotations["hami.io/numa-hint"]
    if numaHint == "" {
        return true // 未指定 NUMA 要求,跳过
    }

    requiredNUMA, _ := strconv.Atoi(numaHint)
    for _, device := range node.Devices {
        if device.NUMANode == requiredNUMA && device.HasEnoughResources(pod) {
            return true
        }
    }
    return false
}

UUID 白黑名单

通过注解控制 Pod 可以使用或排除特定的 GPU 设备:

metadata:
  annotations:
    # 白名单:只使用指定的 GPU
    hami.io/gpu-uuid-include: "GPU-abc123,GPU-def456"
    # 黑名单:排除指定的 GPU
    hami.io/gpu-uuid-exclude: "GPU-xyz789"

健康状态与配额检查

  • 健康状态调度器从 Device Plugin 上报的设备状态中获取健康信息,排除不健康的设备
  • 配额检查结合 Kubernetes ResourceQuota 检查命名空间级别的 GPU 资源配额

Score 阶段

Score 阶段对 Filter 通过的节点进行评分,影响最终的节点选择。

binpack/spread/topology-aware 策略

HAMi 支持三种评分策略,可在全局或 Pod 级别配置:

图 2: 评分策略对比
图 2: 评分策略对比

评分算法

评分的核心是计算每个节点上 GPU 资源的"匹配度":

// 评分阶段伪代码
func Score(args *extender.ExtenderArgs) (map[string]int64, error) {
    pod := args.Pod
    nodes := args.Nodes.Items
    policy := getScorePolicy(pod) // 从注解或全局配置获取

    scores := map[string]int64{}
    for _, node := range nodes {
        nodeInfo := cache.GetNode(node.Name)
        switch policy {
        case "binpack":
            scores[node.Name] = binpackScore(nodeInfo, pod)
        case "spread":
            scores[node.Name] = spreadScore(nodeInfo, pod)
        case "topology-aware":
            scores[node.Name] = topologyScore(nodeInfo, pod)
        }
    }
    return scores, nil
}

binpack 策略评分

优先将 Pod 调度到 GPU 使用率高的节点,最大化单节点资源利用率:

func binpackScore(node *NodeInfo, pod *v1.Pod) int64 {
    var totalScore int64
    for _, device := range node.Devices {
        // 设备使用率越高,分数越高
        memUsage := float64(device.UsedMemory) / float64(device.TotalMemory)
        coreUsage := float64(device.UsedCores) / float64(device.TotalCores)
        usageScore := int64((memUsage * 0.6 + coreUsage * 0.4) * 100)
        totalScore += usageScore
    }
    return totalScore / int64(len(node.Devices))
}

适用场景:资源受限环境,希望尽量少用节点。

spread 策略评分

优先将 Pod 调度到 GPU 使用率低的节点,实现负载均衡:

func spreadScore(node *NodeInfo, pod *v1.Pod) int64 {
    var totalScore int64
    for _, device := range node.Devices {
        memUsage := float64(device.UsedMemory) / float64(device.TotalMemory)
        coreUsage := float64(device.UsedCores) / float64(device.TotalCores)
        // 使用率越低,分数越高
        usageScore := int64((1 - (memUsage*0.6 + coreUsage*0.4)) * 100)
        totalScore += usageScore
    }
    return totalScore / int64(len(node.Devices))
}

适用场景:多 GPU 分布式训练,需要设备间高速通信。

topology-aware 策略评分

考虑 GPU 拓扑结构,优先选择同一 NUMA 节点或 PCIe 域内的设备:

func topologyScore(node *NodeInfo, pod *v1.Pod) int64 {
    var score int64

    // NUMA 亲和性评分(权重 50%)
    numaScore := calculateNUMAAffinity(node, pod)
    score += numaScore * 50

    // PCIe 距离评分(权重 30%)
    pcieScore := calculatePCIEDistance(node, pod)
    score += pcieScore * 30

    // P2P 互访能力评分(权重 20%)
    p2pScore := calculateP2PCapability(node, pod)
    score += p2pScore * 20

    return score
}

适用场景:多 GPU 分布式训练,需要设备间高速通信。

节点级 vs 卡级策略

HAMi 支持两层评分策略:

# 全局配置
scheduler:
  policy:
    nodeSchedulerPolicy: binpack    # 节点级策略
    gpuSchedulerPolicy: spread      # 卡级策略(在同一节点的多张卡之间)
  • 节点级策略决定选择哪个节点
  • 卡级策略在选定节点后,决定选择哪张 GPU 卡

Bind 阶段

Bind 阶段将 Pod 绑定到选定的节点,并记录设备分配决策。

绑定流程

图 3: Bind 阶段流程
图 3: Bind 阶段流程

节点加锁

func (s *Scheduler) Bind(args *extender.ExtenderBindingArgs) (*extender.ExtenderBindingResult, error) {
    // 1. 获取节点级互斥锁
    lock := s.lockManager.Acquire(args.NodeName)
    defer lock.Release()

    // 2. 读取节点当前资源状态
    nodeInfo := s.cache.GetNode(args.NodeName)

    // 3. 计算具体的设备分配方案
    pod, _ := s.client.CoreV1().Pods(args.PodNamespace).Get(ctx, args.PodName, metav1.GetOptions{})
    gpuReq := parsePodGPURequest(pod)
    allocation := nodeInfo.ComputeAllocation(gpuReq)

    if allocation == nil {
        return nil, fmt.Errorf("资源不足以分配")
    }

    // 4. 写入分配注解
    allocJSON, _ := json.Marshal(allocation)
    patch := map[string]interface{}{
        "metadata": map[string]interface{}{
            "annotations": map[string]string{
                "hami.io/device-allocated":    string(allocJSON),
                "hami.io/device-bind-phase":   "allocating",
                "hami.io/bind-node":           args.NodeName,
            },
        },
    }
    patchPod(s.client, pod.Name, pod.Namespace, patch)

    // 5. 执行绑定
    bind := &v1.Binding{
        ObjectMeta: metav1.ObjectMeta{Name: args.PodName, UID: args.PodUID},
        Target:     v1.ObjectReference{Kind: "Node", Name: args.NodeName},
    }
    err := s.client.CoreV1().Pods(args.PodNamespace).Bind(ctx, bind, metav1.CreateOptions{})

    // 6. 更新本地缓存
    if err == nil {
        s.cache.UpdateAllocation(args.NodeName, allocation)
    }

    return &extender.ExtenderBindingResult{}, err
}

释放锁

锁在 defer lock.Release() 中自动释放,无论绑定成功或失败。这确保:

  • 绑定成功后,其他 Pod 的调度请求能看到最新的资源状态
  • 绑定失败后,资源不会被永久锁定

配额管理

命名空间 ResourceQuota 集成

HAMi 支持通过 Kubernetes 原生 ResourceQuota 限制命名空间级别的 GPU 资源使用:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: gpu-quota
  namespace: ai-team
spec:
  hard:
    nvidia.com/gpu: "10"          # 最多 10 个 GPU 设备
    nvidia.com/gpumem: "40000"    # 最多 40000 MB 显存
    nvidia.com/cores: "200"    # 最多 200% 算力

按命名空间限制设备内存总量

调度器在 Filter 阶段检查当前命名空间已使用的 GPU 资源总量:

func checkQuota(namespace string, req *GPURequest) bool {
    quota := getResourceQuota(namespace)

    used := getNamespaceGPUUsage(namespace)
    if used.Memory+req.Memory > quota.Memory {
        return false // 显存配额超限
    }
    if used.Count+req.Count > quota.Count {
        return false // GPU 数量配额超限
    }
    return true
}

防止资源滥用

配额管理的设计目标:

  • 公平分配多个团队共享集群时,防止单个团队垄断 GPU 资源
  • 成本控制通过配额限制各命名空间的 GPU 使用上限
  • 自助服务团队在配额范围内自主调度,无需管理员干预

事件增强

Pod 事件中显示成功/失败节点数量

调度器在调度完成后,会在 Pod 事件中记录详细的调度信息:

# 查看调度事件
kubectl describe pod ai-inference

# Events:
#   Type    Reason           From              Message
#   ----    ------           ----              -------
#   Normal  Scheduled        hami-scheduler    Successfully assigned ai-inference to gpu-node-01
#                                             Filter: 3/5 nodes feasible, 2 insufficient GPU memory
#                                             Score: node-01=85, node-02=72, node-03=68
#                                             Device: GPU-abc123 (4000MB/30cores allocated)

标准化错误码

错误码含义建议操作
InsufficientGPUMemory节点显存不足扩容 GPU 节点或降低显存请求
InsufficientGPUCores节点算力不足扩容或降低算力请求
DeviceTypeMismatch设备类型不匹配检查注解中的设备类型
DeviceUnhealthy设备不健康检查 GPU 硬件状态
QuotaExceeded命名空间配额超限联系管理员调整配额
NUMAUnsatisfiedNUMA 亲和性不满足调整 NUMA 拓扑要求
UUIDFilteredUUID 白黑名单过滤检查 UUID 注解配置

日志分级

调度器使用 klog 分级日志:

# v4 级别:节点摘要
kubectl logs -n hami-system hami-scheduler-xxx -v=4
# I0101 "Filter result" node="gpu-01" feasible=true score=85
# I0101 "Filter result" node="gpu-02" feasible=false reason="InsufficientGPUMemory"

# v5 级别:设备细节
kubectl logs -n hami-system hami-scheduler-xxx -v=5
# I0101 "Device fit check" node="gpu-01" device="GPU-abc123" freeMem=8000 reqMem=4000 fit=true
# I0101 "Device fit check" node="gpu-01" device="GPU-def456" freeMem=2000 reqMem=4000 fit=false

领导者选举与节点锁

领导者选举

多副本调度器通过 Kubernetes Lease 实现领导者选举:

// 领导者选举配置
leaderelection.LeaderElectionConfig{
    Lock: &resourcelock.LeaseLock{
        LeaseMeta: metav1.ObjectMeta{
            Name:      "hami-scheduler-leader",
            Namespace: "hami-system",
        },
        Client: client.CoordinationV1(),
        LockConfig: resourcelock.ResourceLockConfig{
            Identity: hostname,
        },
    },
    LeaseDuration: 15 * time.Second,
    RenewDeadline: 10 * time.Second,
    RetryPeriod:   2 * time.Second,
    Callbacks: leaderelection.LeaderCallbacks{
        OnStartedLeading: runScheduler,
        OnStoppedLeading: func() {
            klog.Info("Leader election lost, shutting down")
            os.Exit(1)
        },
    },
}

节点锁实现

type NodeLockManager struct {
    locks map[string]*sync.Mutex
    mu    sync.Mutex
}

func (m *NodeLockManager) Acquire(nodeName string) *sync.Mutex {
    m.mu.Lock()
    defer m.mu.Unlock()

    if _, ok := m.locks[nodeName]; !ok {
        m.locks[nodeName] = &sync.Mutex{}
    }
    m.locks[nodeName].Lock()
    return m.locks[nodeName]
}

节点锁保障:即使同一个 Leader 调度器并行处理多个 Pod 的 Bind 请求,同一节点的资源分配操作也是串行执行的。

调度器配置

以下是通过 Helm values.yaml 配置调度器的完整示例:

# values.yaml - 调度器配置
scheduler:
  enabled: true
  nameOverride: "hami-scheduler"

  # 镜像配置
  image:
    repository: projecthami/hami
    tag: v2.5.0
    pullPolicy: IfNotPresent

  # 副本数(建议 >= 2)
  replicas: 3

  # 调度策略
  policy:
    nodeSchedulerPolicy: binpack      # binpack | spread
    gpuSchedulerPolicy: binpack       # binpack | spread | topology-aware

  # 资源超卖配置
  overcommit:
    enabled: true
    deviceMemoryScaling: 1.0          # 显存超卖比例
    deviceCoreScaling: 1.0            # 算力超卖比例

  # 领导者选举
  leaderElect:
    enabled: true
    leaseDuration: 15s
    renewDeadline: 10s
    retryPeriod: 2s

  # 节点缓存同步
  cache:
    resyncPeriod: 300s                # 全量同步周期
    watchTimeout: 30s                 # Watch 超时

  # 资源限制
  resources:
    requests:
      cpu: 500m
      memory: 512Mi
    limits:
      cpu: "2"
      memory: 2Gi

  # 优先级
  priorityClassName: "system-cluster-critical"

  # 反亲和性
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: hami-scheduler
        topologyKey: kubernetes.io/hostname

  # 容忍度
  tolerations:
  - key: "node-role.kubernetes.io/control-plane"
    operator: "Exists"
    effect: "NoSchedule"

Scheduler Extender 注册

kube-scheduler 需要配置 Extender 以调用 HAMi:

# kube-scheduler 配置(ConfigMap)
apiVersion: v1
kind: ConfigMap
metadata:
  name: scheduler-config
  namespace: kube-system
data:
  scheduler-config.yaml: |
    apiVersion: kubescheduler.config.k8s.io/v1
    kind: KubeSchedulerConfiguration
    profiles:
    - schedulerName: hami-scheduler
      extenders:
      - urlPrefix: "http://hami-scheduler.hami-system:9000"
        filterVerb: "/filter"
        scoreVerb: "/score"
        bindVerb: "/bind"
        weight: 1
        managedResources:
        - name: nvidia.com/gpu
        - name: nvidia.com/gpumem
        - name: nvidia.com/cores

小结

本章详细介绍了 HAMi Scheduler Extender 的设计与实现:

  • 架构:SchedulerExtender 模式,HTTP 路由,节点缓存与 Informer 同步
  • Filter:阶段设备类型匹配、资源余量检查、NUMA 亲和性、UUID 过滤、健康状态和配额检查
  • Score:阶段 binpack/spread/topology-aware 三种评分策略,节点级与卡级双层策略
  • Bind:阶段节点加锁、分配注解写入、绑定执行、锁释放
  • 配额管理:命名空间 ResourceQuota 集成,防止资源滥用
  • 事件增强:调度详情事件、标准化错误码、分级日志
  • 高可用:领导者选举、节点级锁

在下一章中,我们将深入 Device Plugin 和设备抽象层的实现细节。

创建于 2026/06/04 更新于 2026/06/04 3299 字 阅读约 7 分钟