.NET Microservices — Health Checks & Readiness Probes

ASP.NET Health Endpoints for AKS/Container Apps


Introduction

Health checks are critical for container orchestration (Kubernetes, Azure Container Apps) to determine if your service is ready to receive traffic and if it's still healthy. Azure Kubernetes Service (AKS) uses health probes to make routing decisions:

  • Liveness Probe — Is the container running? (restart if not)
  • Readiness Probe — Can the container receive traffic? (add/remove from service)
  • Startup Probe — Has the container started? (wait for initialization)

Why Health Checks Matter

Without Health Checks

User Requests → Service (Starting) → Fail/Timeout
             → Service (Degraded) → Slow/Fail
             → Service (DB Down) → Error

With Health Checks

Liveness:  Every 10s, restart if failed
Readiness: Every 5s, remove from LB during problems
Startup:   Wait 60s for init, then liveness takes over

User Requests → Service (Healthy) → OK
             → Service (Starting) → Wait
             → Service (Degraded) → Skip

ASP.NET Core Health Checks

Install Package

dotnet add package AspNetCore.HealthChecks.UI
dotnet add package AspNetCore.HealthChecks.UI.Client
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks

Basic Health Check Endpoint

// Program.cs
builder.Services.AddHealthChecks();

// Endpoints
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    // Only check if process is running
    Predicate = _ => false
});

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

Custom Health Checks

Database Health Check

public class SqlServerHealthCheck : IHealthCheck
{
    private readonly string _connectionString;
    
    public SqlServerHealthCheck(IConfiguration configuration)
    {
        _connectionString = configuration.GetConnectionString("DefaultConnection");
    }
    
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            using var connection = new SqlConnection(_connectionString);
            await connection.OpenAsync(cancellationToken);
            
            // Test query
            await connection.ExecuteScalarAsync(
                "SELECT 1", 
                cancellationToken: cancellationToken);
            
            return HealthCheckResult.Healthy("Database connection successful");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy(
                "Database connection failed",
                ex);
        }
    }
}

Redis Health Check

public class RedisHealthCheck : IHealthCheck
{
    private readonly IConnectionMultiplexer _redis;
    
    public RedisHealthCheck(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }
    
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var db = _redis.GetDatabase();
            var pong = await db.PingAsync();
            
            return HealthCheckResult.Healthy(
                "Redis connection successful",
                new Dictionary<string, object>
                {
                    ["latency"] = pong.TotalMilliseconds
                });
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Redis connection failed", ex);
        }
    }
}

Service Bus Health Check

public class ServiceBusHealthCheck : IHealthCheck
{
    private readonly ServiceBusClient _client;
    
    public ServiceBusHealthCheck(ServiceBusClient client)
    {
        _client = client;
    }
    
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var admin = _client.GetNamespaceAdministrationClient();
            await admin.GetQueueAsync("orders", cancellationToken);
            
            return HealthCheckResult.Healthy("Service Bus accessible");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Service Bus not accessible", ex);
        }
    }
}

Register All Health Checks

// Program.cs
builder.Services.AddHealthChecks()
    // Custom checks
    .AddCheck<SqlServerHealthCheck>("sqlserver", tags: new[] { "db", "ready" })
    .AddCheck<RedisHealthCheck>("redis", tags: new[] { "cache", "ready" })
    .AddCheck<ServiceBusHealthCheck>("servicebus", tags: new[] { "messaging", "ready" })
    
    // Built-in checks
    .AddDbContextCheck<OrderDbContext>("efcore", tags: new[] { "db", "ready" })
    .AddAzureBlobStorage("BlobStorageConnection", tags: new[] { "storage", "ready" })
    .AddAzureServiceBusQueue("ServiceBusConnection", "orders", tags: new[] { "messaging", "ready" });

Health Check UI

Add UI Endpoint

// Program.cs
builder.Services.AddHealthChecksUI(options =>
{
    options.SetEvaluationTimeInterval(TimeSpan.FromSeconds(30));
    options.MaximumHistoryEntriesPerEndpoint(50);
    options.AddApiEndpoint("/health-api", "API Health");
});

app.MapHealthChecksUI(options => 
{
    options.UiPath = "health";
    options.UiPathPrefix = "/health-ui";
});

