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

Two-Mode Architecture: server + agent

Two-Mode Architecture: server + agent

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Shepherd - один бінарник, три режими. --mode server запускає control plane. --mode agent запускає worker. --mode standalone запускає все разом. Один і той самий код, різна конфігурація.

Усе, що попередні частини будували окремо - API Server, сховище на BoltDB, шедулер, reconcile-петлі, агент ноди - тут сходиться в одному main(). Лишається єдине питання: що саме з цього піднімає конкретний процес.

Точка входу

func main() {
    var (
        addr     = flag.String("addr", ":9876",
            "API server listen address")
        dataDir  = flag.String("data-dir",
            "/var/lib/shepherd", "Data directory")
        nodeName = flag.String("node-name", "",
            "Node name (for agent mode)")
        apiAddr  = flag.String("api-addr", "",
            "API server address (for agent mode)")
        mode     = flag.String("mode", "server",
            "Run mode: server, agent, or standalone")
    )
    flag.Parse()

    logger := log.New(os.Stdout, "[shepherd] ",
        log.LstdFlags|log.Lshortfile)

    switch *mode {
    case "server":
        runServer(*addr, *dataDir, logger)
    case "agent":
        if *apiAddr == "" {
            fmt.Fprintln(os.Stderr,
                "agent mode requires --api-addr")
            os.Exit(1)
        }
        runAgent(*nodeName, *apiAddr, logger)
    case "standalone":
        runStandalone(*addr, *dataDir, *nodeName, logger)
    }
}

Що запускає кожен режим

graph TB
    subgraph "server mode"
        S_API["API Server :9876"]
        S_SCHED["Scheduler (goroutine)"]
        S_RC["ReplicationController (goroutine)"]
        S_SC["ServiceController (goroutine)"]
        S_NC["NodeController (goroutine)"]
        S_DB[("BoltDB")]
        S_API --> S_DB
        S_SCHED --> S_DB
        S_RC --> S_DB
    end

    subgraph "agent mode"
        A_AGENT["Agent"]
        A_SHEEP["Sheep Runtime"]
        A_AGENT -->|"HTTP"| REMOTE["Remote API Server"]
        A_AGENT --> A_SHEEP
    end

    subgraph "standalone mode"
        ST_API["API Server :9876"]
        ST_SCHED["Scheduler"]
        ST_RC["ReplicationController"]
        ST_SC["ServiceController"]
        ST_NC["NodeController"]
        ST_DB[("BoltDB")]
        ST_AGENT["Agent"]
        ST_SHEEP["Sheep Runtime"]
        ST_AGENT -->|"HTTP localhost:9876"| ST_API
        ST_AGENT --> ST_SHEEP
    end

Server mode

func runServer(addr, dataDir string, logger *log.Logger) {
    os.MkdirAll(dataDir, 0755)

    store, _ := shepherd.NewStore(dataDir + "/shepherd.db")
    defer store.Close()

    stopCh := make(chan struct{})

    scheduler := shepherd.NewScheduler(store, logger)
    go scheduler.Run(stopCh)

    replicationCtrl := shepherd.NewReplicationController(
        store, scheduler, logger)
    go replicationCtrl.Run(stopCh)

    serviceCtrl := shepherd.NewServiceController(store, logger)
    go serviceCtrl.Run(stopCh)

    nodeCtrl := shepherd.NewNodeController(store, logger)
    go nodeCtrl.Run(stopCh)

    api := shepherd.NewAPIServer(addr, store, scheduler, logger)

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    go func() {
        <-sigCh
        close(stopCh)
        api.Shutdown(context.Background())
    }()

    api.Start()
}

5 goroutines: scheduler, 3 контролери (replication, service, node) і signal handler з попередньої частини. API Server блокує main goroutine - тільки він і тримає main живим.

Agent mode

func runAgent(nodeName, apiAddr string, logger *log.Logger) {
    agent := shepherd.NewAgent(nodeName, apiAddr, logger)

    stopCh := make(chan struct{})
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    go func() { <-sigCh; close(stopCh) }()

    if err := agent.Run(stopCh); err != nil {
        logger.Fatalf("agent: %v", err)
    }
}

Agent спілкується з API Server тільки через HTTP. Він не імпортує пакети scheduler або controller. Це означає, що server і agent можуть працювати на різних машинах - і що межу тримає граф імпортів, а не коментар у коді. Якщо хтось колись вкрутить прямий виклик, компілятор почне тягнути BoltDB у бінарник агента, і це одразу видно.

Standalone mode - найцікавіше

