Add filtering to Health Checks middleware

This allows each middleware to be configured with a specific set of
checks (by name). See the comments in the sample for how this is
frequently used.

This is also addresses aspnet/Home#2575 - or at least the part that we
plan to do. We think that any sort of built-in system of metadata or
tags is vast overkill, and doesn't really align with the primary usage
of health checks.

We're providing building blocks, and these can be put together to
build more complicated things like what's described in aspnet/Home#2575.
This commit is contained in:
Ryan Nowak 2018-07-31 18:00:51 -07:00 committed by Ryan Nowak
parent 47f427d5ac
commit 64124e9c85
7 changed files with 292 additions and 1 deletions

View File

@ -0,0 +1,80 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace HealthChecksSample
{
// Pass in `--scenario liveness` at the command line to run this sample.
public class LivenessProbeStartup
{
public void ConfigureServices(IServiceCollection services)
{
// Registers required services for health checks
services
.AddHealthChecks()
.AddCheck("identity", () => Task.FromResult(HealthCheckResult.Healthy()))
.AddCheck(new SlowDependencyHealthCheck());
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// This will register the health checks middleware twice:
// - at /health/ready for 'readiness'
// - at /health/live for 'liveness'
//
// Using a separate liveness and readiness check is useful in an environment like Kubernetes
// when an application needs to do significant work before accepting requests. Using separate
// checks allows the orchestrator to distinguish whether the application is functioning but
// not yet ready or if the application has failed to start.
//
// For instance the liveness check will do a quick set of checks to determine if the process
// is functioning correctly.
//
// The readiness check might do a set of more expensive or time-consuming checks to determine
// if all other resources are responding.
//
// See https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/ for
// more details about readiness and liveness probes in Kubernetes.
//
// In this example, the liveness check will us an 'identity' check that always returns healthy.
//
// In this example, the readiness check will run all registered checks, include a check with an
// long initialization time (15 seconds).
// The readiness check uses all of the registered health checks (default)
app.UseHealthChecks("/health/ready", new HealthCheckOptions()
{
// This sample is using detailed status to make more apparent which checks are being run - any
// output format will work with liveness and readiness checks.
ResponseWriter = HealthCheckResponseWriters.WriteDetailedJson,
});
// The liveness check uses an 'identity' health check that always returns healty
app.UseHealthChecks("/health/live", new HealthCheckOptions()
{
// Filters the set of health checks run by this middleware
HealthCheckNames =
{
"identity",
},
// This sample is using detailed status to make more apparent which checks are being run - any
// output format will work with liveness and readiness checks.
ResponseWriter = HealthCheckResponseWriters.WriteDetailedJson,
});
app.Run(async (context) =>
{
await context.Response.WriteAsync("Go to /health/ready to see the readiness status");
await context.Response.WriteAsync(Environment.NewLine);
await context.Response.WriteAsync("Go to /health/live to see the liveness status");
});
}
}
}

View File

@ -18,6 +18,7 @@ namespace HealthChecksSample
{ "basic", typeof(BasicStartup) },
{ "detailed", typeof(DetailedStatusStartup) },
{ "writer", typeof(CustomWriterStartup) },
{ "liveness", typeof(LivenessProbeStartup) },
};
}

View File

@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace HealthChecksSample
{
// Simulates a health check for an application dependency that takes a while to initialize.
// This is part of the readiness/liveness probe sample.
public class SlowDependencyHealthCheck : IHealthCheck
{
public static readonly string HealthCheckName = "slow_dependency";
private readonly Task _task;
public SlowDependencyHealthCheck()
{
_task = Task.Delay(15 * 1000);
}
public string Name => HealthCheckName;
public Task<HealthCheckResult> CheckHealthAsync(CancellationToken cancellationToken = default(CancellationToken))
{
if (_task.IsCompleted)
{
return Task.FromResult(HealthCheckResult.Healthy("Dependency is ready"));
}
return Task.FromResult(HealthCheckResult.Unhealthy("Dependency is still initializing"));
}
}
}

View File

