Перейти к содержимому

Graceful Shutdown

Зачем знать: Падающий сервис теряет in-flight запросы, оставляет открытые транзакции и не сбрасывает буферы. Graceful shutdown — стандарт production: вы закрываете listener, ждёте завершения текущих запросов, освобождаете ресурсы. В Kubernetes без graceful shutdown ваш rollout даст 503 пользователям. На middle-уровне это must-have.

  1. Базовая концепция
  2. В Go идиоматично
  3. Gotchas
  4. Best practices
  5. Вопросы на собесе
  6. Practice
  7. Источники

Graceful shutdown — завершение работы сервиса в три шага:

  1. Stop accepting новые запросы (закрыть listener).
  2. Drain in-flight запросов (дать им завершиться).
  3. Close resources (БД, очереди, exporters).

Hard shutdown = немедленный os.Exit(1). Это:

  • Прерывает HTTP-запросы (клиент видит connection reset);
  • Откатывает транзакции БД;
  • Теряет буферизованные данные (Kafka producer, OTel spans);
  • Оставляет «зомби»-состояния в downstream.
type Server struct { ... }
// Shutdown — graceful
func (s *Server) Shutdown(ctx context.Context) error
// Close — abrupt
func (s *Server) Close() error
  • Shutdown(ctx):

    • Закрывает все listeners (Accept возвращает error);
    • Закрывает idle connections;
    • Ждёт завершения активных request’ов;
    • Возвращает, когда всё чисто или ctx отменился.
    • ListenAndServe() возвращает http.ErrServerClosed.
  • Close():

    • Немедленно закрывает listener и все connections, включая активные.
    • Используется как «emergency stop».
type Server struct { ... }
// GracefulStop — graceful
func (s *Server) GracefulStop()
// Stop — abrupt
func (s *Server) Stop()
  • GracefulStop():
    • Закрывает listener;
    • Ждёт завершения текущих RPC;
    • Блокирующий вызов — без timeout-механизма.
  • Stop():
    • Прерывает RPC сразу.

⚠️ У GracefulStop() нет встроенного таймаута. Нужно оборачивать вручную.

Linux сигналы, важные для shutdown:

СигналИсточникПоведение
SIGINT (2)Ctrl+C в терминалеможно поймать, типично — graceful shutdown
SIGTERM (15)kill, k8s defaultможно поймать, k8s шлёт это для termination
SIGKILL (9)kill -9НЕЛЬЗЯ поймать, мгновенный exit
SIGHUP (1)terminal disconnectтрадиционно — reload config
SIGQUIT (3)Ctrl+\можно поймать; Go runtime по дефолту печатает stack traces

В k8s lifecycle: PID 1 в контейнере получает SIGTERM, ждёт terminationGracePeriodSeconds, потом SIGKILL.

Современный pattern — signal.NotifyContext создаёт context, отменяемый сигналом:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// сервер работает
go runServer(ctx)
// блокируемся до сигнала
<-ctx.Done()
log.Println("shutdown signal received")

Когда приходит SIGINT/SIGTERM — ctx.Done() закрывается. Все, кто подписан на этот ctx, узнают об остановке.

Порядок имеет значение:

1. Readiness probe FAIL (LB перестаёт слать новые запросы)
2. (опционально) Sleep N (LB обновляет endpoints)
3. Stop HTTP/gRPC listener (Accept перестаёт работать)
4. Drain in-flight requests (active handlers завершаются)
5. Close downstream:
- HTTP/gRPC client conn
- Kafka producer (flush)
- DB pool
- Trace exporters (flush spans)
- Metrics exporters (flush)
6. Logger sync (flush stdout/file)

Логика: сначала перестать принимать, потом дать завершиться, потом отрезать downstream.

Pod termination sequence:
1. kubectl delete pod → pod status: Terminating
2. Endpoints controller убирает pod из Service endpoints
(НО это асинхронно с шагом 3!)
3. PreStop hook (если есть) — выполняется СИНХРОННО
4. SIGTERM → PID 1
5. Wait up to terminationGracePeriodSeconds (default 30s)
6. SIGKILL

⚠️ Шаги 2 и 3-4 происходят параллельно. Это значит:

  • Если приложение быстро остановилось, оно может ещё получать запросы (LB не обновился).
  • Решение — preStop: sleep 5, чтобы дать LB обновить endpoints до SIGTERM.
