Опирается на правила: R-RES-HC-1R-RES-HC-4 и R-RES-HC-X1R-RES-HC-X2 из Resilience — индекс правил → раздел 10. Health checks.

Важно знать

  • На каждую внешнюю систему — отдельный checker-тип: SberHealthChecker, ReceiptHealthChecker.
  • Probe кешируется с TTL ~30s через sync.Mutex + поле lastCheck time.Time. Не каждый /health/ready ходит во внешнюю систему.
  • Probe-метод — light: GET /health или HEAD /, не реальный бизнес-вызов (RegisterOrder, GetTransactions).
  • В K8s: readinessProbe смотрит на /health/ready (включает внешние), livenessProbe — на /health/live (только само приложение). Внешние системы не влияют на liveness — k8s не должен перезапускать pod, если Sber недоступен.
  • Probe без TTL-кеша при K8s-проверках каждые 5–10s и 5 pod'ах = десятки запросов в минуту к внешней системе силами собственного health-check'а.
  • Business-операция в probe изменяет состояние системы, нагружает внешний сервис, порождает мусорные данные в prod.
  • Timeout probe — короткий: 3s; отдельный context.WithTimeout, не зависит от callTimeout адаптера.

Health check внешней системы — способ сообщить K8s, что pod не готов принимать трафик. Не глобальный «pinger Sber» и не тест живучести платёжного шлюза. Поэтому probe должен быть дешёвым (для нас и для внешней системы) и точным (отражать реальную готовность к работе).

Структура health-checker'а

R-RES-HC-1, R-RES-HC-2: отдельный тип на каждую систему, TTL-кеш через sync.Mutex.

// adapters/out/sber/health.go

type SberHealthChecker struct {
    client  *http.Client
    baseURL string
    ttl     time.Duration

    mu        sync.Mutex
    lastCheck time.Time
    lastOK    bool
    lastErr   error
}

func NewSberHealthChecker(client *http.Client, baseURL string, ttl time.Duration) *SberHealthChecker {
    return &SberHealthChecker{
        client:  client,
        baseURL: baseURL,
        ttl:     ttl,
    }
}

func (h *SberHealthChecker) Check(ctx context.Context) error {
    h.mu.Lock()
    defer h.mu.Unlock()

    if time.Since(h.lastCheck) < h.ttl {
        if !h.lastOK {
            return fmt.Errorf("sber: last probe failed: %w", h.lastErr)
        }
        return nil
    }

    probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, h.baseURL+"/health", nil)
    if err != nil {
        h.lastCheck = time.Now()
        h.lastOK = false
        h.lastErr = err
        return fmt.Errorf("sber health probe: build request: %w", err)
    }

    resp, err := h.client.Do(req)
    h.lastCheck = time.Now()

    if err != nil {
        h.lastOK = false
        h.lastErr = err
        return fmt.Errorf("sber health probe: %w", err)
    }
    _ = resp.Body.Close()

    if resp.StatusCode >= 500 {
        h.lastOK = false
        h.lastErr = fmt.Errorf("status %d", resp.StatusCode)
        return fmt.Errorf("sber health probe: %w", h.lastErr)
    }

    h.lastOK = true
    h.lastErr = nil
    return nil
}

TTL-кеш — sync.Mutex + поля lastCheck/lastOK/lastErr. Mutex берётся на всю операцию: конкурентные вызовы Check сериализуются, probe не дублируется.

Альтернатива при простом случае: atomic.Value со структурой {ok bool, err error, at time.Time} — чтение без блокировки, запись через atomic.Value.Store. Подходит, если probe-функция сама потокобезопасна.

Light probe — GET /health, не business

R-RES-HC-3: probe-метод — технический endpoint, не бизнес-операция.

Если внешняя система имеет /health или /status — используем его:

req, _ := http.NewRequestWithContext(probeCtx, http.MethodGet, h.baseURL+"/health", nil)

Если /health нет — HEAD на корневой путь. Обычно отвечает без реальной обработки:

req, _ := http.NewRequestWithContext(probeCtx, http.MethodHead, h.baseURL+"/", nil)

Если API-gateway внешней системы не отвечает на HEAD / — самый дешёвый GET с минимальной нагрузкой:

// например, для Receipt-сервиса, у которого есть ping-endpoint
req, _ := http.NewRequestWithContext(probeCtx, http.MethodGet, h.baseURL+"/api/ping", nil)

Никаких бизнес-вызовов:

// нельзя: изменяет состояние
sberAdapter.RegisterOrder(ctx, testOrder)

// нельзя: нагружает, портит аналитику
sberAdapter.GetTransactions(ctx, lastMonth)

