Two-Mode Architecture: server + agent¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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