apiVersion: v1
kind: Pod
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]

package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: myHandler(),
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Запускаем сервер
go func() {
log.Println("server listening on", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
// Ждём сигнал
<-ctx.Done()
log.Println("shutting down...")
// Граничное время на shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
srv.Close() // hard kill
}
log.Println("server stopped")
}

Ключевые моменты:

  • signal.NotifyContext — один источник для сигнала.
  • ListenAndServe возвращает http.ErrServerClosed после Shutdown — это норма.
  • shutdownCtx с таймаутом (например, 30s) гарантирует, что мы не висим вечно.
  • Fallback srv.Close() — если graceful не уложился в таймаут.
package main
import (
"context"
"log"
"net"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
)
func main() {
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
// ... s.RegisterService(...)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
log.Println("gRPC listening on", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}()
<-ctx.Done()
log.Println("gRPC shutting down...")
done := make(chan struct{})
go func() {
s.GracefulStop()
close(done)
}()
select {
case <-done:
log.Println("gRPC stopped gracefully")
case <-time.After(30 * time.Second):
log.Println("gRPC shutdown timeout, forcing")
s.Stop()
}
}

⚠️ GracefulStop() блокирующий и без таймаута. Оборачиваем в горутину + select с time.After.

Типовая ситуация: один процесс держит несколько серверов и фоновых workers.

package main
import (
"context"
"errors"
"log"
"net"
"net/http"
"os/signal"
"sync"
"syscall"
"time"
"google.golang.org/grpc"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// HTTP сервер
httpSrv := &http.Server{
Addr: ":8080",
Handler: httpHandler(),
ReadHeaderTimeout: 5 * time.Second,
}
// gRPC сервер
grpcLis, _ := net.Listen("tcp", ":9090")
grpcSrv := grpc.NewServer()
// ... регистрация сервисов
// Worker
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runWorker(ctx)
}()
// Запуск серверов
go func() {
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("http: %v", err)
stop() // триггерим shutdown всех
}
}()
go func() {
if err := grpcSrv.Serve(grpcLis); err != nil {
log.Printf("grpc: %v", err)
stop()
}
}()
<-ctx.Done()
log.Println("shutdown started")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Параллельно останавливаем HTTP и gRPC
var shutdownWg sync.WaitGroup
shutdownWg.Add(2)
go func() {
defer shutdownWg.Done()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown: %v", err)
httpSrv.Close()
}
}()
go func() {
defer shutdownWg.Done()
stopped := make(chan struct{})
go func() {
grpcSrv.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
case <-shutdownCtx.Done():
log.Println("grpc forced stop")
grpcSrv.Stop()
}
}()
shutdownWg.Wait()
// Ждём worker (он сам реагирует на ctx)
wg.Wait()
// Закрываем downstream
closeDB()
flushKafka()
flushTraces(shutdownCtx)
log.Println("all done")
}
func runWorker(ctx context.Context) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
log.Println("worker stopped")
return
case <-t.C:
// do work
}
}
}

Если у вас pool горутин обрабатывают задачи — нужно дать им завершить in-flight:

type Pool struct {
jobs chan Job
wg sync.WaitGroup
}
func NewPool(n int) *Pool {
p := &Pool{jobs: make(chan Job, 100)}
for i := 0; i < n; i++ {
p.wg.Add(1)
go p.worker()
}
return p
}
func (p *Pool) worker() {
defer p.wg.Done()
for job := range p.jobs {
job.Do()
}
}
func (p *Pool) Submit(j Job) { p.jobs <- j }
// Shutdown — graceful: закрываем jobs, ждём
func (p *Pool) Shutdown(ctx context.Context) error {
close(p.jobs)
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
import "github.com/jackc/pgx/v5/pgxpool"
pool, _ := pgxpool.New(ctx, dsn)
defer pool.Close() // блокирующий: ждёт завершения всех queries
// Если нужен timeout:
done := make(chan struct{})
go func() {
pool.Close()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
log.Println("db close timeout")
}

database/sql:

db.Close() // блокирует, ждёт active queries; idle conns закрывает

segmentio/kafka-go:

producer := &kafka.Writer{ ... }
defer producer.Close() // flush + close

confluent-kafka-go:

producer := kafka.NewProducer(...)
producer.Flush(15 * 1000) // 15 секунд на flush
producer.Close()

⚠️ Без flush — buffered messages теряются.

import "go.opentelemetry.io/otel/sdk/trace"
tp := trace.NewTracerProvider(...)
// ...
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := tp.Shutdown(shutdownCtx); err != nil {
log.Printf("trace shutdown: %v", err)
}

Без Shutdown — pending spans потеряются.

golang.org/x/sync/errgroup упрощает orchestration:

import "golang.org/x/sync/errgroup"
func runApp(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return runHTTPServer(ctx)
})
g.Go(func() error {
return runGRPCServer(ctx)
})
g.Go(func() error {
return runWorker(ctx)
})
return g.Wait()
}

