diff --git a/samples/HealthChecksSample/LivenessProbeStartup.cs b/samples/HealthChecksSample/LivenessProbeStartup.cs new file mode 100644 index 0000000000..d7645b5f0e --- /dev/null +++ b/samples/HealthChecksSample/LivenessProbeStartup.cs @@ -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"); + }); + } + } +} diff --git a/samples/HealthChecksSample/Program.cs b/samples/HealthChecksSample/Program.cs index bd72511db6..4d9455ed2d 100644 --- a/samples/HealthChecksSample/Program.cs +++ b/samples/HealthChecksSample/Program.cs @@ -18,6 +18,7 @@ namespace HealthChecksSample { "basic", typeof(BasicStartup) }, { "detailed", typeof(DetailedStatusStartup) }, { "writer", typeof(CustomWriterStartup) }, + { "liveness", typeof(LivenessProbeStartup) }, }; } diff --git a/samples/HealthChecksSample/SlowDependencyHealthCheck.cs b/samples/HealthChecksSample/SlowDependencyHealthCheck.cs new file mode 100644 index 0000000000..1ca03f88ae --- /dev/null +++ b/samples/HealthChecksSample/SlowDependencyHealthCheck.cs @@ -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 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")); + } + } +} diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs index 5f3ca6df47..3a220f2595 100644 --- a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckMiddleware.cs @@ -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); } /// @@ -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 checks, + ISet 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(names, StringComparer.OrdinalIgnoreCase); + var matches = new List(); + + 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(); + } } } diff --git a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs index 6426fcbc3b..b57374f2ae 100644 --- a/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs +++ b/src/Microsoft.AspNetCore.Diagnostics.HealthChecks/HealthCheckOptions.cs @@ -14,6 +14,16 @@ namespace Microsoft.AspNetCore.Diagnostics.HealthChecks /// public class HealthCheckOptions { + /// + /// Gets a set of health check names used to filter the set of health checks run. + /// + /// + /// If is empty, the 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. + /// + public ISet HealthCheckNames { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public IDictionary ResultStatusCodes { get; } = new Dictionary() { { HealthCheckStatus.Healthy, StatusCodes.Status200OK }, diff --git a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareSampleTest.cs b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareSampleTest.cs index 1007017e11..4e716ed3b9 100644 --- a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareSampleTest.cs +++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareSampleTest.cs @@ -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(); + + 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(); + + 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()); + } } } diff --git a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs index b81747581c..74d23c81bf 100644 --- a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs +++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/HealthCheckMiddlewareTests.cs @@ -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(() => new TestServer(builder)); + Assert.Equal( + "The following health checks were not found: 'Bazzzzzz'. Registered health checks: 'Foo, Bar, Baz'.", + ex.Message); + } } }