func runStandalone(addr, dataDir, nodeName string,
    logger *log.Logger) {
    os.MkdirAll(dataDir, 0755)

    store, _ := shepherd.NewStore(dataDir + "/shepherd.db")
    defer store.Close()

    stopCh := make(chan struct{})

    // Запускаємо все як в server mode
    scheduler := shepherd.NewScheduler(store, logger)
    go scheduler.Run(stopCh)
    // ... контролери ...

    api := shepherd.NewAPIServer(addr, store, scheduler, logger)

    // API Server в goroutine (не блокуємо main)
    go func() {
        if err := api.Start(); err != nil &&
            err.Error() != "http: Server closed" {
            logger.Fatalf("api server: %v", err)
        }
    }()

    // Agent з'єднується з localhost
    if nodeName == "" {
        host, _ := os.Hostname()
        nodeName = host
    }
    actualAddr := addr
    if actualAddr[0] == ':' {
        actualAddr = "localhost" + actualAddr
    }
    agent := shepherd.NewAgent(nodeName, actualAddr, logger)

    go func() {
        agent.Run(stopCh) // Agent retry handles API readiness
    }()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh

    close(stopCh)
    api.Shutdown(context.Background())
}

Дивись, яка штука: Agent з'єднується з API Server через HTTP, навіть коли вони в одному процесі. Тому Agent має retry при реєстрації - API Server може ще не бути готовим:

for attempt := 0; attempt < 10; attempt++ {
    if err := a.register(); err == nil {
        break
    }
    a.logger.Printf("agent: register attempt %d failed, retrying...",
        attempt+1)
    time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
}

Backoff: 500ms, 1s, 1.5s, 2s... Максимум 10 спроб. Строго кажучи, це лінійний backoff, а не експоненційний - затримка росте на константу, а не в рази. Тут це працює, бо очікування обмежене і клієнт рівно один; але з півсотнею агентів, які перепідключаються до перезапущеного сервера, саме такий патерн і дає thundering herd.

Той самий механізм реєстрації та heartbeat працює в усіх режимах. Standalone просто робить цю гонку помітною з першого ж старту.

Чому HTTP навіть для localhost

Можна було зробити прямий виклик Go функцій в standalone mode. Але тоді: - Потрібен окремий code path для standalone - Баги можуть проявлятися тільки в multi-node, але не в standalone - Agent code стає складнішим (два інтерфейси: HTTP і direct)

HTTP завжди - один code path. Standalone працює так само як multi-node, тільки localhost замість remote address.

Це той самий аргумент, що і в desired vs actual state: компонент, який має бути коректним лише щодо інтерфейсу, а не щодо топології деплойменту, набагато важче зламати. А оскільки створення пода і так асинхронне, ніщо в цьому потоці не залежало від того, чи агент і API Server стоять на одній машині.

Deployment topologies

graph TB
    subgraph "Dev/Test: standalone"
        DEV["shepherd --mode standalone"]
        DEV --> DEV_C["Контейнери на тій же машині"]
    end

    subgraph "Small: 1 server + N agents"
        SMALL_S["shepherd --mode server<br/>machine-1"]
        SMALL_A1["shepherd --mode agent<br/>machine-2"]
        SMALL_A2["shepherd --mode agent<br/>machine-3"]
        SMALL_A1 -->|HTTP| SMALL_S
        SMALL_A2 -->|HTTP| SMALL_S
    end

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

Standalone mode має single point of failure: один процес впав - весь кластер мертвий. В multi-node mode agent продовжує працювати, навіть якщо server впав (контейнери не зупиняються, тільки нові поди не створюються). Це не задумана відмовостійкість, а побічний ефект того, що агент має власну reconcile-петлю - але ефект реальний, і саме так kubelet переживає падіння control plane.

Друга проблема: standalone agent і server ділять одну машину. Якщо контейнер з'їсть всю пам'ять - він вб'є і control plane. В multi-node mode control plane ізольований.

