Опирается на правила:
R-RES-HC-1…R-RES-HC-4иR-RES-HC-X1…R-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/ready | R-RES-HC-X1 | sync.Mutex + поле lastCheck; TTL ~30s |
Probe вызывает business-метод (RegisterOrder, GetTransactions) | R-RES-HC-X2 | GET /health или HEAD / внешней системы |
Внешние системы в /health/live → K8s перезапускает pod при недоступности Sber | R-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-1 | return fmt.Errorf("sber health probe: %w", err), h.lastOK = false |
Куда дальше
- Async и polling — task-queue вместо polling-цикла в handler'е.
- Bulkhead —
semaphore.NewWeightedper-system; изоляция одновременных вызовов. - Circuit breaker —
gobreaker.CircuitBreakerper-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.