一、我以前根本不知道这东西是给谁写的
刚做开发那会儿,看别人项目里有个接口叫 /api/health,点进去就返回个 ok。
我当时心想:这玩意儿有啥用?又不查数据库,又不处理业务,就干巴巴返回个200。谁写的,闲的吧。
直到有一次,服务挂了。
不是完全挂,是半死不活那种——进程还在,但请求全超时。负载均衡不知道,还在哗哗地导流量过来。用户那边白屏,运维冲过来问:“你这服务有没有健康检查端点?”
我说没有。
他叹了口气。那个叹气声我到现在还记得。不是骂人,是那种“又来了”的疲惫。
后来他跟我解释了半天,我才明白这东西到底是给谁用的。负载均衡靠它决定要不要导流量,K8s靠它决定要不要重启Pod,注册中心靠它决定要不要摘掉节点。没有这个端点,服务挂了,全世界都不知道,还一个劲往你身上发请求。
说白了,/api/health 就是服务对外说的唯一一句话:“我还活着,可以来找我。”
开发花三分钟写好,运维靠它睡个好觉。
二、这东西到底怎么写,运维最想要什么
1. 最简版:返回200
csharp
[HttpGet("/api/health")]
public IActionResult Health()
{
return Ok("healthy");
}
能跑。进程活着就返回200。但这个版本的毛病也很明显:进程活着不代表业务活着。数据库连接池满了、Redis挂了、消息队列堆积了,它全不知道,照常返回200。运维最怕的就是这种——监控全绿,业务全挂。
这个版本只适合服务刚启动还没接依赖的阶段,或者给负载均衡做个最基本的存活探测。
2. 进阶版:逐个检查依赖
csharp
[HttpGet("/api/health")]
public async Task<IActionResult> Health()
{
var dbOk = await CheckDbAsync();
var redisOk = await CheckRedisAsync();
var mqOk = await CheckRabbitMQAsync();
var healthy = dbOk && redisOk && mqOk;
return healthy ? Ok(new { status = "healthy" })
: StatusCode(503, new { status = "unhealthy", db = dbOk, redis = redisOk, mq = mqOk });
}
能定位到具体是哪个组件挂了。但问题来了:同步检查,串行执行,一个慢全慢。
比如Redis超时3秒,整个 /api/health 就卡3秒。负载均衡器那边超时2秒就直接判定你挂了,把节点摘掉。这就是典型的“健康检查把自己搞死”。
3. 正确姿势:并行检查 + 超时控制
csharp
[HttpGet("/api/health")]
public async Task<IActionResult> Health()
{
var checks = new Dictionary<string, Task<bool>>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
checks["db"] = CheckDbAsync(cts.Token);
checks["redis"] = CheckRedisAsync(cts.Token);
checks["mq"] = CheckRabbitMQAsync(cts.Token);
var results = new Dictionary<string, string>();
foreach (var (name, task) in checks)
{
try
{
var ok = await task;
results[name] = ok ? "healthy" : "unhealthy";
}
catch (OperationCanceledException)
{
results[name] = "timeout";
}
catch
{
results[name] = "unhealthy";
}
}
var healthy = results.All(r => r.Value == "healthy");
return healthy ? Ok(new { status = "healthy", checks = results })
: StatusCode(503, new { status = "unhealthy", checks = results });
}
每个检查独立跑,互不拖累。全局2秒超时,不会无限等。单个依赖超时只影响自己,不影响其他组件的判断结果。
4. .NET 原生方案:HealthChecks 中间件
上面是自己手写的版本。.NET有现成的轮子,直接用 HealthChecks 中间件更省事:
csharp
builder.Services.AddHealthChecks()
.AddSqlServer(connectionString, timeout: TimeSpan.FromSeconds(2), name: "db")
.AddRedis(redisConnection, timeout: TimeSpan.FromSeconds(1), name: "redis")
.AddRabbitMQ(timeout: TimeSpan.FromSeconds(2), name: "mq")
.AddUrlGroup(new Uri("https://api.downstream.com/health"), timeout: TimeSpan.FromSeconds(2), name: "downstream");
映射到端点时,可以自定义返回格式,顺便带上每个依赖的耗时和异常信息:
csharp
app.MapHealthChecks("/api/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
var result = new
{
status = report.Status.ToString(),
totalDuration = report.TotalDuration.TotalMilliseconds + "ms",
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds + "ms",
error = e.Value.Exception?.Message
})
};
context.Response.ContentType = "application/json";
context.Response.StatusCode = report.Status == HealthStatus.Healthy ? 200 : 503;
await context.Response.WriteAsJsonAsync(result);
}
});
返回示例:
json
{
"status": "Unhealthy",
"totalDuration": "2134ms",
"checks": [
{ "name": "db", "status": "Healthy", "duration": "15ms", "error": null },
{ "name": "redis", "status": "Unhealthy", "duration": "2012ms", "error": "Timeout" },
{ "name": "mq", "status": "Healthy", "duration": "8ms", "error": null },
{ "name": "downstream", "status": "Healthy", "duration": "96ms", "error": null }
]
}
运维拿到这个,一眼就知道:Redis超时了2秒,其他正常。不用翻日志、不用查监控大盘,一个端点就是一份诊断报告。
5. K8s 场景:liveness 和 readiness 要区分
健康检查写到一定程度,K8s 的两种探针就该分开了:
-
liveness probe:服务还活着吗?挂了就重启Pod。应该检查最轻量的东西,比如进程是否响应。别查数据库,别查Redis。 否则数据库一挂,所有Pod轮番重启,雪崩。
-
readiness probe:服务能接请求了吗?不能就先别导流量。这个可以查依赖,比如数据库连不上就返回未就绪,K8s暂时不给这个Pod导流量,但不会重启它。
大多数团队直接用一个 /api/health 同时当 liveness 和 readiness,小项目没问题。但上了规模,建议分开:
csharp
// liveness:只要进程活着就行,什么都不查
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});
// readiness:检查所有依赖,用来决定要不要导流量
app.MapHealthChecks("/health/ready", new HealthCheckOptions { ... });
6. 一个容易被忽略的坑:健康检查不能暴露到公网
/api/health 如果暴露在公网,外部随便谁都能调一下看你的依赖信息——数据库地址、Redis IP、下游API全在返回体里。所以必须:
-
防火墙/IP白名单只允许内网或监控节点访问
-
或者区分公网版和内网版:
csharp
// 公网版:只告诉活着没
app.MapHealthChecks("/api/health/public", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
await context.Response.WriteAsync(report.Status == HealthStatus.Healthy ? "ok" : "fail")
});
// 内网版:带完整诊断信息
app.MapHealthChecks("/api/health/internal", new HealthCheckOptions
{
ResponseWriter = WriteDetailedReport
});
7. 性能边界:健康检查本身不能拖垮服务
负载均衡可能每秒都在探测 /api/health。如果每次都真的去连数据库、真的连Redis,开销不是零。几种优化:
-
缓存结果:检查一次,缓存5-10秒,下次探测直接返回缓存
-
后台轮询:健康检查在后台定时跑,
/api/health只返回上一次结果,请求本身不触发任何连接 -
熔断逻辑:如果依赖已经挂了,没必要每次都去连,直接返回失败
一个 /api/health 写成这样,开发多花了半小时,运维少熬了半个夜。朋友就是这么当的。
三、加上了,朋友就有了
后来只要是我负责的服务,我都加上 /api/health。小项目没什么依赖,就写个最简版,返回200就行。复杂点的,把数据库、Redis、下游API都检查上,一个端点就是一份诊断报告。跟SSL证书监控、数据库端口监控一起,全接到云哨兵上。
配完之后,事情就变简单了。每周上云哨兵看一眼周报,三个任务整整齐齐列在那里,不用挨个服务器登进去查,不用凭感觉猜“应该还活着吧”。扫一眼,心里有数,该干嘛干嘛。
一个小项目的周报截图:

三个任务,SSL证书正常,数据库端口全绿,/api/health 有一次告警。就这一次,在凌晨,几分钟后自己恢复了。偶发性的小波动,不用急着半夜爬起来查。等数据积累多了,看看有没有规律,再统一排查。
做好 /api/health,然后花几分钟在云哨兵配置一下,告警就有了,汇报也有了。朋友也就有了。
写在最后
/api/health 不是什么新技术,花几分钟就能加。
但它有意思的地方在于,一个端点,两种思维。
开发看它,就是一个接口,返回200完事。运维看它,是整个服务唯一能主动报平安的窗口。开发多检查一个依赖,运维就少排查一个问题。开发统一了标准,运维就有了全局视野。
以前我写代码只管功能跑不跑得通,后来才意识到,服务上线之后还有另一个角色在盯着它——那个人半夜被叫起来的时候,第一个想找的就是 /api/health。
一个端点,一份周报,开发和运维之间那堵墙,其实没那么厚。
如果你手里管着一堆服务但不知道它们到底稳不稳,先把 /api/health 加上。写代码的人、看服务的人、中间件、云哨兵,人也好物也罢,都有一个共同的锚点。
458

被折叠的 条评论
为什么被折叠?



