ASP.NET Core Web API'de Health Check: Liveness, Readiness ve Veritabanı Kontrolü
ASP.NET Core Web API'de health check: AddDbContextCheck ile veritabanı kontrolü, IHealthCheck ile Redis kontrolü, liveness ve readiness ayrımı, Degraded'ın neden 200 döndüğü, JSON çıktısı ve timeout tuzakları.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Bir önceki yazıda API'ye Redis'i bağladık. Artık uygulamanın ayakta olması yetmiyor; veritabanına ve Redis'e de ulaşabiliyor olması lazım. Bu yazıda bunu dışarıya söyleyen endpoint'leri kuracağız.
ASP.NET Core'da her health check üç sonuçtan birini döner: Healthy, Degraded ya da Unhealthy. Şimdi tahmin et: /health endpoint'ine bağlı check'lerden biri Degraded dönerse, endpoint hangi HTTP status code'u döner? Biri Unhealthy dönerse? İpucu: cevaplar aynı değil, ve biri pek çok kişiyi şaşırtıyor.
Health Check Kime Lazım?
Bir API'yi tek başına, tek bir sunucuda çalıştırırken "ayakta mı" sorusunu sen sorarsın. Production'da bu soruyu makineler sorar. Load balancer, trafiği hangi instance'a göndereceğine karar vermek için; Kubernetes ya da benzeri bir orchestrator, hangi container'ı yeniden başlatacağına karar vermek için; monitoring araçları da sana gece ikide alarm atıp atmayacağına karar vermek için.
Bu makinelerin hepsi aynı şeyi ister: düzenli aralıklarla çağırabilecekleri, hızlı cevap veren ve cevabı status code'a bakarak anlaşılan bir endpoint. Burada önemli bir ayrım var: "process çalışıyor" ile "iş görebiliyor" aynı şey değil. Kestrel istekleri kabul ediyor olabilir, ama veritabanı bağlantısı kopmuşsa her ürün isteği 500 döner. İyi bir health check bu farkı görünür kılar. Bir hastane benzetmesiyle: nabız var mı sorusu ayrı, ameliyata girebilir mi sorusu ayrı.
İlk Endpoint: AddHealthChecks ve MapHealthChecks
En basit hâli iki satır:
builder.Services.AddHealthChecks();
// app.Build()'den sonra
app.MapHealthChecks("/health");
Hiç check kaydetmediğin için sonuç her zaman Healthy. Bu bile işe yarar, çünkü cevap gelmesi uygulamanın istek alabildiğini gösterir. Response'a dikkatle bak:
curl -i http://localhost:5305/health
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 03 Nov 2026 21:33:39 GMT
Server: Kestrel
Cache-Control: no-store, no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Transfer-Encoding: chunked
Healthy
Gövde düz metin, tek kelime. Middleware cache header'larını da kendisi ekliyor; bir önceki yazıdaki gibi bir proxy'nin sağlık durumunu cache'leyip eski cevabı vermesini istemezsin.
Veritabanı Kontrolü: AddDbContextCheck
Asıl değer, gerçek bağımlılıkları kontrol etmekte. EF Core kullanıyorsan Microsoft'un hazır paketi var. Sürümünü hedef framework'üne göre seç: net6.0 için 6.0.36, net10.0 için 10.0.14.
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore --version 6.0.36
Bu yazıda veritabanı olarak SQLite kullanıyoruz; AppDbContext'imiz en sade hâliyle şöyle:
using Microsoft.EntityFrameworkCore;
namespace ProductCatalog.Api.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Product> Products => Set<Product>();
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Check'i kaydetmek tek satır: .AddDbContextCheck<AppDbContext>("database"). Arkada Database.CanConnectAsync() çağırıyor; bağlanabiliyorsa Healthy, bağlanamıyorsa Unhealthy. Bir sorgu çalıştırmıyor, tablolara bakmıyor, sadece "konuşabiliyor muyum" diye soruyor.
İlk denemede bunu farkında olmadan test ettik. Connection string Data Source=catalog.db idi ama dosyayı henüz oluşturmamıştık. SQLite provider'ı dosya yoksa veritabanını "yok" sayıyor ve check Unhealthy döndü. Dosya oluşunca, gerçek projede migration'ı uyguladığın anda, sonuç Healthy oldu. Yani bu check, "deploy ettim ama migration'ı unuttum" durumunu da yakalıyor.
Kendi Health Check'ini Yazmak: IHealthCheck
Redis için de bir kontrol istiyoruz. Hazır paketler var, birazdan değineceğiz, ama kendi check'ini yazmak hem kolay hem öğretici. Tek bir method'u olan bir interface:
using System.Diagnostics;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ProductCatalog.Api.Health;
public class CacheHealthCheck : IHealthCheck
{
private readonly IDistributedCache _cache;
private readonly int _slowThresholdMs;
public CacheHealthCheck(IDistributedCache cache, IConfiguration configuration)
{
_cache = cache;
_slowThresholdMs = configuration.GetValue("HealthChecks:CacheSlowThresholdMs", 200);
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Olmayan bir key'i okumak yeterli: cevap gelirse Redis ayakta
await _cache.GetStringAsync("health:probe", cancellationToken);
}
catch (Exception ex)
{
// Kayıtta verilen failureStatus'u kullan (varsayılanı Unhealthy)
return new HealthCheckResult(context.Registration.FailureStatus, "Cache is unreachable.", ex);
}
var elapsedMs = stopwatch.ElapsedMilliseconds;
var data = new Dictionary<string, object> { ["elapsedMs"] = elapsedMs };
return elapsedMs >= _slowThresholdMs
? HealthCheckResult.Degraded("Cache is responding slowly.", data: data)
: HealthCheckResult.Healthy("Cache is responding.", data);
}
}
Üç durumun üçünü de kullanıyoruz. Redis'e ulaşamıyorsak sonuç kayıtta belirtilen FailureStatus; bunu doğrudan Unhealthy yazmak yerine context'ten almak, check'i kaydeden kişiye "benim için bu kritik değil, Degraded say" deme imkânı veriyor. Ulaşıyor ama eşikten yavaşsa Degraded: çalışıyor ama bir şeyler ters. Her şey yolundaysa Healthy. data sözlüğü de sonuca ek bilgi taşıyor; birazdan JSON çıktısında göreceğiz.
Check DI'dan oluşturulduğu için constructor'da istediğin service'i alabilirsin. Kaydı da diğerleriyle birlikte yapıyoruz:
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddDbContextCheck<AppDbContext>("database", tags: new[] { "ready" })
.AddCheck<CacheHealthCheck>("cache", tags: new[] { "ready" });
Liveness ve Readiness'i Ayırmak
Kayıtlardaki tags boşuna değil. Orchestrator'lar genelde iki ayrı soru sorar. Liveness: "Bu process hâlâ yaşıyor mu, yoksa kilitlenmiş mi? Yeniden başlatayım mı?" Readiness: "Şu an trafik alabilir mi? Load balancer'a ekleyeyim mi?" İkisinin cevabı farklı check'lerden gelmeli:
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = HealthResponseWriter.WriteJsonAsync
});
Predicate, o endpoint'te hangi check'lerin çalışacağını seçen filtre. Liveness'ta sadece self var, ve o da bir lambda'dan ibaret: cevap verebiliyorsan yaşıyorsun. Readiness'ta veritabanı ve cache var. Redis container'ını durdurup iki endpoint'i çağırınca fark ortaya çıkıyor:
curl -s -w " %{http_code}\n" http://localhost:5301/health/live
Healthy 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5301/health/ready
503
Doğru davranış tam olarak bu. Redis düştü diye process'i yeniden başlatmak hiçbir şeyi çözmez; yeni process de Redis'e ulaşamaz. Ama o instance'a trafik göndermeyi durdurmak mantıklı.
Tahminin cevabı da burada. Varsayılan eşleme şöyle: Healthy 200, Degraded da 200, Unhealthy 503. Eşiği HealthChecks__CacheSlowThresholdMs=0 environment variable'ıyla sıfıra çekip denedik: cache check'i Degraded döndü, /health gövdede Degraded yazdı ama status code 200 kaldı. Mantığı şu: Degraded "çalışıyorum ama dikkat" demek, trafiği kesmek için yeterli bir sebep sayılmıyor. Senin senaryonda sayılıyorsa eşlemeyi değiştirebilirsin:
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = HealthResponseWriter.WriteJsonAsync,
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
}
});
Bu hâliyle aynı denemede /health/ready 503 döndü.
JSON Çıktısı
Load balancer'a status code yeter, ama bir insan "neden 503?" diye sorduğunda tek kelime yetmez. ResponseWriter ile çıktıyı istediğin gibi yazabilirsin:
using System.Text.Json;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ProductCatalog.Api.Health;
public static class HealthResponseWriter
{
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
public static Task WriteJsonAsync(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json; charset=utf-8";
var payload = 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,
error = entry.Value.Exception?.Message,
data = entry.Value.Data
})
};
return context.Response.WriteAsync(JsonSerializer.Serialize(payload, Options));
}
}
Redis ayaktayken /health/ready:
{
"status": "Healthy",
"totalDurationMs": 2.7468,
"checks": [
{
"name": "database",
"status": "Healthy",
"description": null,
"durationMs": 2.4839,
"error": null,
"data": {}
},
{
"name": "cache",
"status": "Healthy",
"description": "Cache is responding.",
"durationMs": 0.5245,
"error": null,
"data": {
"elapsedMs": 0
}
}
]
}
Genel durum, en kötü check'in durumu. Her check'in süresi de ayrı ayrı görünüyor; yavaşlayan bir bağımlılığı buradan yakalarsın. Hazır bir format istersen AspNetCore.HealthChecks.UI.Client paketindeki UIResponseWriter.WriteHealthCheckUIResponse benzer bir JSON üretir. Aynı ailede Redis, SQL Server ve PostgreSQL için hazır check'ler de var (AspNetCore.HealthChecks.Redis gibi). Sürüm seçerken dikkat: bu ailede net6.0'ı hedefleyen hat 6.0.x, en yeni 9.0.0 sürümleri ise net8.0 hedefliyor.
Sık Düşülen Tuzaklar
Liveness'a veritabanı check'i koymak en pahalı hatadır. Veritabanı birkaç saniyeliğine yanıt vermezse orchestrator bütün instance'ları aynı anda yeniden başlatır; veritabanı geri geldiğinde de soğuk başlayan uygulamalar onu bir anda yüklenir. Liveness'ı sade tut, bağımlılıkları readiness'a koy.
Yavaş check, olmayan check'ten kötüdür. Redis kapalıyken readiness cevabı net6.0 projesinde 6.0.36 cache paketiyle hemen geldi (totalDurationMs: 115), ama net10.0 projesinde, yani 10.0.14 paketiyle, 6 saniye sürdü; hata mesajı "The message timed out in the backlog ... (5000ms)" diyordu. Probe'ların çoğu bu kadar beklemez. Check'e bir timeout verebilirsin:
.AddCheck<CacheHealthCheck>("cache", tags: new[] { "ready" }, timeout: TimeSpan.FromSeconds(2));
Ama tek başına işe yaramadı: süre yine 6 saniyeydi, çünkü timeout cancellation token üzerinden işliyor ve Redis çağrısı token'ı dinlemiyor. Check içindeki çağrıyı .WaitAsync(cancellationToken) ile sarınca (.NET 6 ile gelen bir method) cevap 2,03 saniyede geldi ve hata "A task was canceled." oldu:
await _cache.GetStringAsync("health:probe", cancellationToken).WaitAsync(cancellationToken);
JSON çıktısı bilgi sızdırır. net10.0 projesinde Redis kapalıyken error alanında sunucu adresi, port ve client sürümü yazıyordu. Ayrıntılı endpoint'i internete açma: .RequireHost(...) ile sadece iç ağdan gelen host'a bağla ya da ayrı bir porttan sun; dışarıya açacaksan sadece status code dönen sade endpoint'i aç.
Son olarak, health endpoint'i pipeline'ın geri kalanından etkilenir. Fallback authorization policy kullanıyorsan probe'lar 401 alır; endpoint'e .AllowAnonymous() eklemen gerekir. HTTPS redirection da HTTP üzerinden gelen probe'u 307 ile geri çevirebilir; probe'un hangi protokolle geldiğini kontrol et.
Küçük Bir Deneme
★☆☆ CacheHealthCheck'in kaydına failureStatus: HealthStatus.Degraded ekle. Redis'i durdur ve /health/ready'nin artık 503 değil 200 döndüğünü, gövdede Degraded yazdığını gör.
★★☆ Connection string'i Data Source=/nonexistent/catalog.db yapıp uygulamayı environment variable ile başlat. Database check'inin sonucunu ve description alanını incele; AddDbContextCheck'e kendi test sorgusunu veren customTestQuery parametresini araştır.
★★★ /health/ready'yi .RequireHost("localhost:5301") ile kısıtla, sonra aynı endpoint'i 127.0.0.1:5301 üzerinden çağırıp 404 aldığını gör. Ardından liveness için ayrı ve herkese açık, readiness için kısıtlı bir düzen kur.
Health check'ler çalışan uygulamanın nabzını ölçüyor. Peki yazdığımız kodun doğru çalıştığını deploy'dan önce, daha uygulamayı ayağa kaldırmadan nasıl bileceğiz? Bir sonraki yazıda xUnit ve Moq ile unit test yazmaya başlıyoruz.