feat(api): health-пробы /health/live и /health/ready (P0-4)

/health/live — liveness без зависимостей (Predicate=false).
/health/ready — readiness: DatabaseReadyHealthCheck (CanConnect + нет
неприменённых миграций), тег ready, 503 если не готово. JSON-ответ по
каждому чеку. docker-compose api healthcheck + Dockerfile.api → /health/ready,
web ждёт api service_healthy. /health сохранён для обратной совместимости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-27 02:23:48 +05:00
parent 6f1566c2c3
commit 82bf53bb5c
4 changed files with 96 additions and 3 deletions

View file

@ -32,7 +32,7 @@ ENV DOTNET_NOLOGO=1
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD curl -fsS http://localhost:8080/health || exit 1 CMD curl -fsS http://localhost:8080/health/ready || exit 1
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"] ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]

View file

@ -34,6 +34,14 @@ services:
ports: ports:
- "8080:8080" # api - "8080:8080" # api
healthcheck:
# Готовность = БД отвечает + миграции применены (см. /health/ready).
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes: volumes:
- api-data:/app/App_Data - api-data:/app/App_Data
- api-logs:/app/logs - api-logs:/app/logs
@ -44,7 +52,8 @@ services:
container_name: food-market-web container_name: food-market-web
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- api api:
condition: service_healthy
ports: ports:
- "8081:80" # web SPA, not on 80 (legacy nginx holds it) - "8081:80" # web SPA, not on 80 (legacy nginx holds it)

View file

@ -0,0 +1,43 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace foodmarket.Api.Infrastructure.Health;
/// <summary>Readiness-проба: приложение готово принимать трафик только когда
/// БД отвечает И все миграции применены. Если есть неприменённые миграции —
/// схема не соответствует коду (например, контейнер поднялся раньше, чем
/// отработал <c>Migrate()</c>, или откатили не ту версию) — отдаём Unhealthy,
/// чтобы оркестратор/прокси не слал на этот инстанс запросы.</summary>
public sealed class DatabaseReadyHealthCheck : IHealthCheck
{
private readonly AppDbContext _db;
public DatabaseReadyHealthCheck(AppDbContext db) => _db = db;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
if (!await _db.Database.CanConnectAsync(cancellationToken))
{
return HealthCheckResult.Unhealthy("Нет соединения с БД.");
}
var pending = (await _db.Database.GetPendingMigrationsAsync(cancellationToken)).ToList();
if (pending.Count > 0)
{
return HealthCheckResult.Unhealthy(
$"Неприменённые миграции: {pending.Count} (первая: {pending[0]}).",
data: new Dictionary<string, object> { ["pendingMigrations"] = pending });
}
return HealthCheckResult.Healthy("БД доступна, миграции применены.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Ошибка проверки готовности БД.", ex);
}
}
}

View file

@ -145,6 +145,12 @@
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
builder.Services.AddAuthRateLimiting(); builder.Services.AddAuthRateLimiting();
// Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
builder.Services.AddHealthChecks()
.AddCheck<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
"database", tags: ["ready"]);
// Email-отправка через MailKit. Singleton — внутри открывает scope для // Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения. // на каждой отправке без рестарта приложения.
@ -220,6 +226,22 @@
app.MapControllers(); app.MapControllers();
// Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
// => ни один чек не запускается). Используется для рестарта зависшего контейнера.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => false,
ResponseWriter = WriteHealthResponse,
});
// Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово.
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteHealthResponse,
});
// Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда).
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow })); app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
app.MapGet("/api/_debug/whoami", (HttpContext ctx) => app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
@ -290,3 +312,22 @@
{ {
Log.CloseAndFlush(); Log.CloseAndFlush();
} }
// JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию
// неудобен для мониторинга; отдаём структурировано.
static Task WriteHealthResponse(HttpContext context,
Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report)
{
context.Response.ContentType = "application/json";
return context.Response.WriteAsJsonAsync(new
{
status = report.Status.ToString(),
totalDurationMs = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
}),
});
}