Async Scheduling: чому Pod створюється як Pending¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Коли ти робиш 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з reasonUnschedulable.- 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.
Ресурси¶
- kube-scheduler: асинхронна модель шедулера
- Scheduling Framework: точки розширення від filter до bind
- Pod Lifecycle: фази поду і
PodScheduledcondition - Scheduler Performance Tuning:
percentageOfNodesToScoreі компроміс точність/латентність - Kubernetes SLIs/SLOs: офіційні цілі, зокрема pod startup latency
- Omega paper: оптимістична конкурентність у шедулерах
- Go concurrency patterns: класична доповідь Роба Пайка
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Label Selectors | Наступна: Graceful Shutdown
