Перейти до змісту

Scheduler: як вибрати ноду для поду

Scheduler: як вибрати ноду для поду

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Коли под створюється, він у стані Pending і без призначеної ноди. Scheduler має знайти найкращу ноду для нього. В Shepherd це працює через два етапи: фільтрація (відсій непридатних) і скоринг (вибір найкращого).

graph LR
    A["Всі ноди"] --> B["Filter<br/>Ready? Heartbeat?<br/>Resources? Labels?"]
    B --> C["Feasible ноди"]
    C --> D["Score<br/>Least-loaded?<br/>Resource balance?"]
    D --> E["Вибрана нода"]

Reconciliation loop

Scheduler працює як ticker-based loop кожні 2 секунди:

func (s *Scheduler) Run(stopCh <-chan struct{}) {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-stopCh:
            return
        case <-ticker.C:
            s.reconcile()
        }
    }
}

func (s *Scheduler) reconcile() {
    pods, _ := s.store.ListPods("")
    for _, pod := range pods {
        if pod.Status.Phase == PodPending &&
            pod.Spec.NodeName == "" {
            s.SchedulePod(pod)
        }
    }
}

Шукаємо всі поди в стані Pending без призначеної ноди. Для кожного викликаємо SchedulePod.

Фільтрація нод

func (s *Scheduler) filterNodes(nodes []*Node,
    pod *Pod) []*Node {
    var feasible []*Node

    for _, node := range nodes {
        // Нода повинна бути Ready
        if node.Status.Condition != NodeReady {
            continue
        }

        // Heartbeat має бути свіжим (< 30 секунд)
        if time.Since(node.Status.LastHeartbeat) >
            30*time.Second {
            continue
        }

        // NodeSelector повинен збігатися
        if !matchLabels(node.Metadata.Labels,
            pod.Spec.NodeSelector) {
            continue
        }

        // Перевірка ресурсів
        totalReq := podResourceRequests(pod)
        alloc := node.Status.Allocatable
        if totalReq.CPU > 0 && totalReq.CPU > alloc.CPU {
            continue
        }
        if totalReq.Memory > 0 &&
            totalReq.Memory > alloc.Memory {
            continue
        }
        if node.Status.PodCount >= alloc.Pods {
            continue
        }

        feasible = append(feasible, node)
    }
    return feasible
}

Чотири перевірки:
1. Нода Ready (не впала)
2. Heartbeat свіжий (агент відповідає)
3. Labels збігаються з nodeSelector поду
4. Достатньо ресурсів (CPU, memory, pod count)

Скоринг

func (s *Scheduler) scoreNodes(nodes []*Node,
    pod *Pod) []scoredNode {
    scored := make([]scoredNode, len(nodes))

    for i, node := range nodes {
        score := 0

        // Prefer nodes with fewer pods
        score += (node.Status.Allocatable.Pods -
            node.Status.PodCount) * 10

        // Prefer nodes with more available CPU
        if node.Status.Allocatable.CPU > 0 {
            usedRatio := float64(
                node.Status.Allocatable.CPU -
                podResourceRequests(pod).CPU) /
                float64(node.Status.Allocatable.CPU)
            score += int(usedRatio * 50)
        }

        // Prefer nodes with more available memory
        if node.Status.Allocatable.Memory > 0 {
            usedRatio := float64(
                node.Status.Allocatable.Memory -
                podResourceRequests(pod).Memory) /
                float64(node.Status.Allocatable.Memory)
            score += int(usedRatio * 50)
        }

        scored[i] = scoredNode{node: node, score: score}
    }
    return scored
}

Два критерії:
- Least-loaded - менше подів = вищий скор
- Resource balance - більше вільних ресурсів = вищий скор

Призначення поду

