RO EN

Reziliență în .NET (4/6): health checks avansate, de la /health/live la /health/ready

Reziliență în .NET (4/6): health checks avansate, de la /health/live la /health/ready ✨ Imagine generată cu AI
Doru Bulubașa
07 octombrie 2026
32 vizualizări

Marți, 14:07. Baza de date are o problemă de I/O și răspunde în 3 secunde la orice query, timp de aproximativ un minut. Neplăcut, dar nu dramatic: aplicația ar fi răspuns mai încet un minut, apoi totul ar fi revenit la normal.

Doar că endpoint-ul de health check verifică și baza de date, iar același endpoint e folosit ca liveness probe în Kubernetes. Probe-ul are un timeout de o secundă. Trei verificări eșuate la rând, iar kubelet-ul decide că aplicația e moartă și o repornește. Pe toate cele 8 poduri, aproape simultan.

Podurile noi pornesc la rece: fără cache, fără conexiuni în pool, cu JIT-ul încă la lucru. Toate încearcă să deschidă conexiuni către o bază de date care oricum se chinuie. Health check-ul eșuează din nou. Restart din nou. La 14:30 problema de I/O s-a rezolvat de mult, dar aplicația e încă într-o buclă de restart pe care și-a provocat-o singură.

Health check-urile sunt mecanismul prin care aplicația își comunică starea infrastructurii din jur: orchestratorului, load balancer-ului, sistemului de monitorizare. Dacă le configurezi greșit, nu doar că nu te protejează, ci devin chiar ele cauza incidentului. În acest articol vedem cum arată un design corect.

Trei întrebări diferite

Greșeala din povestea de mai sus vine dintr-o confuzie: un singur endpoint /health care încearcă să răspundă la trei întrebări diferite. Kubernetes, și în general orice orchestrator modern, le pune separat, pentru că fiecare răspuns declanșează o acțiune diferită:

  • Startup: Ai terminat de pornit? Cât timp răspunsul e „nu”, celelalte două probe sunt suspendate. Dacă pornirea durează prea mult, containerul e repornit.
  • Liveness: Mai trăiești? Te ajută un restart? Un eșec repetat înseamnă restart. E acțiunea cea mai drastică, deci întrebarea trebuie să fie cea mai îngustă.
  • Readiness: Poți primi trafic acum? Un eșec înseamnă că podul e scos din load balancer, fără restart. Când răspunsul redevine pozitiv, traficul revine.

Din asta rezultă regula de bază a întregului articol:

Liveness verifică doar problemele pe care un restart le rezolvă. Readiness verifică doar problemele pe care așteptarea le rezolvă. Ce nu se rezolvă prin niciuna dintre ele nu are ce căuta în probe.

O bază de date lentă nu se repară dacă repornești aplicația. Deci nu are ce căuta în liveness.

Health checks în ASP.NET Core: bazele

ASP.NET Core are suport nativ pentru health checks. Fiecare verificare implementează IHealthCheck și returnează unul dintre trei rezultate: Healthy, Degraded sau Unhealthy. Implicit, endpoint-ul răspunde cu 200 pentru Healthy și Degraded și cu 503 pentru Unhealthy.

Cheia designului sunt tag-urile. Înregistrezi toate verificările o singură dată, le etichetezi după rol, apoi expui endpoint-uri diferite, fiecare cu propriul filtru:

builder.Services.AddHealthChecks()
    .AddCheck<StartupHealthCheck>("startup", tags: ["startup", "ready"])
    .AddCheck<OutboxWorkerHealthCheck>("outbox-worker", tags: ["live"])
    .AddDbContextCheck<AppDbContext>("database",
        failureStatus: HealthStatus.Degraded, tags: ["deps"]);

var app = builder.Build();

app.MapHealthChecks("/health/startup", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("startup")
});

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

AddDbContextCheck vine din pachetul Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore și verifică dacă se poate deschide o conexiune prin contextul EF Core. Pentru alte dependențe (Redis, Cosmos DB, Azure Service Bus, RabbitMQ), pachetele comunitare AspNetCore.HealthChecks.* au verificări gata făcute.

Observă că baza de date are tag-ul deps, nu live sau ready. Revenim imediat la motiv.

Liveness: cât mai îngust posibil

Cea mai sigură variantă de liveness probe este cea care nu verifică nimic. Dacă procesul primește cererea HTTP și răspunde, e în viață. Pentru majoritatea aplicațiilor e suficient:

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false  // nicio verificare: doar „procesul răspunde”
});

Cu Predicate = _ => false, endpoint-ul returnează 200 Healthy fără să ruleze nicio verificare. Dacă procesul e blocat, thread pool-ul e epuizat sau aplicația a intrat într-o stare din care nu mai poate răspunde la HTTP, probe-ul dă timeout și Kubernetes face exact ce trebuie: restart.

