Graceful Shutdown: signals, channels, cleanup¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Shepherd запускає кілька goroutines: scheduler, replication controller, service controller, node controller, API server, agent. Коли приходить SIGTERM, всі повинні зупинитися акуратно. Ось як це зробити через один канал.
Stop channel¶
func runServer(addr, dataDir string, logger *log.Logger) {
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)
// Graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
logger.Println("shutting down...")
close(stopCh)
api.Shutdown(context.Background())
}()
api.Start()
}
graph TB
SIG["SIGTERM / SIGINT"] --> CLOSE["close(stopCh)"]
CLOSE --> S["Scheduler: return"]
CLOSE --> RC["ReplicationController: return"]
CLOSE --> SC["ServiceController: return"]
CLOSE --> NC["NodeController: return"]
CLOSE --> API["api.Shutdown()"]
Один close(stopCh) зупиняє всі goroutines. Кожен контролер має select на stopCh:
func (s *Scheduler) Run(stopCh <-chan struct{}) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-stopCh:
s.logger.Println("scheduler stopped")
return
case <-ticker.C:
s.reconcile()
}
}
}
Коли канал закривається, <-stopCh повертає zero value без блокування. Всі goroutines, які слухають цей канал, прокинуться і завершаться.
Чому close(), а не send?¶
close(ch) будить всіх слухачів. ch <- struct{}{} будить тільки одного. З close одним рядком зупиняємо все.
Standalone mode¶
В standalone mode API Server і Agent працюють в одному процесі:
func runStandalone(addr, dataDir, nodeName string,
logger *log.Logger) {
// ... start all controllers ...
go api.Start()
go agent.Run(stopCh)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
logger.Println("shutting down...")
close(stopCh)
api.Shutdown(context.Background())
}
<-sigCh блокує main goroutine до сигналу. Після сигналу close(stopCh) зупиняє все.
Чому це краще, ніж context.Cancel¶
Можна було використати context.WithCancel. Але для простих випадків канал зрозуміліший: один close - всі зупиняються. Context додає складність (Done(), Err(), Value()), яка тут не потрібна.
Про що варто пам'ятати¶
Ми не чекаємо, поки reconcile завершиться. close(stopCh) сигналізує зупинку, але якщо контролер посеред write операції в BoltDB - транзакція може не завершитися чисто. В production потрібен graceful drain з timeout.
Дві окремі пастки. close(stopCh) двічі панікує - закриття вже закритого каналу валить процес; тому закривати має рівно один власник. І api.Shutdown(context.Background()) без deadline може зависнути назавжди, якщо лишився довгий keep-alive конект: контекст без timeout тут означає "чекай вічно".
💡 Цікаві факти¶
close(ch)як broadcast - це ідіома, описана ще в "Go Concurrency Patterns" Роба Пайка. Закритий канал віддає zero value необмежену кількість разів, тому всі слухачі прокидаються одночасно. Надіслати в канал так не вийде.- SIGKILL (
kill -9) не можна перехопити чи проігнорувати - ядро вбиває процес без шансу на cleanup. Тому graceful shutdown реагує на SIGTERM: це "ввічливе" прохання, на яке ще можна відповісти. SIGSTOP - другий сигнал, який не перехопити: він заморожує процес, не питаючи дозволу. - Kubernetes дає поду
terminationGracePeriodSeconds(за замовчуванням 30): спершу SIGTERM, потім очікування, і лише потім SIGKILL. Нашclose(stopCh)- це той самий "ввічливий" перший крок, тільки без таймера. docker stopпрацює за тим самим сценарієм, але з дефолтом 10 секунд (--timeміняє його): SIGTERM, очікування, SIGKILL. Тому timeout у твоємуclose(stopCh)має бути меншим за оркестраторський, інакше завжди діставатимеш SIGKILL.net/httpмає вбудованийServer.Shutdown(ctx)ще з Go 1.8: він припиняє приймати нові конекти й чекає, поки добіжать активні. Саме його ми й викликаємо - не винаходимо власний.- PID 1 особливий: ядро не застосовує до нього дефолтні дії для сигналів, тому процес, що працює як PID 1 у контейнері й не обробляє SIGTERM явно, просто ігнорує його, і
docker stopвичікує весь grace period перед SIGKILL. Саме заради цього й існуютьtiniта--init. - Починаючи з Go 1.16 є
signal.NotifyContext: він згортає обробник сигналу іcontext.Contextв один об'єкт, тому прихід SIGTERM скасовує контекст, а ручнийsigChзникає повністю. chan struct{}- це тип нульового розміру: значення не несе даних, важлива лише подія (закриття). Це канонічний "лише-сигнальний" канал, і компілятор навіть не виділяє пам'ять під його елементи.
Що я зрозумів, поки розбирався з темою¶
Мене довго бентежило, чому всюди радять канал chan struct{}, а не chan bool. Доки сам не написав цей shutdown. struct{} займає нуль байт і чітко каже: значення не несе сенсу, важливий лише факт події (тут - закриття). А головне відкриття приземленіше: я звик, що зупинка - це "надіслати команду стоп". А виявилось, що чистіша модель - це не слати нічого, а просто закрити канал і дати кожній goroutine самій помітити це у своєму select. Менше коду, нема кому загубити повідомлення.
Що можна покращити¶
- Додати
sync.WaitGroup: shutdown-горутина має дочекатись, поки всі контролери реально вийшли, перш ніж закривати store і процес. - Дати
api.Shutdownконтекст із timeout (context.WithTimeout), щоб зависання на keep-alive не блокувало вихід назавжди. - Реалізувати справжній drain: контролер, що в середині reconcile, має домалювати поточну ітерацію (або відкотити транзакцію), а не обриватись посеред write.
- Винести signal-handling у
signal.NotifyContext(Go 1.16+) - це зв'язує сигнал і context в один об'єкт і прибирає ручнийsigCh. - Розглянути
golang.org/x/sync/errgroup, коли goroutines можуть падати: він поєднуєWaitGroup, поширення помилок і спільне скасування контексту в одному примітиві.
Спробуй сам¶
# Запусти shepherd і зупини Ctrl+C:
sudo ./shepherd --mode standalone
# Побачиш в логах:
# shutting down...
# scheduler stopped
# replication controller stopped
Далі - Two-Mode Architecture: один бінарник для server, agent і standalone.
Ресурси¶
- os/signal:
Notify,StopіNotifyContext - signal(7): семантика SIGTERM/SIGKILL
- http.Server.Shutdown: graceful draining конектів у стандартній бібліотеці
- Go concurrency patterns: класична доповідь Роба Пайка про
closeяк broadcast - sync.WaitGroup і errgroup - очікування завершення goroutines
- Pod termination: grace period у Kubernetes
- PodDisruptionBudget: керовані voluntary disruptions
- docker stop: SIGTERM, grace period, потім SIGKILL
- tini: чому PID 1 потрібен справжній init для прокидання сигналів
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Async Scheduling | Наступна: Two-Mode Architecture
