Node Agent: kubelet¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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.
Ресурси¶
- kubelet reference — CLI і прапорці node-агента
- Nodes — поведінка ноди та kubelet
- Pod lifecycle — що саме впроваджує агент
- Container Runtime Interface (CRI) — як справжній kubelet говорить з containerd або CRI-O
- Node authorizer — що kubelet може читати і записувати
- Static Pods — поди, які kubelet запускає з локальних manifest-ів
- Large-scale Borg cluster management (EuroSys 2015) — звідки взявся патерн borglet/kubelet
- Kubernetes pause image source — infra-контейнер, що тримає network namespace поду
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Node Health | Наступна: Pod Lifecycle
