Опирается на правила: R-OBS-HC-1R-OBS-HC-3 и R-OBS-HC-X1R-OBS-HC-X3 из Observability Style Guide → раздел 4. Health checks.

Важно знать

  • Liveness и readiness — разные роуты, разные семантики, разные зависимости.
  • /health/live — UP пока процесс отвечает. Не должен зависеть от внешних систем.
  • /health/ready — UP когда сервис готов принимать трафик: БД доступна, зависимости прогреты.
  • Custom check для критичной внешней системы — с TTL-кешем, чтобы probe не дудосила provider.
  • /info содержит git_commit, build_version, service_name — для отладки версии в проде.
  • Health — техническое состояние, не бизнес. order_count > N → DOWN — антипаттерн (R-OBS-HC-X1).
  • Liveness не зависит от DB: иначе K8s рестартует pod в loop при кратковременной недоступности.
  • Эндпоинты health/info размещают на отдельном management-порту (R-OBS-CFG-1).

Health checks — то, на что смотрит Kubernetes, ALB и load balancer, решая «дать ли трафик в этот pod». Неправильно настроенные probes — главный источник каскадных отказов: одна реплика DB лагает → все pods unhealthy → весь сервис недоступен.

Liveness vs Readiness

R-OBS-HC-1: FastAPI не имеет встроенного Actuator — роуты создаются явно. Зато разделение liveness/readiness выходит чистым: никаких XML-конфигов, только функции.

from fastapi import FastAPI

app = FastAPI()


@app.get("/health/live")
async def liveness() -> dict:
    return {"status": "UP"}


@app.get("/health/ready")
async def readiness() -> dict:
    checks = await run_readiness_checks()
    status = "UP" if all(c["status"] == "UP" for c in checks) else "DOWN"
    return {"status": status, "checks": checks}
ЭндпоинтЧто проверяетЧто делает K8s
/health/liveПроцесс отвечает, event loop не зависUP → продолжать; DOWN → рестартует pod
/health/readyDB доступна, зависимости прогретыUP → шлёт трафик; DOWN → снимает из Service endpoints

Семантическое различие критично:

  • Если DB недоступна 5 секунд — readiness должен стать DOWN (трафик уйдёт на другие реплики), liveness должен остаться UP (рестарт pod не поможет, DB та же).
  • Если event loop завис — liveness DOWN, K8s убивает pod, новый стартует с чистым состоянием.

K8s манифест для FastAPI-сервиса:

spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /health/live
          port: 9090
        initialDelaySeconds: 10
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 9090
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 2

Порт 9090 — отдельный management-порт, не business 8080. В FastAPI management-приложение монтируется отдельным ASGI-процессом (R-OBS-CFG-1):

import uvicorn
from fastapi import FastAPI

management_app = FastAPI(docs_url=None, redoc_url=None)

management_app.include_router(health_router)
management_app.include_router(info_router)
management_app.include_router(metrics_router)
if __name__ == "__main__":
    import asyncio

    async def serve_both():
        config_business = uvicorn.Config(app, host="0.0.0.0", port=8080)
        config_management = uvicorn.Config(management_app, host="0.0.0.0", port=9090)
        server_business = uvicorn.Server(config_business)
        server_management = uvicorn.Server(config_management)
        await asyncio.gather(server_business.serve(), server_management.serve())

    asyncio.run(serve_both())

Custom check с TTL-кешем

R-OBS-HC-2: для каждой критичной внешней системы — отдельная async-функция с TTL-кешем. Без кеша: K8s проверяет readiness каждые 5 секунд × 10 реплик = 20 ping/s на provider только от health-check.

import asyncio
import time
from dataclasses import dataclass, field
from typing import Optional

import httpx


@dataclass
class CachedCheck:
    result: dict
    expires_at: float


_sber_cache: Optional[CachedCheck] = None
_sber_lock = asyncio.Lock()
SBER_TTL_SECONDS = 10


async def check_sber_payment() -> dict:
    global _sber_cache
    now = time.monotonic()

    if _sber_cache and _sber_cache.expires_at > now:
        return _sber_cache.result

    async with _sber_lock:
        if _sber_cache and _sber_cache.expires_at > now:
            return _sber_cache.result

        result = await _do_check_sber()
        _sber_cache = CachedCheck(result=result, expires_at=now + SBER_TTL_SECONDS)
        return result


async def _do_check_sber() -> dict:
    try:
        async with httpx.AsyncClient(timeout=2.0) as client:
            resp = await client.get("https://pay.sber.ru/ping")
            resp.raise_for_status()
        return {"status": "UP", "provider": "sber"}
    except Exception as exc:
        return {"status": "DOWN", "provider": "sber", "error": str(exc)}

