Урок 27: Graceful shutdown — полный production-сценарий

Урок 27. Graceful shutdown — полный production-сценарий

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-graceful-shutdown && cd go-graceful-shutdown
go mod init go-graceful-shutdown

# Устанавливаем зависимости (для демонстрации)
go get github.com/redis/go-redis/v9
go get github.com/jackc/pgx/v5/pgxpool
go get github.com/rs/zerolog
go mod tidy

# Структура
mkdir -p internal/shutdown
mkdir -p cmd/server

💻 Файл: internal/shutdown/shutdown.go

package shutdown

import (
    "context"
    "fmt"
    "log"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  КОМПОНЕНТ ДЛЯ ОСТАНОВКИ                               ║
// ╚══════════════════════════════════════════════════════════╝

// Component — интерфейс для любого компонента, который нужно остановить
type Component interface {
    Name() string
    Shutdown(ctx context.Context) error
}

// Manager — управляет graceful shutdown всех компонентов
type Manager struct {
    components []Component
    timeout    time.Duration // Общий таймаут на остановку
    logger     *log.Logger
}

// NewManager — создаёт менеджер
func NewManager(timeout time.Duration, logger *log.Logger) *Manager {
    return &Manager{
        timeout: timeout,
        logger:  logger,
    }
}

// Register — регистрирует компонент для остановки
// Порядок регистрации ВАЖЕН: останавливаются в обратном порядке!
func (m *Manager) Register(components ...Component) {
    m.components = append(m.components, components...)
}

// ShutdownAll — останавливает все компоненты в обратном порядке
func (m *Manager) ShutdownAll(ctx context.Context) error {
    m.logger.Println("🛑 Начало graceful shutdown...")

    // Создаём контекст с общим таймаутом
    shutdownCtx, cancel := context.WithTimeout(ctx, m.timeout)
    defer cancel()

    var mu sync.Mutex
    var errs []error

    // Останавливаем в ОБРАТНОМ порядке (последний зарегистрированный — первый останавливается)
    for i := len(m.components) - 1; i >= 0; i-- {
        comp := m.components[i]
        m.logger.Printf("  ⏳ Остановка %s...", comp.Name())

        start := time.Now()
        err := comp.Shutdown(shutdownCtx)
        elapsed := time.Since(start)

        if err != nil {
            m.logger.Printf("%s: ошибка за %v: %v", comp.Name(), elapsed, err)
            mu.Lock()
            errs = append(errs, fmt.Errorf("%s: %w", comp.Name(), err))
            mu.Unlock()
        } else {
            m.logger.Printf("%s: остановлен за %v", comp.Name(), elapsed)
        }
    }

    if len(errs) > 0 {
        return fmt.Errorf("shutdown errors: %v", errs)
    }

    m.logger.Println("✅ Graceful shutdown завершён")
    return nil
}

// WaitForSignal — блокируется до получения сигнала на остановку
func WaitForSignal(logger *log.Logger) os.Signal {
    sigCh := make(chan os.Signal, 1)
    // SIGINT — Ctrl+C
    // SIGTERM — kill / docker stop / k8s termination
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    sig := <-sigCh
    logger.Printf("📡 Получен сигнал: %v", sig)
    return sig
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ГОТОВЫЕ КОМПОНЕНТЫ                                    ║
// ╚══════════════════════════════════════════════════════════╝

// HTTPServer — обёртка для http.Server
type HTTPServer struct {
    server *http.Server
    name   string
}

func NewHTTPServer(name string, srv *http.Server) *HTTPServer {
    return &HTTPServer{name: name, server: srv}
}

func (h *HTTPServer) Name() string {
    return h.name
}

func (h *HTTPServer) Shutdown(ctx context.Context) error {
    // 1. Сначала перестаём принимать НОВЫЕ запросы
    // 2. Ждём завершения АКТИВНЫХ запросов (до таймаута)
    return h.server.Shutdown(ctx)
}

// GRPCServer — обёртка для gRPC сервера
type GRPCServer struct {
    server *grpc.Server
    name   string
}

func NewGRPCServer(name string, srv *grpc.Server) *GRPCServer {
    return &GRPCServer{name: name, server: srv}
}

func (g *GRPCServer) Name() string {
    return g.name
}

func (g *GRPCServer) Shutdown(ctx context.Context) error {
    // GracefulStop ждёт завершения активных RPC
    done := make(chan struct{})
    go func() {
        g.server.GracefulStop()
        close(done)
    }()

    select {
    case <-done:
        return nil
    case <-ctx.Done():
        // Если не успели — жёсткая остановка
        g.server.Stop()
        return fmt.Errorf("gRPC graceful stop timeout: %w", ctx.Err())
    }
}

// DBPool — обёртка для pgxpool
type DBPoolCloser struct {
    pool *pgxpool.Pool
    name string
}

func NewDBPoolCloser(pool *pgxpool.Pool) *DBPoolCloser {
    return &DBPoolCloser{pool: pool, name: "PostgreSQL"}
}

func (d *DBPoolCloser) Name() string {
    return d.name
}

func (d *DBPoolCloser) Shutdown(ctx context.Context) error {
    d.pool.Close()
    return nil
}

// RedisCloser — обёртка для redis.Client
type RedisCloser struct {
    client *redis.Client
    name   string
}

func NewRedisCloser(client *redis.Client) *RedisCloser {
    return &RedisCloser{client: client, name: "Redis"}
}

func (r *RedisCloser) Name() string {
    return r.name
}

func (r *RedisCloser) Shutdown(ctx context.Context) error {
    return r.client.Close()
}

import (
    "net/http"
    "google.golang.org/grpc"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/redis/go-redis/v9"
)

💻 Файл: cmd/server/main.go

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "time"

    "go-graceful-shutdown/internal/shutdown"
)

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🚀 Запуск production-сервера...")

    // ╔══════════════════════════════════════════════════════╗
    // ║  1. ИНИЦИАЛИЗАЦИЯ КОМПОНЕНТОВ                       ║
    // ╚══════════════════════════════════════════════════════╝

    // HTTP-сервер
    mux := http.NewServeMux()
    mux.HandleFunc("/health", healthHandler)
    mux.HandleFunc("/ready", readyHandler)

    httpServer := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // Имитация других компонентов (в реальности — pgxpool, redis.Client)
    // dbPool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    // redisClient := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

    // ╔══════════════════════════════════════════════════════╗
    // ║  2. РЕГИСТРАЦИЯ КОМПОНЕНТОВ В МЕНЕДЖЕРЕ            ║
    // ╚══════════════════════════════════════════════════════╝

    // Создаём менеджер с общим таймаутом 15 секунд
    manager := shutdown.NewManager(15*time.Second, log.Default())

    // Регистрируем в порядке ИНИЦИАЛИЗАЦИИ
    // Останавливаться будут в ОБРАТНОМ порядке:
    // 1. HTTP (перестаём принимать запросы)
    // 2. БД (закрываем после завершения запросов)
    // 3. Redis
    manager.Register(
        shutdown.NewHTTPServer("HTTP API", httpServer),
        // shutdown.NewDBPoolCloser(dbPool),    // В реальном коде
        // shutdown.NewRedisCloser(redisClient), // В реальном коде
    )

    // Добавляем фейковые компоненты для демонстрации
    manager.Register(&FakeComponent{name: "Kafka Consumer", delay: 2 * time.Second})
    manager.Register(&FakeComponent{name: "Metrics Exporter", delay: 500 * time.Millisecond})

    // ╔══════════════════════════════════════════════════════╗
    // ║  3. ЗАПУСК СЕРВЕРА                                  ║
    // ╚══════════════════════════════════════════════════════╝

    go func() {
        log.Println("✅ Сервер на :8080")
        if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Ошибка HTTP: %v", err)
        }
    }()

    // ╔══════════════════════════════════════════════════════╗
    // ║  4. ОЖИДАНИЕ СИГНАЛА И ОСТАНОВКА                   ║
    // ╚══════════════════════════════════════════════════════╝

    // Блокируемся до получения SIGINT/SIGTERM
    shutdown.WaitForSignal(log.Default())

    // Останавливаем все компоненты
    ctx := context.Background()
    if err := manager.ShutdownAll(ctx); err != nil {
        log.Printf("⚠️ Ошибки при остановке: %v", err)
        os.Exit(1)
    }

    log.Println("👋 Приложение остановлено")
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ВСПОМОГАТЕЛЬНЫЕ ОБРАБОТЧИКИ                           ║
// ╚══════════════════════════════════════════════════════════╝

