Desired State vs Actual State: парадигма Kubernetes¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Вся архітектура Kubernetes побудована на одній ідеї. Ти кажеш "хочу 3 репліки web-сервера". Система читає це як бажаний стан і постійно виправляє реальний стан, щоб він збігався. Це декларативний підхід.
Imperative vs Declarative¶
Imperative (як скрипт): "створи контейнер A, потім B, потім C". Якщо B впаде - скрипт не знає, що робити.
Declarative (як Kubernetes): "має бути 3 контейнери". Якщо один впаде, система побачить "є 2, потрібно 3" і створить ще один.
graph TD
subgraph "Imperative"
I1["Крок 1: create A"] --> I2["Крок 2: create B"]
I2 --> I3["Крок 3: create C"]
I3 --> I4["B crashed... тепер що?"]
end
subgraph "Declarative"
D1["Desired: replicas=3"]
D2["Observe: 2 running"]
D3["Compare: 2 < 3"]
D4["Act: create 1 more"]
D1 --> D2 --> D3 --> D4 --> D2
end
Три контролери - один паттерн¶
У Shepherd кожен контролер працює за одним і тим самим паттерном:
ReplicationController¶
func (rc *ReplicationController) reconcileDeployment(dep *Deployment) {
// Observe
matchingPods := findPodsByLabels(dep.Spec.Selector)
current := len(matchingPods)
// Compare
desired := dep.Spec.Replicas
// Act
if current < desired {
// Створити поди
} else if current > desired {
// Видалити зайві
}
}
ServiceController¶
func (sc *ServiceController) reconcileService(svc *Service) {
// Observe
pods := findPodsByLabels(svc.Spec.Selector)
// Compare + Act
var endpoints []string
for _, pod := range pods {
if pod.Status.Phase == PodRunning && pod.Status.PodIP != "" {
for _, port := range svc.Spec.Ports {
endpoints = append(endpoints,
fmt.Sprintf("%s:%d", pod.Status.PodIP, port.TargetPort))
}
}
}
svc.Status.Endpoints = endpoints
}
NodeController¶
func (nc *NodeController) reconcile() {
// Observe
nodes := listAllNodes()
for _, node := range nodes {
// Compare
if time.Since(node.Status.LastHeartbeat) > 30*time.Second {
// Act
node.Status.Condition = NodeNotReady
}
}
}
Чому це працює¶
- Самовідновлення - не потрібно реагувати на конкретні збої, контролер сам виправить стан
- Ідемпотентність - запустив reconcile 10 разів, результат той самий
- Decoupling - контролер не знає, хто і чому змінив стан
- Простота - кожен контролер дуже простий, бо робить тільки одне: порівнює і виправляє
Що варто знати¶
Eventual consistency означає затримку. Под не стає Running миттєво. Між "бажаний стан змінився" і "реальний стан вирівнявся" проходить час. Для критичних систем це може бути неприйнятно.
Ще дві пастки. Reconcile, який порівнює стани через ==, може зациклитись на flapping: дефолти, які сервер додає до об'єкта, не збігаються з тим, що ти надіслав, і контролер раз за разом "виправляє" те, що вже правильно. І немає backoff: якщо act постійно падає (нема ресурсів на нодах), цикл крутиться на повну, спалюючи CPU і логи.
І ще одне, про що рідко говорять вголос: "самовідновлення" нічого не відновлює. Коли ти видаляєш под, контролер не повертає той самий под - він створює новий, з іншим імʼям, іншим IP і порожнім диском. Стан вирівнюється на рівні кількості, а не на рівні конкретного екземпляра. Це працює рівно доти, доки твій застосунок ставиться до себе як до худоби, а не як до улюбленця.
💡 Цікаві факти¶
- Сам термін "reconciliation loop" прийшов не з Kubernetes, а з контролерної теорії та робототехніки: це класичний control loop, як термостат, що порівнює бажану і реальну температуру.
- У Kubernetes немає одного "оркестратора".
kube-controller-manager— це десятки незалежних контролерів (Deployment, ReplicaSet, Node, Job...), кожен крутить власний reconcile поверх спільного стану в etcd. - Декларативна модель приїхала в Kubernetes прямо з Borg і Omega — внутрішніх систем Google. Omega першою зробила спільний стан, який усі контролери читають і правлять оптимістично, без центрального диспетчера.
- "Level-triggered, not edge-triggered" — фраза-мантра дизайнерів Kubernetes. Контролер реагує на поточний стан світу, а не на подію зміни. Тому втрачена подія не ламає систему: наступний reconcile усе одно побачить розбіжність.
- Ідея старша за Kubernetes на два десятиліття. CFEngine (1993) Марка Берджеса ввів "convergent operators": операції, які можна виконувати нескінченно, і система лише наближається до описаного стану. Потім із цього виросла Promise Theory, а з неї — Puppet, Chef і Terraform. Kubernetes тут не винахідник, а найпомітніший спадкоємець.
metadata.generationіstatus.observedGeneration— це вбудований механізм "контролер уже бачив твою зміну?".generationінкрементується лише при змініspec, ніколи при змініstatus. Контролер копіює його вobservedGenerationпісля reconcile. Саме на цій парі чисел тримаєтьсяkubectl rollout status: інакше він показував би тобі статус попереднього деплою і рапортував успіх.specіstatus— це фізично різні ендпоінти API. Status subresource означає, щоkubectl applyне може записатиstatus, а контролер не може записатиspec, і на них вішаються різні RBAC-права. Розділення "бажане vs реальне" не домовленість у голові розробника, а обмеження на рівні сервера.- Справжні контролери Kubernetes не опитують API кожні N секунд. Вони тримають локальний кеш через informer і слухають
watch. Періодичний resync теж є, але це страховка на випадок загубленої події, і за замовчуванням він міряється годинами (типово 10-12 з джиттером), а не секундами. Тобто цикл ізtime.Ticker, який я написав у Shepherd, — це саме те, чого production-контролери свідомо не роблять. - Найпоширеніший інцидент у GitOps — це два контролери, які борються за одне поле. HPA виставляє
spec.replicas: 7, Argo CD бачить розбіжність із Git і повертає3, HPA знову ставить7. Ніхто не помиляється, обидва просто роблять свою роботу. Тому в Argo CD єignoreDifferencesсаме дляspec/replicas— стандартне рішення на цей випадок. - Пастка з дефолтами колись була проблемою й самого
kubectl. Щоб зрозуміти, що ти видалив поле, а не просто не згадав його,kubectl applyзберігав твій попередній YAML цілим JSON-рядком в анотаціїkubectl.kubernetes.io/last-applied-configurationі робив three-way merge. Server-Side Apply прибрав цей милиць: тепер сервер памʼятає, який менеджер володіє яким полем.
Що я зрозумів, поки розбирався з темою¶
Найбільше мене переучило усвідомлення, що reconcile має бути тупим і повторюваним, а не розумним. Спершу тягне написати "якщо прийшла подія X — зроби Y". Але як тільки ти ловиш події, ти зобовʼязаний їх не губити, а це вже черги, ретраї, дедуплікація. Коли ж контролер просто щоразу дивиться "скільки є, скільки треба" — уся ця складність зникає. Загублений тік нічого не ламає: наступний підхопить. Це не про елегантність коду, це про те, що систему стає можливо міркувати в голові.
Що можна покращити¶
- Замінити фіксований інтервал на event-driven цикл: Watch API замість опитування кожні N секунд. Reconcile спрацьовує лише на реальну зміну, а не вхолосту.
- Додати exponential backoff і rate limiting на чергу reconcile — як
workqueueвclient-go. Невдалі спроби відкладаються, а не крутяться на повну. - Записувати в
Statusне лише поточні числа, а йconditions(Progressing, Available) іobservedGeneration— щоб клієнт відрізняв "ще працюю" від "застряг". - Зробити reconcile по-справжньому ідемпотентним до дефолтів: нормалізувати спостережений стан перед порівнянням, щоб прибрати flapping.
Спробуй сам¶
# Видали под - контролер відновить:
sheepctl delete pod web-0
sleep 10 && sheepctl get pods
# Зміни replicas - контролер підлаштує:
sheepctl scale deployment web --replicas 1
sleep 10 && sheepctl get pods
Далі - Label Selectors: чому labels кращі за foreign keys для зв'язків між ресурсами.
Ресурси¶
- Kubernetes controllers — desired vs actual state, аналогія з термостатом просто в доці
- Kubernetes objects: spec і status — звідки береться розділення бажаного і реального
- API conventions —
conditions,observedGeneration, правила для status subresource - Writing Controllers — офіційний гайд SIG API Machinery, включно з "не припускай, що подія дійшла"
- Kubernetes architecture (design proposals archive) — першоджерело "level-triggered, not edge-triggered"
- Declarative application management in Kubernetes — есе Браяна Гранта про те, чому декларативність, а не скрипти
- Efficient detection of changes —
watch,resourceVersion, bookmark-події - client-go informers — локальний кеш замість опитування, і навіщо потрібен resync period
- client-go workqueue — rate limiting і exponential backoff, яких немає в моєму циклі
- controller-runtime — усе вищеперелічене, зібране в один Reconciler
- Kubebuilder: controller implementation — реальний reconcile, написаний крок за кроком
- Server-Side Apply — field managers замість
last-applied-configurationі three-way merge - Operator pattern — той самий цикл, винесений у власний домен
- Argo CD: diffing —
ignoreDifferencesі як розводять HPA з GitOps, щоб вони не билися заreplicas - Borg, Omega and Kubernetes — історія декларативної оркестрації від людей, які її будували
- Large-scale cluster management at Google with Borg — з чого все почалося
- Omega: flexible, scalable schedulers — спільний стан і оптимістична конкуренція без центрального диспетчера
- Promise Theory — формалізація Марка Берджеса, з якої виросла вся конвергентна конфігурація
- CFEngine — 1993 рік, convergent operators, прабатько desired state
- Control loop — той самий термостат, лише в термінах теорії керування
- Patterns of Distributed Systems — релевантні патерни
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Push в свій реєстр | Наступна: Label Selectors
