☰
.NET Core健康检查端点从入门到实践:K8s探针与依赖检查避坑指南
2026/10/16 3:41:34 网站建设 项目流程

前阵子帮团队排查一个线上问题,现象很典型:订单服务在容器编排平台上是"Running"状态,外部请求也已经打进来了,但接口大量超时。平台一侧没有任何告警,因为从它的视角看,容器一直活着。排查到最后才发现,问题出在数据库连接池耗尽——进程活着,但业务线程全都卡在拿连接上。从那次之后,我养成了一个习惯:任何对外提供HTTP服务的应用,都必须有一个诚实的健康检查端点(Health Check Endpoint)。

健康检查端点本质上是一个轻量HTTP接口,比如/health,它不处理业务,只负责回答"我现在到底能不能接流量"。回答的依据不是进程是否存活,而是服务自己检查完各项依赖之后得出的结论。.NET Core Web从2.2开始提供官方健康检查能力,经过几个大版本迭代,到今天API已经很成熟了。这篇文章我会从一个最小可用的/health端点讲起,一直讲到K8s探针配合、自定义依赖检查、结构化报告,以及我在真实环境里踩过的一些坑。无论你是刚接触这个概念,还是已经在项目里加了健康检查但觉得不太对劲,这篇都能给你一些参考。

1. 为什么端口通了不代表服务健康

1.1 进程存活、端口监听与真实可用之间的差距

容器编排平台默认的探活逻辑很简单:看进程还在不在,看端口能不能连上。但"端口能连上"和"服务能正常处理请求"之间,隔着相当远的距离。

举个常见的例子:应用依赖的数据库连接池被耗尽时,监听端口的进程依然活着,TCP三次握手依然正常完成,但任何业务请求进来后都会在等待连接这一步卡住。此时从平台的视角看,一切正常;从用户的视角看,接口已经超时到不可用。

类似的场景还有很多:

  • 依赖的外部API服务故障,进程本身没有任何异常,但核心业务接口已经在抛异常。
  • 磁盘空间写满,日志写不进去,临时文件创建失败,进程依然存活。
  • 消息队列积压严重,消费端跟不上生产速度,HTTP接口虽然能响应,但数据链路已经半瘫痪。
  • 内存泄漏导致GC频繁触发,进程还在,但吞吐量已经掉到正常水平的十分之一。

TCP探活只能回答"这个端口上有没有进程在监听",健康检查端点回答的是"这个进程现在能不能正常干活"。后者是一个应用层面的判断,需要服务自己基于真实依赖状态来回答。容器平台和负载均衡器需要这样一个应用层入口来做决策,这就是健康检查端点存在的根本原因。

1.2 谁在消费健康检查端点

动手设计健康检查端点之前,先想清楚谁会来调用它,因为不同调用方对端点的要求完全不同。

调用方关注点对端点的要求
容器编排平台的存活探针进程是否卡死、是否应该重启快速响应、状态稳定、失败代价高昂
容器编排平台的就绪探针服务是否准备好接收流量依赖检查真实、摘流及时
负载均衡器后端探测后端节点是否可服务响应快、超时短、状态码语义准确
监控系统与告警平台状态趋势、依赖健康详情结构化数据、持续可采集
运维脚本与诊断工具手动确认服务能否接入流量路径固定、输出易读

明确了调用方之后,你就能理解后续很多设计决策:为什么探针端点要轻量,为什么就绪检查要区分依赖级别,为什么输出要做成JSON而不是文本。健康检查端点的价值不在于它本身写得有多花哨,而在于它在故障发生时能给外部系统一个明确、可靠的信号。

2. 最小可用实现:两条语句让/health端点上线

2.1 AddHealthChecks与MapHealthChecks的分工

在.NET 6+的项目里,最简健康检查端点的核心代码只有两行。以Minimal API风格的Program.cs为例:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddHealthChecks(); var app = builder.Build(); app.MapHealthChecks("/health"); app.MapControllers(); app.Run();

