2018年,一个技术团队在生产环境遇到了一个诡异的现象:每当数据库响应变慢,整个服务集群就会在几分钟内完全瘫痪。排查后发现,罪魁祸首是健康检查——当数据库变慢时,应用的健康检查端点开始超时,负载均衡器将服务器标记为不健康,剩余服务器承受更多流量,进一步恶化,形成恶性循环。

这不是个案。Google SRE团队在《Site Reliability Engineering》中专门用一章讨论级联故障,其中健康检查是关键触发点。Netflix在研究应用层DDoS攻击时发现,精心构造的请求可以利用健康检查机制引发"雪崩效应"。Colin Breck在分析Kubernetes探针时列举了大量因配置不当导致的生产事故。

健康检查本应是系统可靠性的保障,却成了分布式系统中最容易被忽视的故障源头。

从二进制到谱系:健康检查的认知误区

大多数开发者对健康检查的理解停留在"进程是否存活"这个层面。一个典型的健康检查端点可能长这样:

@app.route('/health')
def health():
    return 'OK', 200

这种实现的问题在于:它只回答了"进程是否在运行",而非"服务是否可用"。

AWS在Builder’s Library中提出了一个更精细的分层模型,将健康检查分为四个层次:

Liveness Checks(存活检查):验证基本连通性和进程存在。包括TCP端口监听、HTTP基础响应、EC2状态检查等。这类检查不关心应用逻辑,只确认"机器还活着"。

Local Health Checks(本地健康检查):验证应用能否正常运行。检查磁盘读写、关键进程状态、本地资源可用性。这类检查针对服务器独有资源,不会因共享依赖故障而产生误判。

Dependency Health Checks(依赖健康检查):验证应用能否与外部系统交互。检查数据库连接、缓存服务、下游API可用性。这是最有价值也最危险的一层——它能发现真实的可用性问题,但也可能因共享依赖故障导致整个集群被误判为不健康。

Anomaly Detection(异常检测):通过对比同类服务器行为发现异常。包括时钟偏移检测、版本过旧检测、响应时间异常检测。这需要集群层面的数据聚合和分析。

这个分层模型揭示了一个核心问题:健康检查的深度与其安全性呈反比关系。越深入的检查越能发现真实问题,但也越容易因误判导致级联故障。

TCP端口存活:最危险的"假阳性"

最基础的健康检查是TCP端口探测——负载均衡器尝试建立TCP连接,成功则认为服务健康。这种检查看似简单可靠,实则隐藏着严重问题。

TCP三次握手成功只意味着:

  1. 服务器的TCP协议栈正常
  2. 目标端口有进程监听
  3. 网络路由可达

不保证

  • 应用进程能处理请求
  • 应用进程未被死锁阻塞
  • 应用响应符合预期

AWS团队分享过一个真实案例:一个Web服务器因bug进入异常状态,对所有请求返回空白错误页面。由于服务器处理错误页面的速度极快,负载均衡器使用的"最少连接"算法反而给它分配了更多流量。结果是一个故障服务器承担了不成比例的请求量,大幅降低了整体服务质量。

更隐蔽的问题是"灰度故障"(Gray Failure)。哈佛大学的研究团队在一篇论文中定义:灰度故障是指系统处于"半死不活"状态——未被健康检查标记为故障,但性能已严重退化。典型的灰度故障包括:

  • 进程被GC暂停频繁卡顿
  • 线程池耗尽,请求排队
  • 磁盘IO延迟飙升
  • 网络丢包率上升

这些状态下,TCP端口依然可以建立连接,但服务实际上已不可用。负载均衡器继续向这些"僵尸服务器"发送流量,直到用户投诉或监控系统报警。

Kubernetes探针:三个层次的复杂博弈

Kubernetes引入了三种探针:Startup、Liveness、Readiness,分别解决启动、存活、就绪三个不同层面的问题。这套设计在理论上很优雅,但在实践中充满了陷阱。

Startup Probe:启动期间的生存权

Startup探针解决的是慢启动容器的生存问题。对于需要加载大型缓存、建立复杂连接的应用,启动时间可能长达数分钟。如果在此期间Liveness探针就开始工作,容器会被反复重启,永远无法完成启动。