var isReady atomic.Bool

func init() {
    isReady.Store(true)
}

// healthHandler — для liveness probe (жив ли процесс)
func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

// readyHandler — для readiness probe (готов ли принимать запросы)
func readyHandler(w http.ResponseWriter, r *http.Request) {
    if isReady.Load() {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Ready"))
        return
    }
    w.WriteHeader(http.StatusServiceUnavailable)
    w.Write([]byte("Not Ready"))
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ФЕЙКОВЫЙ КОМПОНЕНТ (для демонстрации)                 ║
// ╚══════════════════════════════════════════════════════════╝

type FakeComponent struct {
    name  string
    delay time.Duration
}

func (f *FakeComponent) Name() string {
    return f.name
}

func (f *FakeComponent) Shutdown(ctx context.Context) error {
    log.Printf("    [%s] имитация остановки (%v)...", f.name, f.delay)

    select {
    case <-time.After(f.delay):
        return nil
    case <-ctx.Done():
        return fmt.Errorf("timeout: %w", ctx.Err())
    }
}

import (
    "fmt"
    "sync/atomic"
)

🚀 Запуск и тестирование

# Запуск сервера
go run ./cmd/server/main.go

# В другом терминале:
# Проверка health
curl http://localhost:8080/health

# Проверка readiness
curl http://localhost:8080/ready

# Отправка graceful shutdown (Ctrl+C в первом терминале)
# ИЛИ:
kill -TERM $(pgrep go-graceful)

# Наблюдаем порядок остановки:
# 1. HTTP API (перестаёт принимать запросы)
# 2. Kafka Consumer (ждёт обработки текущих сообщений)
# 3. Metrics Exporter
# 4. БД
# 5. Redis

📊 Порядок graceful shutdown

ПорядокКомпонентДействиеПричина
1Load BalancerОтвести трафикНовые запросы не приходят
2Health CheckReadiness → falseK8s перестаёт отправлять запросы
3HTTP ServerShutdown(ctx)Ждём завершения активных запросов
4gRPC ServerGracefulStop()Ждём завершения RPC
5Kafka ConsumerCommit + CloseФиксируем offset’ы
6DB PoolClose()Закрываем соединения
7RedisClose()Закрываем соединения
8TracingShutdown(ctx)Отправляем оставшиеся спаны
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →