Надежность сервисов: healthcheck, retry и observability
Сервис считается готовым не тогда, когда он компилируется, а когда его можно безопасно запускать, останавливать, мониторить и чинить. Учебный проект часто заканчивается на "сервер отвечает на запрос". Production-мышление начинается с других вопросов: что будет при падении базы, как понять, что сервис завис, что увидит пользователь при таймауте внешнего API, как корректно остановить процесс во время деплоя.
Надежность - это не одна библиотека. Это набор привычек: таймауты, health checks, понятные логи, метрики, ограниченные retry, graceful shutdown и аккуратная деградация.
Healthcheck
Healthcheck показывает, жив ли сервис:
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) })
Такой endpoint полезен для Docker Compose, Kubernetes, load balancer и ручной проверки.
Но "процесс жив" и "сервис готов принимать трафик" - не всегда одно и то же. Поэтому часто разделяют:
- liveness - процесс не завис и может отвечать;
- readiness - сервис готов принимать реальные запросы;
- startup - сервис еще запускается.
Readiness может проверять базу, очередь, миграции или обязательные внешние зависимости:
func readiness(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { http.Error(w, "database unavailable", http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) w.Write([]byte("ready")) } }
Не делайте healthcheck слишком тяжелым. Если endpoint каждую секунду делает десять дорогих запросов, проверка сама станет источником нагрузки.
Graceful shutdown
Во время деплоя процесс получает сигнал завершения. Если просто остановить его мгновенно, текущие запросы оборвутся. Graceful shutdown дает им время завершиться.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "err", err) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("shutdown server: %w", err) }
Мысленно это похоже на закрытие магазина. Вы не пускаете новых покупателей, но даете тем, кто уже внутри, закончить покупку.
Timeout
Любая внешняя операция должна иметь ограничение по времени:
- HTTP-запрос;
- SQL-запрос;
- обращение к очереди;
- вызов внешнего API;
- ожидание результата из goroutine.
Пример HTTP-клиента:
client := &http.Client{ Timeout: 5 * time.Second, }
Пример context timeout:
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() rows, err := db.QueryContext(ctx, "SELECT id, title FROM books")
Без таймаутов сервис может зависать не полностью, а постепенно: goroutine ждут, соединения заняты, очередь растет, пользователи видят долгие ответы.
Retry с backoff
Retry помогает при временных сбоях: сеть дернулась, внешний API на секунду перегружен, база разорвала соединение. Но retry может и навредить. Если сервис уже перегружен, тысячи повторных запросов усилят проблему.
Используйте лимит попыток и exponential backoff:
delay := 200 * time.Millisecond for attempt := 1; attempt <= 3; attempt++ { err := callAPI(ctx) if err == nil { return nil } if attempt == 3 { return fmt.Errorf("call api after retries: %w", err) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): delay *= 2 } }
Rate limiting
Rate limiting ограничивает количество запросов. Он защищает ваш сервис от перегрузки и помогает не нарушить лимиты внешнего API.
limiter := rate.NewLimiter(rate.Every(time.Second), 5) if !limiter.Allow() { http.Error(w, "too many requests", http.StatusTooManyRequests) return }
В реальных API лимиты часто считают отдельно по пользователю, IP, API key или тарифу.
Rate limit должен возвращать понятный статус:
429 Too Many Requests
Иногда добавляют заголовки Retry-After или X-RateLimit-Remaining.
Structured logs
Логи должны помогать расследовать проблему:
slog.Info("request handled", "method", r.Method, "path", r.URL.Path, "status", status, "duration_ms", duration.Milliseconds(), "request_id", requestID, )
Хороший лог отвечает:
- что произошло;
- с каким объектом;
- сколько заняло;
- чем закончилось;
- какой request id связывает события.
Не пишите секреты в логи: токены, пароли, номера карт, private keys.
Metrics
Метрики показывают агрегированную картину:
- requests per second;
- error rate;
- latency p50/p95/p99;
- количество активных goroutine;
- размер очереди;
- количество retry;
- количество timeout;
- использование CPU и памяти.
Лог отвечает на вопрос "что случилось в конкретном запросе". Метрика отвечает на вопрос "что происходит с системой в целом".
Если latency p95 растет, значит 5% запросов стали медленными. Это может быть важнее, чем среднее значение, потому что пользователи чувствуют именно долгие ответы.
Tracing
Tracing показывает путь одного запроса через несколько сервисов. Это особенно полезно в микросервисах:
frontend -> api-gateway -> books-service -> database -> recommendations-service
Если весь запрос занял 2 секунды, trace помогает увидеть, где именно ушло время: в базе, во внешнем API или в очереди.
Graceful degradation
Не каждая зависимость одинаково важна. Если не работает второстепенная часть, сервис может продолжить работу в ограниченном режиме:
- нет рекомендаций - показать страницу без них;
- не работает email - сохранить задачу на повтор;
- внешний API медленный - вернуть кэш;
- аналитика недоступна - не ломать основной запрос.
Это не значит, что ошибки можно игнорировать. Их нужно логировать и мониторить. Но пользователь не должен страдать из-за необязательной функции, если основной сценарий можно выполнить.
Чеклист
- Есть
/healthи readiness-проверка. - Сервер завершает работу через graceful shutdown.
- Внешние вызовы имеют timeout.
- Retry ограничены и используют backoff.
- Повторяются только безопасные операции.
- Есть rate limiting для публичных или дорогих endpoints.
- Логи структурированы и содержат request id.
- Секреты не попадают в логи.
- Есть метрики ошибок и latency.
- Необязательные зависимости имеют план graceful degradation.