Ще дві дрібниці, на яких легко спіткнутись: - Agent у standalone з'єднується з localhost:9876. Якщо хтось перевизначить --addr на конкретний інтерфейс (не :9876), парсинг actualAddr[0] == ':' зламається, і agent піде не туди. - Retry на реєстрації обмежений 10 спробами (~27.5с сумарно). Якщо API Server стартує довше - agent здається і нода ніколи не зареєструється. - actualAddr[0] індексує рядок без перевірки на порожнечу - --addr "" панікує ще до того, як agent кудись піде. - У switch немає default. --mode serevr не падає: процес стартує, нічого не пише і виходить з кодом 0. Друкарська помилка виглядає точнісінько як успішний запуск.

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

  • Kubernetes теж по суті "один код, різні ролі", але роздає це окремими бінарниками: kube-apiserver, kube-scheduler, kube-controller-manager, kubelet. K3s від Rancher пішов навпаки - запхав усе в один бінарник k3s із сабкомандами server і agent, рівно як у нас.
  • kubelet (агент Kubernetes) спілкується з API Server теж тільки через HTTP/gRPC, навіть коли вони на одній машині. Жодних прямих викликів - той самий принцип, що й тут.
  • "Standalone" у нашому сенсі - це фактично те, що Docker Desktop і minikube роблять для розробника: control plane і worker в одному процесі/VM, щоб не піднімати кластер заради одного контейнера.
  • Сам трюк "один бінарник, режим обирається прапорцем" - давня unix-традиція: busybox визначає, ким йому бути, за argv[0]. Ми робимо те саме явним прапорцем --mode.
  • Docker за ті самі роки пішов у протилежний бік. Єдиний демон docker зразка 2013-го розібрали на dockerd, containerd, containerd-shim і runc - саме щоб перезапуск демона не вбивав запущені контейнери. Консолідація і декомпозиція - обидві правильні відповіді, просто оптимізують різне (встановлення однією командою проти незалежного рестарту).
  • k0s запхав в один файл ще більше за k3s: containerd, runc і весь control plane лежать усередині одного статичного бінарника, тому k0s controller на голій VM не потребує жодного встановленого рантайму.
  • У Kubernetes є свій "standalone", і він дивніший за прапорець: компоненти control plane запускаються як static pods - YAML-файли в /etc/kubernetes/manifests, які kubelet стартує напряму, нікого не питаючи. Тобто kubelet піднімає той API Server, у якому потім реєструється. Саме цю циклічну залежність тихо розплутує kubeadm.
  • HTTP через loopback не такий дорогий, як звучить: трафік через lo взагалі не торкається мережевої карти й драйвера, а MTU loopback-інтерфейсу в Linux - 65536 байт замість 1500, тому великий JSON проходить через ядро кількома сегментами, а не десятками.
  • У стандартному пакеті flag в Go немає ні сабкоманд, ні enum-типу - саме тому стільки CLI тягнуть Cobra, і саме тому в нас --mode server це прапорець, а не сабкоманда shepherd server. Починаючи з Go 1.16 є flag.Func, який дозволяє валідувати значення прямо під час парсингу - і наша тиха друкарська помилка стала б помилкою безкоштовно.
  • "Той самий бінарник, різна роль" трапляється й там, де не чекаєш: systemd - це systemd як PID 1 і systemd --user на кожну сесію, а git - це один диспетчер, який запускає git-<subcommand> зі свого libexec.

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

Спочатку я хотів зробити standalone "розумним" - щоб agent у тому ж процесі викликав control plane напряму, без HTTP. Здавалося, що це швидше й чистіше. А вийшло навпаки: з'явився б другий code path, який ніхто б толком не тестував, і баги жили б саме там.

Коли змусив себе ходити через HTTP навіть на localhost, раптом усе спростилось - standalone став просто окремим випадком multi-node. І до мене дійшло, що retry на реєстрації - це не "костиль для standalone", а чесне відображення того, що в розподіленій системі ніхто не гарантує порядок старту.

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

  • Замінити фіксований retry (10 спроб) на справжній health-check endpoint API Server: agent чекає GET /healthz, а не вгадує по таймеру.
  • Додати --mode валідацію: зараз невідомий режим тихо нічого не робить (switch без default). Варто падати з явною помилкою.
  • Зробити graceful shutdown симетричним для standalone: спершу дочекатись зупинки agent, потім - API Server, щоб поди встигли коректно завершитись.
  • Для multi-node додати TLS і автентифікацію між agent і server - зараз HTTP відкритий, що прийнятно тільки для dev.
  • Використати flag.Func для --mode, щоб невідоме значення падало ще на парсингу зі списком дозволених режимів, а не мовчки виходило з кодом 0.
  • Розбирати --addr через net.SplitHostPort замість actualAddr[0] == ':' - воно коректно тримає порожній хост, IPv6-літерали і порожній рядок без паніки.

Спробуй сам

# Standalone (все в одному):
sudo shepherd --mode standalone --addr :9876
# В іншому терміналі:
sheepctl info
sheepctl nodes

# Multi-node:
# Terminal 1 (control plane):
shepherd --mode server --addr :9876 --data-dir /tmp/shepherd-server
# Terminal 2 (worker 1):
sudo shepherd --mode agent --node-name worker-1 --api-addr localhost:9876
# Terminal 3 (worker 2):
sudo shepherd --mode agent --node-name worker-2 --api-addr localhost:9876
# Terminal 4:
sheepctl nodes  # побачиш worker-1 і worker-2

Далі - BoltDB vs etcd: коли embedded база достатньо і коли потрібен distributed consensus.

Ресурси

  • Kubernetes Components: control plane vs node-компоненти
  • Kubernetes architecture: повний архітектурний огляд
  • kubelet: агент, з якого списаний наш agent mode
  • Static pods: як kubelet піднімає control plane без API Server
  • k3s architecture: сабкоманди server і agent одного бінарника
  • k0s: один статичний бінарник із containerd і runc всередині
  • BusyBox FAQ: один бінарник, багато особистостей через argv[0]
  • flag і flag.Func - прапорці зі стандартної бібліотеки і валідація на парсингу
  • net.SplitHostPort: правильний спосіб розібрати listen-адресу
  • Exponential Backoff and Jitter: чому лінійний retry на багатьох клієнтах закінчується погано

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

Попередня: Graceful Shutdown | Наступна: Embedded vs External DB