appsettings.json

{
  "HealthChecks-UI": {
    "HealthChecks": [
      {
        "Name": "API Health",
        "Uri": "https://localhost:5001/health-api"
      },
      {
        "Name": "Order Service",
        "Uri": "http://orders-service/health"
      }
    ]
  }
}

Kubernetes Probes Configuration

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: order-service
        image: myregistry/order-service:1.0
        ports:
        - containerPort: 80
        
        # Liveness: Restart if failing
        livenessProbe:
          httpGet:
            path: /health/live
            port: 80
          initialDelaySeconds: 10
          periodSeconds: 10
          failureThreshold: 3
          timeoutSeconds: 5
          
        # Readiness: Add to service when ready
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
          timeoutSeconds: 3
          
        # Startup: Wait for init to complete
        startupProbe:
          httpGet:
            path: /health/ready
            port: 80
          initialDelaySeconds: 0
          periodSeconds: 5
          failureThreshold: 30  # 30 * 5s = 150s max startup time
          timeoutSeconds: 3
        
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"

Probe Settings Explained

SettingLivenessReadinessStartup
PurposeRestart containerAdd to serviceWait for init
initialDelaySeconds10s5s0
periodSeconds10s5s5s
failureThreshold3 (30s)3 (15s)30 (150s)
timeoutSeconds5s3s3s

Dependency Health with Details

Detailed Health Response

[ApiController]
[Route("health")]
public class HealthController : ControllerBase
{
    private readonly HealthCheckService _healthCheckService;
    
    public HealthController(HealthCheckService healthCheckService)
    {
        _healthCheckService = healthCheckService;
    }
    
    [HttpGet("ready")]
    public async Task<IActionResult> GetReadiness()
    {
        var result = await _healthCheckService.CheckHealthAsync(
            cancellationToken: HttpContext.RequestAborted,
            tags: new[] { "ready" });
        
        var response = new
        {
            status = result.Status.ToString(),
            totalDuration = result.TotalDuration.TotalMilliseconds,
            checks = result.Entries.Select(e => new
            {
                name = e.Key,
                status = e.Value.Status.ToString(),
                duration = e.Value.Duration.TotalMilliseconds,
                description = e.Value.Description,
                exception = e.Value.Exception?.Message,
                data = e.Value.Data
            })
        };
        
        return result.Status switch
        {
            HealthStatus.Healthy => Ok(response),
            HealthStatus.Degraded => StatusCode(503, response),
            _ => StatusCode(503, response)
        };
    }
}

Custom Health Status

Aggregated Health with Business Metrics

public class BusinessHealthCheck : IHealthCheck
{
    private readonly IOrderRepository _orderRepository;
    private readonly IInventoryService _inventoryService;
    
    public BusinessHealthCheck(
        IOrderRepository orderRepository,
        IInventoryService inventoryService)
    {
        _orderRepository = orderRepository;
        _inventoryService = inventoryService;
    }
    
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        var checks = new Dictionary<string, object>();
        var isHealthy = true;
        
        // Check pending orders
        var pendingCount = await _orderRepository.GetPendingOrderCountAsync();
        if (pendingCount > 1000)
        {
            checks["pendingOrders"] = pendingCount;
            isHealthy = false;
        }
        
        // Check inventory
        var lowStockCount = await _inventoryService.GetLowStockCountAsync();
        if (lowStockCount > 50)
        {
            checks["lowStockItems"] = lowStockCount;
        }
        
        // Check database connection
        try
        {
            await _orderRepository.GetAsync(Guid.Empty);
        }
        catch
        {
            isHealthy = false;
            checks["database"] = "unhealthy";
        }
        
        var status = isHealthy ? HealthStatus.Healthy : HealthStatus.Degraded;
        
        return new HealthCheckResult(
            status,
            "Business health check",
            null,
            checks);
    }
}

Best Practices

PracticeDescription
Separate liveness/readinessDifferent purposes
Fast checks onlyNo heavy operations in liveness
Include dependenciesReadiness should verify dependencies
Return detailsHelp with debugging
Use tagsControl which checks run when

Azure Integration Hub - Advanced Level