这两行各管一件事:

  • AddHealthChecks()负责在依赖注入容器中注册健康检查服务,包括HealthCheckService、默认的HealthCheckOptions以及后续要添加的所有自定义检查项。
  • MapHealthChecks("/health")负责在请求管道中挂载健康检查中间件,把指定路径的请求交给健康检查框架处理。

这里有一个容易忽略的点:MapHealthChecks依赖HealthCheckService,如果项目里没有调用AddHealthChecks,运行时大概率会因为服务未注册而抛异常。虽然某些编排方式下框架可能会兜底,但规范做法永远是先注册服务再映射端点,不要赌隐式行为。

如果你用的还是.NET Core 2.x或者3.x的Startup.cs风格,对应写法是app.UseHealthChecks("/health")。后面我会专门讲不同版本之间API的差异,现在先按.NET 6+的方式走,因为这是当前的主流写法。

2.2 默认响应与状态码语义

跑起来之后,用curl -i http://localhost:5000/health验证一下,你会看到默认响应非常简单。

健康时返回HTTP 200,响应体就是一行文本Healthy:

HTTP/1.1 200 OK Content-Type: text/plain Healthy

当某个检查项失败时,响应会变成503,响应体为Unhealthy。这套默认映射规则是:

健康状态默认HTTP状态码默认响应体
Healthy200Healthy
Degraded200Degraded
Unhealthy503Unhealthy

注意一个坑:Degraded(亚健康)默认返回的是200,而不是503。这意味着如果某个检查项返回Degraded,负载均衡器依然会把流量打进来。很多团队的预期是"既然不健康了,至少给个5xx让LB摘掉节点",但框架默认不是这样。

如果需要调整,可以通过HealthCheckOptions覆盖状态码映射:

app.MapHealthChecks("/health", new HealthCheckOptions { ResultStatusCodes = { [HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable } });

改完之后,Degraded状态也会返回503,负载均衡器就能把节点摘掉了。我的建议是:除非你有明确的灰度放量需求,否则Degraded当作503处理会让系统行为更可预测。

3. 自定义检查:让端点反映数据库、外部依赖与资源水位

3.1 实现IHealthCheck接口

只跑通上面的最小实现,/health端点永远返回Healthy,因为默认情况下没有任何检查项参与评估。这就像一个不看病例就下诊断的医生,意义不大。要让健康检查端点有实际价值,必须自定义检查逻辑。

.NET Core健康检查框架的核心接口是IHealthCheck,只有两个成员需要实现:

public interface IHealthCheck { Task<HealthCheckResult> CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default); }

返回值是HealthCheckResult,有静态工厂方法:

  • HealthCheckResult.Healthy():正常。
  • HealthCheckResult.Degraded():可用但存在隐患。
  • HealthCheckResult.Unhealthy():不可用。

举一个最常见的数据库检查实现:

public class DatabaseHealthCheck : IHealthCheck { private readonly string _connectionString; public DatabaseHealthCheck(IConfiguration configuration) { _connectionString = configuration.GetConnectionString("AppDb") ?? throw new ArgumentNullException(nameof(_connectionString)); } public async Task<HealthCheckResult> CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { try { await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken); using var command = connection.CreateCommand(); command.CommandText = "SELECT 1"; await command.ExecuteScalarAsync(cancellationToken); return HealthCheckResult.Healthy("数据库连接正常"); } catch (Exception ex) { return HealthCheckResult.Unhealthy("数据库连接失败", ex); } } }

这里有几个细节值得展开:

  • 为什么执行SELECT 1而不是只调用OpenAsync?因为连接能打开不代表能执行查询,数据库可能处于只读状态、账号权限可能只允许连接不允许查询。执行一次最简单的查询,能更真实地反映"数据库是否可用"。
  • 为什么一定要透传cancellationToken?健康检查框架在执行时可能会被调用方取消,不传的话连接会一直等,导致健康检查请求挂死。
  • 为什么异常捕获后返回Unhealthy而不是重新抛出?因为健康检查框架不依赖异常传播来表达状态,把细节放进HealthCheckResult里,后续格式化和日志记录才有数据可用。

