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

Node Health: heartbeat і failure detection

Node Health: heartbeat і failure detection

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Як 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 у namespace kube-node-lease: крихітний об'єкт, який дешево оновлювати. Сьогодні детекція їде на дешевому Lease, а важке оновлення повного статусу спрацьовує значно рідше — два heartbeat навмисне розвели по частоті.
  • Дефолтний node-monitor-grace-period у Kubernetes — близько 40 секунд, але це не "час до смерті ноди". Це лише поріг для переходу в NotReady. Виселення подів керується окремими taint-ами NoExecute з власним tolerationSeconds (типово 300 секунд).
  • Коли node controller переводить ноду в NotReady, він ставить taint node.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 рядків.

Ресурси

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

Попередня: Service Discovery | Наступна: Node Agent