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:
parent
6f1566c2c3
commit
82bf53bb5c
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue