День 2: Отказоустойчивость и жизненный цикл сервиса

🎯 Цели дня

  1. Освоить паттерн Circuit Breaker и Retry с экспоненциальной задержкой.
  2. Реализовать корректный Graceful Shutdown HTTP-сервера.
  3. Понять порядок закрытия внешних зависимостей (БД, очереди).
  4. Подготовить ответы на вопросы о деплое в Kubernetes.

📖 Теоретический минимум (45 мин)

Circuit Breaker

Три состояния:

  • Closed (норма) — запросы проходят.
  • Open (блокировка) — запросы сразу отклоняются, чтобы не усугублять сбой.
  • Half-Open (проверка) — пропускается тестовый запрос, чтобы проверить, восстановился ли сервис.

Зачем: Без CB один упавший downstream тянет за собой всю цепочку (каскадный сбой).

Retry с exponential backoff + jitter

delay = base_delay * 2^attempt + random_jitter

Почему jitter обязателен: Без него все сервисы одновременно долбят восстановившийся сервис (Thundering herd problem) и снова его кладут.

Graceful Shutdown в K8s

  1. Pod получает SIGTERM.
  2. Сервис должен убрать себя из readiness-пробы (чтобы не получать новые запросы).
  3. Дать активным запросам доработать (обычно 10–30 сек).
  4. Закрыть внешние подключения (БД, очереди).
  5. Завершиться.

Важно: Через 30 сек после SIGTERM K8s шлёт SIGKILL (принудительное убийство). Если не успел — данные могут потеряться.

💻 Практика (2–3 часа)

Задание 1: Circuit Breaker

Реализуй простую версию или подключи sony/gobreaker:

go
type CircuitBreaker struct {
    name        string
    maxFailures int
    timeout     time.Duration
    failures    atomic.Int32
    state       atomic.Int32 // 0=closed, 1=open, 2=half-open
    lastFailure atomic.Int64
}

var ErrCircuitOpen = errors.New("circuit breaker is open")

func (cb *CircuitBreaker) Execute(ctx context.Context, fn func(context.Context) error) error {
    state := cb.state.Load()

    if state == 1 { // open
        lastFail := time.Unix(0, cb.lastFailure.Load())
        if time.Since(lastFail) > cb.timeout {
            cb.state.Store(2) // half-open
        } else {
            return fmt.Errorf("%s: %w", cb.name, ErrCircuitOpen)
        }
    }

    err := fn(ctx)
    if err != nil {
        cb.failures.Add(1)
        cb.lastFailure.Store(time.Now().UnixNano())
        if cb.failures.Load() >= int32(cb.maxFailures) {
            cb.state.Store(1) // open
        }
        return err
    }

    cb.failures.Store(0)
    cb.state.Store(0) // closed
    return nil
}

Тест: Внешний сервис 3 раза возвращает 500 → CB переходит в Open → следующие запросы сразу возвращают ErrCircuitOpen без реальных вызовов.

Задание 2: Graceful Shutdown

Подними HTTP-сервер с эндпоинтом /slow (спит 5 секунд):

go
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
        fmt.Fprint(w, "ok")
    })
    mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
        select {
        case <-time.After(5 * time.Second):
            fmt.Fprint(w, "done")
        case <-r.Context().Done():
            log.Println("клиент отключился")
        }
    })

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

    go func() {
        log.Println("сервер на :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("получен сигнал завершения...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("принудительное завершение: %v", err)
    }

    // Здесь закрываешь подключения: БД, RabbitMQ, Redis
    // db.Close()
    // rabbitConn.Close()

    log.Println("сервер остановлен корректно")
}

Эксперимент:

  1. Запусти curl http://localhost:8080/slow.
  2. Сразу отправь SIGTERM: kill <pid>.
  3. Убедись, что запрос дорабатывает (или отменяется по контексту клиента).

🏗️ Архитектурное задание для AgentForge (1 час)

Добавь в свой boilerplate корректный shutdown. Опиши порядок действий в main():

  1. Перехват os.Signal (SIGINT, SIGTERM).
  2. Вызов srv.Shutdown(ctx) с таймаутом 10 сек.
  3. Закрытие пула соединений с БД (db.Close()).
  4. Закрытие коннекта к RabbitMQ/Redis.

Вопрос: Почему именно такой порядок? Ответ: Сначала останавливаем приём новых → даём доработать старым → рвём внешние связи. Если закрыть БД раньше, активные запросы упадут с ошибкой.

🗣️ Топ-5 вопросов для самопроверки

  1. Почему retry без jitter опасен в распределённых системах? Ответ: Thundering herd problem — все сервисы одновременно долбят восстановившийся сервис и снова его кладут.

  2. Что произойдет с активными запросами при деплое в Kubernetes без Graceful Shutdown? Ответ: K8s убивает под через 30 сек после SIGTERM. Активные запросы оборвутся, клиенты получат 502.

  3. Как правильно настроить http.Client для микросервисных вызовов? Ответ: Всегда задавать Timeout, переиспользовать один инстанс клиента (для keep-alive соединений).

  4. В каких состояниях может быть Circuit Breaker? Ответ: Closed (норма), Open (блокировка), Half-Open (проверка).

  5. Почему порядок закрытия ресурсов при shutdown важен? Ответ: Если закрыть БД раньше, чем доработают активные запросы, они упадут с ошибкой. Правильный порядок: стоп приём → доработка → закрытие внешних связей.

🧩 Проверь себя

1. Зачем в retry нужен exponential backoff + jitter?
2. Какой правильный порядок Graceful Shutdown?
3. Что сделает srv.Shutdown(ctx), если активные запросы не завершились за таймаут контекста?
4. Как правильно настроить http.Client для микросервисных вызовов?
5. Зачем нужен Circuit Breaker?
6. В каких состояниях может находиться Circuit Breaker?
7. Что происходит через 30 секунд после SIGTERM в Kubernetes?

✅ Чек-лист дня

  • Реализовал Circuit Breaker (свой или через sony/gobreaker)
  • Настроил Graceful Shutdown с корректным порядком закрытия ресурсов
  • Понимаю, почему retry без jitter опасен
  • Могу объяснить, что происходит при деплое в K8s
  • Добавил shutdown в свой boilerplate