健康检查这东西在 ASP.NET Core 里属于那种“系统自带但经常被忽略”的能力。很多团队把服务丢进 Kubernetes 或者 Docker Compose 之后才发现探针需要一个 HTTP 端点——然后第一个反应是去 NuGet 上搜第三方包搜了一圈回来发现框架原生就支持只是入口藏得比较深文档又散。我是做 .NET 后端出身这几年前后参与过好几个微服务体系的搭建健康检查从最开始“写个 Controller 返回 ok”到后来把原生的 Health Checks 完整吃透中间踩了不少坑。这篇就把 ASP.NET Core 原生健康检查服务从原理到落地完整拆一遍它背后的组件结构是什么样的、注册和执行机制怎么工作、怎么从最简单的/health端点一路做到符合 Kubernetes 就绪探针要求的双端点方案以及我实际项目里遇到过的那些典型翻车现场。适合谁看刚接触 ASP.NET Core 的小白能把最基础的接入跑通已经在生产环境用了但没有系统梳理过的同学可以重点看第五、六章的配置语义和排查思路。我自己更看重后者——很多坑不是功能不会用而是没搞懂它内部的执行逻辑。1. 健康检查到底在解决什么问题1.1 一次不算罕见的线上事故先讲个场景。某团队维护一个订单查询服务部署在容器平台上前端网关通过负载均衡把流量分发到三个实例。某次发布后其中一个实例因为内存泄漏响应已经基本卡死但进程还在端口还监听负载均衡探测 TCP 连接都是通的——因为 TCP 握手还能完成只是应用层不再正常处理请求。结果就是每三个请求里约有一个请求会超时网关重试又把压力放大整个服务雪崩。这事的根源在于端口通不等于是活着的进程在不等于是健康的。TCP 探测只知道“操作系统还有这个 socket 在监听”但应用内部的状态——依赖的数据库连接池是否可用、缓存是否还能读写、消息队列是否积压——它一概不知。健康检查就是为了补上这层“应用视角”的探活能力。1.2 健康检查不是日志也不是监控告警很多同学会把健康检查和监控告警混在一起这是理解上最大的误区。监控告警解决的是“出了问题要知道”日志解决的是“出了问题怎么查”而健康检查解决的是“外部系统如何自动判断我还能不能接流量”。它本质上是一个被外部编排系统消费的“接口契约”。容器平台通过它决定要不要重启实例负载均衡通过它决定要不要摘除节点服务网格通过它决定要不要切流量。所以它有两个非常硬的指标要求一是够快探针请求不能拖太久否则平台会认为实例卡死二是够准既要能反映真实可用性又不能因为一次偶发抖动就频繁触发重启。1.3 原生方案能省掉哪些麻烦ASP.NET Core 从 2.2 版本开始引入原生的 Health Checks 中间件和能力核心程序集叫Microsoft.Extensions.Diagnostics.HealthChecks在Microsoft.AspNetCore.App共享框架里已经内置不需要额外安装基础包。这意味着你做微服务也好、单体应用也好只要用 ASP.NET Core 就跑不掉这套能力。原生方案最大的价值是“不绑架你的代码结构”。它不要求你继承什么基类 Controller不要求你引入特定容器平台的 SDK只是让你注册一组检查项然后对外暴露一个端点。检查项执行完框架自动汇总体检报告输出 200 或者 503。你在本地调试、在 Docker 里跑、放进 K8s这套逻辑完全一致。后面我会从源码级别的组件结构讲清楚它为什么能做到这一点。2. 原生组件的底层结构与执行语义2.1 三个核心角色IHealthCheck、HealthCheckService、中间件原生健康检查在架构上就三个核心角色理解这三个角色后面所有配置都能对号入座。第一个是IHealthCheck接口。这是所有检查项的抽象契约框架原生提供的Microsoft.Extensions.Diagnostics.HealthChecks程序集里它就是个很简单的接口只有一个方法public interface IHealthCheck { TaskHealthCheckResult CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken default); }你的检查逻辑只需要实现它返回一个HealthCheckResult。框架不关心你内部是查数据库还是访问 HTTP 接口只要有返回值就行。这叫“依赖倒置”——框架定义契约开发者填充实现。第二个是HealthCheckService。它是一个注册在 DI 容器里的核心服务负责管理所有检查项的生命周期并且在调用CheckHealthAsync时把所有检查项并行跑起来再汇总成一个HealthReport。这个服务是自动注册的——只要你调用了AddHealthChecks()它就在容器里了。第三个是MapHealthChecks端点。注意它在现代 ASP.NET Core 里走的是端点路由不是传统意义上的中间件——虽然老版本里的app.UseHealthChecks()写法也有但 6.0 之后官方推荐用app.MapHealthChecks()。它负责把HealthCheckService的返回结果翻译成 HTTP 响应。2.2 HealthReport 与 HealthStatus 到底长什么样一次健康检查执行完毕后框架会吐出两个关键模型。HealthReport是整体报告包含两个属性Status是总体状态Entries是一个字典Key 是检查项名字Value 是每一项的详细结果。这三个状态值你必须在心里记牢状态值含义HTTP 映射Healthy检查通过200Degraded可用但有降级200默认Unhealthy检查失败503这里有两个很容易踩的认知偏差。第一Degraded默认也返回 200。你如果觉得“降级了就不该接流量”需要自己在选项里调整对应关系。第二总体状态不是求平均而是取所有检查项里“最差”的那个——只要有一个检查项返回Unhealthy整个报告就是Unhealthy哪怕其他五项全过。这就是你配置检查项粒度时要特别小心的原因检查项不是越多越好每加一个整体可用性的下限就被拉低一分。HealthReport.Entry里还带Description和Duration描述信息可以在自定义检查时从HealthCheckResult里传入耗时是框架自动统计的导出日志或者做大盘时很好用。2.3 一次健康检查的执行流程把上面的角色串起来一次完整的健康检查流程是这样的外部探针K8s、Docker、负载均衡请求/health端点。端点路由找到MapHealthChecks注册的处理器。处理器调用容器里的HealthCheckService.CheckHealthAsync。服务遍历所有注册的检查项按Predicate过滤器筛掉不参与本次执行的项。剩余检查项并行执行每个项独立跑自己的CheckHealthAsync。所有检查项返回后框架聚合HealthReport取最差 Status 作为总体状态。根据配置的ResultStatusCodes返回 200 或 503。如果有自定义ResponseWriter在返回前插入写响应体的逻辑。这里最容易忽略的是第 4 步的Predicate。它就是你部署双端点方案存活探针只看基础项、就绪探针看全量依赖的核心机制。后面第四节我会专门细说。3. 从零接入最小可用的四步操作3.1 最简注册与端点暴露最小可用方案只需要三行代码以 .NET 8 的 minimal API 写法为例var builder WebApplication.CreateBuilder(args); builder.Services.AddHealthChecks(); var app builder.Build(); app.MapHealthChecks(/health); app.Run();启动服务后请求GET /health默认返回一个纯文本内容HealthyHTTP 状态码 200。这就已经可以被容器平台的 HTTP 探针消费了。如果你还在用 .NET Core 3.1 或者 5.0老写法是app.UseHealthChecks(/health)功能等价只是路由注册机制不同。迁移到 6.0 时记得把中间件写法换成端点路由写法否则在新框架下虽然还能跑但和路由系统融合得不好。3.2 添加数据库检查最简方案只代表“进程活着”离“应用健康”还很远。绝大多数业务系统第一个要加的就是数据库检查。原生框架里做数据库检查有两条路如果你用 EF Core直接加原生支持的AddDbContextCheckT这是官方内置的路径是Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore包builder.Services.AddDbContextOrderDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Orders))); builder.Services.AddHealthChecks() .AddDbContextCheckOrderDbContext(orders_db);这个检查项背后做的事情是打开上下文执行一个简单的查询来验证数据库连接和读写能力。实现原理是调用context.Database.CanConnectAsync()能连上就算 Healthy。如果你不用 EF Core而是用 Dapper 或者原生 ADO.NET原生包没提供直接的AddSqlServer这时候可以选择引入社区维护的AspNetCore.HealthChecks.SqlServer包或者自己写检查项。我的建议是如果项目里就为了健康检查引一个社区包先掂量一下——你只是要验证“数据库能连”一个几十行的自定义检查完全够用没必要把整个包引进来后面升级还多一个兼容性负担。3.3 自定义检查项的两种写法框架支持两种自定义方式委托写法和实现IHealthCheck的类写法。委托写法适合简单场景注册的时候直接传 lambdabuilder.Services.AddHealthChecks() .AddCheck(memory, () { var info GC.GetGCMemoryInfo(); var usedPercent info.MemoryInUseBytes * 1.0 / info.TotalAvailableMemoryBytes; return usedPercent 0.8 ? HealthCheckResult.Unhealthy(内存占用超过 80%) : HealthCheckResult.Healthy($内存占用 {usedPercent:P}); });类写法适合复杂场景也方便做单元测试。我项目里的磁盘空间检查就是这么写的public class DiskSpaceHealthCheck : IHealthCheck { private readonly string _path; private readonly double _warningThreshold; private readonly double _failThreshold; public DiskSpaceHealthCheck(string path, double warningThreshold, double failThreshold) { _path path; _warningThreshold warningThreshold; _failThreshold failThreshold; } public TaskHealthCheckResult CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken default) { var drive new DriveInfo(_path); var freePercent drive.AvailableFreeSpace * 1.0 / drive.TotalSize; var status freePercent _failThreshold ? HealthStatus.Unhealthy : freePercent _warningThreshold ? HealthStatus.Degraded : HealthStatus.Healthy; var result new HealthCheckResult( status, description: $磁盘剩余 {freePercent:P}路径 {_path}); return Task.FromResult(result); } }注册的时候通过AddCheckT带泛型的方式注册builder.Services.AddHealthChecks() .AddCheckDiskSpaceHealthCheck(disk_root, failureStatus: HealthStatus.Unhealthy, tags: new[] { self }, timeout: TimeSpan.FromSeconds(5));这里特别说一下failureStatus这个参数。它定义的是“当这个检查项抛异常时算作什么状态”。默认值是Unhealthy但我见过有人把一些非关键检查配成Degraded这样依赖抖动不会直接拉低整个服务的可用状态。这种方式对“弱依赖”场景很实用——缓存挂了服务还能降级运行就不该让它变成 503。3.4 分组暴露多个端点生产环境里大部分团队需要的不止一个探针端点。Kubernetes 的标准做法是存活探针和服务就绪探针分开存活探针只管进程要不要重启就绪探针管流量要不要切进来。这两个探针的检查范围是明显不同的。原生方案通过Predicate解决这个问题。注册检查项时打上tags标签暴露端点时用Predicate过滤builder.Services.AddHealthChecks() .AddCheckDiskSpaceHealthCheck(disk_root, tags: new[] { self }) .AddDbContextCheckOrderDbContext(orders_db, tags: new[] { ready }) .AddCheck(message_queue, checkQueueHealth, tags: new[] { ready }); var app builder.Build(); app.MapHealthChecks(/health/live, new HealthCheckOptions { Predicate _ false }); app.MapHealthChecks(/health/ready, new HealthCheckOptions { Predicate check check.Tags.Contains(ready) });注意存活探针那个端点我用的是_ false意思是“这个端点不执行任何检查项只证明进程还活着”。这其实是微软文档明确推荐的存活探针写法——进程能响应 HTTP 请求本身就是存活的充分证明没必要在存活探针里塞复杂逻辑。逻辑越重越容易在 CPU 尖峰或线程池饥饿时误判造成不必要的重启。4. 再进一步响应内容、配置项与容器集成4.1 自定义响应输出默认的响应就是一段纯文本Healthy、Degraded或Unhealthy对机器消费是够了但对排查问题不够爽。我习惯把所有检查项的名称、状态、耗时和描述一起输出成 JSON这样在 K8s 里kubectl exec curl /health/ready一眼就能看出是哪个依赖挂了。通过HealthCheckOptions.ResponseWriter自定义写响应app.MapHealthChecks(/health/ready, new HealthCheckOptions { Predicate check check.Tags.Contains(ready), ResponseWriter WriteHealthReport }); static Task WriteHealthReport(HttpContext context, HealthReport report) { var payload new { status report.Status.ToString(), timestamp DateTime.UtcNow, durationMs report.TotalDuration.TotalMilliseconds, checks report.Entries.Select(e new { name e.Key, status e.Value.Status.ToString(), description e.Value.Description, durationMs e.Value.Duration.TotalMilliseconds }) }; context.Response.ContentType application/json; charsetutf-8; return context.Response.WriteAsync(JsonSerializer.Serialize(payload)); }这里有个性能细节健康检查端点本身就是给探针高频调用的每个请求都做一次JsonSerializer.Serialize也没问题但不要在响应时间里做太多额外 IO。我见过有人在ResponseWriter里接了一个写日志的异步方法结果日志系统抖动时健康检查也跟着变慢探针超时误报属于典型的自己给自己挖坑。4.2 超时、缓存与并发控制健康检查有两层时间问题需要分开理解一是每个检查项自身的Timeout二是探针访问端点时 HTTP 层的等待时间。检查项超时在注册时通过 timeSpan 参数指定builder.Services.AddHealthChecks() .AddCheckExternalApiHealthCheck(payment_gateway, timeout: TimeSpan.FromSeconds(3));这个超时是在HealthCheckContext里通过CancellationToken传给检查项的。一个检查如果超时框架会把它标记为Unhealthy并把Description写成超时相关信息。所以设计检查项时内部逻辑要尊重context.CancellationToken——比如查数据库时把 token 传给命令而不是自顾自地执行完。第二层问题是并发。默认情况下HealthCheckService会把所有检查项并行执行。这在大检查项数量很多时会造成资源尖峰。比如你有 20 个检查项每个都去查一次数据库探针一打进来数据库瞬间多 20 条连接。控制并发的直接思路是减少检查项、合并同源检查或者用信号量在自定义检查内部做并发限制。如果你需要控制检查项的总体耗时还应该关注中间的HealthCheckOptions有没有配置AllowCachingResponses。这个选项和IHealthCheckPublisher相关默认它不直接缓存探针请求的结果。也就是说探针每一次打到端点都会真实地重新执行一遍所有检查项。这意味着你的检查项必须都是轻量的重检查应该放到后台服务里做状态更新探针只读最近一次的状态——这就是进阶玩法里状态缓存模式的核心思想。4.3 Docker HEALTHCHECK 与 K8s 就绪/存活探针容器平台集成是健康检查最主流的消费场景。Docker 的HEALTHCHECK指令可以直接探测 ASP.NET Core 的端点FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY --frombuild /app/out . EXPOSE 8080 HEALTHCHECK --interval30s --timeout5s --start-period15s --retries3 \ CMD [dotnet, HealthDemo.dll, --healthcheck]start-period很重要。应用启动初期数据库上下文预热、配置加载都可能超过正常的健康检查耗时探针过早打入会被误判失败。--retries3配合--interval30s意味着容器真正判定 Unhealthy 需要最长 90 多秒给足了应用抖动恢复的空间。Kubernetes 的配置更常见存活和就绪分开两个探针livenessProbe: httpGet: path: /health/live port: 8080 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3有个细节值得注意就绪探针的initialDelaySeconds通常应该比存活探针长一点因为应用启动初期依赖连接可能还没预热完成过早开始就绪探测会让服务一直处于未就绪状态在部署时表现为滚动更新被卡住。5. 常见问题与排查技巧实录5.1 数据库检查飘红但业务正常这个场景我至少遇到过三次EF Core 的AddDbContextCheckT在某些连接字符串配置下会偶发超时或连接受限导致/health/ready返回 503但实际业务请求是正常的。排查思路是先看Duration——如果数据库检查项的耗时长期贴着超时阈值走那问题很可能出在连接池不够或者网络链路上而不是数据库本身挂了。还有一个容易被忽略的原因EF Core 的连接字符串里如果没有设置Connect Timeout默认可能很长健康检查超时却是死的。结果就是数据库已经连接困难健康检查一直在超时边缘试探。我在自定义数据库检查里会专门用SqlConnectionStringBuilder设置一个较短的Connect Timeout比如 3 秒并配合检查项自身的Timeout一起约束public class SqlConnectionHealthCheck : IHealthCheck { private readonly string _connectionString; public SqlConnectionHealthCheck(IConfiguration configuration) { var builder new SqlConnectionStringBuilder( configuration.GetConnectionString(Default)); builder.ConnectTimeout 3; _connectionString builder.ConnectionString; } public async TaskHealthCheckResult CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken) { try { await using var connection new SqlConnection(_connectionString); await connection.OpenAsync(cancellationToken); return HealthCheckResult.Healthy(); } catch (Exception ex) { return HealthCheckResult.Unhealthy(ex.Message); } } }5.2 健康检查端点导致 CPU 尖刺健康检查端点本身很轻量但如果你把大量检查项塞进探针请求链路并且每个检查项都做网络 IO那么探针周期一到整个服务的 CPU 和网络都会出现周期性尖刺。尤其在 K8s 集群里所有 Pod 的探针周期天然是同步的每隔 10 秒一波全量检查恰好形成“探针风暴”。我的处理办法是分级探针链路里只保留“纯本地、无网络”的检查项进程状态、内存、线程池队列长度所有依赖外部服务的检查项交给后台的IHealthCheckPublisher定期执行结果写入内存缓存有单独的诊断端点提供详情。5.3 发布器发布卡住IHealthCheckPublisher是原生框架里一个容易被忽略的扩展点。它的作用是把健康检查结果定期发布到外部系统比如写入 Prometheus 或者自定义的监控平台。通过HealthCheckPublisherOptions配置执行周期builder.Services.ConfigureHealthCheckPublisherOptions(options { options.Period TimeSpan.FromSeconds(30); options.Predicate check check.Name ready; options.Delay TimeSpan.FromSeconds(30); });踩坑点在于发布器是在后台的HealthCheckPublisherHostedService里执行的它默认串行执行同一个周期内所有发布器排队跑。如果其中某个发布器代码里有一个同步阻塞调用比如.Result或者.Wait()整个管道的后续发布器都会被拖住且看不到任何异常——因为后台服务吞掉了异常。5.4 探针与优雅停机冲突这个坑在容器平台下特别突出。默认情况下K8s 向 Pod 发送SIGTERM后ASP.NET Core 会触发优雅停机流程此时 HTTP 端点还在监听但应用已经开始释放资源。如果就绪探针在这个窗口内打进来可能正好赶上数据库连接池被释放返回 503——而平台收到 503 反而会认为应用未就绪影响滚动更新的完成判断。正确思路是在应用收到SIGTERM的第一时间让就绪探针端点主动返回失败同时保持存活探针返回成功。这样平台会先把流量摘掉再等进程退出。原生框架没有直接提供这个“停机即不健康”的开箱功能但可以通过IApplicationLifetime的ApplicationStopping事件配合一个内存标志实现public sealed class ShutdownHealthCheck : IHealthCheck { private volatile bool _stopping; public void BeginShutdown() _stopping true; public TaskHealthCheckResult CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken default) { var status _stopping ? HealthStatus.Unhealthy : HealthStatus.Healthy; return Task.FromResult(new HealthCheckResult( status, description: _stopping ? 应用正在停机 : 运行正常)); } }在Program.cs中注入事件var shutdownCheck new ShutdownHealthCheck(); builder.Services.AddSingleton(shutdownCheck); builder.Services.AddHealthChecks() .AddCheck(shutdown, shutdownCheck, tags: new[] { ready });然后在ApplicationStopping里触发BeginShutdown()。这点看起来很小但在生产环境的滚动发布中能避免很多“发布卡住”的诡异问题。6. 从存活到业务可用进阶玩法扩展6.1 自检接口与关键链路拨测健康检查做到“依赖可用”还不够上一层是“关键链路可用”。我有一个服务健康检查全部通过但核心下单链路因为某个配置项错误导致接口 500。探针不关心业务接口所以这类问题探针永远发现不了。解决思路是加一层“拨测型”检查选择一两个最关键、最轻量的真实业务接口在检查项里用 HTTP 客户端请求验证返回码和关键字段。注意控制超时和频率并在拨测接口上做好幂等和限流避免健康检查把自己的业务接口打挂了。6.2 健康数据发布与大盘展示健康检查结果默认只能通过 HTTP 端点被动消费但排查“服务为什么被重启”时你往往需要看历史趋势——过去两小时里数据库检查项失败了多少次、耗时均值是多少。这时候就能用上IHealthCheckPublisher。实现一个简单的发布器写入内存环形缓冲暴露一个诊断端点不需要额外引入存储public sealed class HealthHistoryPublisher : IHealthCheckPublisher { private readonly RingBufferHealthReportSnapshot _history; public HealthHistoryPublisher(RingBufferHealthReportSnapshot history) { _history history; } public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) { var snapshot new HealthReportSnapshot(DateTime.UtcNow, report.Status); _history.Add(snapshot); return Task.CompletedTask; } }如果团队已经有 Prometheus也可以直接发布成 metrics 格式把HealthStatus映射成数值方便配置告警规则。6.3 双探针 分级检查策略最后把前面所有内容整合成一套完整的实践策略。我目前在项目里的标准模板是三个端点端点用途检查范围失败后果/health/live存活探针不执行任何业务检查K8s 重启容器/health/ready就绪探针数据库、消息队列、关键外部依赖摘流量不重启/health/detail人工诊断全量检查项含自定义响应仅供开发排查检查项分级遵循两个原则第一能放进存活探针的一定是“本地、秒级、无网络”的检查第二能放进就绪探针的一定是“启动时间敏感、对外部依赖有硬依赖”的检查。像磁盘空间这种偏运维的指标我既不放存活也不放就绪只在诊断端点里展示——因为它适合触发告警但不适合触发容器重启。这套策略跑了大半年最直接的收益是减少了大量“无谓重启”。以前把数据库检查放在存活探针里数据库做日常维护重启时所有实例被 K8s 批量重启整个服务不可用。拆开之后就完全避免了这个连锁反应。最后分享一个我在实践中非常受用的习惯每次改健康检查配置不要只在本地curl一下 200 就完事。把服务部署到测试环境后手动停掉其中一个依赖比如把数据库容器暂停观察探针端点是否如预期返回 503、容器平台是否如预期摘流量。这个操作只要重复三次以上你对这套机制的理解会远超过读十遍文档也能提前发现第五、六章里那些只有在真实故障中才会暴露的边界问题。