@ -2,6 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@ -14,6 +16,7 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
private readonly RequestDelegate _next;
private readonly HealthCheckOptions _healthCheckOptions;
private readonly IHealthCheckService _healthCheckService;
private readonly IHealthCheck[] _checks;
public HealthCheckMiddleware(
RequestDelegate next,
@ -38,6 +41,8 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
_next = next;
_healthCheckOptions = healthCheckOptions.Value;
_healthCheckService = healthCheckService;
_checks = FilterHealthChecks(_healthCheckService.Checks, healthCheckOptions.Value.HealthCheckNames);
}
/// <summary>
@ -53,7 +58,7 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
}
// Get results
var result = await _healthCheckService.CheckHealthAsync(httpContext.RequestAborted);
var result = await _healthCheckService.CheckHealthAsync(_checks, httpContext.RequestAborted);
// Map status to response code - this is customizable via options.
if (!_healthCheckOptions.ResultStatusCodes.TryGetValue(result.Status, out var statusCode))
@ -73,5 +78,41 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
await _healthCheckOptions.ResponseWriter(httpContext, result);
}
}
private static IHealthCheck[] FilterHealthChecks(
IReadOnlyDictionary<string, IHealthCheck> checks,
ISet<string> names)
{
// If there are no filters then include all checks.
if (names.Count == 0)
{
return checks.Values.ToArray();
}
// Keep track of what we don't find so we can report errors.
var notFound = new HashSet<string>(names, StringComparer.OrdinalIgnoreCase);
var matches = new List<IHealthCheck>();
foreach (var kvp in checks)
{
if (!notFound.Remove(kvp.Key))
{
// This check was excluded
continue;
}
matches.Add(kvp.Value);
}
if (notFound.Count > 0)
{
var message =
$"The following health checks were not found: '{string.Join(", ", notFound)}'. " +
$"Registered health checks: '{string.Join(", ", checks.Keys)}'.";
throw new InvalidOperationException(message);
}
return matches.ToArray();
}
}
}

View File

@ -14,6 +14,16 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
/// </summary>
public class HealthCheckOptions
{
/// <summary>
/// Gets a set of health check names used to filter the set of health checks run.
/// </summary>
/// <remarks>
/// If <see cref="HealthCheckNames"/> is empty, the <see cref="HealthCheckMiddleware"/> will run all
/// registered health checks - this is the default behavior. To run a subset of health checks,
/// add the names of the desired health checks.
/// </remarks>
public ISet<string> HealthCheckNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<HealthCheckStatus, int> ResultStatusCodes { get; } = new Dictionary<HealthCheckStatus, int>()
{
{ HealthCheckStatus.Healthy, StatusCodes.Status200OK },

View File

@ -6,6 +6,7 @@ using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Newtonsoft.Json;
using Xunit;
namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
@ -58,5 +59,69 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
// Ignoring the body since it contains a bunch of statistics
}
[Fact]
public async Task LivenessProbeStartup_Liveness()
{
var expectedJson = JsonConvert.SerializeObject(new
{
status = "Healthy",
results = new
{
identity = new
{
status = "Healthy",
description = "",
data = new { }
},
},
}, Formatting.Indented);
var builder = new WebHostBuilder()
.UseStartup<HealthChecksSample.LivenessProbeStartup>();
var server = new TestServer(builder);
var client = server.CreateClient();
var response = await client.GetAsync("/health/live");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType.ToString());
Assert.Equal(expectedJson, await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task LivenessProbeStartup_Readiness()
{
var expectedJson = JsonConvert.SerializeObject(new
{
status = "Unhealthy",
results = new
{
identity = new
{
status = "Healthy",
description = "",
data = new { }
},
slow_dependency = new
{
status = "Unhealthy",
description = "Dependency is still initializing",
data = new { }
},
},
}, Formatting.Indented);
var builder = new WebHostBuilder()
.UseStartup<HealthChecksSample.LivenessProbeStartup>();
var server = new TestServer(builder);
var client = server.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType.ToString());
Assert.Equal(expectedJson, await response.Content.ReadAsStringAsync());
}
}
}

View File

@ -346,5 +346,67 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal("Healthy", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task CanFilterChecks()
{
var builder = new WebHostBuilder()
.Configure(app =>
{
app.UseHealthChecks("/health", new HealthCheckOptions()
{
HealthCheckNames =
{
"Baz",
"FOO",
},
});
})
.ConfigureServices(services =>
{
services.AddHealthChecks()
.AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!")))
// Will get filtered out
.AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("A-ok!")))
.AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!")));
});
var server = new TestServer(builder);
var client = server.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString());
Assert.Equal("Healthy", await response.Content.ReadAsStringAsync());
}
[Fact]
public void CanFilterChecks_ThrowsForMissingCheck()
{
var builder = new WebHostBuilder()
.Configure(app =>
{
app.UseHealthChecks("/health", new HealthCheckOptions()
{
HealthCheckNames =
{
"Bazzzzzz",
"FOO",
},
});
})
.ConfigureServices(services =>
{
services.AddHealthChecks()
.AddCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!")))
.AddCheck("Bar", () => Task.FromResult(HealthCheckResult.Unhealthy("A-ok!")))
.AddCheck("Baz", () => Task.FromResult(HealthCheckResult.Healthy("A-ok!")));
});
var ex = Assert.Throws<InvalidOperationException>(() => new TestServer(builder));
Assert.Equal(
"The following health checks were not found: 'Bazzzzzz'. Registered health checks: 'Foo, Bar, Baz'.",
ex.Message);
}
}
}