Când merită să adaugi ceva în liveness? Doar pentru stări interne din care aplicația nu poate ieși singură, dar din care un restart o scoate garantat. Exemplul clasic e un worker din fundal care s-a blocat: un consumer de coadă sau un procesor de Outbox care nu mai procesează nimic, deși procesul răspunde perfect la HTTP.

public sealed class WorkerHeartbeat
{
    private long _lastBeatTicks = DateTime.UtcNow.Ticks;

    public void Beat() =>
        Interlocked.Exchange(ref _lastBeatTicks, DateTime.UtcNow.Ticks);

    public TimeSpan SinceLastBeat =>
        DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastBeatTicks), DateTimeKind.Utc);
}

public sealed class OutboxWorkerHealthCheck(WorkerHeartbeat heartbeat) : IHealthCheck
{
    private static readonly TimeSpan MaxSilence = TimeSpan.FromMinutes(2);

    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken cancellationToken = default)
    {
        var silence = heartbeat.SinceLastBeat;

        return Task.FromResult(silence < MaxSilence
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Unhealthy($"Worker-ul Outbox nu a mai raportat de {silence.TotalSeconds:N0}s."));
    }
}

Worker-ul apelează heartbeat.Beat() la fiecare iterație a buclei, inclusiv atunci când nu are nimic de procesat. Altfel, o coadă goală ar arăta exact ca un worker blocat. WorkerHeartbeat se înregistrează ca singleton, ca worker-ul și health check-ul să vadă aceeași instanță.

Startup: pentru aplicațiile care pornesc greu

Unele aplicații au nevoie de timp până să fie utilizabile: aplică migrări, încarcă un cache de referință, încălzesc conexiuni, descarcă configurări. Fără un startup probe, singura soluție e să ghicești un initialDelaySeconds pentru liveness. Dacă e prea mic, Kubernetes omoară aplicația înainte să termine de pornit. Dacă e prea mare, un container blocat la pornire e detectat foarte târziu.

Startup probe-ul rezolvă elegant problema. ASP.NET Core nu are o verificare de pornire predefinită, dar pattern-ul e simplu: un singleton cu un flag pe care un serviciu de fundal îl setează când a terminat.

public sealed class StartupHealthCheck : IHealthCheck
{
    private volatile bool _completed;

    public void MarkCompleted() => _completed = true;

    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken cancellationToken = default) =>
        Task.FromResult(_completed
            ? HealthCheckResult.Healthy("Pornire finalizată.")
            : HealthCheckResult.Unhealthy("Pornire în curs."));
}

public sealed class WarmupService(
    StartupHealthCheck startup,
    IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var cache = scope.ServiceProvider.GetRequiredService<ReferenceDataCache>();

        await cache.LoadAsync(stoppingToken);   // nomenclatoare, cursuri, setări

        startup.MarkCompleted();
    }
}
builder.Services.AddSingleton<StartupHealthCheck>();
builder.Services.AddHostedService<WarmupService>();

AddCheck<StartupHealthCheck> folosește instanța singleton din container, deci health check-ul și serviciul de warm-up lucrează cu același obiect. În configurarea de mai sus, aceeași verificare are și tag-ul ready: cât timp warm-up-ul nu s-a terminat, podul nu primește trafic.

Readiness: dependențele partajate sunt o capcană

Readiness pare locul natural pentru baza de date: dacă baza e jos, podul nu poate servi cereri, deci nu e „gata”. Logic, dar periculos.

Gândește-te ce se întâmplă când baza de date are o problemă. Nu o are doar un pod, ci toate. Toate își declară readiness-ul eșuat în același timp, iar Kubernetes le scoate pe toate din load balancer. Ingress-ul nu mai are niciun backend și răspunde cu 503 la absolut orice, inclusiv la:

  • pagini statice și endpoint-uri care nu au nevoie de baza de date;
  • răspunsuri care puteau fi servite din cache;
  • mesajul tău frumos de tip „Facturarea e temporar indisponibilă, încearcă din nou în câteva minute”;
  • endpoint-urile de administrare cu care ai fi putut diagnostica problema.

Ai transformat o funcționalitate degradată într-o aplicație complet căzută. Exact aceeași capcană am semnalat-o în articolul despre circuit breaker, legată de starea breaker-ului.

Readiness trebuie să răspundă la întrebarea „e această instanță capabilă să servească trafic?”, nu „e tot sistemul sănătos?”. Lucrurile care țin de instanță își au locul aici: warm-up-ul neterminat, un cache local care nu s-a încărcat, o instanță supraîncărcată care vrea să primească mai puțin trafic. O dependență partajată de toate instanțele nu are ce căuta aici, pentru că scoaterea podului din trafic nu o repară și nu mută traficul pe o instanță mai sănătoasă. Toate sunt la fel de afectate.