Двойная проверка внутри asyncio.Lock — защита от race condition при параллельных coroutine: первый захватывает лок, остальные ждут; когда первый кладёт в кеш и отпускает — остальные видят свежий результат и не идут к provider.

Для DB и Redis auto-check — через asyncpg и redis.asyncio:

async def check_postgres(pool: asyncpg.Pool) -> dict:
    try:
        async with pool.acquire(timeout=1.0) as conn:
            await conn.fetchval("SELECT 1")
        return {"status": "UP", "component": "postgres"}
    except Exception as exc:
        return {"status": "DOWN", "component": "postgres", "error": str(exc)}


async def check_redis(redis_client) -> dict:
    try:
        await redis_client.ping()
        return {"status": "UP", "component": "redis"}
    except Exception as exc:
        return {"status": "DOWN", "component": "redis", "error": str(exc)}

Итоговый readiness:

from fastapi import APIRouter, Response

health_router = APIRouter()


@health_router.get("/health/ready")
async def readiness(response: Response) -> dict:
    checks = await asyncio.gather(
        check_postgres(postgres_pool),
        check_redis(redis_client),
        check_sber_payment(),
    )
    checks_list = list(checks)
    all_up = all(c["status"] == "UP" for c in checks_list)

    if not all_up:
        response.status_code = 503

    return {
        "status": "UP" if all_up else "DOWN",
        "checks": checks_list,
    }

Статус 503 при DOWN — обязателен: K8s читает HTTP-статус, а не тело.

/info с git и build метаданными

R-OBS-HC-3: аналог /actuator/info — эндпоинт, который отдаёт версию и commit сборки. Без него каждый инцидент начинается с «какая версия в проде сейчас» и поиска через CI-логи.

Метаданные записываем в файл на этапе сборки CI:

# .github/workflows/build.yml
- name: Write build info
  run: |
    cat > app/build_info.json << EOF
    {
      "git_commit": "${{ github.sha }}",
      "git_branch": "${{ github.ref_name }}",
      "build_time": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
      "version": "${{ github.run_number }}"
    }
    EOF
import json
from pathlib import Path

from fastapi import APIRouter

info_router = APIRouter()


def _load_build_info() -> dict:
    path = Path(__file__).parent / "build_info.json"
    if path.exists():
        return json.loads(path.read_text())
    return {"git_commit": "dev", "version": "local"}


_BUILD_INFO = _load_build_info()


@info_router.get("/info")
async def info() -> dict:
    return {
        "service": {"name": "order-service"},
        "build": _BUILD_INFO,
    }

Результат /info:

{
  "service": {"name": "order-service"},
  "build": {
    "git_commit": "5380f21abc...",
    "git_branch": "main",
    "build_time": "2026-06-18T14:30:00Z",
    "version": "142"
  }
}

Liveness зависит от внешних систем

R-OBS-HC-X2: классическая ловушка.

# КАТАСТРОФА — liveness вернёт 503 при недоступности DB
@app.get("/health/live")
async def liveness_bad():
    result = await check_postgres(postgres_pool)
    if result["status"] != "UP":
        raise HTTPException(status_code=503, detail=result)
    return {"status": "UP"}

Сценарий: PG лагает 30 секунд из-за VACUUM FULL. Liveness DOWN → K8s убивает pod → новый стартует, та же DB лагает → DOWN → убивает → loop. Все реплики падают за минуту, сервис недоступен.

Liveness обязан проверять только сам процесс — event loop жив, asyncio отвечает. Внешние зависимости — только в readiness.

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

АнтипаттернПравилоЧто взамен
Business-state в health check (if order_count > N: status = "DOWN")R-OBS-HC-X1техническое состояние; бизнес → SLO
Liveness зависит от DB/Redis/externalR-OBS-HC-X2только readiness зависит от внешних
Probe делает business-операцию (create_test_order)R-OBS-HC-X3light probe (ping, cached, SELECT 1)
check без TTL-кеша при частом опросе K8sR-OBS-HC-2TTL 5–30 секунд + asyncio.Lock
/health/ready возвращает 200 при DOWN в телеR-OBS-HC-1HTTP 503 при любом DOWN-компоненте
Нет /info с git_commit и версиейR-OBS-HC-3build_info.json из CI + /info эндпоинт

Куда дальше

  • Конфигурация — management-порт, exposed эндпоинты, отдельный ASGI.
  • Context propagation — contextvars, request_id/user_id в middleware с очисткой.
  • Logging — structlog, structured context, PII-маскировка.
  • Metrics — prometheus-client, бизнес-метрики, cardinality.
  • SLO и алерты — бизнес-цели отдельно от probes, error budget.
  • Tracing — OTel автоинструментация, manual span, sampling.