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

Reconciliation Loop: серце Shepherd

Reconciliation Loop: серце Shepherd

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Reconciliation loop — серце будь-якого оркестратора. Ідея одна: ти описуєш бажаний стан, а контролери постійно порівнюють його з реальним і виправляють різницю. Уся архітектура Kubernetes побудована саме на ній — і ми в Shepherd відтворюємо ту саму модель, беручи Kubernetes за взірець для нашої реалізації.

Паттерн

graph TD
    A["Observe<br/>прочитати поточний стан"] --> B["Compare<br/>порівняти з бажаним"]
    B --> C{"Є різниця?"}
    C -->|Так| D["Act<br/>виправити"]
    C -->|Ні| E["Sleep<br/>почекати"]
    D --> E
    E --> A

У Shepherd кожен контролер працює за цим паттерном. Тікер прокидається кожні кілька секунд, контролер дивиться на стан у Store і виправляє, що не збігається.

ReplicationController

Найяскравіший приклад. Deployment каже "хочу 3 репліки", контролер рахує скільки є і додає або видаляє:

func (rc *ReplicationController) Run(stopCh <-chan struct{}) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-stopCh:
            return
        case <-ticker.C:
            rc.reconcile()
        }
    }
}

func (rc *ReplicationController) reconcile() {
    deployments, _ := rc.store.ListDeployments("")
    for _, dep := range deployments {
        rc.reconcileDeployment(dep)
    }
}

func (rc *ReplicationController) reconcileDeployment(
    dep *Deployment) {
    allPods, _ := rc.store.ListPods(dep.Metadata.Namespace)

    var matchingPods []*Pod
    for _, pod := range allPods {
        if matchLabels(pod.Metadata.Labels, dep.Spec.Selector) {
            matchingPods = append(matchingPods, pod)
        }
    }

    current := len(matchingPods)
    desired := dep.Spec.Replicas

    if current < desired {
        // Scale up
        for i := 0; i < desired-current; i++ {
            rc.createPodForDeployment(dep, current+i)
        }
    } else if current > desired {
        // Scale down - видаляємо з кінця
        for i := 0; i < current-desired; i++ {
            pod := matchingPods[len(matchingPods)-1-i]
            rc.store.DeletePod(
                pod.Metadata.Namespace, pod.Metadata.Name)
        }
    }

    // Оновлюємо статус deployment
    ready := 0
    for _, pod := range matchingPods {
        if pod.Status.Phase == PodRunning { ready++ }
    }
    dep.Status.Replicas = len(matchingPods)
    dep.Status.ReadyReplicas = ready
    rc.store.UpdateDeployment(dep)
}

Чому це працює надійно

Reconciliation loop самовідновлюється. Якщо: - Под впав - контролер побачить, що реплік менше ніж потрібно, і створить нову - Ноду видалили - поди стануть Failed, контролер створить нові, scheduler призначить їх на іншу ноду - Хтось вручну видалив под - те саме, контролер відновить

Нікого не цікавить, чому стан змінився. Контролер просто бачить різницю і виправляє.

Три контролери в Shepherd

Контролер Інтервал Що робить
ReplicationController Кількість подів = spec.replicas
ServiceController Endpoints сервісу = Running поди
NodeController 10с Heartbeat timeout = NotReady

Всі три працюють паралельно як goroutines з одним stopCh каналом для shutdown.

Ідемпотентність

Кожен reconcile повинен бути ідемпотентним. Виклик двічі поспіль дає той самий результат. Це важливо, бо контролер прокидається регулярно і завжди обробляє весь стан, а не тільки зміни.

Де можна наступити

Reconciliation кожні 5 секунд означає затримку до 5 секунд. Для швидкого scale-up це повільно. Kubernetes поєднує event-driven (watch) і periodic (resync) для балансу швидкості і надійності.

  • Якщо один reconcile падає з паніки в goroutine — цикл просто зупиняється, а решта системи нічого не помічає. Реальні контролери загортають кожну ітерацію в recover і метрику помилок.
  • Два контролери, що працюють з однаковими подами, можуть "перетягувати ковдру": один створює, інший видаляє. У Kubernetes це вирішують owner references і єдиний контролер-власник на ресурс.

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

  • Сам термін "reconcile" і модель level-triggered (а не edge-triggered) Kubernetes свідомо запозичив з мережевого обладнання: маршрутизатори давно працюють за принципом "звіряйся з бажаним станом, а не реагуй на окрему подію". Тому навіть якщо контролер пропустив подію — наступний цикл усе одно все виправить.
  • У Kubernetes майже жоден контролер не звертається до API напряму на кожній ітерації — між ними стоїть informer з локальним кешем і чергою з дедуплікацією. Наш прямий ListPods на кожному тіку — саме те, що informer прибирає.
  • controller-runtime (фреймворк, на якому будують більшість операторів) зводить весь контролер до однієї функції Reconcile(req) (Result, error) — повернеш requeue і він сам поставить тебе в чергу знову. Те саме «observe-compare-act», тільки за тебе крутить чергу фреймворк.
  • Idempotency тут не побажання, а вимога виживання: контролер обробляє весь стан на кожному тіку, тож неідемпотентна дія накопичувала б ефект кожні 5 секунд.

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

Довго не вкладалося в голові, чому контролер не реагує на події напряму — здавалося марнуванням: навіщо щоразу перечитувати весь стан, якщо змінився один под? Аж поки сам не зловив баг, де пропущена подія залишила систему в неузгодженому стані назавжди. Тоді дійшло: level-triggered цикл стійкий саме тому, що йому байдуже, скільки подій він проґавив — він завжди дивиться на повну картину. Едж-тригер швидший, але одна загублена подія — і ти розсинхронізований назавжди.

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

  • Додати recover у кожну ітерацію reconcile, щоб паніка в одному контролері не вбивала весь цикл.
  • Замінити фіксований тікер на чергу з backoff: при помилці перепланувати з експоненційною затримкою, а не чекати наступного тіку.
  • Зробити кроком до event-driven: watch на Store, що будить контролер одразу при зміні, а periodic resync лишити як страхувальну сітку.
  • Додати метрики тривалості reconcile і кількості помилок — без них непомітно, що цикл відстає.

Спробуй сам

# Створи deployment з 3 репліками:
sheepctl apply -f - <<'EOF'
{"kind":"Deployment","metadata":{"name":"web"},"spec":{"replicas":3,"selector":{"app":"web"},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"minimal"}]}}}}
EOF
sheepctl get pods     # 3 поди з'являться
# Видали один под вручну:
sheepctl delete pod web-0
sleep 10 && sheepctl get pods  # контролер відновить!

Reconciliation працює. Далі - ReplicationController детальніше: scale up і scale down.

Ресурси

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

Попередня: Scheduler