3.2 注册多个检查项

有了自定义检查类之后,注册到AddHealthChecks即可:

builder.Services.AddHealthChecks() .AddCheck<DatabaseHealthCheck>("database") .AddCheck<ExternalApiHealthCheck>("external_api") .AddCheck("cache", () => { // 简单委托式检查 return HealthCheckResult.Healthy("缓存服务检查通过"); });

每个检查项都有一个名字,这个名字会出现在健康检查报告里,方便定位问题。命名规则我建议用简短的小写英文加下划线,比如database、external_api、disk_space,不要用中文和空格,因为后续对接Prometheus等系统时,标签值越干净越好用。

AddCheck支持三种注册方式:

  • AddCheck<T>(name):注册一个实现IHealthCheck的类,适合有状态的复杂检查。
  • AddCheck(name, Func<HealthCheckResult>):注册一个委托,适合简单快速的检查。
  • AddCheck(name, Func<IServiceProvider, IHealthCheck>):从容器中解析实例,适合需要依赖注入更多服务的情况。

委托式注册的典型场景是磁盘空间检查:

builder.Services.AddHealthChecks() .AddCheck("disk_space", () => { var drive = new DriveInfo("/"); var freePercent = drive.AvailableFreeSpace * 100.0 / drive.TotalSize; if (freePercent < 5) return HealthCheckResult.Unhealthy($"磁盘可用空间仅剩{freePercent:F1}%"); if (freePercent < 15) return HealthCheckResult.Degraded($"磁盘可用空间不足{freePercent:F1}%"); return HealthCheckResult.Healthy("磁盘空间充足"); });

磁盘检查在健康检查里要谨慎使用。健康检查端点的调用频率通常不低,如果K8s每5秒探一次,磁盘状态查询的频率就相当可观,且磁盘IO本身又比较敏感。我的实践经验是:磁盘检查放在深度体检端点里,而不是放在高频探针端点上。

3.3 执行策略:超时、执行顺序与状态缓存

健康检查端点的每次请求,默认会把所有已注册的检查项全部执行一遍。这里有几个现实问题:

第一,检查项太多、依赖响应慢,健康检查端点自身就会变慢。K8s探针有超时时间,默认1秒,负载均衡器的超时也很短。如果你的健康检查要在数据库上跑一个复杂的查询,超时是必然的。

第二,框架默认按注册顺序串行执行检查项。如果前一个检查要等30秒,后面的所有检查都被阻塞。所以在设计检查项时,项目数量不建议超过10个,单个检查的执行时间最好控制在200毫秒以内。

第三,高频探活会导致依赖系统压力增大。比如K8s每5秒探一次,每次都要重新连接数据库,连接池上会有大量短暂连接。这在高并发场景下是一个真实的隐患。

针对这些问题,我的处理方式有几种:

  • 给每个检查设置超时,避免单点拖死整个端点。
  • 对高频探针端点只放轻量检查,把耗时长的检查放到独立的深度体检端点。
  • 在检查内部对结果做短时间缓存,比如1秒内的结果直接复用,降低底层依赖的压力。

AddCheck里可以直接传超时参数,例如:

builder.Services.AddHealthChecks() .AddCheck<DatabaseHealthCheck>("database", timeout: TimeSpan.FromSeconds(3));

超时之后这个检查会被标记为Unhealthy,同时日志里会记录超时信息。注意超时时间要大于数据库查询的实际耗时,否则会出现"明明数据库没问题,健康检查却报不健康"的误判。

4. 与K8s探针配合:liveness和readiness各看各的端点

4.1 为什么一个/health不够

把服务部署进Kubernetes之后,健康检查端点要和探针机制配合起来。这里有个关键概念容易混淆:K8s里的探针有两种,livenessProbe和readinessProbe,它们失败后的处理方式完全不同。

探针类型失败后的动作本质目的
livenessProbe杀掉容器,重新拉起判断进程是否需要重启
readinessProbe从Service Endpoints中摘除节点判断流量是否应该继续打进来

如果所有检查项都堆在同一个/health端点上,数据库故障时会出现一个尴尬局面:readinessProbe会正确地把节点摘掉,但livenessProbe也会持续失败,导致K8s反复重启容器。数据库故障通常不是通过重启应用解决的,此时重启只会放大故障影响,甚至进入重启循环。

所以实践上要把健康检查拆成两个端点:

  • /health/live:只检查进程和基础资源,正常情况下永远返回Healthy。它只需要回答"进程还活着吗、还能不能继续跑"。
  • /health/ready:检查数据库、缓存、外部依赖等,决定是否接收流量。依赖故障时返回503,让K8s摘流。

4.2 用Predicate按标签拆分检查集合

.NET Core的健康检查框架支持给每个检查项打标签,然后按标签过滤。在Program.cs里这样配置:

builder.Services.AddHealthChecks() .AddCheck<DatabaseHealthCheck>("database", tags: new[] { "ready" }) .AddCheck<ExternalApiHealthCheck>("external_api", tags: new[] { "ready" }) .AddCheck("process", () => HealthCheckResult.Healthy(), tags: new[] { "live" }); var app = builder.Build(); app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = check => check.Tags.Contains("live") }); app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });

