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

Node Agent: kubelet

Node Agent: kubelet

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Agent — це kubelet Shepherd-а. Він працює на кожній ноді, реєструється з API Server, відправляє heartbeats і запускає контейнери для призначених подів.

Структура

type Agent struct {
    nodeName  string
    apiAddr   string
    mgr       *container.Manager
    logger    *log.Logger
    capacity  NodeResources
}

Agent знає своє ім'я, адресу API Server-а і має доступ до container.Manager (Sheep runtime).

Реєстрація з retry

func (a *Agent) Run(stopCh <-chan struct{}) error {
    a.mgr.Init()

    // Retry для standalone mode
    for attempt := 0; attempt < 10; attempt++ {
        if err := a.register(); err == nil {
            break
        } else if attempt == 9 {
            return fmt.Errorf("register after 10 attempts: %w", err)
        }
        time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
    }

    heartbeat := time.NewTicker(10 * time.Second)
    reconcile := time.NewTicker(3 * time.Second)

    for {
        select {
        case <-stopCh: return nil
        case <-heartbeat.C: a.sendHeartbeat()
        case <-reconcile.C: a.reconcilePods()
        }
    }
}

Retry потрібен для standalone mode, де API Server і Agent стартують одночасно. API Server може ще не бути готовим.

Реєстрація ноди

func (a *Agent) register() error {
    hostname, _ := os.Hostname()
    node := Node{
        Kind: "Node",
        Metadata: ObjectMeta{
            Name: a.nodeName,
            Labels: map[string]string{
                "hostname": hostname,
                "os":       runtime.GOOS,
                "arch":     runtime.GOARCH,
            },
        },
        Spec: NodeSpec{Address: a.nodeName},
        Status: NodeStatus{
            Condition:   NodeReady,
            Capacity:    a.capacity,
            Allocatable: a.capacity,
        },
    }
    return a.post("/api/v1/nodes", node)
}

Labels автоматично включають hostname, OS і архітектуру. nodeSelector в подах може використовувати ці labels.

Детекція ресурсів

func detectCapacity() NodeResources {
    var memTotal int64
    data, err := os.ReadFile("/proc/meminfo")
    if err == nil {
        fmt.Sscanf(string(data), "MemTotal: %d kB", &memTotal)
        memTotal *= 1024
    }
    if memTotal == 0 {
        memTotal = 2 * 1024 * 1024 * 1024 // 2GB default
    }

    return NodeResources{
        CPU:    int64(runtime.NumCPU()) * 1000, // millicores
        Memory: memTotal,
        Pods:   110,
    }
}

Читає пам'ять з /proc/meminfo, CPU з runtime.NumCPU(). 110 подів на ноду — default Kubernetes limit.

Reconciliation подів

func (a *Agent) reconcilePods() {
    pods, _ := a.listMyPods()

    for _, pod := range pods {
        if pod.Spec.NodeName != a.nodeName { continue }

        switch pod.Status.Phase {
        case PodPending:
            a.startPod(pod)
        case PodRunning:
            a.checkPod(pod)
        }
    }
}

Кожні 3 секунди Agent перевіряє свої поди. Pending — запускає. Running — перевіряє здоров'я.

Запуск поду

func (a *Agent) startPod(pod *Pod) {
    var statuses []ContainerStatus

    for _, cs := range pod.Spec.Containers {
        var env []string
        for k, v := range cs.Env {
            env = append(env, fmt.Sprintf("%s=%s", k, v))
        }

        opts := container.RunOpts{
            Name:  fmt.Sprintf("%s-%s",
                pod.Metadata.Name, cs.Name),
            Image:   cs.Image,
            Command: cs.Command,
            Config: container.Config{
                Hostname: pod.Metadata.Name,
                Env: env,
                Memory: cs.Resources.Memory,
            },
        }

        c, err := a.mgr.Create(opts)
        if err != nil {
            statuses = append(statuses, ContainerStatus{
                Name: cs.Name, State: "failed"})
            continue
        }

        a.mgr.Start(c.ID)

        statuses = append(statuses, ContainerStatus{
            Name: cs.Name, ContainerID: c.ID,
            Ready: true, State: "running"})
    }

    // Оновлюємо IP
    if pod.Status.PodIP == "" {
        for _, cs := range statuses {
            if cs.ContainerID != "" {
                c, _ := a.mgr.Get(cs.ContainerID)
                if c != nil && c.Network != nil {
                    pod.Status.PodIP = c.Network.IPAddress
                    break
                }
            }
        }
        if pod.Status.PodIP == "" {
            pod.Status.PodIP = "127.0.0.1"
        }
    }

    allReady := true
    for _, s := range statuses {
        if !s.Ready { allReady = false; break }
    }

    if allReady {
        pod.Status.Phase = PodRunning
    } else {
        pod.Status.Phase = PodFailed
    }

    a.updatePodStatus(pod)
}
graph TD
    A["Pod Pending<br/>NodeName = this node"] --> B["Для кожного контейнера в spec"]
    B --> C["Create container"]
    C --> D["Start container"]
    D --> E["Get IP from network"]
    E --> F{"Всі Ready?"}
    F -->|Так| G["Pod = Running"]
    F -->|Ні| H["Pod = Failed"]

