在 Azure App Service 的配置页里打开 Health Check,本意是让平台自动发现不健康的实例并把它从负载均衡里摘掉,结果却发现在“实例”列表里每个实例都持续显示 Unhealthy,甚至健康检查设置页直接飘红。这个现象我在实际项目里遇到过,也被同事问过好几轮,问题大多数时候不在平台,而在我们自己对健康检查的“误解”和应用代码的细节上。这篇文章我不打算只列现象,而是把 Health Check 的探测原理讲透,再按经验把常见原因、排查路径和最终解法串起来,带你一步步把 Unhealthy 变成 Healthy。
1. 先把原理搞明白:健康检查到底在探测什么
1.1 一次健康检查请求的完整路径
开启 Health Check 之后,App Service 平台会每隔约 10 秒向每个实例发送一次 HTTP GET 请求,路径就是你填的那个值,比如 /health。这个请求不是从公网进来的,而是平台内部网络直接打到实例本身,绕过了公共入口前端。可以理解为:客户端从互联网访问你的站点,走的是“公网入口 → 负载均衡 → 实例”;而健康检查走的是“平台基础设施 → 实例”的一条内部旁路。
探测请求能不能算成功,主要看三点:
- 请求能到达你的应用进程,并且应用能正常处理这条请求;
- 返回的 HTTP 状态码落在 2xx 到 3xx 范围内;
- 响应足够快,不能拖到平台判定超时。
平台在连续两次探测失败后,会把该实例标记为 Unhealthy;之后如果连续两次探测成功,实例才会恢复 Healthy。这里有个容易忽略的点:健康检查只是在“验证应用是否还活着、能不能响应请求”,它并不负责判断你的数据库是否正常、第三方 API 是否可用,除非你在健康检查路径里自己实现了这些依赖检查。
1.2 “不健康”之后会发生什么
实例被标记 Unhealthy 后,平台会停止向这个实例分发新的请求,已经建立的连接通常不会被立刻切断,老的请求可以继续慢慢结束。这是很多人理解偏差的地方:Unhealthy 不代表你的应用立即被杀死,它代表的是平台觉得这个实例“不可用于接收新流量”。
如果实例持续 Unhealthy,平台会尝试恢复它,比如回收应用进程,极端情况下会重启实例。但有一个非常关键的设计:如果所有实例都不健康,平台不会把整个应用从流量中全部摘除,反而会继续把请求分发给所有实例。这种“全部不健康就不摘”的策略,本质上是避免出现整个站点全部不可用的更恶劣情况。
所以你在界面上看到 Unhealthy,要有一个清醒的认知:这更像是“存活/负载均衡探针”的结果,而不是你应用业务健康状况的终极裁判。搞清楚这一点,后面的排查思路才不会被带偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么开启了健康检查反而一直 Unhealthy:常见原因逐个排查
2.1 健康检查路径写错或返回码不对
最直接的一个原因,就是路径本身有问题。填的路径在你的应用里根本不存在,返回 404;路径对应的接口需要登录才能访问,返回 401;或者应用刚启动时还没完成初始化,任何请求都返回 500。这些状态码只要大于等于 400,平台就会认为探测失败,连续两次失败后实例自然就被标记成 Unhealthy。
我在项目中还见过更隐蔽的情况:有人把健康检查路径指向了 /Home/Index,首页本身要查数据库、加载用户信息,结果数据库连接池抖动了一下,首页返回了 500,健康检查跟着失败。所以健康检查路径千万别用业务主页,最好单独开一个轻量路径,比如 /health 或者 /api/health,并且确保这个路径是匿名可访问的。
2.2 健康检查端点被重定向到别的地方
重定向也是高频坑。健康检查地址如果返回 301/302,比如跳转到登录页、跳转到自定义域名、或者因为强制 HTTPS 而跳走,平台的探测进程未必会跟着重定向继续请求,或者重定向目标本身又不满足条件,结果就会被判定为 Unhealthy。
我在实际排查中发现,很多站点为了安全,在应用前面加了 URL Rewrite 规则,把 HTTP 强制跳转到 HTTPS,或者把无 www 的请求跳转到带 www 的域名。这些规则对普通用户是友好的,但健康检查的探测请求是从平台内部直接打到实例上的,它可能走的是 HTTP 入口,也可能用的主机名是默认的 *.azurewebsites.net 域名。如果重写规则对这种内部探测不友好,就会卡在跳转这一环。稳妥的做法是:让 /health 在 HTTP 和 HTTPS 下都能直接返回 200,不要跳转,也不要依赖某个特定域名才能访问。
2.3 健康检查端点响应太慢,拖到超时
有一个我踩过很深坑的场景:应用本身没有挂,但健康检查一直 Unhealthy。后来发现健康检查路径里检查了一大堆外部依赖,每次都要访问数据库、调用第三方接口,响应时间经常超过 1 秒甚至 2 秒。
平台对健康检查的容忍度其实比较低,它要求这个路径必须“瞬间响应”。如果响应时间动不动就超过平台超时阈值,或者应用在并发高的时段把线程池耗尽,导致健康检查请求排队等不到处理,探测就会失败。严重的时候,实例会因为连续探测失败被摘除,而摘除后又触发了应用重启,重启过程又慢,结果就是无限循环 Unhealthy。
这里要重点提醒:健康检查不是让你做全链路业务健康巡检的地方,它更接近一个“心跳接口”。响应时间建议控制在 200 到 500 毫秒以内,最理想是几十毫秒就返回。
2.4 应用启动太慢,陷入“启动失败→重启”的循环
启动慢是导致 Unhealthy 被误报的另一个常见原因。假设你的应用冷启动需要 40 秒,里面要跑 EF Core 的迁移、加载规则引擎、初始化缓存,但平台的探测从实例一开始拉起就在进行了,每 10 秒打一次,连续失败两次就会被标记为 Unhealthy。平台为了“恢复”,可能会回收甚至重启实例,于是又是一轮 40 秒的冷启动,又是一轮探测失败,最终表现为实例一直 Unhealthy。
如果你在应用日志里看到进程反复重启,或者实例的启动时间明显偏长,就要优先排查启动流程。很多适合放在部署阶段的任务(比如数据库迁移)不应该放在应用启动阶段,否则健康检查会因为启动太慢永远无法通过。
2.5 访问限制和网络策略误伤了探测请求
App Service 的“访问限制”(Access Restrictions)功能可以按 IP 白名单或黑名单控制访问入口。如果你的站点配置了 IP 白名单,只允许公司出口 IP 访问,那健康检查的探测请求很可能会命中 403。
为什么?因为健康检查请求来自平台基础设施的内部网络,来源 IP 在 Azure 云服务标签范围内,不一定在你的白名单列表里。于是会出现一个很诡异的现象:从浏览器访问 /health 明明返回 200,但实例状态一直 Unhealthy。这时候一定要检查访问限制配置,把 AppService 服务标签(Service Tag)放行,或者在 VNet 集成场景里检查网络安全组(NSG)规则,确保平台内部的探测请求能到达应用。
2.6 平台配置与定价层问题:Always On 和容器端口
有些朋友在免费(F1)或共享(D1)层使用 App Service,也没有开启 Always On。这类计划下的站点在空闲一段时间后会被系统回收,进程被停掉,健康检查自然探测不到任何响应,Unhealthy 也就不奇怪了。健康检查在真实生产环境最好搭配 Basic 及以上计划来用,并确保 Always On 处于开启状态。
Linux 容器场景更容易踩配置坑。比如容器里实际监听的是 8080 端口,但容器设置里写的端口是 80;或者启动命令写错了,容器一直处于崩溃重启状态。健康检查探针打向错误的端口,永远拿不到响应,实例就一直 Unhealthy。这种情况不要急着怀疑健康检查功能本身,先去看容器设置和日志流。
2.7 日志里出现 kubelet 相关的不健康消息是什么情况
在 App Service Linux 的某些场景下,你可能会在诊断信息或底层日志里看到类似 kubelet is unhealthy due to a misconfiguration 的英文消息。很多同学第一次看到这种消息会以为自己的应用配置出了问题,但这其实是底层 Kubernetes 节点上报的状态信息,不是应用本身抛出的异常。
App Service Linux 底层运行在托管 Kubernetes 集群上,当承载实例的节点资源紧张、镜像拉取失败,或者节点探活配置出现问题时,kubelet 就会报出类似消息。作为用户,你能调整的并不是 kubelet,而是应用侧配置和容器配置。遇到这种日志,优先检查实例规格是否够用、容器镜像是否能正常拉取、启动命令是否正确、健康检查路径是否指向了应用真正监听的端口。把这些应用层问题清的差不多了,底层探活消息通常会随之消失。
3. 五分钟现场排查:从界面到日志的完整路径
3.1 先用浏览器和命令行直接打一次健康检查地址
最快的定位方式,是直接通过公网访问你的健康检查地址。打开浏览器访问 https://你的应用名.azurewebsites.net/health,看返回什么。如果返回 200,说明这个路径在公网侧是通的;如果返回 401、404、500、302,问题大概率就出在应用侧。
浏览器有时会掩盖细节,我更推荐用命令行使出真相。在命令行执行:
bash复制curl -i https://myapp.azurewebsites.net/health
用 -i 把响应头打印出来,这样可以清楚地看到状态码、Location 重定向地址、Set-Cookie 等关键信息。再看一眼响应耗时:
bash复制curl -w "time_total:%{time_total}s\n" https://myapp.azurewebsites.net/health
如果 time_total 经常在 1 秒以上,说明这个端点太重了,即便此刻返回 200,后续也很容易被平台判定为超时。
3.2 用 Kudu/SSH 调试控制台模拟实例本机探测
公网访问正常不代表实例内部探测正常,因为健康检查走的是到实例本机的内部路径,能绕开一些外部因素。要模拟这种“本机视角”,可以打开高级工具。
Windows 版 App Service 可以访问 https://你的应用名.scm.azurewebsites.net,进入调试控制台后,用命令行对本机回环地址发起同样的请求。Linux 版则可以直接用 SSH 进入容器内测试。这里不需要纠结具体端口号,关键是思路:从实例本机视角发起一次 HTTP 请求,如果连本机回环都拿不到 200,那这个实例肯定是无法响应健康检查的;反之,如果本机回环返回 200,而平台仍然报告 Unhealthy,那就要往网络策略、访问限制、平台调度方向排查。
3.3 用平台自带的诊断工具看探测历史
Azure Portal 里 App Service 有一个“诊断并解决问题”(Diagnose and Solve Problems)入口,进去之后按“可用性和性能”分类,能找到健康检查相关的检测器。这个面板会展示每个实例的探测历史、健康状态变化时间线,以及平台判断不健康的理由。
这套工具的价值在于,它能帮你确认 Unhealthy 是“一直都有”还是“某个时间点之后才开始”。如果是从启用健康检查那一刻就开始,那基本就是路径或配置问题;如果是某次部署之后才出现,那就要回看这次部署改了什么,是不是引入了新的认证逻辑、重定向规则,或者依赖了某个不稳定的外部服务。
3.4 容器场景:看日志流和容器设置
Linux 容器应用可以先打开“日志流”(Log Stream),观察容器启动期间有没有异常输出。常见的镜像拉取失败、端口绑定失败、启动命令报错都能在这里看到。然后再回头检查容器设置里面的三个关键项:镜像地址、启动命令、端口号。
az webapp config container show --name <应用名> --resource-group <资源组名> 这条命令可以把当前容器的实际配置导出来,对比一下和你预期的是否一致。很多时候 Unhealthy 的根因,就是端口号写错了,或者启动命令在本地能跑、到了 App Service 环境里缺少某个参数。
4. 修好了之后:健康检查端点的正确设计方式
4.1 一个足够轻量的健康检查端点长什么样
先说 Windows 上的 .NET 应用。如果你是 ASP.NET Core,最省事的做法是用内置的 MapHealthChecks:
csharp复制var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();
然后在 Health Check 配置里,把路径填成 /health。这个端点会返回 200 还是 503,取决于你注册的检查项是否通过。要注意,如果你只做进程级存活检查,连数据库检查都不需要注册,只写下面这一行就够了:
csharp复制app.MapGet("/health", () => Results.Ok("OK"));
如果是 Node.js,Express 应用可以这样写:
js复制const express = require('express');
const app = express();
app.get('/health', (req, res) => {
res.status(200).send('OK');
});
app.listen(process.env.PORT || 3000);
核心原则就一条:只要有进程在,就立刻返回 200,不要做任何额外计算,也不要碰外部依赖。
4.2 要不要在健康检查里检查数据库?我的建议
很多人的第一反应是:“健康检查那不就应该把数据库连一遍,确认整个链路没问题吗?”这个想法听起来合理,但在 App Service Health Check 这个场景里,我的建议是别这么做,至少不要把依赖检查做成同步阻塞的方式。
原因在于:数据库是共享依赖,一个数据库实例往往被多个应用共用,数据库抖动时,你可能希望应用实例保持存活,用缓存继续服务,而不是因为数据库慢了就被平台判定为 Unhealthy,然后被摘除重启。重启应用本身又会带来新的缓存冷启动、连接池重建,反而放大故障。
如果你确实需要“依赖检查”这种 readiness 语义,建议外部依赖的检查控制在极短超时内,比如数据库连接超时设置为 1 秒,并且将检查结果缓存一段时间,避免每一次探测都触发一次真实的外部调用。更合理的方式,是做两层监控:App Service 健康检查只管实例存活,业务级的可用性监控用 Application Insights 的可用性测试从外部发起,这样两边职责分明。
4.3 上线前照着勾一遍的检查清单
每次配置健康检查,我基本都会过一遍下面这份清单,所有项都满足再收工:
- 路径以
/开头,并且是应用里真实存在的路由。 - 状态码稳定返回 200,不在 4xx/5xx 范围。
- 匿名可访问,不依赖登录态、Cookie、IP 白名单。
- 不产生重定向,HTTP 和 HTTPS 都能直接响应。
- 响应时间稳定在 500 毫秒以内,最好 200 毫秒以内。
- 端点本身没有副作用,不写数据库、不发消息、不消耗大资源。
- 默认的
*.azurewebsites.net域名仍然能访问,没有被重写规则禁用。 - 访问限制规则放行 AppService 服务标签或平台内部来源。
- 使用付费计划并开启 Always On。
- Linux 容器场景,健康检查路径和应用监听端口一致,启动命令正确。
这条清单看起来繁琐,但每一条背后都对应着我遇到过的真实事故。填坑填多了,就老实了。
5. 常见问题与排查技巧实录
5.1 症状定位速查表
| 症状 | 可能原因 | 快速确认方法 |
|---|---|---|
公网访问 /health 返回 404 |
路径写错或路由不存在 | 检查路由配置,确认是否有对应 handler |
公网访问 /health 跳转到登录页 |
认证拦截或重定向规则 | 查看响应头 Location |
| 公网 200,但实例仍然 Unhealthy | 访问限制/网络策略拦截,或本机探测失败 | 用 Kudu/SSH 模拟本机请求 |
| 启动后前几分钟一直 Unhealthy | 应用启动太慢、数据库迁移耗时 | 看日志流和启动日志 |
| 间歇性 Unhealthy | 资源耗尽、依赖响应慢、线程池占满 | 看 CPU、内存、可用性指标 |
| Linux 容器一直 Unhealthy | 端口配置错、启动命令错、镜像拉取失败 | 检查容器设置和日志流 |
| 日志出现 kubelet 相关的 unhealthy 消息 | 底层节点探活状态,非应用直接抛错 | 检查容器配置、实例规格、镜像状态 |
5.2 几个容易忽略的细节
第一,状态恢复不是瞬时的。平台连续两次探测失败才判定 Unhealthy,同样也需要连续两次成功才能恢复 Healthy。也就是说,你刚修好代码,面板上可能还要等十几秒甚至更久才会变绿,这很正常,不要因为一时没恢复又反复去重启实例,反而打断探测节奏。
第二,健康检查状态和实例是否真正被摘除,不是一回事。Unhealthy 表示最近探测失败,但实例可能还承担着存量连接;具体调度行为受平台策略影响,你看到的状态变化会滞后。排查时以探测历史和实例日志为准,不要只看面板颜色。
第三,健康检查路径改动后,平台会立即按新路径重新探测,不需要重启应用。但如果你同时改了代码,一定要确认新的健康检查路径已经部署到所有实例,否则会出现一部分实例还在用旧路径、一部分实例已经切到新路径的过渡状态。
第四,日志里没看到 /health 的请求,不一定代表探测没发生。健康检查探测请求不一定全部进入 AppServiceHTTPLogs,所以别因为日志里没有记录就怀疑平台没有在探测。想看探测结果,直接使用诊断工具里的健康检查面板最可靠。
第五,健康检查是摘除机制,不是治愈机制。如果所有实例都因为同一个问题(比如数据库连不上、配置错误)而不健康,即使平台把所有实例都标记为 Unhealthy,它也不会自动把错误配置修好。先解决根因,再谈健康状态。
我在实际使用中的体会是:遇到 Unhealthy,先别急着改健康检查配置,花两分钟做三件事——打开公网地址看状态码、打开诊断面板看探测历史、想清楚这个 Unhealthy 是从什么时候开始的。这三招能解决掉绝大多数场景。最后一句话送给大家:Health Check 是让实例状态更真实,不是让业务更聪明,别把太重的东西塞进这个路径里,它会用 Unhealthy 狠狠教育你。
