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

Desired State vs Actual State: парадигма Kubernetes

Desired State vs Actual State: парадигма Kubernetes

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Вся архітектура 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
        }
    }
}

Чому це працює

  1. Самовідновлення - не потрібно реагувати на конкретні збої, контролер сам виправить стан
  2. Ідемпотентність - запустив reconcile 10 разів, результат той самий
  3. Decoupling - контролер не знає, хто і чому змінив стан
  4. Простота - кожен контролер дуже простий, бо робить тільки одне: порівнює і виправляє

Що варто знати

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 для зв'язків між ресурсами.

Ресурси

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

Попередня: Push в свій реєстр | Наступна: Label Selectors