Если один сервер падает → ctx отменяется → остальные тоже завершаются.

Если у вас 10 компонентов — удобно их обобщить:

type Component interface {
Start(ctx context.Context) error
Stop(ctx context.Context) error
}
type App struct {
components []Component
}
func (a *App) Run(parent context.Context) error {
ctx, cancel := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
for _, c := range a.components {
if err := c.Start(ctx); err != nil {
return err
}
}
<-ctx.Done()
shutdownCtx, sc := context.WithTimeout(context.Background(), 30*time.Second)
defer sc()
// Reverse order
for i := len(a.components) - 1; i >= 0; i-- {
if err := a.components[i].Stop(shutdownCtx); err != nil {
log.Printf("stop %T: %v", a.components[i], err)
}
}
return nil
}

Этот pattern используют uber-go/fx, go-kit, etc.

func TestGracefulShutdown(t *testing.T) {
srv := newServer()
go srv.Run()
// Дёргаем эндпоинт
resp, err := http.Get("http://localhost:8080/long-running")
require.NoError(t, err)
// Запускаем shutdown
proc, _ := os.FindProcess(os.Getpid())
proc.Signal(syscall.SIGTERM)
// Запрос должен завершиться нормально
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "ok", string(body))
// Новые запросы должны отбиваться
_, err = http.Get("http://localhost:8080/")
require.Error(t, err)
}

Или unit-тест:

func TestServer_Shutdown(t *testing.T) {
srv := &http.Server{
Addr: ":0",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond) // in-flight request
w.WriteHeader(200)
}),
}
lis, _ := net.Listen("tcp", ":0")
go srv.Serve(lis)
// Делаем долгий запрос
var resp *http.Response
var err error
go func() {
resp, err = http.Get("http://" + lis.Addr().String())
}()
time.Sleep(10 * time.Millisecond)
// Shutdown
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, srv.Shutdown(ctx))
// Старый запрос должен закончиться успешно
time.Sleep(150 * time.Millisecond)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}

err := srv.ListenAndServe()
if err != nil {
log.Fatal(err) // ПЛОХО: после Shutdown это http.ErrServerClosed
}

Правильно:

if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
grpcSrv.GracefulStop() // блокирует НАВЕЧНО, если client держит RPC

Решение — обернуть в горутину + select:

done := make(chan struct{})
go func() { grpcSrv.GracefulStop(); close(done) }()
select {
case <-done:
case <-time.After(30 * time.Second):
grpcSrv.Stop() // force
}
signal.Notify(ch, syscall.SIGKILL) // НЕРАБОТАЕТ

SIGKILL не доставляется в user space. Если кто-то шлёт kill -9 — вы ничего не можете сделать. В k8s SIGKILL приходит после terminationGracePeriodSeconds, поэтому graceful должен укладываться в этот лимит.

func handler(w http.ResponseWriter, r *http.Request) {
time.Sleep(60 * time.Second) // НЕ остановится на shutdown
w.WriteHeader(200)
}

Shutdown() ждёт handler. Если handler 60s, shutdown 60s. Решение:

select {
case <-time.After(60 * time.Second):
case <-r.Context().Done():
return
}
// ПЛОХО:
db.Close() // активные queries обломятся
httpSrv.Shutdown() // handler пытается обратиться к db → ошибка

Правильный порядок:

  1. HTTP/gRPC Shutdown (handlers завершаются);
  2. Worker pool drain;
  3. DB close;
  4. Kafka, OTel exporters.

Pod получает SIGTERM, но LB ещё 3-5 секунд шлёт ему запросы (пока Endpoints не обновились). Без preStop sleep — те запросы получат connection refused.

lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]

