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

Async Scheduling: чому Pod створюється як Pending

Async Scheduling: чому Pod створюється як Pending

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Коли ти робиш sheepctl apply pod.json, API Server повертає 201 Created одразу. Под ще не запущений, навіть не запланований. Він Pending. Це не баг - це свідоме рішення, яке робить систему стійкою до збоїв.

Що відбувається в API Server

func (api *APIServer) createPod(w http.ResponseWriter,
    r *http.Request) {
    var pod Pod
    json.NewDecoder(r.Body).Decode(&pod)

    pod.Kind = "Pod"
    if pod.Metadata.Namespace == "" {
        pod.Metadata.Namespace = "default"
    }
    if pod.Metadata.UID == "" {
        pod.Metadata.UID = generateUID()
    }
    pod.Metadata.CreatedAt = time.Now()
    pod.Status.Phase = PodPending  // Завжди Pending!

    api.store.CreatePod(&pod)

    api.store.RecordEvent(Event{
        Type:    "Normal",
        Reason:  "Created",
        Message: fmt.Sprintf("Pod %s created", pod.Metadata.Name),
        Object:  "pod/" + pod.Metadata.Name,
    })

    go api.scheduler.SchedulePod(&pod) // fire-and-forget

    respondJSON(w, http.StatusCreated, pod)
}

Три ключових моменти:
1. Phase = PodPending - завжди, без винятків
2. go api.scheduler.SchedulePod() - асинхронний виклик
3. respondJSON(201) - одразу після збереження, не чекаючи scheduling

Синхронний vs асинхронний підхід

graph TB
    subgraph "Синхронний підхід (не робимо так)"
        S1["POST /pods"] --> S2["Save pod"]
        S2 --> S3["Find node"]
        S3 --> S4["Wait for agent"]
        S4 --> S5["Wait for container start"]
        S5 --> S6["Response: 201 Running"]
        S3 -->|"Нема нод"| S7["Timeout 30s"]
        S7 --> S8["Response: 503"]
    end

    subgraph "Асинхронний підхід (як робимо)"
        A1["POST /pods"] --> A2["Save pod (Pending)"]
        A2 --> A3["Response: 201 Pending"]
        A2 -.->|"async"| A4["Scheduler picks up"]
        A4 -.-> A5["Agent starts containers"]
    end

Синхронний підхід означає, що HTTP запит блокується на секунди або навіть хвилини. Якщо нод немає - timeout. Якщо agent повільний - клієнт чекає. Якщо API Server під навантаженням - всі workers зайняті очікуванням.

Асинхронний: API Server відповідає за мілісекунди. Scheduling, запуск контейнерів - все відбувається у фоні.

Три стадії Pending

Под в стані Pending може бути в різних ситуаціях:

stateDiagram-v2
    [*] --> PendingNoNode : API створив
    PendingNoNode --> PendingWithNode : Scheduler призначив
    PendingWithNode --> Running : Agent запустив
    PendingNoNode --> PendingNoNode : Scheduler не знайшов ноду

    note right of PendingNoNode
        NodeName = ""
        Чекає scheduler
    end note

    note right of PendingWithNode
        NodeName = "node-1"
        Чекає agent
    end note

Як відрізнити? Поле pod.Spec.NodeName:
- Пусте - scheduler ще не подивився або не знайшов ноду
- Заповнене - scheduler призначив, agent ще не запустив

// Scheduler шукає тільки поди без ноди
func (s *Scheduler) reconcile() {
    pods, _ := s.store.ListPods("")
    for _, pod := range pods {
        if pod.Status.Phase == PodPending &&
            pod.Spec.NodeName == "" {
            s.SchedulePod(pod)
        }
    }
}

Resilience через reconciliation

Ось чому go scheduler.SchedulePod() в createPod - це fire-and-forget. Навіть якщо ця goroutine crashed:

// Scheduler reconciliation 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() // підхопить будь-які Pending поди
        }
    }
}

Reconciliation loop знайде Pending под без NodeName і запланує його. Не потрібно retry logic, не потрібно error handling для goroutine - loop виправить стан за 2 секунди.

Таймлайн створення поду

T+0ms:    sheepctl apply → POST /api/v1/pods
T+1ms:    API Server saves pod (Phase: Pending, NodeName: "")
T+2ms:    API Server responds 201 Created
T+2ms:    go scheduler.SchedulePod() fired
T+100ms:  Scheduler assigns pod to node-1
T+3000ms: Agent reconcile tick - бачить Pending pod на своїй ноді
T+3500ms: Agent creates and starts container
T+3600ms: Agent updates pod (Phase: Running, PodIP: 10.20.0.2)

Від створення до Running - ~3.5 секунди. Для production pipeline це прийнятно. Для real-time - ні, але оркестрація контейнерів не потребує real-time.

Є один момент