func (s *Scheduler) SchedulePod(pod *Pod) {
    nodes, _ := s.store.ListNodes()
    feasible := s.filterNodes(nodes, pod)

    if len(feasible) == 0 {
        pod.Status.Message = "no feasible nodes"
        s.store.UpdatePod(pod)
        return
    }

    scored := s.scoreNodes(feasible, pod)
    sort.Slice(scored, func(i, j int) bool {
        return scored[i].score > scored[j].score
    })

    selected := scored[0].node

    pod.Spec.NodeName = selected.Metadata.Name
    pod.Status.Phase = PodPending // все ще Pending!
    pod.Status.Message = fmt.Sprintf(
        "scheduled to node %s", selected.Metadata.Name)
    pod.Status.HostIP = selected.Spec.Address

    s.store.UpdatePod(pod)

    s.store.RecordEvent(Event{
        Type:    "Normal",
        Reason:  "Scheduled",
        Message: fmt.Sprintf("Pod %s scheduled to node %s",
            pod.Metadata.Name, selected.Metadata.Name),
    })
}

Зверни увагу: под залишається Pending навіть після планування. Він стане Running тільки коли агент на ноді реально запустить контейнери. Це eventual consistency.

matchLabels

func matchLabels(nodeLabels, selector map[string]string) bool {
    if len(selector) == 0 { return true }
    for k, v := range selector {
        if nodeLabels[k] != v { return false }
    }
    return true
}

Всі ключі selector повинні збігатися. Пустий selector - збігається з будь-чим.

Порівняння зі справжнім Kubernetes

В K8s scheduler набагато складніший:
- Десятки predicates (наші filters)
- Scoring з конфігурованими вагами
- Preemption (виселення подів з нижчим пріоритетом)
- Pod affinity/anti-affinity
- Taints і tolerations

Але базова модель та сама: filter, score, bind.

Про що варто пам'ятати

Scoring не враховує вже запланованих, але ще не запущених подів. Якщо 10 подів створюються одночасно, всі можуть потрапити на одну ноду (вона виглядає найвільнішою на момент планування). В Kubernetes є reserved resources для цього.

Ще дві пастки. Heartbeat у 30 секунд означає, що мертва нода ще пів хвилини вважається придатною — под може бути призначений на ноду, якої вже немає. І sort.Slice нестабільний: при однакових скорах порядок нод недетермінований, тому "перша найкраща" може мінятися від запуску до запуску.

💡 Цікаві факти

  • Двофазна модель filter → score прийшла в Kubernetes напряму з Borg і Omega — внутрішніх систем Google. Omega-paper окремо описує, як перехід від монолітного шедулера до "shared state" дозволив запускати кілька шедулерів паралельно без блокувань.
  • У реального kube-scheduler фаза скорингу нормалізує всі бали до діапазону 0–100 перед підсумовуванням з вагами — інакше плагін з великими абсолютними числами "перекрив" би всі інші.
  • Дефолтний плагін балансування в Kubernetes називається NodeResourcesBalancedAllocation — він віддає перевагу нодам, де CPU і memory використані рівномірно, а не тим, де просто багато вільного. Наш скоринг цього не робить.
  • Preemption (виселення подів з нижчим пріоритетом, щоб звільнити місце) додали не одразу — спочатку Kubernetes просто лишав под у Pending, поки місце не звільниться саме.

Що я зрозумів, поки розбирався з темою

Найбільший інсайт — що "scheduled" і "running" це різні стани, і шедулер відповідає тільки за перше. Спочатку я писав код так, ніби призначення ноди = запуск поду, і ніяк не міг зрозуміти, чому статус не сходиться. Аж поки не дійшло: шедулер лише ставить NodeName, а далі вже агент на ноді сам вирішує, коли реально стартувати контейнери. Розділення відповідальності тут не теоретичне — воно прямо впливає на те, де шукати баг.

Що можна покращити

  • Додати reserved resources: рахувати вже призначені, але ще не запущені поди, щоб пачка подів не злетіла на одну ноду.
  • Зробити сортування детермінованим — sort.SliceStable плюс tie-break по імені ноди.
  • Винести ваги скорингу (*10, *50) у конфіг замість хардкоду — це перший крок до pluggable-шедулера як у Kubernetes.
  • Додати найпростіший anti-affinity: не ставити дві репліки одного deployment на ту саму ноду.

Спробуй сам

# Створи под і подивись events:
sheepctl apply -f examples/pod.json
sheepctl events | head -5
# Побачиш: Created, Scheduled, pod → node
sheepctl get pods

Scheduler призначає ноди. Далі - reconciliation loop, серце всієї системи.

Ресурси

Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow

Попередня: BoltDB State Store | Наступна: Reconciliation Loop