Этот sleep блокирует SIGTERM, давая LB время обновить endpoints.

lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 60"] # 60s
terminationGracePeriodSeconds: 30 # 30s

Это БАГ: preStop выполняется внутри grace period. После 30s pod SIGKILL’нется, preStop не завершится. preStop + shutdown ≤ terminationGracePeriodSeconds.

readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 10

Если probe каждые 10s, и pod получает SIGTERM — LB ещё 10s может слать запросы. Решение:

  • При SIGTERM сразу делайте /readyz отдавать 503;
  • Sleep в preStop > periodSeconds;
  • Уменьшить periodSeconds.
var ready atomic.Bool
ready.Store(true)
http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(200)
})
// При shutdown
<-ctx.Done()
ready.Store(false) // probe начнёт failить
time.Sleep(15 * time.Second) // дать LB обновиться
srv.Shutdown(...)
producer := kafka.NewProducer(...)
// ... отправили 1000 messages
producer.Close() // ПЛОХО: без Flush буфер потеряется

Перед Close — producer.Flush(timeoutMs).

db.Close() // активные транзакции откатятся

Сначала дайте handlers завершиться (HTTP Shutdown), потом db.Close.

var srv *http.Server // ПЛОХО
func main() {
srv = &http.Server{...}
}
// в другой горутине:
srv.Shutdown(...) // race condition: srv может быть nil

Передавайте srv через канал или используйте sync.Once.

<-ctx.Done()
srv.Shutdown(...)
// забыли вернуться из main → процесс висит

Убедитесь, что все горутины завершены (через wg.Wait()), потом main возвращает control.

log.Fatal(err) // os.Exit(1) — defer'ы НЕ выполнятся

log.Fatal и os.Exit пропускают defer. Используйте только в main() после явного cleanup, или возвращайте error.

T=0: pod SIGTERM
T=100ms: новый запрос прилетел
T=101ms: handler начал обработку
T=200ms: Shutdown called

Shutdown всё равно дождётся этого handler’а. Это OK, потому что k8s даёт terminationGracePeriodSeconds.

Если есть cgo-зависимости (например, на C-библиотеки) — их Cleanup иногда нужно делать вручную, defer не помогает в main. Это редкий edge case.

