
1. 内容整体设计与思路拆解先问自己一个问题你的服务到底“活着”吗很多开发者对健康检查的第一反应是“探活”以为只要进程没挂、端口能通服务就是健康的。但真实场景根本不是这么回事。我见过太多系统进程活着、端口通着但实际业务已经跑不动了——数据库连接池耗尽、消息队列堆积、缓存节点失联、依赖的下游API超时……这些故障不会让进程退出也不会让端口关闭但你的服务对外已经是个半死状态。负载均衡器还在傻乎乎地往这个节点转发流量结果就是用户的请求全部超时、报错整个系统被拖垮。健康检查端点Health Check Endpoint就是为了解决这个问题而生的。它不只是告诉你“进程在不在”而是主动去验证“这个服务有没有能力处理请求”。在.NET Core Web中它通过暴露一个HTTP端点默认通常是/health或/healthz让外部系统、容器编排器、负载均衡器以极低的成本来问服务一句“你现在行不行”我记得最早在做微服务拆分的时候服务数量从3个涨到十几个每次上线最头疼的不是写业务代码而是运维那边反复问“这个服务怎么判断它好了”。光靠端口探测根本不靠谱因为服务起来之后要等数据库连接、要预加载缓存、要注册到服务发现中心这一整套就绪流程可能要花好几秒。端口通了不代表服务就绪了。后来我们把健康检查端点系统性地引入这些问题一下子清爽了。这篇内容适合谁看我觉得三类人值得认真读一下第一正在做微服务或者容器化部署需要接入Kubernetes探针或者负载均衡健康检查的开发者第二被线上“假死”服务坑过想通过系统化方式彻底解决探活问题的运维或全栈工程师第三刚开始学.NET Core想理解框架里这些看似不起眼但极其重要的内置能力的朋友。这文章会从原理讲起一步步带你实现一个带真实业务判断的健康检查端点。2. 核心细节解析与实操要点2.1 健康检查不只是探活它有三种粒度.NET Core的健康检查体系其实分三个层次。搞懂这个后面做设计才不会跑偏。第一层是进程级Liveness。这层只关心进程是不是活着通常由基础设施来探。进程一旦僵死或者被OOM杀掉探针就会失败编排器会替你重启容器。这一层的探针频率比较高但检查内容极其轻量用框架默认的AddCheck加一个返回Healthy的检查就够了。注意进程级探针绝对不应该去连数据库、查外部依赖因为当你真的依赖不健康时Kubernetes会基于全节点的Liveness情况决定是否重启整个容器导致集群雪崩。Liveness的哲学是“别管那么多活着就好”。第二层是就绪级Readiness。服务启动后可能要初始化一堆东西连数据库、加载静态配置、预热内存缓存、注册到服务发现。这个时候进程虽然活着但还没准备好接收流量。Readiness探针就要承担这个“准备好了吗”的职责。最典型的实现是检查关键依赖是否可用比如查询一次数据库、检查Redis是否能PING通。就绪探针失败正常响应是让负载均衡或者K8s Service把流量摘掉但容器本身不会被重启。这层是真正会把业务“卡住”的地方设计时一定要小心别因为探针写得过于苛刻而误杀。第三层是自定义业务健康级。这是最高级别的检查用来判断核心业务路径是否健康。比如一个订单服务关键业务是写出库单、扣库存那健康检查里就应该模拟一次“订单号用户ID”的极小事务操作验证写库和读缓存的主链路是否正常。再比如一个推荐服务核心是召回那健康检查里可以跑一次小规模的召回如果召回结果为空且伴随上游超时就判定为不健康。这一层通常不会被基础设施直接用而是供网关、监控看板、SRE团队去判断是否需要切流或人工介入。2.2 框架默认的两种健康检查模式.NET Core抽象了IHealthCheck接口默认实现有好几种但最常用的是两种AddCheck和AddUrlGroup。AddCheck可以注册一个自定义的检查项后面我们会专门讲怎么写自定义检查。AddUrlGroup则是直接去请求一个外部URL如果返回2xx就认为健康否则不健康。这个在检查下游HTTP服务的时候非常方便比如订单服务要依赖用户服务那就可以用AddUrlGroup(https://user-service/api/ping)作为其中一个子检查。注意AddUrlGroup会阻塞等待响应所以一定要设置超时否则探针自身会变成慢查询把整个端点拖垮。还有一个很容易忽略的类是HealthCheckService它允许你在任意地方手动触发健康检查而不一定是通过HTTP端点。比如在后台任务里定期检查依赖状态或者把检查结果推送到监控系统。这个在实际项目中用处很大我们后面会讲一个具体场景。2.3 “分组检查”是生产环境的标配刚刚开始的阶段可能会把所有检查都塞到同一个端点里反正只用一个/health。但这在复杂系统中并不合适。想象一下某个依赖Redis抖动而你用的是同一个端点做Liveness和ReadinessK8s可能会同时判断你的Pod“不健康”然后把你重启。但其实你的Java逻辑还活得挺好只是Redis暂时慢了。这就导致一个缓存抖动引起全节点循环重启系统雪崩。解决办法就是分组。.NET Core从某个版本开始支持HealthCheckOptions里的Predicate你可以根据注册时的Tag来区分哪组检查对应哪个端点。比如注册的时候给数据库检查打上database标签给Redis检查打上cache标签然后配置/health/live只看进程活着只包含live标签的检查项/health/ready包含数据库、缓存、下游API等所有关键依赖/health/startup只包含启动时依赖的检查这样负载均衡和编排器各取所需互不干扰。探Liveness的看/health/live探Readiness的看/health/ready。分组这事听起来简单但设计得不好就会在故障时不断互相打架实际经验里吃过不少亏。3. 实操过程与核心环节实现3.1 最小可运行版本一分钟搭出健康端点首先创建一个普通的ASP.NET Core Web API项目目标框架我们选.NET 8.NET 6/7同理。然后在Program.cs里加上健康检查中间件。var builder WebApplication.CreateBuilder(args); // 注册健康检查服务默认不添加任何检查项 builder.Services.AddHealthChecks(); var app builder.Build(); // 暴露 /health 端点返回纯文本 app.MapHealthChecks(/health); app.Run();就这么简单跑起来之后访问/health返回的是Healthy字符串HTTP状态码200。注意默认的MapHealthChecks返回的内容是精简的Healthy或Unhealthy状态码分别是200和503。这个默认行为其实很合理对基础设施来说只需要知道一个二进制状态不需要看长篇JSON越简单越不容易出错。但你会发现这个最小版本其实什么都没检查。它只是告诉外部“我这个进程还在”没有任何业务判断。但这已经比单纯的TCP端口探测好得多因为它至少确认了HTTP管线是通的、中间件都正常加载了。3.2 真正能“做事”的健康检查添加自定义检查接下来我们实现一个真正会去查数据库的健康检查。假设我们有个模拟的仓储服务它的健康取决于能否连上数据库。添加Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore包是不需要的我们用原生方式写一个自定义检查类。先定义检查逻辑public class DatabaseHealthCheck : IHealthCheck { private readonly IDbConnection _connection; public DatabaseHealthCheck(IDbConnection connection) { _connection connection; } public async TaskHealthCheckResult CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken default) { try { // 打开连接并执行一次最简单的查询 if (_connection.State ! ConnectionState.Open) { 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); } } }这里有几个细节必须提一下。第一数据库连接需要在每次检查时重新尝试打开不要复用长期连接因为连接可能早已断开而.NET的连接池是透明的你直接复用可能拿到的是一条坏连接。第二超时很重要。OpenAsync默认可能会阻塞很长时间所以务必要传入cancellationToken并且在依赖注入时配置超时。我们建议在调用方通过CancellationTokenSource控制最长为3秒超过就认为是失败。第三SELECT 1虽然简单但已经能验证连接可用性。不要随便执行一条重量级SQL作为健康检查那样会白白增加数据库压力。然后在Program.cs里注册这个检查builder.Services.AddScopedDatabaseHealthCheck(); builder.Services.AddHealthChecks() .AddCheckDatabaseHealthCheck(database, tags: new[] { readiness, database });这里我用了AddScoped因为DatabaseHealthCheck内部依赖IDbConnection而连接通常是作用域生命周期的。但要注意健康检查本身是短生命周期的对象每次请求都会创建一个新的实例性能无所谓但别泄露资源记得及时处理连接。3.3 配置分组健康端点变身就绪和存活现在把分组能力用上。修改一下Program.csbuilder.Services.AddHealthChecks() .AddCheckDatabaseHealthCheck(database, tags: new[] { readiness, database }) .AddCheckRedisHealthCheck(redis, tags: new[] { readiness, cache }) .AddCheckDiskSpaceHealthCheck(disk, tags: new[] { live }); app.MapHealthChecks(/health/live, new HealthCheckOptions { // 只执行带 live 标签的检查项 Predicate reg reg.Tags.Contains(live) }); app.MapHealthChecks(/health/ready, new HealthCheckOptions { Predicate reg reg.Tags.Contains(readiness) });这样/health/live只会执行磁盘空间检查因为它只关心进程是否还能运行而/health/ready会执行数据库和Redis检查因为这两个是“准备好接收流量”的必要条件。注意一下读取磁盘空间或者内存使用的健康检查可以放在Liveness分组里因为这些资源一旦耗尽进程可能很快就不行了需要重启来释放资源。但数据库检查绝对不能放在Liveness里因为数据库出问题通常不是重启应用就能解决的。这个原则在容器编排环境里尤其重要。3.4 让前端看得懂返回结构化结果默认的HealthCheckOptions返回纯文本但很多监控面板和网关更喜欢JSON我们要把响应体改成JSON同时控制返回的状态码。app.MapHealthChecks(/health/detail, new HealthCheckOptions { ResponseWriter async (context, report) { context.Response.ContentType application/json; charsetutf-8; var response new { status report.Status.ToString(), durationMs (report.TotalDuration.TotalMilliseconds).ToString(0.00), entries report.Entries.ToDictionary( e e.Key, e new { status e.Value.Status.ToString(), description e.Value.Description, error e.Value.Exception?.Message, duration e.Value.Duration.TotalMilliseconds.ToString(0.00) }) }; await System.Text.Json.JsonSerializer.SerializeAsync( context.Response.Body, response); } });这一步要注意的是report.TotalDuration是自检查开始到完成的总耗时能帮你看出哪个检查拖慢了整体。另一个细节是如果某个检查项抛了异常序列化时会包含Exception信息这个在内部监控很有用但如果暴露给公网可能会泄露内部信息。所以建议在公网暴露的端点不要输出异常详情只有内网或管理端点才完整展示。3.5 接入Kubernetes探针的实战配置如果你用的是Kubernetes那健康检查端点最常见的用途就是配置livenessProbe和readinessProbe。下面是yaml片段apiVersion: apps/v1 kind: Deployment metadata: name: sample-api spec: replicas: 3 selector: matchLabels: app: sample-api template: metadata: labels: app: sample-api spec: containers: - name: api image: myregistry/sample-api:latest ports: - containerPort: 8080 livenessProbe: httpGet: path: /health/live port: 8080 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3关键参数解释一下。initialDelaySeconds是容器启动后等多少秒才开始第一次探活这个要稍微大于服务的启动时间否则服务还在初始化就被探针打死了。periodSeconds是探测间隔生产环境一般10秒一次太频繁会增加不必要的压力。timeoutSeconds设为3秒比较合理如果探活请求超过3秒就视为失败以免探针自身拖慢请求。failureThreshold是连续失败几次才会把容器标记为不健康一般设3次防止偶发抖动引起的误杀。有一个很多人踩过的坑readinessProbe探测路径如果和livenessProbe的路径配置成同一个那就失去了分离的意义。一定要两个端点分组清晰。还有的团队把/health同时给两个探针用结果数据库一抖动所有Pod都被重启完全没有起到“剔除流量但不重启”的缓冲作用。我再强调一次liveness探针只探“活”readiness探针才探“准备好了”。3.6 用AddUrlGroup检查下游依赖如果你有多个外部HTTP依赖可以用AddUrlGroup来做最快速的连通性检查。builder.Services.AddHealthChecks() .AddUrlGroup( new Uri(https://user-service.example.com/api/ping), name: user-service, tags: new[] { readiness }) .AddUrlGroup( new Uri(https://payment-service.example.com/status), name: payment-service, tags: new[] { readiness });默认情况下AddUrlGroup会检查HTTP状态码是否在200-299这已经够用。但要注意它使用的是HttpClient而默认的HttpClient可能没有设置超时所以最好通过IHttpClientFactory去设置超时时间。推荐写法是单独注册一个命名的HttpClient用于健康检查超时设为3秒。另外探活下游服务的路径不要用业务接口要找那种成本极低的ping接口或者健康端点。如果你的下游服务也有/health直接拿来用就行。有些服务没有专门的ping接口可能连个根路径都会返回404但只要404也算200-499里的一个状态那AddUrlGroup默认会把它识别为失败因为它只看2xx。所以你要么配置一个真正的ping要么写自定义检查来处理。实际项目里我就见过因为下游接口返回404导致整个服务被判不健康的情况排查半天才发现是探活路径写错了。4. 常见问题与排查技巧实录4.1 探针一直返回503但服务明明能用这种情况最常见的原因是探针检查了某个依赖而那个依赖恰好不可用。你要在/health/detail这种详细端点里看具体是哪一项失败。不要只看/health/ready返回503就去重启Pod先看看entries里的每一项状态。如果真的只是Redis不可用而你其实是可以降级运行的那就不应该把它放在readiness分组里而是放到custom分组里让监控看不阻塞流量。排查的时候有个小技巧用curl -i直接请求探针端点看返回头和响应体。按我的经验90%的探针问题都能从响应体的entries字段看出原因比如发现某个检查抛出异常“Unable to connect to Redis”那基本就是Redis连接串写错了或Redis实例挂了。4.2 健康检查本身导致服务性能下降有的团队把健康检查做得很重比如每次检查都要执行一次完整的数据库迁移脚本或者复杂的聚合查询。这就本末倒置了。健康检查是为了验证服务可用性不是做压力测试。你要控制每个检查的超时时间还要控制总检查时间。我当时给自己定的规矩是每个健康检查的总耗时必须控制在100毫秒以内服务器上的实际平均耗时大多在10-30毫秒。如果发现某个检查超过1秒那一定是设计有问题不是数据库慢就是查询太重。还有一点不要在高频访问下做写操作类检查比如检查时去写一条日志、更新一次心跳表。这样会把无关的写入压力打到数据库上。除非你的业务场景允许否则尽量用只读检查。4.3 健康检查端点被扫爆怎么办健康检查端点是公网可访问的一旦暴露出去就会遭遇各种扫描。虽然它没有敏感数据但也会消耗你的资源。建议做法是将健康检查端点仅绑定到内部端口或者由反向代理挂在内网路由不暴露到公网。比如可以单独监听localhost:8080然后只允许内网访问。或者在中间件层面加IP白名单。另外一个更稳妥的做法是给健康检查端点加一个自定义的鉴权头比如内部约定一个X-Health-Token反向代理在转发请求时把这个头带上直接请求没有这个头就返回404。这样扫到的人什么也得不到。4.4 探针被“重试风暴”搞垮这是我在一个真实场景里踩过的大坑。当时某个核心依赖挂了/health/ready开始返回503。负载均衡器检测到之后为了寻找健康节点会在很短时间内疯狂重试每个失败的节点导致所有节点的探针端点被打满CPU飙升。而其他正常的业务请求也开始变慢最后形成恶性循环。解决办法有两个第一是让负载均衡器的重试间隔变长增加failureThreshold。第二是给健康检查端点本身加一个简单的并发控制比如基于信号量限制同一时刻最多有多少个健康检查请求在执行多余的请求直接返回503或429。这个思路虽然简单但非常有效。// 定义一个静态信号量控制同时执行的健康检查数量 private static readonly SemaphoreSlim HealthCheckGate new(1, 1); app.MapHealthChecks(/health/ready, async context { if (!HealthCheckGate.Wait(0)) { context.Response.StatusCode StatusCodes.Status503ServiceUnavailable; return; } try { var result await context.RequestServices .GetRequiredServiceHealthCheckService() .CheckHealthAsync(options options.Predicate reg reg.Tags.Contains(readiness)); // 根据 result.Status 返回200或503 } finally { HealthCheckGate.Release(); } });这里用信号量把并发压到1探针请求就不会因为拥堵而互相干扰。生产环境可以放宽到2或3但绝不能无限制并发。顺带提一句健康检查的响应速度对集群的稳定影响极大。你的探针如果每次都耗时500毫秒而timeoutSeconds设了3秒那三个Pod一起失败感觉不到问题但如果你有50个Pod探针全部超时所有Pod都被标记为不健康流量全被摘除业务直接原地爆炸。这种故障一定要防。4.5 如何在容器启动时进行“启动就绪”等待很多服务启动过程需要几秒甚至十几秒但K8s默认的initialDelaySeconds只有几秒很容易在启动阶段就被打挂。一种稳妥的方案是使用启动探针startupProbe它会先执行一遍如果成功就停止之后才轮到Liveness和Readiness。你可以把启动探针指向一个特殊端点比如/health/startup这个端点里只注册启动所需的检查项比如数据库连接和配置加载。startupProbe: httpGet: path: /health/startup port: 8080 failureThreshold: 30 periodSeconds: 2这个配置的意思是如果启动探针连续失败30次每次间隔2秒才认为容器启动失败并重启。这样你的应用有60秒的时间来初始化能应对绝大多数启动场景。一旦启动探针成功K8s就会开始Liveness和Readiness的正常探测。4.6 检查结果缓存防止高频探活拖垮依赖如果periodSeconds设置很短比如1秒而你的健康检查每次都查数据库那么数据库每秒都会收到一次探针查询。如果服务有10个副本就是每秒10个探针查询。对于数据库来说虽然不算大但对于一些昂贵的检查比如检查外部API就不是小开销了。这时候可以做一个简单的缓存把上一次的检查结果缓存起来如果在设定的缓存窗口内比如5秒再次收到探针请求就直接返回缓存结果而不重新执行检查。用MemoryCache就行。public class CachedHealthCheck : IHealthCheck { private readonly IMemoryCache _cache; private readonly IHealthCheck _inner; public CachedHealthCheck(IMemoryCache cache, IHealthCheck inner) { _cache cache; _inner inner; } public async TaskHealthCheckResult CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken default) { const string cacheKey $custom-health-{context.Registration.Name}; if (_cache.TryGetValue(cacheKey, out HealthCheckResult cached)) { return cached; } var result await _inner.CheckHealthAsync(context, cancellationToken); var cacheEntryOptions new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromSeconds(5) }; _cache.Set(cacheKey, result, cacheEntryOptions); return result; } }注意缓存时间不要设得太长否则会失去实时性。一般5-10秒都算合理具体取决于你的业务敏感度。4.7 健康检查端点响应状态码的选择默认情况下MapHealthChecks在健康时返回200不健康时返回503。这个设计是符合多数监控系统的约定的。但如果你用的负载均衡只认200其他都判为失败那503也是正确的。还有的网关期望500但这种情况较少见。如果你自定义响应一定要保持标准健康200不健康503超时或错误503。不要自作聪明地返回其他状态码否则监控系统可能无法正确识别。我曾经见过有人把不健康设计成204结果监控那边完全不识别导致故障没被发现。这种低级错误在真实项目中并不鲜见。5. 实战扩展健康检查的更多应用场景5.1 在后台任务里主动检查依赖健康检查端点是被动应答机制但有些场景需要我们主动监测依赖状态。比如做一个定时任务每30秒检查一次所有下游依赖如果发现某个依赖连续三次不健康就向团队即时通信工具推送告警。用.NET Core的BackgroundService非常方便。public class DependencyMonitor : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerDependencyMonitor _logger; public DependencyMonitor(IServiceScopeFactory scopeFactory, ILoggerDependencyMonitor logger) { _scopeFactory scopeFactory; _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using var scope _scopeFactory.CreateScope(); var healthCheckService scope.ServiceProvider.GetRequiredServiceHealthCheckService(); // 只检查部分关键项而不是全部 var report await healthCheckService.CheckHealthAsync( options options.Predicate reg reg.Tags.Contains(critical), stoppingToken); if (report.Status HealthStatus.Unhealthy) { _logger.LogWarning(关键依赖不健康: {Entries}, string.Join(;, report.Entries .Where(e e.Value.Status ! HealthStatus.Healthy) .Select(e ${e.Key}:{e.Value.Status}))); } await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } } }这种模式很适合做主动告警而不必等外部探活来打你的时候才发现问题。同时后台任务里可以用更长的超时和更全面的检查逻辑不用像探针那样挤压性能。5.2 与网关结合做动态切流在一些高可用架构里API网关会定期拉取每个后端的健康状态然后动态调整路由权重。如果你的服务健康检查能返回详细的“依赖异常”信息网关就能据此把流量切换到其他区域或降级方案上。做法很简单给网关暴露一个/health/router端点它返回的内容不只是Healthy/Unhealthy还包含每个依赖的状态。网关拿到之后如果发现核心依赖不正常就把该节点标记为“降级”将流量部分切走。这个比单纯靠负载均衡的503剔除更精细。5.3 结合监控指标量化服务稳定性健康检查的结果也可以作为Prometheus指标暴露。比如注册一个简单的计数器统计健康检查的成功/失败次数。这样在你分析某个时期服务稳定性时可以从历史指标里回看当时探针的状态而不需要依赖事后翻日志。不过要注意这个计数器本身不要放在健康检查链路里否则会引入不必要的性能损耗。6. 踩坑汇总与个人经验最后分享几个我这些年反复踩到的坑。第一个坑把健康检查做成“心理安慰”。有些人只是加了AddHealthChecks()里面什么都没配以为这样就行了。但这样做的探活效果跟TCP端口探测几乎没有区别既不能发现依赖问题也不能避免假死。健康检查一定要纳入你要依赖的关键组件。第二个坑健康检查里输出太多详情。给内网看可以但公网暴露的端点如果带上异常堆栈和数据库连接串信息那就是一个巨大的安全漏洞。实际上我见过有人把connectionString写到响应体里幸好只是内部测试环境不然后果不堪设想。生产环境的公网端点只返回Healthy或Unhealthy就好。第三个坑探针路径命名不一致。比如有的用/api/health有的用/health有的用/healthz部署的时候全靠人肉记忆非常容易在K8s配置里写错路径。建议团队里定一个规范存活探针/health/live就绪探针/health/ready详情探针/health/detail。统一路径降低出错概率。第四个坑健康检查不可用时反而把故障放大。很多中间件和框架本身就有故障时的兜底逻辑比如熔断降级。如果你在健康检查里把某个依赖判为不健康然后网关把整个服务摘除可能本来还能降级服务的流量一下子全没了反而造成更大的故障。健康检查的作用是发现问题但如何处理问题要非常小心。我的经验是Liveness探针宁可保守一些Readiness探针也要分级把“能降级继续服务”的判断放到业务层而不是一刀切。说实话健康检查端点这个功能虽然看着简单属于“基础工程”但真正把它用对、用透真的能避免很多线上事故。以前没有这套机制的时候我们全靠手工排查节点状态十几台服务器一个个看累死不说还容易漏。有了它只要配置正确整个集群的运维状态都会清晰很多。你如果还没有给自己的服务加健康检查不管是不是.NET Core项目都建议立刻加上。等哪天因为它而救了你的系统一次你就会明白它有多值钱了。