ASP.NET Core 健康检查:返回预先评估的结果

7
我正在评估使用Microsoft Health Checks来改善内部负载均衡器的路由。目前,我对该功能及其周围的社区提供的支持非常满意。然而,有一件事我还没有找到,想问一下是否可以直接做到:
健康检查似乎会在请求时立即检索自身状态。但是,因为我们的服务可能在给定的时刻难以处理大量请求,查询第三方组件(例如SQL Server)可能需要时间才能响应。因此,我们希望每隔几秒钟预先评估该健康检查状态,并在调用健康检查API时返回该状态。
原因是我们希望尽快让我们的负载均衡器获取健康状态。使用预先评估的结果似乎已经足够适用于我们的用例。
现在的问题是:是否可以向ASP.NET Core健康检查添加一种“投票”或“自动更新”机制?还是说我必须实现自己的健康检查,从后台服务返回周期性预评估的结果?请注意,我想在每个请求上使用预评估的结果,这不是HTTP缓存,其中实时结果会被缓存用于下一个请求。

您可以将其反转并定期向服务器推送指标。 HealthCheck支持推送,例如Prometheus。 此软件包包含大量用于Prometheus(实际上是Prometheus Gateway)、App Insights、Seq和Datadog的检查和发布者。 - Panagiotis Kanavos
1
还有一个直接的端点供Prometheus轮询。Prometheus会像你描述的那样,通过调用端点来轮询其来源的事件。但这对于CLI应用程序并不起作用,因此使用Prometheus网关作为“缓存”,以保存应用程序发布的数据,直到Prometheus请求它。 - Panagiotis Kanavos
3个回答

8

Panagiotis的答案非常棒,带我找到了一种优雅的解决方案,我很想留下来给下一个遇到这个问题的开发人员......

为了实现周期性更新而不需要实现后台服务或任何计时器,我注册了一个。通过这个,ASP.NET Core会定期运行已注册的健康检查并将它们的结果发布到相应的实现中。

在我的测试中,健康报告默认每30秒发布一次。

// add a publisher to cache the latest health report
services.AddSingleton<IHealthCheckPublisher, HealthReportCachePublisher>();

我注册了我的实现 HealthReportCachePublisher,它不做任何其他事情,只是将已发布的健康报告保存在静态属性中。

我并不是很喜欢静态属性,但在这种情况下似乎足够适用。

/// <summary>
/// This publisher takes a health report and keeps it as "Latest".
/// Other health checks or endpoints can reuse the latest health report to provide
/// health check APIs without having the checks executed on each request.
/// </summary>
public class HealthReportCachePublisher : IHealthCheckPublisher
{
    /// <summary>
    /// The latest health report which got published
    /// </summary>
    public static HealthReport Latest { get; set; }

    /// <summary>
    /// Publishes a provided report
    /// </summary>
    /// <param name="report">The result of executing a set of health checks</param>
    /// <param name="cancellationToken">A task which will complete when publishing is complete</param>
    /// <returns></returns>
    public Task PublishAsync(HealthReport report, CancellationToken cancellationToken)
    {
        Latest = report;
        return Task.CompletedTask;
    }
}

现在真正的魔法就在这里发生了

正如在每个Health Checks示例中所看到的那样,我将健康检查映射到路由/health并使用UIResponseWriter.WriteHealthCheckUIResponse返回一个美观的json响应。

但是我还映射了另一个路由/health/latest。在那里,一个谓词_ => false阻止执行任何健康检查。但是,我不会返回零个健康检查的空结果,而是通过访问静态的HealthReportCachePublisher.Latest返回以前发布的健康报告。

app.UseEndpoints(endpoints =>
{
    // live health data: executes health checks for each request
    endpoints.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions()
    {
        ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
    });

    // latest health report: won't execute health checks but return the cached data from the HealthReportCachePublisher
    endpoints.MapHealthChecks("/health/latest", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions()
    {
        Predicate = _ => false, // do not execute any health checks, we just want to return the latest health report
        ResponseWriter = (context, _) => UIResponseWriter.WriteHealthCheckUIResponse(context, HealthReportCachePublisher.Latest)
    });
});

这种方式调用 /health 将会在每个请求上执行所有健康检查并返回实时的健康报告。如果有很多要检查或需要进行网络请求,这可能需要一些时间。

调用 /health/latest 将始终返回最新的预先评估的健康报告。这非常快速,可以帮助负载均衡器根据健康报告来路由传入的请求。


稍作补充: 上面的解决方案使用路由映射取消健康检查的执行,并返回最新的健康报告。如建议所示,我试图首先构建一个进一步的健康检查,该检查应返回最新的缓存健康报告,但这有两个缺点:

  • 新的健康检查也将出现在结果中(或必须通过名称或标记进行筛选)。
  • 没有简单的方法将缓存的健康报告映射到 HealthCheckResult。如果复制属性和状态代码,则可能有效。但是,生成的 JSON 基本上是一个包含内部健康报告的健康报告。那不是你想要的结果。

1
经过所有的搜索,完整的答案是如此简单,我想知道为什么它没有被包含在文档的示例中!大多数示例都过于复杂,但这个看似复杂的场景比它看起来要容易得多。 - Panagiotis Kanavos
这很棒。看到一种基于推送的方式做这件事情而不是拉取式的方式真是太好了。 - JJS

5

简短版

这已经可用,并且可以与常见的监控系统集成。您可能能够直接将Health Check与您的监控基础架构联系起来。

详细信息

健康检查中间件通过定期发布指标到目标,通过实现IHealthCheckPublisher.PublishAsync接口方法的任何注册类来覆盖此操作。

services.AddSingleton<IHealthCheckPublisher, ReadinessPublisher>();