Predicate是一个Func<HealthCheckRegistration, bool>,返回true的检查项才会被该端点执行。通过标签来筛选,比把检查分类写死在代码里更灵活,后续加一个新依赖只需要给它打上标签,不用改端点映射逻辑。

对应的K8s部署配置大致这样:

spec: containers: - name: my-app ports: - containerPort: 80 name: http livenessProbe: httpGet: path: /health/live port: http initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /health/ready port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 2 successThreshold: 1

4.3 探针参数经验值

探针参数看起来简单,实际调参时有几个经验值得记住。

initialDelaySeconds要给应用足够的启动时间。有些服务启动时要做数据库迁移、缓存预热,这个时间如果设得太短,启动期间探针就会疯狂失败。一般建议从30秒起步,视启动耗时调整。

timeoutSeconds要和健康检查端点的实际耗时对齐。K8s探针的默认超时是1秒,如果你的/health/ready执行一次要3秒,探针会直接判定失败。所以探针端点上的检查项必须轻量,单个检查的执行时间控制在几百毫秒以内。

periodSeconds方面,readinessProbe可以设得密一些(3到5秒),因为readiness失败只是摘流,代价相对小;livenessProbe建议设得疏一些(10到15秒),因为liveness失败意味着重启容器,频繁重启对服务和下游依赖都是冲击。

failureThreshold的作用是避免瞬时抖动引发误判。比如数据库连接偶发超时,如果失败一次就触发重启,那发布和运行会很不稳定。一般liveness的阈值设3次以上,readiness可以设2次左右。

还有一个滚动更新场景要特别留意:新版本Pod上的/health/ready如果一直返回503,K8s会把滚动更新卡住,旧Pod不会继续被替换。反过来,如果新Pod还没完成预热就急于返回200,流量会提前打进来,造成发布期间大量报错。我见过一个项目用内存标志位解决这个问题:应用启动后先在后台执行预热逻辑,预热完成前/health/ready直接返回503,预热完成后切换为检查依赖状态。

5. 让健康检查输出变成可观测性的一部分

5.1 从文本响应升级为JSON报告

默认的Healthy文本响应只适合人工看一眼,不适合机器解析。当健康检查端点的消费方变成监控系统、运维脚本和排障工具时,需要输出结构化数据。

通过HealthCheckOptions.ResponseWriter可以完全自定义响应内容:

app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), ResponseWriter = async (context, report) => { context.Response.ContentType = "application/json; charset=utf-8"; var payload = new { status = report.Status.ToString(), checks = report.Entries.Select(entry => new { name = entry.Key, status = entry.Value.Status.ToString(), description = entry.Value.Description, durationMs = entry.Value.Duration.TotalMilliseconds }), totalDurationMs = report.TotalDuration.TotalMilliseconds }; await context.Response.WriteAsJsonAsync(payload, context.RequestAborted); } });

