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

Graceful Shutdown: signals, channels, cleanup

Graceful Shutdown: signals, channels, cleanup

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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.

Ресурси

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

Попередня: Async Scheduling | Наступна: Two-Mode Architecture