配置示例:

startupProbe:
  httpGet:
    path: /health/startup
    port: 8080
  failureThreshold: 30
  periodSeconds: 10

这意味着最多等待300秒(30×10)让容器完成启动。Startup探针成功之前,Liveness和Readiness探针不会生效。

Liveness Probe:重启还是不重启?

Liveness探针检测到失败时,Kubelet会重启容器。这个设计基于"Let It Crash"哲学——让运行时环境处理故障恢复,而非在应用层进行复杂的错误处理。

但这里有一个致命陷阱:什么情况应该触发重启?

Colin Breck分享过一个案例:应用启动时需要加载一个大型缓存。如果缓存加载失败,应用会记录错误但继续运行。这导致一个诡异现象——Pod处于Running状态但永远不Ready,因为Readiness探针检查缓存加载状态。由于没有配置Liveness探针,这些"僵尸Pod"永远不会被重启,无法自动恢复。

配置Liveness探针后,缓存加载失败会触发容器重启,给应用重新尝试的机会。但这里有一个微妙的权衡:如果重启过于激进,可能导致服务在短暂的依赖故障期间完全不可用。

Readiness Probe:流量开关的精细控制

Readiness探针失败不会触发重启,只会将Pod从Service的Endpoints中移除,停止接收新流量。这是实现优雅降级和滚动更新的关键机制。

但Readiness探针也有自己的陷阱:

陷阱一:依赖检查导致的级联故障

如果Readiness探针检查数据库连接,当数据库变慢时,所有Pod可能同时被标记为NotReady,服务瞬间完全不可用。

陷阱二:检查过深导致的性能问题

Readiness探针默认每10秒执行一次。如果检查逻辑复杂(如执行真实数据库查询),会给后端系统带来额外负载。

陷阱三:检查风暴

当大量Pod同时启动或同时从故障恢复时,它们可能同时对依赖系统发起健康检查请求,形成"检查风暴"。

深层检查的悖论:知道得越多,越危险

既然浅层检查有那么多问题,为什么不直接检查所有依赖?

Box公司的工程副总裁Tamar Bercovici在Velocity大会上分享了一个观点:判断数据库健康最好的方式是执行真实查询,而非监控CPU、锁超时等间接指标。这些间接指标只是"次级信号",无法准确判断数据库能否处理客户端流量。

这个观点很有道理——执行SELECT 1比检查CPU使用率更能反映数据库的真实可用性。但这里隐藏着一个分布式系统中最危险的陷阱:共享依赖故障时的误判

假设一个服务有100个实例,每个实例的健康检查都包含数据库探测。当数据库响应变慢时:

  1. 所有实例的健康检查开始超时
  2. 负载均衡器将所有实例标记为不健康
  3. 服务完全不可用

这就是深层健康检查的悖论:它能准确反映服务的可用性,但也可能因外部因素将整个集群判定为不可用

Google SRE团队对这个问题有清晰的描述:

当单个服务器失败健康检查时,负载均衡器停止向它发送流量。但当所有服务器同时失败健康检查时,问题就复杂了——如果继续拒绝流量,服务完全不可用;如果忽略健康检查继续发送流量,可能将请求发送到真正有问题的服务器。

Fail-Open:打破死循环的关键机制

解决这个悖论的方案是Fail-Open机制。

Fail-Open的含义是:当健康检查失败达到一定阈值时,不再信任健康检查结果,而是允许流量通过

AWS的Network Load Balancer实现了这个机制——当所有目标都标记为不健康时,负载均衡器会继续向所有目标发送流量。Application Load Balancer和Route 53也支持类似行为。

这个设计背后的逻辑是:健康检查可能产生假阳性(False Positive),但不会产生假阴性(False Negative)。如果健康检查说服务有问题,可能只是检查本身出了问题;如果健康检查说服务正常,服务大概率是正常的。

Fail-Open的实现需要谨慎配置阈值。太低的阈值会导致Fail-Open频繁触发,失去健康检查的意义;太高的阈值则无法有效保护。

反馈回路:让服务主动报告状态

