最近处理了一个挺典型的 Azure App Service 故障:应用本身访问完全正常,页面秒开,数据库连接也没报错,但一启用 Health Check 功能,实例状态就齐刷刷变成 Unhealthy。紧接着负载均衡把流量摘了,服务开始出现零星 403/502,整个团队一脸懵。其实这种“健康检查显示 Unhealthy 但应用表面正常”的场景,在 App Service 上非常常见。这次我就把 Health Check 从机制到排查完整拆一遍,重点回答一个核心问题:为什么你的实例会持续显示 Unhealthy,以及怎么一步步找到根因并解决。不管你是刚接触 Azure App Service 的开发者,还是被这个功能坑过、想彻底搞懂原理的运维同学,这篇内容应该都能帮到你。
1. Health Check 在 App Service 里到底是什么
1.1 探针的运行机制
Health Check 是 App Service 平台内置的实例健康探测机制,它的作用很直白:平台按照你配置的时间间隔,向运行中的每一个实例上的指定路径发起 HTTP 请求,然后根据响应判断这个实例是否还能正常承接业务流量。
这里有几个容易忽略的关键点。第一,探测是“按实例”进行的,不是按整个应用整体探测。如果你的 App Service Plan 扩展到了多个实例,每一个实例都会收到独立的探针请求。第二,探针请求是由平台的前端负载均衡层发起的,它本质上是在模拟一个不带任何用户身份信息的普通请求,路径就是你配置的那个健康检查路径。第三,探测从实例被调度出来的那一刻就开始,不一定等你的应用完全启动完成。平台默认的探测间隔是 10 秒,最快可以调到 1 秒,如果应用本身启动慢,前面几次探测大概率会失败。
理解了这三件事,你就能明白一个基本事实:Unhealthy 是平台对你配置的健康路径的“响应信号”做出的判定,它反映的不一定是“应用挂了”,更可能是“探针看到的信号不符合预期”。
1.2 健康与不健康的判定规则
判定规则本身不复杂,官方文档写得很清楚:健康检查请求返回 200 到 299 之间的状态码,实例就算健康;返回其他任何状态码,包括 301、302、401、403、404、500、503,都算不健康。
另外还有两个隐藏规则。第一,探针不会无限期等待你的应用响应,如果超过一定时间没有收到响应,也算一次失败。第二,连续失败的次数达到阈值后,实例才会被标记为 Unhealthy。我记得官方给出的默认阈值是连续 5 次失败,达到这个阈值后,实例会被移出负载均衡轮询,不再接收新的业务请求,同时平台会尝试回收这个实例。如果回收后探活恢复了,实例会被重新加回;如果一直失败,平台就会反复执行“摘除—回收—再摘除”的循环,表现就是我们看到的“持续 Unhealthy”。
还有一个容易误会的点:健康检查请求是平台内部发起的,不是从公网入口打进来的。但如果你在应用层配置了访问限制(Access Restrictions)、IP 白名单之类的策略,仍然可能把探针请求挡在应用之外,这点留到后面展开。
1.3 开启这个功能能带来什么
很多团队把 Health Check 当成一个“开了就不用管”的功能,其实它的价值主要体现在两个方面。
第一,自动摘除故障实例。没有健康检查时,一个实例哪怕内存爆了、线程池耗尽、返回 500,负载均衡照样会把流量分给它。开启后,只要探针连续失败,平台就会把坏实例摘掉,避免用户请求打到半死不活的实例上。
第二,配合 Deployment Slot 做发布校验。在自动交换槽配置里,你可以指定一个健康检查 URL,只有目标槽的健康检查通过,交换才会继续。这个能力非常实用,能拦住一批“代码没问题但启动即报错”的发布。
不过所有这些都是有代价的:你需要提供一个既真实反映业务可用性、又足够轻量的健康端点。配置不好,就会出现标题里的现象——实例一直 Unhealthy,平台不断回收,业务反而更不稳定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 导致 Unhealthy 的几类高频原因
2.1 路径配置错了,一切白搭
我排查过大量 Unhealthy 案例,第一个要怀疑的永远不是应用,而是你填的那个健康检查路径本身。
路径写错是最基础的问题,比如 /health 打成 /helth,探针直接吃 404。其次是大小写问题,Windows 上的 .NET 应用对路径大小写不敏感,但 Linux 容器、Nginx、Tomcat 这些对大小写敏感,/Health 和 /health 可能就是两个完全不同的路由。还有尾斜杠问题,/health 和 /health/ 在某些路由配置下表现不一致,填进去容易踩坑。
更隐蔽的一类问题:路径确实存在,但它是需要登录的页面。后台管理系统常见这种路由,所有页面都要求认证,健康检查探针没有用户的登录态,请求过去直接 302 到登录页,实例就会被判定不健康。
还有一类配置问题出在“路径填得太宽泛”。很多人图省事直接填 /,根路径虽然一定存在,但根路径往往受全局中间件影响,比如某个第三方组件在根路径上做初始化检查,偶尔返回 500,健康检查就会被误伤。反过来,根路径返回 200 也代表不了业务健康——数据库都连不上了,根路径照样可能 200。
所以路径选择本身就是一门学问,我的建议是单独建一个 /healthz 这样的端点,不和业务路由混在一起,后面最佳实践部分会展开。
2.2 探针请求被鉴权、IP 限制和重定向拦截
这是第二大高频原因,也是最容易让团队绕弯路的坑。
先说鉴权。如果你给应用开了 Azure AD 认证,或者用了 App Service 内置的 Easy Auth,又或者代码里有全局认证中间件,而健康检查路径没有在匿名白名单里,探针每次请求都会拿到 401 或 302。本机 curl 测试的时候你带了 token,能正常通,但平台探针不带任何身份信息,结果就是“本机能通、平台判定不健康”的诡异局面。
再说 IP 限制。App Service 的 Access Restrictions 功能可以按 IP 白名单控制访问,如果你只放行了办公网 IP 或某个 VNet 网段,探针请求来源不在白名单里,请求会在到达应用之前被平台拦掉,返回 403。日志上看不到应用层任何记录,因为请求根本没进来。
然后是重定向。有些应用配置了全局 HTTPS 跳转或者登录跳转,健康检查路径返回 302。平台探针会不会跟随重定向?我在实测中观察到,它不会把 302 当成成功响应来处理。所以健康端点必须直出 200,不要搞重定向链,也不要在前面加 WAF 质询页面。
2.3 健康检查端点自带副作用,把自己拖垮
还有一种情况是健康检查路径本身“太重”了。比如你把健康检查直接指向一个会做复杂操作的接口:每次探测都查一次数据库、清一次缓存、给下游发一条消息、或者调用某个外部系统。
单个请求可能看不出问题,但平台是按实例、按间隔持续打的。假设你有 5 个实例,间隔 10 秒,每个实例每 10 秒被探测一次,频率其实不高;可如果健康检查里那个外部调用本身很慢,比如下游接口要 5 秒才响应,或者数据库连接池已经占满,健康检查就会跟着变慢,最终触发探针超时。
更糟的是,如果健康检查端点有写操作,比如每次探测都往数据库插一条记录,那它就不是一个只读的探测接口了,流量一上来反而会给应用增加额外负担。健康检查端点应该是幂等的、无副作用的,这是设计上最基本的要求。
2.4 启动慢、回收频繁和实例级故障
实例并不是“一直”不健康,而是“启动阶段”不健康,这也是持续 Unhealthy 的一个隐藏机制。
App Service 的探针从实例调度出来就开始工作,如果你的应用冷启动需要 30 秒,而探针间隔是 10 秒,那么前几次探测大概率失败。如果连续失败 5 次,实例被判定 Unhealthy 并被移出轮询,平台尝试回收,回收后实例重新启动,再次进入探测失败——这个循环一旦形成,你就会看到实例状态永远停留在 Unhealthy。
容易加剧这个问题的因素很多:Windows 上没开 Always On,App Pool 空闲被回收,导致冷启动频发;Linux 自定义容器镜像太大,拉取和启动时间超过探针忍耐范围;应用启动时依赖的数据库或配置中心暂时不可用,反复重试后才起来;还有内存或线程池耗尽导致探针请求排队超时。
这种问题的特征很明显:实例状态一会儿 Unhealthy,一会儿恢复,过一会儿又 Unhealthy。如果你看到这种反复横跳的状态,优先怀疑启动时间和生命周期问题。
2.5 配置层面的“假 Unhealthy”问题
这里想多说一句。如果你在大规模容器平台上待过,应该见过类似 “kubelet is unhealthy due to a misconfiguration” 的报错。这种报错的典型特征就是:组件本身没挂,是配置和预期不一致导致探活失败。Azure App Service 的 Health Check 也一样,很多“持续 Unhealthy”最终查出来都不是应用真的不可用,而是健康检查本身的配置和应用实际情况不匹配:路径不对、鉴权拦截、超时设置不合理、依赖检查范围太大,任何一环对不上,都会形成假警报。
所以排查 Unhealthy 的第一个心态应该是:先怀疑配置,再怀疑应用。把 Unhealthy 当成一个配置和探针的信号,不要默认应用已经挂了,这样才能避免在错误的方向上浪费大量时间。
3. 一次从现象到根因的完整排查过程
3.1 第一步:确认探针是否真的打到了应用
排查 Unhealthy 的第一步不是看应用日志,而是先确认探针请求到底有没有到达你的应用。这一步能立刻把问题分成两类:探针根本没到应用,还是探针到了但响应不对。
实操上有三个途径。第一,打开 App Service 的日志流(Log stream),观察一段时间内有没有对健康检查路径的访问记录。第二,如果配置了 Diagnostic settings 到 Log Analytics,可以直接查 AppServiceHTTPLogs 表,按路径过滤,看健康检查请求的数量、响应码和时间分布。第三,Linux 容器可以进 SSH,Windows 可以进 Kudu,直接看容器内访问日志,比如 Nginx 的 access log。
如果日志里完全没有健康检查路径的请求记录,那问题大概率出在网络层、访问限制或者平台侧的配置上;如果能看到请求记录但响应码不对,那问题就在应用侧。先做这个区分,后面所有排查才有的放矢。
3.2 第二步:从响应码和响应时间找线索
确认探针到达应用后,下一步是模拟探测,看具体返回什么。我一般直接在本地用 curl 打一次:
bash复制curl -s -o /dev/null -w "HTTP %{http_code} - %{time_total}s\n" https://your-app.azurewebsites.net/health
但有个重要的前提:本机 curl 的结果不能完全代表平台探针的结果。两者的区别在于,你的 curl 可能带了 Cookie 或认证头,探针不带;你的来源 IP 可能被访问限制放行,探针来源没有;你走的是公网 DNS 和公网入口,探针走的是平台内部路径。
所以我建议至少测三种场景:不带任何认证头的裸请求、带认证头的请求、以及从 Kudu 或 SSH 进入实例内部用 localhost 探测。对比三种结果,能快速定位是鉴权问题、网络限制问题还是应用本身的问题。健康检查路径返回 401 和返回超时的排查方向完全不同,不要混在一起猜。
3.3 第三步:区分是应用全局问题还是单个实例问题
Health Check 是按实例探测的,所以一定要分清范围:是所有实例都 Unhealthy,还是只有某一个实例 Unhealthy。这两种情况的原因差异很大。
所有实例都 Unhealthy,大概率是共享配置问题,比如健康检查路径配错、鉴权全局拦截、依赖服务整体不可用。只有某个实例 Unhealthy,则大概率是实例本地状态问题,比如磁盘写满、内存泄漏、临时文件损坏、容器 OOM。Windows 上可以打开对应实例的 Kudu 控制台,看事件日志、进程、磁盘空间;Linux 容器可以 SSH 进去执行 top、df -h、dmesg 看有没有 OOM 记录。
同时可以观察平台的回收行为:如果实例 ID 一直在变化,说明平台正在反复回收不健康实例。配合实例启动日志,能看出每次回收前发生了什么,这是定位根因的关键证据。
3.4 两个真实案例的复盘
案例一:路径鉴权导致的全实例 Unhealthy。
生产环境是 .NET 6 应用,全站开了 Easy Auth,只放行了少数几个路径匿名访问。配置 Health Check 时填了 /health,但忘了在认证白名单里加上这个路径。结果探针每次请求都被 302 到登录页,所有实例全部 Unhealthy,平台不断回收实例,流量持续下降。排查时用了第 3.2 节的方法,裸 curl 不带认证头访问 /health,果然拿到 302。把 /health 加入匿名白名单之后,实例状态在下一轮探测周期就恢复了。
案例二:Linux 容器启动慢导致反复 Unhealthy。
自定义容器镜像很大,冷启动要一分多钟,健康检查间隔保持默认 10 秒。探针连续失败 5 次后实例被摘除并重建,新实例又重复同样的过程,表面看就是持续 Unhealthy。实际原因是启动窗口不够,而不是应用有问题。解决方式两步走:把健康检查间隔调大到 30 秒甚至 60 秒,让实例有足够时间完成初始化;同时在容器里做一个快速就绪端点,核心进程一启动就能返回 200,把依赖检查逻辑放到单独的接口里,不要阻塞探针响应。
4. 常见问题速查表与配置避坑技巧
4.1 问题、原因、对策速查表
把上面提到的典型场景整理成一张速查表,排查时可以对照着来。
| 现象 | 可能原因 | 排查方向 / 对策 |
|---|---|---|
| 所有实例 Unhealthy,健康路径返回 401/302 | 健康端点被鉴权或重定向拦截 | 检查 Easy Auth、全局认证中间件,把 /health 加入匿名白名单 |
| 日志里完全没有健康检查请求 | 访问限制挡住了探针 | 检查 Access Restrictions、服务端点策略,确认探针来源放行 |
| 实例启动后短暂 Unhealthy 然后恢复 | 冷启动慢于探针间隔 | 调大健康检查间隔,或做快速就绪端点 |
| 实例一直 Unhealthy,实例 ID 反复变化 | 平台反复回收实例 | 查看回收前的实例日志、OOM、启动失败原因 |
| 健康路径返回 500/503 | 依赖服务(数据库、Redis)不可用 | 检查依赖健康情况,健康端点加超时和短路 |
| 只有单个实例 Unhealthy | 实例本地资源问题 | 进 Kudu/SSH 看磁盘、内存、进程、事件日志 |
排查的时候先对照现象列确定大致方向,再用第 3 节的步骤深入,效率会高很多。
4.2 健康端点设计的最佳实践
经过这么多轮踩坑,我总结出的健康端点设计原则可以浓缩成几句话:路径独立、逻辑轻量、响应快速、完全幂等。
路径上,单独建 /health 或 /healthz 之类的端点,别复用业务接口,更别直接用根路径。返回值上,健康返回 200,不健康返回 503,响应体可以简单到一行文本,不需要携带多余信息。逻辑上,不要每次探测都查数据库、查缓存、调下游,如果一定要做依赖检查,必须加超时控制和短路机制,把外部调用的延迟限制在几百毫秒以内。业务上,健康端点不能有写操作,不能发消息,不能改状态。
另外要区分“就绪检查”和“健康检查”。App Service 的 Health Check 更适合判断实例是否就绪,数据库迁移、缓存预热、数据初始化这类耗时操作不要放在探针路径里。老应用如果一时半会改不动,可以先从根路径跑通机制,再逐步切换到一个更贴近业务的健康检查路径,不要一上来就追求完美。
这里也补充一句,如果你用 Terraform 或 Bicep 管理 App Service,健康检查路径对应 site config 里的 healthCheckPath 一类属性,IaaC 里也要检查这个配置有没有被误改或覆盖,有时候生产环境的配置和代码仓库里的不一致,问题就出在这里。
4.3 一些容易被忽略的小细节
最后分享几个容易踩但又很少被写进文档的细节。
第一,健康检查请求不带用户 Cookie,也不带认证头,所以别假设探针和正常请求有同样的身份信息。凡是需要登录才能访问的路径,都不适合直接当作健康检查路径。
第二,健康检查不能替代 Application Insights 的 Availability Test。前者管的是平台层面的实例摘除,后者管的是用户视角的可用性监控,两者关注点不同,应该同时存在。只靠平台健康检查,你无法感知外部网络链路、DNS 解析、CDN 这些环节的问题。
第三,使用 Deployment Slot 时,各个槽的健康检查配置是独立的。自动交换如果配置了健康检查 URL,交换会在目标槽健康检查通过之后才继续。我曾经见过因为目标槽的健康检查路径配错,导致整个发布流程卡在交换阶段的情况,上线窗口被白白浪费。
第四,修改健康检查配置后,实例状态不会立刻刷新,平台要经过下一轮探测才会更新。改完配置别急着下结论,等一两个探测周期再判断是否生效。
第五,如果实例持续不健康并且平台反复回收,要注意收集回收前那一刻的日志,因为实例一重启,进程内的日志和数据都会丢,只有平台日志和持久化日志能保留现场。
最后的一点点个人体会
我在实际排查这类问题时,最大的感受是:Health Check 本身是一个把“应用可用性”翻译成平台调度信号的薄功能,但它关联的环节特别多——路径、鉴权、网络限制、启动时间、依赖服务、实例生命周期,任何一个环节有偏差,最终都会以 Unhealthy 的形式暴露出来。后来我给自己定了一条规矩:任何应用接入 Health Check 之前,先写一个最小的 /healthz 端点,做到无鉴权、无副作用、1 秒内返回 200,先把探针通路跑通,再逐步加入有必要的依赖检查。这样既保住了健康检查“摘除坏实例”的核心价值,又避免它成为新的故障源。再分享一个小技巧:在把健康检查路径写进配置之前,先模拟三种请求——不带认证、带认证、从实例内部 localhost 访问——对比结果再决定填什么路径,你能避开绝大多数坑。