Agent бере IP-адресу першого контейнера і ставить її як PodIP. Це спрощення — в Kubernetes всі контейнери поду ділять один network namespace.

Обмеження

Agent створює окремий контейнер для кожного container spec в поді. В Kubernetes всі контейнери поду ділять один network namespace (через pause container). У Shepherd кожен контейнер має свій IP, що ламає pod networking model.

Ще кілька гострих кутів:

  • detectCapacity() читає /proc/meminfo — це Linux-специфіка. На macOS файлу немає, тому код тихо падає на default 2 ГБ. Жоден scheduler не дізнається, що цифра вигадана.
  • Agent reconcile-ить тільки Pending і Running. Якщо под видалили з API, поки Agent не дивився, контейнер лишається працювати — orphan. Немає звірки "що реально запущено" проти "що має бути запущено".

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

  • Назва kubelet — від "kubernetes" + "-let" (як "applet", "servlet"): маленький агент. І попри назву, це один з найбільших і найскладніших компонентів Kubernetes — далеко не 350 рядків.
  • Реальний kubelet давно не запускає контейнери сам. Він говорить через CRI (Container Runtime Interface) — gRPC-протокол — з рантаймом на кшталт containerd чи CRI-O. Наш Agent, який напряму викликає mgr.Create(), ближчий до того, як kubelet працював до версій ~1.5, коли Docker був вшитий усередину.
  • Pause-контейнер у Kubernetes (також "infra container") — це крихітна програма, яка нічого не робить, окрім як спить і тримає network namespace. Її єдина задача — пережити рестарти прикладних контейнерів, щоб IP поду не змінювався. Образ pause — один з найчастіше запусканих контейнерів у світі: близько 700 КБ на диску і приблизно 250 КБ RAM.
  • 110 подів на ноду — не фізичне обмеження, а консервативний дефолт kubelet (--max-pods). Він пов'язаний радше з тиском на control plane і мережу, ніж з ресурсами самої ноди. На великих кластерах scheduler і API Server відчувають кожен новий pod object задовго до того, як на ноді закінчиться CPU.
  • Головний цикл справжнього kubelet — syncLoop: він watch-ить API на зміни подів і періодично все одно пересинхронізує все. Саме ця комбінація watch + reconcile пояснює, чому пропущені події рідко мають значення — той самий інсайт, що й наш 3-секундний ticker, тільки в production-масштабі.
  • Всередині syncLoop працює Pod Lifecycle Event Generator (PLEG): він слідкує за подіями container runtime і зіставляє їх із подами. Без PLEG kubelet не дізнався б, що контейнер помер, аж до наступного повного sync — окремий міст між "runtime щось сказав" і "статус поду має змінитися".
  • Kubernetes також підтримує static pods: manifest-файли в /etc/kubernetes/manifests/, які kubelet запускає без scheduler-а. Control plane бачить їх як mirror pods. У Shepherd аналога немає — кожен под має прийти через API.
  • Концепція kubelet сягає Borg у Google: borglet на кожній машині був предком сучасного node agent. Kubernetes залишив патерн — один daemon на ноду, який зводить локальну реальність до бажаного стану кластера.
  • Коли нода приєднується до справжнього кластера, kubelet проходить TLS bootstrap: отримує client certificate, підписаний control plane, щоб лише авторизовані агенти могли реєструватися і оновлювати стан ноди. Shepherd це повністю пропускає — будь-який процес, що знає адресу API, може прикинутися нодою.

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

Найбільший інсайт: Agent не "керує" подами, він їх узгоджує. Немає команди "запусти под" — є лише цикл, який щотри секунди питає "які поди мені призначені?" і доводить реальність до бажаного стану. Спочатку мене дратувало, що це polling, а не push. Але саме завдяки цьому Agent самовідновлюється: пропустив подію — наступний tick усе одно все полагодить. Push без reconcile був би крихкішим.

Ще зрозумів, навіщо потрібен retry на реєстрації. Перший запуск standalone-режиму постійно падав, бо Agent стукав у API Server, який ще не встав. Це нагадало, що в розподілених системах "інший компонент ще не готовий" — це норма, а не помилка.

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

  • Додати pause-контейнер на под і ділити один network namespace між контейнерами — тоді PodIP стане справжнім, а не IP першого контейнера.
  • Перейти на CRI-подібний інтерфейс замість прямих викликів container.Manager — тоді рантайм можна було б підміняти.
  • Реалізувати справжню звірку стану: знаходити orphan-контейнери (запущені, але без поду в API) і прибирати їх.
  • Кросплатформена detectCapacity(): окрема гілка для macOS (sysctl hw.memsize) замість тихого fallback на 2 ГБ.

Спробуй сам

# В multi-node mode:
# Terminal 1 (control plane):
shepherd --mode server --addr :9876
# Terminal 2 (worker):
shepherd --mode agent --node-name worker-1 --api-addr localhost:9876
sheepctl nodes  # побачиш worker-1

Agent працює. Далі — повний шлях поду від створення до Running.

Ресурси

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

Попередня: Node Health | Наступна: Pod Lifecycle