1. 问题的现场:一切配置都正常,实例却一直红着
先说结论:大多数时候,Health Check 显示 Unhealthy 并不是应用真的挂了,而是探活路径和 App Service 的探活机制之间出现了认知偏差。
我在接手一个老项目时遇到的情况是这样的:应用是一个部署在 Azure App Service(Windows 计划)上的 .NET Core Web API,后端依赖 Azure SQL 和 Redis。功能一切正常,日志没有任何异常,从公网访问接口响应都在 200ms 以内。但在 Azure 门户的 App Service 概览页上,实例列表里每个实例的健康状态都显示为 "Unhealthy",而且是持续性的,不是偶发。
当时我第一反应是——是不是应用池回收了?是不是某个实例的内存爆了?于是打开诊断日志、Application Insights、指标面板翻了一圈,发现 CPU、内存、请求量全部正常,唯一扎眼的只有 Health Check 状态。
后来我干脆在浏览器里手动请求 Health Check 路径,发现接口本身返回 200 OK,响应体也是标准的 Healthy 字符串。这就很诡异了:路径能通、返回正确、服务正常,但平台探活却判定为不健康。
如果你也遇到类似现象,别急着改代码。这篇文章从根因到排查链路,把 Health Check 的判定机制和实际踩坑点完整拆开讲。整个过程基于 Windows App Service 计划,但很多原理在 Linux 容器方案下同样适用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Health Check 的探活机制:平台到底在检查什么
2.1 探活请求和普通请求的区别
Azure App Service 的 Health Check 功能,本质上是在服务前面加了一个探活器,周期性地向指定路径发送 HTTP 请求。它和我们手动用浏览器或 curl 访问的区别在于:
- 探活请求带有特定的 User-Agent 头(通常是
AlwaysOn或类似标记),在部分日志系统里能看到。 - 探活请求的期望响应码是 200-399,超出这个范围直接判定为 Unhealthy。
- 探活请求的频率不是固定的,平台会根据实例的健康状态动态调整,健康状态下约每 40 秒一次,异常时会加速。
- 探活请求对响应时间敏感。如果连续请求在指定时间窗口内没有返回,会被视为超时。
关键点在于第三和第四点。很多人只关注返回码,忽略了响应时间的窗口限制,这就容易产生"手动访问是好的,平台判定是坏的"这种认知差。
2.2 判定 Unhealthy 的阈值和下架流程
平台内部对实例的健康判定遵循这样的逻辑:
- 探活请求发出后,如果返回码不在 200-399 范围内,标记一次失败。
- 如果响应超时或连接被拒绝,同样标记一次失败。
- 连续失败达到一定次数(实际实现中通常是 2-3 次连续失败),实例被标记为 Unhealthy。
- 实例一旦被标记为 Unhealthy,平台会将其从负载均衡轮转中移除,也就是说新的请求不会再路由到这个实例上。
- 移除后,平台仍会继续发送探活请求,如果实例恢复响应,会重新加入轮转。
这个机制本身的容错性还可以,它不会因为单次探活失败就立刻下架实例,只有连续失败才会触发移除。但问题也在这里——如果某个实例处于"间歇性超时"状态,比如每 40 秒的探活中有 2 次超时、1 次成功,那么它就会在 Healthy 和 Unhealthy 之间反复横跳,但用户看到的往往是最终停留在 Unhealthy 状态。
2.3 探活路径推荐和重定向陷阱
Health Check 配置页面上可以填一个路径,比如 /health 或 /healthcheck。配置本身非常简单,但有个隐藏的坑:如果该路径返回的是一个重定向响应(301/302),探活会判定为失败,因为重定向不在 200-399 范围内。 这里的 200-399 是严格的有效范围吗?实际上官方文档说的是 2xx 和 3xx 都算成功。
但是,很多应用的中间件会做 HTTPS 重定向或路径重写。如果你的 Health Check 路径被某个中间件拦截,返回了一个 301 或 302 到另一个路径,平台会跟随重定向吗?实测结果是不跟随,而是直接判定失败。所以,Health Check 路径必须是最终响应,不能是重定向链。
另外,如果应用启用了身份验证(例如 Azure AD 或 JWT 中间件),Health Check 路径会被 401 拦截,那也是不健康的。常见的做法是在中间件管道中把健康检查路径排除在认证逻辑之外,确保它是匿名可访问的。
3. 我踩过的坑:每个排查步骤背后的逻辑
3.1 日志里没有探活记录,是日志配置的问题
在排查过程中,我首先想到的是看应用日志里有没有探活请求的访问记录。结果发现,日志里只有普通用户的请求,看不到任何来自探活器的请求。这个现象很容易让人误判为"探活请求根本没到达应用"。
实际情况是,在 Azure App Service 的默认配置下,应用日志(例如通过 ILogger 输出到文件系统的日志)不一定会记录来自平台的探活请求,尤其是当你只配置了应用级别的日志而没启用 Web 服务器日志时。要让探活请求可见,需要:
- 在 App Service 的诊断设置中启用 Web 服务器日志(存储到 Blob 或文件系统)。
- 切换到 Application Insights 或第三方日志平台,让请求级日志能捕获到全部流入的请求。
- 通过 Kudu 控制台的 Log Stream 实时观察,但同样需要应用本身打印出请求信息。
如果日志里看不到探活请求,先用 Kudu 的 Log Stream 配合一个简单的 Console 输出(在 Health Check 路径的 Action 里写一行日志),确认探活请求是否真的到达了应用。这一步非常关键,能直接区分是请求没到,还是请求到了但响应不合格。
3.2 手动 curl 探活路径,加了 -L 和不加结果完全不同
我当时的排查链条如下:
- 在浏览器里访问 Health Check 路径,发现能正常返回 200。
- 在 Kudu 的控制台里用 curl 访问,发现正常返回 200。
- 但 App Service 概览页仍然显示 Unhealthy。
后来我尝试在 curl 命令中加上 -L(跟随重定向)和不加 -L 的区别。不加 -L 时,返回的是一个 301 响应;加了 -L 后,才跟随到最终路径返回 200。
这个发现直接指向了问题的核心:Health Check 请求到达应用后,被某个重定向逻辑拦截了。 浏览器和带 -L 的 curl 会自动跟随重定向,所以看起来是正常的;而平台探活器不跟随重定向,直接判定失败。
那是什么代码逻辑产生的重定向呢?顺着这个线索检查,发现项目在 Startup.cs 中配置了全局 HTTPS 重定向中间件(app.UseHttpsRedirection()),这个中间件在检测到 HTTP 请求时,会返回一个 301 到 HTTPS 地址。
但这里有个细节:即使在 Azure App Service 上启用了 HTTPS Only,如果探活器发送的是 HTTP 请求到应用(例如请求到达的是一个内部的 HTTP 端口),中间件仍然会执行重定向逻辑。也就是 HTTPS 重定向中间件和应用实际接收到的协议之间存在不一致。
解决方案有两种:
- 在加 HTTPS 重定向中间件时,排除 Health Check 路径。
- 或者将 Health Check 路径放在中间件管道的更前段,在重定向逻辑之前直接返回响应。
两种方案本质是一回事——让探活请求不经过重定向逻辑。
3.3 响应体也有讲究
除了重定向之外,还有一个很常见的坑:Health Check 路径的响应体。
平台对响应体没有任何强校验,理论上只要返回 2xx/3xx 就行。但很多应用喜欢在 Health Check 里加入依赖项检查,比如检查数据库连接、Redis 连接等。如果某个依赖项短暂抖动,Health Check 会返回 500,实例随之被标记为 Unhealthy。
这种"健康检查过于严格"的设计,虽然能带来更精确的依赖状态感知,但也会导致实例频繁被标记为 Unhealthy 并被移出负载均衡,反而影响整体稳定性。尤其在数据库或 Redis 出现短时间连接不上时,原本可以自动恢复的服务,会因为健康检查的严格判定而被下架,从而引发瞬间的请求雪崩(所有流量路由到剩余实例),加重依赖项的压力。
个人的实践经验是:健康检查路径应该只检查进程本身是否存活,以及应用是否有能力处理请求,不要去检查数据库或外部依赖。 数据库、Redis 这类依赖的健康状态应该通过独立的诊断接口去查看,而不是作为健康检查的判定条件。如果你一定要检查依赖,至少要做超时控制和缓存,避免每次探活都去真实请求外部服务。
3.4 实例级别的坑:Cold Start 和长时间空闲
还有一个容易被忽略的问题:如果 App Service 开启了"Always On"功能未启用,或者计划是 Consumption 级(例如 Function App),那么在一段时间没有请求后,实例可能会被冻结或回收。当 Health Check 探活请求到达时,应用正处于 Cold Start 阶段,需要重新加载程序集、初始化数据库连接池等,这就导致探活响应超时。
在最低配的 B1 计划上,Cold Start 时间可能长达 10-20 秒,而平台探活的超时窗口远低于这个值。因此,只要实例进入 Cold Start,你在门户上就会看到实例显示 Unhealthy,过一会儿才恢复。这种情况通常间歇性出现,特别是在非生产环境且访问量很低时。
我当时排查到这一步时,也曾一度怀疑是这个问题。但观察了一段时间后发现,实例的 Unhealthy 状态是持续性的,不是间歇性的,所以排除了 Cold Start 的嫌疑。如果你遇到的是间歇性 Unhealthy,建议优先检查 Cold Start 和 Always On 开关。
4. 从表象到根因:我最终定位到了哪一行代码
继续说回我的案例。在确认了"探活会收到 301"之后,我打开了 Kudu 控制台,精确定位到程序启动后的请求管线。为了彻底确认,我在 Startup.cs 的 Configure 方法中临时加了一段 app.Use(async (context, next) => { ... }) 的日志中间件,把所有请求的路径和状态码打印出来,然后观察探活请求的行为。
日志输出显示,探活请求到达时,路径确实命中了 /health,但随后返回状态码是 301,Location 头指向 https://<app-name>.azurewebsites.net/health。几乎在同一时间,门户上的实例状态从 Healthy 变为 Unhealthy。
到这里,根因就锁定了:项目启用了 app.UseHttpsRedirection(),导致 HTTP 探活请求被重定向到 HTTPS。 平台探活器不跟随重定向,于是判定失败,连续几次失败后实例就被标记为 Unhealthy。
修复方式是在 HTTPS 重定向中间件之前,先对 /health 路径做短路处理:
csharp复制app.UseWhen(
context => !context.Request.Path.StartsWithSegments("/health"),
appBuilder => appBuilder.UseHttpsRedirection()
);
这段代码的意思是:只有当请求路径不是以 /health 开头时,才应用 HTTPS 重定向中间件。这样 /health 路径无论收到 HTTP 还是 HTTPS 请求,都直接进入后续的 MVC 管道,返回 200。
另外,如果你的应用有多个健康检查路径(例如 /health、/health/live、/health/ready),可以考虑一个统一的前缀,比如 /health,然后在 UseWhen 中排除整个前缀。
4.1 不只是 HTTPS 重定向:其它中间件也有同样问题
从我的经验来看,HTTPS 重定向只是最典型的案例。凡是那些会"提前终止请求管线"的中间件,都有可能导致 Health Check 失效,例如:
| 中间件或配置 | 影响 |
|---|---|
| HTTPS 重定向 | 301 重定向,探活失败 |
| 身份认证中间件 | 401/403,探活失败 |
| IP 限制中间件 | 403,探活失败 |
| 请求体大小限制或特殊请求头处理 | 413 或 400,探活失败 |
| 自定义中间件中的异常返回 | 500,探活失败 |
| 应用池回收后首次请求超时 | 超时,探活失败 |
排查的时候不要只盯着业务代码,要从整个 HTTP 管线的角度去看。Health Check 请求进入应用后,会经过所有中间件。任何一个环节返回非 2xx/3xx,或者长时间不返回,都会导致判定失败。
最直接的做法就是在 Health Check 路径上做"短路",让它尽可能早地结束请求处理,不经过其他中间件。这也是为什么很多成熟微服务框架中,健康检查端点都放在一个独立的端口或尽早触发的管道位置,目的就是避免中间件干扰。
5. 诊断和生产环境的差异:本地健康但云端 Unhealthy
5.1 本地跑得好好的,为什么云端就挂了
一个高频场景是:本地 dotnet run 或 IIS Express 下访问 /health 一切正常,部署到 App Service 后就变成 Unhealthy。除了 HTTPS 重定向之外,还有一个很常见的原因是路径大小写和前缀不匹配。
在 Linux 上路径是大小写敏感的,在 Windows 上则通常不敏感。但如果 App Service 是 Linux 容器,你配置的 Health Check 路径是 /Health,而应用实际注册的路径是 /health,探活请求就会返回 404。404 不在 2xx/3xx 范围里,直接失败。
这个问题我在本地的 Windows 环境完全没遇到,因为路由匹配不区分大小写;部署到 Linux App Service 后,路由匹配变成区分大小写,就出现了这种情况。排查时需要先确认 App Service 的运行系统是 Windows 还是 Linux,然后有针对性地核对路径匹配规则。
5.2 请求协议和原始 IP 的影响
Health Check 请求到达应用时,X-Forwarded-Proto 头可能被设置为 https,但应用可能还保留了 http 的监听地址。如果你在代码里写了强制根据 X-Forwarded-Proto 做重定向的逻辑,同样会导致探活失败。
另外,如果你做了 IP 白名单限制,只允许某些网段的请求访问应用,探活请求可能来自平台内部的 IP 范围,如果被拒绝,同样会显示 Unhealthy。配置 IP 限制时要特别留意,给平台探活源 IP 留好通道,或者在限制逻辑中放行 /health 路径。
5.3 日志级别掩盖了探活错误
有时候日志里确实有探活失败记录,但因为日志级别设置为 Warning 或 Error,而探活失败本身只是 Warning,所以被掩盖或淹没在大量日志中。如果你用 App Service 自带的日志系统,建议在排查期间临时把日志级别调到 Information 或 Debug,并且开启详细错误信息,这样能看到更完整的请求链路。
6. 生产环境中的进阶实践:从 Unhealthy 到稳定健康的调优方案
6.1 健康检查路径的设计原则
基于前面踩过的坑,总结一下健康检查路径的设计原则:
- 只检查进程是否存活,不检查外部依赖。 数据库连接池、Redis、外部 API 的状态不应该成为健康检查的判定条件。
- 响应时间要控制在几百毫秒以内。 健康检查是高频请求,任何慢查询、远程调用都会拖慢探活响应。
- 路径要短、要简单。 避免复杂的路由规则和参数,避免进入认证授权中间件。
- 返回统一的状态码。 只要进程能处理请求,就返回 200;进程不能处理(例如已经停止或正在回收),让它自然超时即可。
6.2 如何设计依赖项检查,不影响高可用
如果你需要依赖项检查,不要做成同步请求。我的方案是:
在应用启动时建立一个后台任务,每 10 秒检查一次数据库和 Redis 连接,把结果缓存到内存中。健康检查端点只读取这个缓存值,并返回 200 或 503。
这样做的好处是:
- 健康检查端点的响应速度非常快(毫秒级),不会因为外部依赖而超时。
- 外部依赖的短暂抖动不会直接将实例下架,因为缓存值可能在 10 秒内恢复。
- 实例的 Healthy/Unhealthy 状态会更稳定,避免频繁抖动。
6.3 Azure App Service 平台侧的健康检查配置参数
在 Azure 门户中,Health Check 配置只有两个核心选项:路径和等待时间(grace period)。等待时间的含义是:在实例启动后,平台等待多长时间才认为它健康并开始发送探活请求。如果你的应用冷启动时间较长,可以适当调大这个值,比如从默认的 30 秒调到 60 秒。
还有一个容易被忽略的点:Health Check 配置是整个 App Service 级别生效的,而不是针对单个实例。假设你有 3 个实例,其中 1 个因为某种原因一直 Unhealthy,平台会自动将其从负载均衡中移除,剩下 2 个实例继续承接流量。如果所有实例都 Unhealthy,平台会默认保留所有实例继续承接流量,避免服务完全不可用。这个机制要清楚:全部 Unhealthy 时不会直接停止服务,而是退化为无健康检查的状态,把所有实例视为可用。
6.4 配合 Application Insights 做主动告警
健康检查状态本身可以在门户上看,但没有自动告警机制。如果你想在实例变为 Unhealthy 时收到通知,可以通过 Application Insights 设置自定义告警,或者利用 ARM 模板/CLI 脚本定期查询实例健康状态,并发送到钉钉、企业微信或邮件。
一个简单的思路是用 Azure CLI 或 PowerShell 调用 REST API 获取 App Service 实例的健康状态,如果检测到任意实例 Unhealthy 超过一定时间,则触发告警。这种方案可以让你第一时间感知到实例状态变化,而不是每次手动刷新门户。
7. 排查清单:如果再遇到 Unhealthy,按这个顺序走
我可以把整个排查过程整理成一份可以直接拿来用的清单:
第一步:确认路径本身
- 浏览器访问 Health Check 路径,看到 200/204 吗?
- 使用 curl(不带
-L)访问,是否出现重定向? - 访问时是否要求认证?匿名访问能否通过?
第二步:确认探活请求是否到达应用
- 在 Health Check 路径的 Action 里加一行日志,看看探活请求是否进来。
- 如果有 Web 服务器日志,搜索来自平台的探活请求记录。
第三步:确认应用版本和运行环境差异
- App Service 运行系统是 Windows 还是 Linux?
- 路径大小写是否匹配?
- 是否启用了 HTTPS Only?启用的同时有没有配置重定向中间件?
第四步:确认实例状态的时间模式
- Unhealthy 是持续性的还是间歇性的?
- 是否集中在某个或某几个实例?
- 是否在应用重启后立刻出现?
第五步:检查代码中的中间件顺序
- HTTPS 重定向是否在健康检查路径前执行?
- 是否有认证、授权中间件拦截了健康检查路径?
- 是否有 IP 白名单逻辑?
第六步:调整平台配置
- 是否需要在 Health Check 路径前加
UseWhen短路? - 是否需要增大启动等待时间(grace period)?
- 是否开启 Always On?
理论上按照这个清单,绝大多数 Unhealthy 问题都能定位到根因。这个功能本身并不复杂,复杂的是应用代码和平台机制之间容易出现各种细节上的错位。
8. 通过这个案例能延伸出什么运维思路
这个案例背后反映的问题,不只是 Azure App Service 的健康检查机制,更是一类通用的分布式系统高可用设计原则。
平台提供的健康检查是对单实例存活状态的抽象判断。它假定一个请求打过去,能快速返回一个明确的状态码,就认为这个实例健康。这个抽象假定在绝大多数情况下成立,但一旦应用逻辑里存在重定向、认证、依赖检查、冷启动等"附加行为",平台探活器就会感知到与预期不符的响应,从而误判。
所以在设计健康检查时,必须遵循一个原则:健康检查请求应该尽可能少地经过业务逻辑和中间件,它的目标是快速判断"进程是否活着",而不是判断"整个系统是否全部正常"。 活着的定义很简单——进程能处理请求并快速返回;其它组件(数据库、Redis、第三方 API)的健康状态应该由另外的系统去监控和告警。
另外一点,如果你经历过多个实例中有个别实例表现异常(比如内存泄漏、请求缓慢),健康检查其实是"将异常实例从负载均衡池中摘除"的利器。在部署新版本后,如果健康检查失败,平台会自动避免将大量流量路由到异常实例上,从而降低故障影响面。这是云原生环境里很关键的一种自愈机制。
这一次排查,最终代码改动就一行,但背后的排查过程花了将近一个小时。我觉得最有价值的不是那一行修复代码,而是把整个排查链路完整走一遍之后,对 App Service 探活机制建立了更清晰的认知。以后再看到 Unhealthy,我不再是从日志出发去猜,而是直接从请求进入应用后的管线角度去判断——那样定位问题的速度快得多。