健康检查的传统模式是"拉取式"——负载均衡器定期探测服务状态。另一种模式是"推送式"——服务主动向下游报告自己的健康状态。

HAProxy的agent-check功能就是一个典型例子。服务在独立端口暴露一个agent接口,可以返回:

75% maxconn:30 drain ready down#maintenance in progress

这个字符串包含了丰富的信息:

  • 75%:建议的权重比例
  • maxconn:30:最大连接数
  • drain:进入排空模式,不接受新连接但处理现有请求
  • ready:准备就绪
  • down:标记为不可用

这种设计允许服务根据自己的内部状态(如队列长度、CPU使用率)动态调整负载均衡策略,而非被动等待健康检查。

imgix团队分享了一个更激进的案例。他们的图像处理worker会在接受请求前评估自己的处理能力——根据当前处理的请求数量、队列状态、socket缓冲区大小等因素决定是否接受新请求。如果决定拒绝,会向下游broker返回状态,让broker选择其他worker或排队等待。

这种"背压"(Backpressure)机制是解决过载问题的根本方法。正如Matt Ranney在讨论Node.js并发问题时指出的:当资源耗尽时,必须有东西让步。与其让系统崩溃,不如在可控范围内拒绝请求

健康检查风暴:当救命稻草变成致命毒药

Netflix在研究应用层DDoS时发现了一个惊人的事实:一个精心构造的请求可以在微服务架构中引发"雪崩效应",导致整个系统瘫痪。

攻击的原理是利用微服务架构的请求放大特性。一个边缘API请求可能触发数千个中间层和后端服务调用。如果攻击者找到了这些"高成本"API,就可以用极小的代价瘫痪整个系统。

健康检查在这个过程中扮演了特殊角色。当某个关键服务因过载而变慢时,依赖它的服务的健康检查开始失败,被标记为不健康,流量转移到其他实例,进一步加剧过载。整个过程在几分钟甚至几秒钟内完成。

这揭示了一个重要原则:健康检查本身不应该成为过载的来源

具体建议包括:

  • 健康检查端点响应时间应控制在50毫秒以内
  • 避免在健康检查中执行数据库查询、磁盘IO等昂贵操作
  • 将依赖检查逻辑放在后台线程中执行,健康检查端点只读取预计算的状态标志
  • 为健康检查预留独立的资源池(线程、连接)

Lyft的Envoy实践:并发限制的力量

Lyft在从单体架构迁移到微服务架构时,级联故障曾是最主要的服务中断原因。他们最终通过Envoy服务网格解决了这个问题。

Envoy的核心机制是分布式熔断器——每个Envoy实例独立统计自己的请求成功率和延迟,当超过阈值时自动触发熔断,无需中心协调。

Lyft重点使用的三个并发限制:

最大连接数(Maximum Connections):限制到上游集群的并发连接数。对于HTTP/1.1服务尤为重要,因为每个请求需要一个独立连接。

最大待处理请求(Maximum Pending Requests):限制等待可用连接的请求数。当队列满时,新请求会被直接拒绝。

最大重试次数(Maximum Active Retries):限制同时进行的重试请求数。Lyft特别强调要"激进地熔断重试",因为重试是导致级联故障的主要原因之一。

这种设计的关键优势是基于本地信息决策——无需网络调用,延迟极低,且能自动适应集群规模变化。Lyft报告称,实施这套机制后,影响用户体验的负载相关事故减少了95%。

gRPC的健康检查协议:标准化的尝试

gRPC社区意识到健康检查在分布式系统中的重要性,制定了标准的健康检查协议。

协议定义了一个独立的gRPC服务grpc.health.v1.Health,提供两个RPC:

Check:单次查询健康状态,返回SERVINGNOT_SERVING。适合中心化监控或负载均衡场景。

Watch:流式订阅健康状态变化。适合客户端持续监控场景。

这个设计的精妙之处在于:健康检查本身是一个标准的gRPC服务,可以利用gRPC的所有特性(流式、元数据、认证等)。客户端可以通过服务配置自动启用健康检查:

{
  "healthCheckConfig": {
    "serviceName": "my-service"
  }
}