// нельзя: порождает test-данные в prod и нагружает систему
customerAdapter.CreateCustomer(ctx, syntheticCustomer)

Регистрация в /health/ready и /health/live

R-RES-HC-4: readiness включает внешние системы, liveness — нет.

Типичная структура обработчиков при chi:

// adapters/in/http/health_handler.go

type HealthHandler struct {
    checkers []NamedChecker
    db       *pgxpool.Pool
}

type NamedChecker struct {
    Name    string
    Checker interface{ Check(context.Context) error }
}

func (h *HealthHandler) Register(r chi.Router) {
    r.Get("/health/live", h.liveness)
    r.Get("/health/ready", h.readiness)
}

func (h *HealthHandler) liveness(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(`{"status":"UP"}`))
}

func (h *HealthHandler) readiness(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    type systemStatus struct {
        Status string `json:"status"`
        Error  string `json:"error,omitempty"`
    }
    result := map[string]systemStatus{}
    overall := true

    if err := h.db.Ping(ctx); err != nil {
        result["db"] = systemStatus{Status: "DOWN", Error: err.Error()}
        overall = false
    } else {
        result["db"] = systemStatus{Status: "UP"}
    }

    for _, nc := range h.checkers {
        if err := nc.Checker.Check(ctx); err != nil {
            result[nc.Name] = systemStatus{Status: "DOWN", Error: err.Error()}
            overall = false
        } else {
            result[nc.Name] = systemStatus{Status: "UP"}
        }
    }

    status := http.StatusOK
    statusStr := "UP"
    if !overall {
        status = http.StatusServiceUnavailable
        statusStr = "DOWN"
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    body := map[string]any{"status": statusStr, "components": result}
    _ = json.NewEncoder(w).Encode(body)
}

Сборка в main.go или wire-файле:

healthHandler := &HealthHandler{
    db: dbPool,
    checkers: []NamedChecker{
        {Name: "sber",      Checker: sberHealthChecker},
        {Name: "receipt",   Checker: receiptHealthChecker},
        {Name: "insurance", Checker: insuranceHealthChecker},
    },
}

Пример K8s-манифеста:

livenessProbe:
  httpGet:
    path: /health/live
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  failureThreshold: 3

Когда включать внешнюю систему в readiness — решение по домену:

  • Без Sber сервис заказов не может провести ни одного платежа → включаем: pod выходит из балансировки, трафик не идёт.
  • Без сервиса каталога заказ можно создать, но не показать детали продукта → не включаем: handler сам вернёт частичный ответ или 503 на конкретный endpoint.
  • Без Receipt-сервиса чек придёт позже через task-queue → не включаем: функциональность деградирует, но сервис остаётся рабочим.

Что запрещено

АнтипаттернПравилоЧто взамен
Probe без TTL-кеша — ходит во внешнюю систему на каждый /health/readyR-RES-HC-X1sync.Mutex + поле lastCheck; TTL ~30s
Probe вызывает business-метод (RegisterOrder, GetTransactions)R-RES-HC-X2GET /health или HEAD / внешней системы
Внешние системы в /health/live → K8s перезапускает pod при недоступности SberR-RES-HC-4Только в /health/ready
Один checker на несколько системR-RES-HC-1Отдельный тип на каждую: SberHealthChecker, ReceiptHealthChecker
Timeout probe = callTimeout адаптера (15s)R-RES-HC-3Отдельный короткий context.WithTimeout(ctx, 3s)
Check паникует или возвращает не-error при сбое пробыR-RES-HC-1return fmt.Errorf("sber health probe: %w", err), h.lastOK = false

Куда дальше

  • Async и polling — task-queue вместо polling-цикла в handler'е.
  • Bulkhead — semaphore.NewWeighted per-system; изоляция одновременных вызовов.
  • Circuit breaker — gobreaker.CircuitBreaker per-system; open/half-open/closed.
  • Конфигурация — envconfig-теги для TTL, BaseURL, таймаутов probe.
  • Fallback — когда Check возвращает ошибку и нужно деградировать.
  • Observability — promauto метрики CB/bulkhead; OTel-spans на adapter-методах.
  • OpenAPI generator binding — сгенерированный клиент и mapper domain-типов.
  • Per-system isolation — отдельный *http.Client + *http.Transport на систему.
  • Retry — retry.Do с RetryIf; когда допустим, когда — нет.
  • Timeouts — иерархия connectTimeout < readTimeout < callTimeout; capTimeout из контекста.
  • Где какая защита — outbound, internal s2s, inbound; что защищать resilience-библиотеками, что — task-queue.