Scheduler: як вибрати ноду для поду¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Коли под створюється, він у стані 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, серце всієї системи.
Ресурси¶
- kube-scheduler concepts — офіційна документація шедулера
- Scheduling, Preemption and Eviction — повний розділ
- Borg paper (Google Research) — система, від якої Kubernetes успадковує ідеї
- Omega paper (Google Research) — архітектура гнучкого шедулера
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: BoltDB State Store | Наступна: Reconciliation Loop