这时请求/health/ready返回的JSON长这样:

{ "status": "Healthy", "checks": [ { "name": "database", "status": "Healthy", "description": "数据库连接正常", "durationMs": 12.3 }, { "name": "external_api", "status": "Healthy", "description": "", "durationMs": 87.1 } ], "totalDurationMs": 101.4 }

为什么要做这一步?想象一下排障场景:线上服务状态异常,你看到Unhealthy,但不知道具体是哪个依赖出了问题。JSON报告里每一项的status、description和durationMs,能让你一眼定位到是数据库还是外部API拖垮了整体状态。durationMs还能暴露出那些"状态健康但响应变慢"的隐患。

5.2 状态变化的日志记录

健康检查端点的调用频率非常高,如果每个请求都记录日志,日志量会非常可怕。正确做法是只在状态变化时记录日志。

实现方式很简单:在ResponseWriter里维护一个内存中的上一次报告状态,和本次比较,状态发生变化时才写入日志。我习惯用一个轻量的静态类来保存状态:

public static class HealthStateTracker { private static HealthStatus? _lastStatus; public static void Track(HealthReport report, ILogger logger) { if (_lastStatus == report.Status) return; logger.LogWarning( "健康检查状态变化: {PreviousStatus} -> {CurrentStatus}, 耗时 {DurationMs}ms", _lastStatus?.ToString() ?? "Unknown", report.Status, report.TotalDuration.TotalMilliseconds); _lastStatus = report.Status; } }

当然,多实例部署时每个实例各自记录自己的状态变化,但结合集中日志平台,这些状态变化日志足够你画出服务可用性趋势。

5.3 对接指标系统与可视化看板

如果团队已经有Prometheus和Grafana,健康检查数据完全可以接入指标体系。通用的做法是把健康状态映射成自定义指标,比如记录healthcheck_status这个Gauge,状态Healthy记1,Unhealthy记0;再把健康检查的耗时记录成Histogram。这样Grafana上可以直接看到每个实例的健康状态和检查耗时趋势。

如果你不想引入太多额外组件,社区里也有专门做健康检查面板的开源组件,配置若干服务端点后聚合成一个看板,失败时还能推送到即时通讯工具。对于没有标准监控平台的团队来说,这是成本最低的可视化方案。

不过我个人的选择是:健康检查数据优先走日志和指标两条通道。日志用于排障时追溯历史状态,指标用于大盘监控和告警触发;可视化看板不是必需品,但有了它会让人更有安全感。

6. 真实环境里踩过的健康检查坑

6.1 全局异常中间件把探活响应切成了JSON

有团队在请求管道里加了一个全局异常处理中间件,把一切未处理异常统一包装成JSON返回。这个中间件默认拦截所有路径,结果健康检查端点也被它接管了。探针请求进来时,如果检查过程抛出异常,中间件把异常信息包装成JSON返回,而不是框架默认的503 + Unhealthy文本。

这个问题隐蔽在:K8s探针只看状态码,一般不会出问题;但某些负载均衡器配置了"期望响应体包含Healthy"这样的校验,就会把正常节点误判为不健康。

解决方式有几种:

  • 把MapHealthChecks放在请求管道的最前端,让健康检查请求不经过业务中间件。
  • 在全局异常中间件里对健康检查路径放行。
  • 统一用自定义ResponseWriter,让所有情况下的响应格式保持一致。

我倾向于第三种,因为即使探针不校验响应体,保持响应格式一致也能避免很多隐性麻烦。

6.2 健康检查变成了数据库连接杀手

在一个高并发项目里,K8s配置了每5秒探一次readiness,readiness里注册了数据库检查。数据库探活的连接数监控显示,健康检查贡献了接近三成的连接占用。原因很简单:每次探活都会新建连接、执行查询、释放连接,连接池里这些短暂连接频繁进出,对数据库服务器形成了不小的压力。