Eventual consistency означає, що одразу після sheepctl apply клієнт бачить Pending. Якщо скрипт робить apply і одразу перевіряє стан - він побачить Pending і може вирішити, що щось зламалось. Потрібен polling або watch:

# Поганий підхід:
sheepctl apply -f pod.json
sheepctl get pods  # ще Pending!

# Правильний підхід:
sheepctl apply -f pod.json
sleep 5 && sheepctl get pods  # Running

В Kubernetes kubectl wait --for=condition=Ready pod/web-0 вирішує це через Watch API. У Shepherd Watch API немає, тому тільки polling.

Ще пара пасток. go scheduler.SchedulePod() плюс reconcile-loop означають, що один і той самий под можуть схопити обидва шляхи майже одночасно - без перевірки NodeName == "" всередині критичної секції він призначиться двічі. І Pending не вічний у голові користувача, але вічний у системі: под без потрібних ресурсів може висіти Pending назавжди, мовчки, поки хтось не подивиться в events.

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

  • У Kubernetes scheduler і kubelet - окремі компоненти, що навіть не говорять напряму. Scheduler лише проставляє pod.spec.nodeName через API Server, а kubelet на ноді сам помічає "о, цей под мій" і запускає його. Точно як наш NodeName.
  • Причому scheduler навіть не робить PATCH на под. Для призначення існує окремий підресурс - POST /api/v1/namespaces/{ns}/pods/{name}/binding. Об'єкт Binding - це фактично "лист про призначення", який API Server розгортає у запис spec.nodeName. Окремий підресурс означає окремий RBAC: можна дозволити компоненту призначати поди, не даючи права переписувати їхній spec цілком.
  • Цей розрив на "призначити" і "запустити" - приклад оптимістичної конкурентності з paper Omega: компоненти не блокують одне одного, а діють незалежно і вирівнюють стан потім.
  • Оптимізм там буквальний: kube-scheduler робить крок assume - записує под у свій локальний кеш як уже розміщений ще до того, як bind-виклик повернеться. Інакше, поки летить один HTTP-запит, він устиг би "продати" ту саму пам'ять ноди наступним десяти подам. Якщо bind провалився - під forgetається і повертається в чергу.
  • Pending у Kubernetes - це не одна причина, а ціла родина: немає ресурсів, не пройшов affinity, taint без toleration, не змонтувався volume. Зовні фаза одна, всередині - десятки сценаріїв. Справжню причину видно не у phase, а в status.conditions - умові PodScheduled=False з reason Unschedulable.
  • Kube-scheduler свідомо не оцінює всі ноди. Параметр percentageOfNodesToScore змушує його зупинитись, знайшовши "достатньо" підхожих (адаптивно, але не менше 5% на великих кластерах). Це прямий обмін точності розміщення на латентність - на кластері у 5000 нод перебирати все просто задорого.
  • У SIG Scalability на це є цифра-обіцянка: 99% подів без stateful-залежностей мають стартувати менш ніж за 5 секунд, і час на pull образу з цього SLO свідомо виключено, бо він не залежить від оркестратора.
  • 201 Created одразу після запису, без очікування запуску - це той самий принцип, що й HTTP 202 Accepted: "прийняв, оброблю пізніше". Async API старші за Kubernetes на десятиліття.

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

Найбільший інсайт: fire-and-forget виглядає недбало, але насправді це чесніше за синхронний виклик. Синхронний apply, який чекає Running, бреше клієнту про надійність - досить впасти agent'у посеред запуску, і ти отримаєш 200 на под, якого нема. Async із reconcile-loop нічого не обіцяє наперед: він просто гарантує, що стан зрештою вирівняється. Я перестав сприймати ту goroutine як "запуск поду" і почав бачити її як підказку шедулеру - приємний оптимізатор латентності, без якого все одно все працює завдяки циклу.

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

  • Прибрати гонку: робити scheduling тільки через reconcile-loop, а go SchedulePod() лишити як необов'язковий fast-path із перевіркою-захопленням (CAS на NodeName).
  • Додати причину в Status: чому под Pending (Unschedulable, InsufficientResources) і подію - щоб не доводилось здогадуватись.
  • Реалізувати sheepctl wait --for=Running поверх polling із timeout - навіть без Watch API це закриє 90% болю в скриптах.
  • Додати backoff на scheduling: якщо ноду не знайдено, не довбати reconcile щодва секунди вічно, а нарощувати інтервал.

Спробуй сам

# Створи под і одразу перевір стан:
sheepctl apply -f examples/pod.json
sheepctl get pods  # Phase: Pending (ще не запланований)
sleep 2
sheepctl get pods  # Phase: Pending (scheduled, agent ще не запустив)
sleep 3
sheepctl get pods  # Phase: Running
sheepctl events    # побачиш Created → Scheduled → Running

Далі - Graceful Shutdown: як один close(channel) зупиняє всі goroutines.

Ресурси

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

Попередня: Label Selectors | Наступна: Graceful Shutdown