Există o excepție legitimă: dependențe per instanță, de exemplu un sidecar local sau o conexiune care se poate bloca doar pe un anumit pod. Acolo, scoaterea podului din trafic chiar ajută, pentru că celelalte instanțe preiau cererile.

Dependențele: un endpoint separat, pentru oameni

Asta nu înseamnă că starea bazei de date sau a API-urilor externe nu contează. Contează enorm, dar pentru monitorizare și oameni, nu pentru orchestrator. Le pui pe un endpoint separat, cu răspuns detaliat, și îl lași să returneze Degraded în loc de Unhealthy când o dependență e jos:

app.MapHealthChecks("/health/deps", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("deps"),
    ResponseWriter = WriteJsonResponse
})
.RequireAuthorization("Operations");

Răspunsul implicit e un simplu text (Healthy, Degraded). Pentru oameni și dashboard-uri, un JSON detaliat e mult mai util:

static Task WriteJsonResponse(HttpContext context, HealthReport report) =>
    context.Response.WriteAsJsonAsync(new
    {
        status = report.Status.ToString(),
        totalDurationMs = report.TotalDuration.TotalMilliseconds,
        checks = report.Entries.Select(entry => new
        {
            name = entry.Key,
            status = entry.Value.Status.ToString(),
            description = entry.Value.Description,
            durationMs = entry.Value.Duration.TotalMilliseconds,
            tags = entry.Value.Tags
        })
    });

Două observații de securitate. Primul lucru: endpoint-ul detaliat nu trebuie să fie public. Îți arată numele dependențelor, topologia și timpii de răspuns, adică exact harta de care are nevoie cineva care vrea să atace sistemul. Protejează-l cu autorizare sau expune-l doar pe un port intern cu RequireHost("*:8081"). Al doilea lucru: nu pune mesajele excepțiilor în description. Un connection string apărut într-un mesaj de eroare e o scurgere de date clasică.

Circuit breaker-ul ca health check

Starea circuit breaker-ului din articolul anterior e un indicator excelent pentru endpoint-ul de dependențe. CircuitBreakerStateProvider din Polly v8 îți dă starea în orice moment:

var anafCircuit = new CircuitBreakerStateProvider();

builder.Services.AddResiliencePipeline("anaf", pipeline =>
    pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        StateProvider = anafCircuit
        // ... restul configurării
    }));

builder.Services.AddHealthChecks()
    .AddCheck("anaf-circuit", () => anafCircuit.CircuitState switch
    {
        CircuitState.Closed => HealthCheckResult.Healthy(),
        CircuitState.HalfOpen => HealthCheckResult.Degraded("Circuit în testare."),
        _ => HealthCheckResult.Degraded("Circuit deschis: serviciul ANAF nu răspunde.")
    }, tags: ["deps"]);

Rezultatul e Degraded, nu Unhealthy. Aplicația funcționează, doar că o funcționalitate e temporar indisponibilă. E exact nuanța pe care starea Degraded a fost creată s-o exprime.

Health checks ieftine și rapide

Health check-urile rulează des. Cu 10 poduri, probe la fiecare 5–10 secunde, plus load balancer-ul și sistemul de monitorizare, ajungi ușor la sute de verificări pe minut. Câteva reguli:

  • Verificări minimale. Deschiderea unei conexiuni sau un SELECT 1, nu un query pe tabela de facturi. Un health check nu e un test funcțional.
  • Nu apela API-uri externe din probe. Pe lângă faptul că adaugi latență, consumi din cota de rate limiting a furnizorului doar ca să afli ce îți spune deja circuit breaker-ul.
  • Timeout explicit per verificare. În Kubernetes, timeoutSeconds are implicit valoarea 1 secundă. O verificare care durează 3 secunde nu returnează „lent”, ci un eșec al probe-ului. AddCheck acceptă un parametru timeout; folosește-l ca să rămâi sub timeout-ul probe-ului.

Health check publisher

Pentru monitorizare nu ai nevoie să aștepți să interogheze cineva endpoint-ul. IHealthCheckPublisher rulează periodic verificările în fundal și îți trimite raportul, pe care îl poți scrie în loguri, metrici sau Application Insights:

builder.Services.Configure<HealthCheckPublisherOptions>(options =>
{
    options.Delay = TimeSpan.FromSeconds(10);
    options.Period = TimeSpan.FromSeconds(30);
    options.Predicate = check => check.Tags.Contains("deps");
});