启用后,客户端会在建立连接时调用WatchRPC,只有收到SERVING状态才会发送业务请求。这实现了健康检查与应用逻辑的解耦——应用只需在状态变化时通知健康检查库,由库负责与客户端通信。

实践指南:从陷阱到方案

综合以上分析,健康检查的设计需要遵循以下原则:

分层检查,各司其职

存活检查(Liveness):只检查进程是否存活。可以是简单的TCP端口探测或/health/live端点。失败后应该重启。

就绪检查(Readiness):检查服务是否准备好接收流量。可以包含本地资源检查,但应谨慎包含依赖检查。失败后应停止接收新流量但不重启。

依赖检查:作为独立机制实现,通过监控系统告警或中心化决策系统处理,不应直接触发流量转移。

深度检查的防护措施

如果必须使用深度检查,必须同时实现:

Fail-Open机制:当不健康实例比例超过阈值时,忽略健康检查结果继续发送流量。

抖动(Jitter):在健康检查间隔中加入随机因素,避免所有实例同时检查依赖。

优雅降级:依赖检查失败时返回降级响应,而非完全不可用。

资源保护

独立线程池:健康检查使用独立的工作线程,避免被业务请求阻塞。

超时配置:健康检查的超时应短于业务请求,避免长时间等待。

优先级保证:在过载时优先处理健康检查请求,确保能正确报告状态。

监控与告警

健康检查本身需要被监控:

  • 不健康实例数量的变化趋势
  • 健康检查端点的响应时间
  • 因健康检查失败导致的重启次数
  • Fail-Open机制的触发频率

这些指标可以揭示系统的潜在问题,在演变成严重故障前发出预警。

结语

健康检查是分布式系统中最基础也最容易被误解的组件。它的设计需要在两个极端之间寻找平衡:检查太浅,无法发现真实问题;检查太深,可能因误判导致级联故障。

关键是要认识到:健康检查不是一个"设置后就忘记"的配置项,而是需要持续关注和调优的系统组件。它需要在准确性、响应速度、安全性之间找到平衡点,需要与熔断器、限流器、监控告警等机制协同工作。

正如Google SRE团队所言:当系统达到一定可靠性水平后,大多数严重事故都源于本意是为了提高可靠性的子系统出现了意外行为。健康检查正是这样的子系统——它是为了保护系统而存在,但如果设计不当,也可能成为系统崩溃的原因。

理解这一点,是构建可靠分布式系统的第一步。


参考资料

  1. Google SRE Team. (2016). Site Reliability Engineering: How Google Runs Production Systems. Chapter 22: Addressing Cascading Failures.

  2. AWS Builder’s Library. Implementing Health Checks. https://aws.amazon.com/builders-library/implementing-health-checks/

  3. Breck, C. (2019). Kubernetes Liveness and Readiness Probes Revisited: How to Avoid Shooting Yourself in the Other Foot. https://blog.colinbreck.com/

  4. Bercovici, T. (2018). Velocity Keynote: Automating Database Failovers at Box.

  5. Behrens, S. & Payne, B. (2017). Starting the Avalanche: Application DDoS In Microservice Architectures. Netflix Tech Blog.

  6. Nino, J. & Hochman, D. (2018). Envoy Service Mesh Case Study: Mitigating Cascading Failure at Lyft. InfoQ.

  7. gRPC Documentation. Health Checking Protocol. https://grpc.io/docs/guides/health-checking/

  8. Kubernetes Documentation. Configure Liveness, Readiness and Startup Probes. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/

  9. Ranney, M. Unbounded Concurrency and the Need for Backpressure in Node.js.

  10. Copyconstruct. (2018). Health Checks and Graceful Degradation in Distributed Systems. https://copyconstruct.medium.com/

  11. Meta Engineering. (2021). More details about the October 4 outage. https://engineering.fb.com/

  12. Netflix. Repulsive Grizzly Framework. GitHub.

  13. HAProxy Documentation. Agent Check. http://www.haproxy.org/

  14. AWS Documentation. Choosing the Right Health Check with Elastic Load Balancing.

  15. Envoy Documentation. Health Checking. https://www.envoyproxy.io/