Node Health: heartbeat і failure detection¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Як control plane знає, що нода жива? Через heartbeats. Агент на кожній ноді відправляє heartbeat кожні 10 секунд. Якщо heartbeat не приходить 30 секунд - NodeController позначає ноду як NotReady. Scheduler перестає призначати на неї поди.
Дві сторони механізму¶
sequenceDiagram
participant A as Agent (node-1)
participant API as API Server
participant NC as NodeController
loop Кожні 10 секунд
A->>API: PUT /api/v1/nodes/node-1<br/>{condition: Ready, lastHeartbeat: now, podCount: 5}
API->>API: Save to BoltDB
end
Note over A: Agent впав (crash, мережа, etc.)
loop Кожні 10 секунд
NC->>API: GET /api/v1/nodes
NC->>NC: time.Since(lastHeartbeat) > 30s?
NC->>API: PUT node-1 {condition: NotReady}
NC->>API: RecordEvent("Warning", "NodeNotReady")
end
Agent - відправник heartbeats¶
func (a *Agent) Run(stopCh <-chan struct{}) error {
// ... registration ...
heartbeat := time.NewTicker(10 * time.Second)
defer heartbeat.Stop()
reconcile := time.NewTicker(3 * time.Second)
defer reconcile.Stop()
for {
select {
case <-stopCh:
return nil
case <-heartbeat.C:
a.sendHeartbeat()
case <-reconcile.C:
a.reconcilePods()
}
}
}
func (a *Agent) sendHeartbeat() {
node, err := a.getNode()
if err != nil {
a.logger.Printf("agent: get node error: %v", err)
return
}
// Оновлюємо стан
containers := a.mgr.List(false) // тільки running
node.Status.PodCount = len(containers)
node.Status.LastHeartbeat = time.Now()
node.Status.Condition = NodeReady
if err := a.put("/api/v1/nodes/"+a.nodeName, node); err != nil {
a.logger.Printf("agent: heartbeat error: %v", err)
}
}
Heartbeat оновлює три речі:
1. PodCount - скільки контейнерів реально працює (scheduler використовує для scoring)
2. LastHeartbeat - timestamp (NodeController перевіряє свіжість)
3. Condition = Ready - кожен heartbeat підтверджує "я живий"
NodeController - детектор відмов¶
type NodeController struct {
store *Store
logger *log.Logger
}
func (nc *NodeController) Run(stopCh <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-stopCh: return
case <-ticker.C: nc.reconcile()
}
}
}
func (nc *NodeController) reconcile() {
nodes, err := nc.store.ListNodes()
if err != nil { return }
for _, node := range nodes {
if time.Since(node.Status.LastHeartbeat) >
30*time.Second {
if node.Status.Condition != NodeNotReady {
nc.logger.Printf(
"node controller: node %s is not ready "+
"(last heartbeat: %s ago)",
node.Metadata.Name,
time.Since(node.Status.LastHeartbeat).
Round(time.Second))
node.Status.Condition = NodeNotReady
nc.store.UpdateNode(node)
nc.store.RecordEvent(Event{
Type: "Warning",
Reason: "NodeNotReady",
Message: fmt.Sprintf(
"Node %s heartbeat timeout",
node.Metadata.Name),
Object: "node/" + node.Metadata.Name,
Timestamp: time.Now(),
})
}
}
}
}
Перевірка if node.Status.Condition != NodeNotReady - захист від спаму подіями. Без неї кожні 10 секунд NodeController записував би нову Warning подію для тієї самої мертвої ноди.
State machine ноди¶
stateDiagram-v2
[*] --> Ready : Agent реєструється
Ready --> Ready : Heartbeat (< 30с)
Ready --> NotReady : Heartbeat timeout (> 30с)
NotReady --> Ready : Heartbeat отримано
NotReady --> [*] : Node видалена
note right of Ready
Scheduler призначає нові поди
Agent працює нормально
end note
note right of NotReady
Scheduler ігнорує ноду
Існуючі поди не переміщуються
end note
Вплив на Scheduler¶
Scheduler перевіряє і condition, і timestamp:
func (s *Scheduler) filterNodes(nodes []*Node,
pod *Pod) []*Node {
var feasible []*Node
for _, node := range nodes {
if node.Status.Condition != NodeReady {
continue // пропускаємо NotReady
}
if time.Since(node.Status.LastHeartbeat) >
30*time.Second {
continue // heartbeat застарілий
}
// ... інші перевірки
feasible = append(feasible, node)
}
return feasible
}
Подвійна перевірка потрібна, бо NodeController має свій інтервал (10с). Може бути ситуація, коли heartbeat вже старий, але NodeController ще не встиг позначити ноду NotReady.
Таймінги¶
| Що відбувається | Час |
|---|---|
| Agent відправляє heartbeat | кожні 10с |
| NodeController перевіряє | кожні 10с |
| Timeout heartbeat | 30с |
| Найгірший випадок: agent впав → NotReady | до 40с (30с timeout + 10с інтервал NC) |
| Найкращий випадок | ~30с |
Де підводні камені¶
30 секунд - компроміс. Менше - більше false positives: мережевий glitch на 15 секунд і нода "впала". Більше - повільніша реакція на реальну відмову.
В Kubernetes default - 40 секунд. І навіть після NotReady поди не видаляються одразу - є grace period 5 хвилин. У Shepherd ми не переміщуємо поди з NotReady нод взагалі. Якщо нода повернеться - поди повернуться з нею. Якщо ні - ReplicationController створить нові на інших нодах (бо бачить менше подів ніж desired).
Ще один момент: якщо мережа між Agent і API Server впала, Agent продовжує працювати і обслуговувати контейнери. Тільки heartbeat не доходить. Control plane вважає ноду мертвою, хоча контейнери працюють. Це split-brain ситуація, і простого рішення немає.
Ще пара речей, на які варто звернути увагу:
time.Since(lastHeartbeat)рахується годинником control plane, а timestamp ставить Agent. Якщо годинники розійшлися (clock skew), нода може "впасти" або "ожити" на рівному місці. У реальному kubelet через це Lease-об'єкти оновлюються окремо від статусу.- Heartbeat несе повний об'єкт ноди через
PUT. Без оптимістичного блокування (resourceVersion) два конкурентні записи можуть затерти один одного — last write wins. На одній ноді з одним Agent це не проблема, але масштабувати так не можна.
💡 Цікаві факти¶
- Kubernetes колись тримав
lastHeartbeatTimeпрямо в статусі ноди — і кожен kubelet кожні кілька секунд переписував увесь об'єкт ноди в etcd. На великих кластерах це створювало шалене навантаження на запис. Рішенням став окремий ресурсLeaseу namespacekube-node-lease: крихітний об'єкт, який дешево оновлювати. Сьогодні детекція їде на дешевому Lease, а важке оновлення повного статусу спрацьовує значно рідше — два heartbeat навмисне розвели по частоті. - Дефолтний
node-monitor-grace-periodу Kubernetes — близько 40 секунд, але це не "час до смерті ноди". Це лише поріг для переходу вNotReady. Виселення подів керується окремими taint-амиNoExecuteз власнимtolerationSeconds(типово 300 секунд). - Коли node controller переводить ноду в
NotReady, він ставить taintnode.kubernetes.io/not-ready:NoExecute, а саме виселення обмежене за швидкістю (--node-eviction-rate, типово 0.1/с = одна нода раз на 10с). Якщо надто велика частка зони стала нездоровою одночасно, контролер навмисне припиняє виселяти — з припущенням "це радше мережа, а не ноди". Вбудоване гальмо проти лавини виселень під час network partition. - Heartbeat — це класичний failure detector. Теорія розподілених систем доводить, що ідеального детектора відмов в асинхронній мережі не існує (FLP impossibility): неможливо відрізнити "нода мертва" від "нода повільна". Будь-який timeout — це ставка.
- Розумніші системи не хардкодять єдиний timeout. Φ (Phi) Accrual failure detector (Cassandra, Akka, Hazelcast) замінює бінарну відповідь "мертва/жива" на рівень підозри φ, що плавно росте і адаптується до спостережуваного розподілу інтервалів між heartbeat — поріг фактично самопідлаштовується під мережу замість того, щоб раз угадати 30с.
- Не всі централізують детекцію, як наш NodeController, що опитує кожну ноду. Gossip у стилі SWIM (Serf, Consul, Hazelcast) змушує кожну ноду випадково пробити кілька сусідів і рознести підозру плітками, тож витрати на ноду лишаються майже сталими — саме так membership масштабується до тисяч нод без єдиного компонента, що стежить за всіма.
- Справжній Kubernetes працює на etcd, у якого є власні heartbeats Raft (типово раз на ~100 мс) між лідером і фолловерами — другий, нижчий рівень "ти живий?" під heartbeat-ами нод. Shepherd усе це оминає: наше сховище — єдиний вбудований файл BoltDB без консенсусу і без лідера, тож цього рівня в нас просто немає — на одну рухому частину менше, але й реплікації з коробки теж немає.
Що я зрозумів, поки розбирався з темою¶
Поки писав цей шматок, до мене нарешті дійшло, що heartbeat — це не "перевірка живості", а домовленість про timeout. Сам по собі heartbeat нічого не гарантує: відсутність сигналу може означати і мертву ноду, і просто лаги мережі. Ми не детектимо смерть — ми вирішуємо, після якого мовчання вважати ноду мертвою.
Друге, що клацнуло: розділення ролей. Agent тільки пише "я живий", а рішення "ти мертвий" приймає окремий компонент, який Agent взагалі не бачить. Спочатку хотілось, щоб Agent сам помічав свої проблеми — але мертвий процес не може повідомити, що він мертвий. Судити має хтось ззовні.
Що можна покращити¶
- Винести heartbeat в окремий легкий об'єкт (як
Leaseу Kubernetes), щоб не переписувати весь об'єкт ноди щоразу — менше навантаження на BoltDB і менше конфліктів запису. - Зробити timeout конфігурованим прапорцем замість хардкоду
30s, і додати degraded-стан міжReadyіNotReady(один-два пропущені heartbeat — це ще не смерть). - Додати node taints і controlled eviction з grace period замість підходу "просто не чіпаємо поди на NotReady-ноді".
- Враховувати clock skew: рахувати "свіжість" heartbeat за серверним часом отримання, а не за timestamp, який поставив Agent.
Спробуй сам¶
# Подивись ноди:
sheepctl nodes
# Побачиш: node-1 Ready pods: 5 last heartbeat: 3s ago
# Зупини agent (Ctrl+C або kill) і чекай:
sleep 35
sheepctl nodes
# Побачиш: node-1 NotReady last heartbeat: 35s ago
sheepctl events | head -3
# Warning NodeNotReady node/node-1 Node node-1 heartbeat timeout
# Перезапусти agent - нода стане Ready:
shepherd --mode agent --node-name node-1 --api-addr localhost:9876
sheepctl nodes # Ready знову
Health monitoring працює. Далі - Node Agent: kubelet за 350 рядків.
Ресурси¶
- Nodes — життєвий цикл, conditions, heartbeats
- Heartbeats and Node Leases — механізм Lease, що замінив повні записи статусу
- Controllers — включно з node controller
- Taint-based Evictions — як NotReady перетворюється на виселення подів через
tolerationSeconds - Прапорці kube-controller-manager —
--node-monitor-grace-period,--node-eviction-rate, пороги по зонах - The Φ Accrual Failure Detector (Hayashibara et al.) — адаптивна підозра замість фіксованого timeout
- SWIM: Scalable Weakly-consistent Membership — gossip-детекція відмов, що масштабується на великі кластери
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Service Discovery | Наступна: Node Agent