Не нужно в Go и не нужно в k8s. В контейнере PID 1 — это ваше приложение, и оно должно сразу обрабатывать сигналы.


  1. signal.NotifyContext — единый context для shutdown сигнала.
  2. Shutdown с таймаутом (30s типично) для HTTP/gRPC.
  3. Fallback Close()/Stop() если graceful не уложился.
  4. Игнорируйте http.ErrServerClosed — это не ошибка.
  5. GracefulStop оборачивайте в select с time.After.
  6. Порядок: listener → handlers → workers → downstream.
  7. /readyz → 503 при shutdown для удаления из LB.
  8. PreStop sleep 5-10s в k8s для смены Endpoints.
  9. preStop + shutdown ≤ terminationGracePeriodSeconds.
  10. Flush Kafka producer перед Close.
  11. Shutdown OTel TracerProvider для отправки pending spans.
  12. db.Close после handlers — не наоборот.
  13. Worker pool drain через close(jobs) + wg.Wait().
  14. errgroup для orchestration нескольких компонентов.
  15. Тестируйте shutdown в CI (запустить, послать SIGTERM, проверить exit code).
  16. Никогда не используйте log.Fatal в shutdown path — пропускает defer.
  17. Слушайте SIGINT + SIGTERM, остальные сигналы — по необходимости.
  18. Log shutdown phases для debug в проде.
  19. Healthcheck различает liveness и readiness.
  20. Не используйте daemonize в Go или контейнерах.

  1. Что такое graceful shutdown? Завершение работы сервиса в 3 шага: stop accepting, drain in-flight, close resources. Без потери данных и обрыва клиентов.

  2. Чем Shutdown отличается от Close в http.Server? Shutdown(ctx) — graceful (ждёт активные запросы). Close() — abrupt (закрывает всё немедленно).

  3. Что возвращает ListenAndServe после Shutdown? http.ErrServerClosed. Это норма, игнорируем через errors.Is.

  4. Чем GracefulStop отличается от Stop в gRPC? GracefulStop ждёт текущие RPC, без встроенного таймаута. Stop — мгновенный.

  5. Как сделать таймаут на GracefulStop? Обернуть в горутину + select с time.After; по таймауту вызвать Stop().

  6. Какие сигналы важны для shutdown в Linux? SIGINT (Ctrl+C), SIGTERM (k8s, kill). SIGKILL — нельзя поймать.

  7. Что такое signal.NotifyContext? Создаёт context, отменяемый при получении сигнала. Удобный паттерн с Go 1.16+.

  8. Можно ли поймать SIGKILL? Нет. Это by design — для emergency stop, не доставляется в user space.

  9. Что делает Kubernetes при удалении pod’а? (1) убирает из Endpoints, (2) preStop hook, (3) SIGTERM, (4) ждёт terminationGracePeriodSeconds, (5) SIGKILL.

  10. Зачем preStop sleep N в k8s? Чтобы дать LB время обновить Endpoints до того, как pod начнёт shutdown — иначе клиенты получат 503.

  11. Какой порядок shutdown компонентов? (1) Stop accepting (HTTP/gRPC listener), (2) drain handlers, (3) close workers, (4) close DB/Kafka, (5) flush exporters.

  12. Зачем нужен terminationGracePeriodSeconds? K8s даёт pod’у это время на graceful shutdown. После — SIGKILL. Default 30s.

  13. Что произойдёт, если shutdown превысит grace period? K8s пришлёт SIGKILL, in-flight requests оборвутся, defer не выполнится.

  14. Как обновить readiness probe при shutdown? При получении сигнала atomic-флагом отвечаем 503 на /readyz. LB перестаёт слать запросы.

  15. Зачем defer pool.Close() в main? Освободить connection pool. ⚠️ log.Fatal пропускает defer, аккуратно с error handling.

  16. Что такое drain worker pool? Закрыть jobs channel + дождаться wg.Wait() — все workers закончат текущие задачи.

  17. Почему Kafka producer.Close без Flush — баг? Buffered messages, не отправленные ещё в Kafka, теряются. Перед Close — Flush(timeoutMs).

  18. Зачем нужен Shutdown у OTel TracerProvider? Чтобы pending spans отправились в коллектор. Без — span’ы теряются.

  19. Можно ли db.Close() сразу после SIGTERM? Нет, сначала дождитесь HTTP/gRPC Shutdown — handlers могут ещё использовать БД.

  20. Чем errgroup помогает graceful shutdown? errgroup.WithContext отменяет ctx при ошибке любой горутины. Все компоненты подписаны на этот ctx и завершаются вместе.


  1. Напишите HTTP-сервер с эндпоинтом /slow (sleep 10s, проверяет ctx.Done). Запустите, дёрните эндпоинт, пошлите SIGTERM. Проверьте, что request завершается, новые отбиваются с connection refused.

  2. Аналогично — для gRPC. Реализуйте GracefulStop с таймаутом 5s; продемонстрируйте fallback на Stop.

  3. Сделайте комбинированный сервис: HTTP (8080) + gRPC (9090) + worker (раз в секунду пишет в БД). Один SIGTERM должен остановить всё:

    • HTTP graceful
    • gRPC graceful
    • Worker — реагирует на ctx
    • DB close в конце
  4. В k8s манифесте настройте:

    • terminationGracePeriodSeconds: 30;
    • preStop: sleep 5;
    • readinessProbe с periodSeconds: 3. Снимите trace pod termination через kubectl describe pod.
  5. Реализуйте паттерн readiness flag: atomic.Bool ready. При SIGTERM — ready.Store(false), /readyz начинает отдавать 503. Sleep 15s, потом срабатывает Shutdown.

  6. Напишите интеграционный тест:

    • Запустить сервер;
    • В loop’е дёргать эндпоинт;
    • Послать SIGTERM;
    • Убедиться, что:
      • in-flight завершились без ошибок;
      • новые запросы получают connection refused;
      • процесс завершился в течение 30s.
  7. Включите Kafka producer. Покажите: без Flush — теряются 50 messages при shutdown; с Flush — нет.

  8. Включите OTel exporter (Jaeger / OTLP). Покажите: без TracerProvider.Shutdown пропадают spans последнего 1s.

  9. Протестируйте сценарий: graceful shutdown превышает grace period — pod получает SIGKILL. Какие данные потеряются?

  10. Реализуйте Lifecycle Manager: компоненты регистрируются, Start/Stop в правильном порядке (reverse при Stop).