发布可以通过 HealthCheckPublisherOptions 进行配置。默认周期为 30 秒。选项可用于添加延迟、过滤要运行的检查等。
services.Configure<HealthCheckPublisherOptions>(options =>
{
    options.Delay = TimeSpan.FromSeconds(2);
    options.Predicate = (check) => check.Tags.Contains("ready");
});

一种选择是使用发布者缓存结果(HealthReport 实例),并从另一个 HealthCheck 端点提供服务。
也许更好的选择是将它们推送到监视系统,例如 Application Insights 或时间序列数据库(如 Prometheus)。AspNetCore.Diagnostics.HealthCheck 包为 App Insights、Seq、Datadog 和 Prometheus 提供了大量现成的检查和发布者。
Prometheus 本身使用轮询。它定期调用所有已注册的源以检索指标。虽然这对于服务有效,但对于 CLI 应用程序则无效。因此,应用程序可以将结果推送到 Prometheus Gateway,该网关会缓存指标,直到 Prometheus 请求它们。
services.AddHealthChecks()
        .AddSqlServer(connectionString: Configuration["Data:ConnectionStrings:Sample"])
        .AddCheck<RandomHealthCheck>("random")
        .AddPrometheusGatewayPublisher();

除了将数据推送到Prometheus Gateway外,Prometheus发布者还提供了一个端点用于直接检索实时指标,通过AspNetcore.HealthChecks.Publisher.Prometheus包。其他应用程序也可以使用相同的端点来检索这些指标:
// default endpoint: /healthmetrics
app.UseHealthChecksPrometheusExporter();

非常感谢你,Panagiotis。关于发布者的提示是纯金,让我找到了一个简单的解决方案,我已经发布了第二个答案,但我希望你能得到解决方案标记。顺便说一句,Prometheus 对我来说很棒,但不适合我们的环境。 - Waescher
我在阅读文档之前不知道这个。我知道有一种方法可以将健康数据发布到Prometheus,但在搜索这个问题时才知道发布已经内置了。 - Panagiotis Kanavos
我实际上在映射步骤停止了,因为我不知道“Predicate”会阻止指标收集。 - Panagiotis Kanavos

0

另一种选择是使用Scrutor,并装饰HealthCheckService。 如果您担心有多个线程重新发布的情况,那么在从内部HealthCheckService获取HealthCheckReport时,您必须添加锁定机制。一个不错的例子在这里

using System.Reflection;
using HealthCheckCache;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

// used by the Decorator CachingHealthCheckService
builder.Services.AddMemoryCache();
builder.Services.AddHttpContextAccessor();

// register all IHealthCheck types - basically builder.Services.AddTransient<AlwaysHealthy>(), but across all types in this assembly.
var healthServices = builder.Services.Scan(scan =>
    scan.FromCallingAssembly()
        .AddClasses(filter => filter.AssignableTo<IHealthCheck>())
        .AsSelf()
        .WithTransientLifetime()
);

// Register HealthCheckService, so it can be decorated.
var healthCheckBuilder = builder.Services.AddHealthChecks();
// Decorate the implementation with a cache
builder.Services.Decorate<HealthCheckService>((inner, provider) =>
    new CachingHealthCheckService(inner,
        provider.GetRequiredService<IHttpContextAccessor>(),
        provider.GetRequiredService<IMemoryCache>()
    )
);

// Register all the IHealthCheck instances in the container
// this has to be a for loop, b/c healthCheckBuilder.Add will modify the builder.Services - ServiceCollection
for (int i = 0; i < healthServices.Count; i++)
{
    ServiceDescriptor serviceDescriptor = healthServices[i];
    var isHealthCheck = serviceDescriptor.ServiceType.IsAssignableTo(typeof(IHealthCheck)) && serviceDescriptor.ServiceType == serviceDescriptor.ImplementationType;
    if (isHealthCheck)
    {
        healthCheckBuilder.Add(new HealthCheckRegistration(
            serviceDescriptor.ImplementationType.Name,
            s => (IHealthCheck)ActivatorUtilities.GetServiceOrCreateInstance(s, serviceDescriptor.ImplementationType),
            failureStatus: null,
            tags: null)
        );
    }

}

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapHealthChecks("/health", new HealthCheckOptions()
{
    AllowCachingResponses = true, // allow caching at Http level
});

app.Run();

public class CachingHealthCheckService : HealthCheckService
{
    private readonly HealthCheckService _innerHealthCheckService;
    private readonly IHttpContextAccessor _contextAccessor;
    private readonly IMemoryCache _cache;
    private const string CacheKey = "CachingHealthCheckService:HealthCheckReport";

    public CachingHealthCheckService(HealthCheckService innerHealthCheckService, IHttpContextAccessor contextAccessor, IMemoryCache cache)
    {
        _innerHealthCheckService = innerHealthCheckService;
        _contextAccessor = contextAccessor;
        _cache = cache;
    }

    public override async Task<HealthReport> CheckHealthAsync(Func<HealthCheckRegistration, bool>? predicate, CancellationToken cancellationToken = new CancellationToken())
    {
        HttpContext context = _contextAccessor.HttpContext;


        var forced = !string.IsNullOrEmpty(context.Request.Query["force"]);
        context.Response.Headers.Add("X-Health-Forced", forced.ToString());
        var cached = _cache.Get<HealthReport>(CacheKey);
        if (!forced && cached != null)
        {
            context.Response.Headers.Add("X-Health-Cached", "True");
            return cached;
        }
        var healthReport = await _innerHealthCheckService.CheckHealthAsync(predicate, cancellationToken);
        if (!forced)
        {
            _cache.Set(CacheKey, healthReport, TimeSpan.FromSeconds(30));
        }
        context.Response.Headers.Add("X-Health-Cached", "False");
        return healthReport;
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接