排查链路:先看数据库连接数的来源统计,确认连接数峰值和时间段是否与探针频率吻合;再看健康检查的执行日志,确认每次执行确实建立了新的数据库连接。

解决方式:

  • 把数据库检查的超时时间设短,避免连接长时间占用。
  • 在检查内部对结果做短缓存,比如1秒内直接返回上一次结果。
  • 降低readiness探针的频率,从5秒改成15秒。
  • 把数据库检查从高频探针端点摘掉,放到低频的深度体检端点。

现在我的默认做法是:高频探针端点只做进程和轻量资源检查,数据库、缓存、外部依赖这些检查放到低频的详细报告端点里,避免健康检查本身成为故障源。

6.3 依赖恢复后端点迟迟不转Healthy

有一次线上数据库发生了几分钟的抖动,健康检查端点如实地返回了503,节点被摘流。数据库恢复之后,/health/ready却依然返回503,过了很久才转绿。

排查之后发现,有人在健康检查外层加了一层简单的内存缓存,缓存时间是30秒。问题出在缓存的是整个HealthCheckResult,包括Unhealthy状态。数据库恢复后30秒内,缓存还没有过期,端点一直返回旧状态。

这个坑的根源是"为了降低依赖压力"加了缓存,但缓存策略没有考虑状态反转。现在我的处理规则是:

  • 缓存健康状态可以,但缓存的TTL要足够短,通常不超过1到2秒。
  • 在健康检查响应头里显式加上Cache-Control: no-store,避免代理服务器或浏览器缓存健康状态。
  • 定期用真实依赖验证缓存结果的正确性,而不是盲目信任缓存。

6.4 框架版本迁移带来的API差异

.NET Core 2.x时期的写法是app.UseHealthChecks("/health"),从3.x开始引入MapHealthChecks,到了.NET 6之后Minimal API成为主流,写法又变了一轮。有一次升级项目框架版本,顺手把健康检查端点迁移过来,结果因为UseHealthChecks和MapHealthChecks在路由注册顺序上的差异,端点路径全部404。

这个问题的坑点在于:升级框架版本时,很多业务代码都能平滑迁移,但健康检查这种基础设施代码往往被忽略。等容器编排平台的探针开始报警时,才想起来要检查映射方式。

我的建议是,把健康检查相关代码收敛成一个独立的HealthCheckExtensions静态类,统一封装端点映射和响应定制。这样框架版本升级时只需要改这一个文件,不会遗漏:

public static class HealthCheckExtensions { public static void MapCustomHealthChecks(this WebApplication app) { 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 = WriteJsonResponse }); } }

6.5 探针超时与深度体检需求的冲突

最后这个坑最隐蔽。有个运维团队希望健康检查能覆盖所有依赖,包括一些响应比较慢的外部服务,于是把健康检查做成了"全链路体检",一次检查要跑几十个依赖,耗时几十秒。结果K8s探针的timeoutSeconds默认1秒,探针每次都失败,容器被反复重启。

这就是探针端点与深度体检需求的冲突。解决方案是把它们分开:

  • 探针端点/health/ready只做快速检查,目标是100毫秒内返回结果。
  • 深度体检端点/health/detailed做全量检查,可以被监控系统低频调用,也可以在人工排障时手动触发。

我一般是这么设计的:/health/live只检查进程存活,/health/ready做核心依赖快速检查,/health/detailed做全链路体检,并且只允许内部网络访问。这样既满足了K8s探针的实时性要求,又保留了排障时的深度诊断能力。

最后再分享一个我坚持了很久的原则:健康检查端点不要写得太复杂,但一定要诚实。宁可让它在依赖故障时立刻返回503,让流量绕行,也不要为了"好看"硬撑着返回Healthy,等真出问题的时候让故障雪上加霜。我通常会在项目里把所有健康检查相关代码收敛成一个单独的扩展类,每个服务启动时先验证自身依赖,再开放探活端口。这套东西不复杂,但它的价值在故障发生那几分钟才真正体现出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询