builder.Services.AddSingleton<IHealthCheckPublisher, LoggingHealthCheckPublisher>();

public sealed class LoggingHealthCheckPublisher(
    ILogger<LoggingHealthCheckPublisher> logger) : IHealthCheckPublisher
{
    public Task PublishAsync(HealthReport report, CancellationToken cancellationToken)
    {
        foreach (var (name, entry) in report.Entries.Where(e => e.Value.Status != HealthStatus.Healthy))
        {
            logger.LogWarning("Health check {Name}: {Status} ({Description})",
                name, entry.Status, entry.Description);
        }

        return Task.CompletedTask;
    }
}

Așa obții alerte de tipul „baza de date e Degraded de 3 minute” fără ca orchestratorul să ia vreo decizie pe baza acestei informații. Oamenii decid, nu kubelet-ul.

Configurarea în Kubernetes

Cu endpoint-urile separate, configurarea probe-urilor devine clară. Imaginile .NET 8+ ascultă implicit pe portul 8080:

containers:
  - name: api
    image: registry.example/facturare-api:1.4.2
    ports:
      - containerPort: 8080
    startupProbe:
      httpGet:
        path: /health/startup
        port: 8080
      periodSeconds: 2
      failureThreshold: 30      # până la 60s pentru pornire
    livenessProbe:
      httpGet:
        path: /health/live
        port: 8080
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3       # restart după ~30s fără răspuns
    readinessProbe:
      httpGet:
        path: /health/ready
        port: 8080
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 2

Observă asimetria: liveness are răbdare (30 de secunde fără răspuns până la restart), pentru că un restart are un cost mare. Readiness reacționează mai repede, pentru că scoaterea temporară din trafic e ieftină și reversibilă.

Oprirea elegantă

Probele au o problemă mai puțin cunoscută și la oprire. Când Kubernetes oprește un pod, la un deploy sau la un scale-down, trimite SIGTERM containerului și, în paralel, începe scoaterea podului din lista de endpoint-uri a serviciului. Cele două se întâmplă simultan, nu în ordine. Timp de câteva secunde, load balancer-ul mai poate trimite cereri către un pod care a început deja să se oprească, iar acestea eșuează.

Soluția uzuală e un preStop hook care întârzie SIGTERM-ul câteva secunde, timp în care propagarea scoaterii din trafic se termină:

    lifecycle:
      preStop:
        exec:
          command: ["sleep", "5"]

Atenție: imaginile chiseled sau distroless nu au sleep. Versiunile recente de Kubernetes au o acțiune sleep nativă pentru lifecycle hooks; verifică dacă clusterul tău o suportă. După SIGTERM, ASP.NET Core termină cererile în curs în limita HostOptions.ShutdownTimeout, care trebuie să încapă, împreună cu preStop, în terminationGracePeriodSeconds (implicit 30 de secunde).

Anti-patterns, pe scurt

  • Un singur endpoint /health folosit pentru toate cele trei probe.
  • Baza de date sau orice dependență partajată în liveness.
  • Dependențe partajate în readiness, care scot toate podurile din trafic simultan.
  • Apeluri la API-uri externe la fiecare probe.
  • Lipsa unui startup probe, compensată cu un initialDelaySeconds ghicit.
  • Verificări mai lente decât timeoutSeconds-ul probe-ului.
  • Endpoint-ul detaliat expus public, cu mesaje de excepție în răspuns.

Recapitulare

  • Startup, liveness și readiness sunt trei întrebări diferite, cu trei consecințe diferite.
  • Liveness verifică doar ce rezolvă un restart. Cea mai sigură variantă: doar „procesul răspunde”, plus heartbeat-uri pentru workerii din fundal.
  • Readiness verifică doar starea instanței, nu a dependențelor partajate.
  • Dependențele merg pe un endpoint separat, protejat, cu rezultat Degraded și răspuns JSON detaliat.
  • Tag-urile permit o singură înregistrare a verificărilor și mai multe endpoint-uri filtrate.
  • IHealthCheckPublisher pentru monitorizare continuă, independentă de probe.
  • Probe-uri ieftine, cu timeout-uri explicite, plus preStop pentru oprire elegantă.

Seria Reziliență în .NET

  1. Fundamente și Polly v8
  2. Retry policies inteligente
  3. Circuit breaker și bulkhead
  4. Health checks avansate — articolul de față
  5. Graceful degradation
  6. Chaos engineering basics

Am repetat în ultimele trei articole aceeași promisiune: „ce faci când o dependență e jos?”. În articolul următor răspundem în sfârșit: graceful degradation, cu fallback, servirea datelor din cache, hedging, feature flags și un exemplu concret despre ce face o aplicație de facturare când sistemul e-Factura nu răspunde.