From 82bf53bb5cb6695d09f2bd1eff80b912c4f744d3 Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 27 May 2026 02:23:48 +0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20health-=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=20/health/live=20=D0=B8=20/health/ready=20(P0-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- deploy/Dockerfile.api | 4 +- deploy/docker-compose.yml | 11 ++++- .../Health/DatabaseReadyHealthCheck.cs | 43 +++++++++++++++++++ src/food-market.api/Program.cs | 41 ++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/food-market.api/Infrastructure/Health/DatabaseReadyHealthCheck.cs diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api index ebb8663..3046f6e 100644 --- a/deploy/Dockerfile.api +++ b/deploy/Dockerfile.api @@ -32,7 +32,7 @@ ENV DOTNET_NOLOGO=1 ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ - CMD curl -fsS http://localhost:8080/health || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \ + CMD curl -fsS http://localhost:8080/health/ready || exit 1 ENTRYPOINT ["dotnet", "foodmarket.Api.dll"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1f4f8f8..58d0d68 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -34,6 +34,14 @@ services: ports: - "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: - api-data:/app/App_Data - api-logs:/app/logs @@ -44,7 +52,8 @@ services: container_name: food-market-web restart: unless-stopped depends_on: - - api + api: + condition: service_healthy ports: - "8081:80" # web SPA, not on 80 (legacy nginx holds it) diff --git a/src/food-market.api/Infrastructure/Health/DatabaseReadyHealthCheck.cs b/src/food-market.api/Infrastructure/Health/DatabaseReadyHealthCheck.cs new file mode 100644 index 0000000..20f2a76 --- /dev/null +++ b/src/food-market.api/Infrastructure/Health/DatabaseReadyHealthCheck.cs @@ -0,0 +1,43 @@ +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace foodmarket.Api.Infrastructure.Health; + +/// Readiness-проба: приложение готово принимать трафик только когда +/// БД отвечает И все миграции применены. Если есть неприменённые миграции — +/// схема не соответствует коду (например, контейнер поднялся раньше, чем +/// отработал Migrate(), или откатили не ту версию) — отдаём Unhealthy, +/// чтобы оркестратор/прокси не слал на этот инстанс запросы. +public sealed class DatabaseReadyHealthCheck : IHealthCheck +{ + private readonly AppDbContext _db; + + public DatabaseReadyHealthCheck(AppDbContext db) => _db = db; + + public async Task 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 { ["pendingMigrations"] = pending }); + } + + return HealthCheckResult.Healthy("БД доступна, миграции применены."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Ошибка проверки готовности БД.", ex); + } + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index d2c1c4e..23c8aad 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -145,6 +145,12 @@ // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). builder.Services.AddAuthRateLimiting(); + // Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены). + // Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал. + builder.Services.AddHealthChecks() + .AddCheck( + "database", tags: ["ready"]); + // Email-отправка через MailKit. Singleton — внутри открывает scope для // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается // на каждой отправке без рестарта приложения. @@ -220,6 +226,22 @@ 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("/api/_debug/whoami", (HttpContext ctx) => @@ -290,3 +312,22 @@ { 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, + }), + }); +}