结论先行
readiness 探针的职责是"我还能不能接流量",不是"我的下游还活不活着"。 一旦把下游依赖(Redis、MySQL、下游 RPC)的可达性塞进 readiness 的判定,一个下游 6 秒的抖动,就会让所有 Pod 同时被判不健康、同时被摘掉流量,剩余副本瞬间过载,过载又拖慢探针响应,导致更多 Pod 失败——这是一个正反馈级联雪崩,最终会把一个"局部依赖故障"放大成"整个服务不可用"。我们这次事故:下游 Redis 一个主从切换抖了 6 秒,12 个 Pod 在 40 秒内全部摘光,网关 5xx 打到 68%,而 Redis 自己 6 秒后就恢复了——服务死得比故障还久。
一、现象与背景(带数字)
- 服务:交易查询服务,Spring Boot 3.1(JDK 17),K8s 1.28.9,12 副本,每副本 4C8G,挂在 Nginx 网关后,稳态约 1.2w QPS。
- 探针配置(事故版,问题就藏在这里):
readinessProbe:
httpGet: { path: /actuator/health, port: 8080 } # 注意:用的是默认 /health
periodSeconds: 5
failureThreshold: 3 # 连续 3 次失败 = 15s 摘流量
timeoutSeconds: 2
- 事故形态:某日 14:22,Redis 集群一次主从切换(正常运维操作),RT 抖动约 6 秒(p99 从 3ms 到 800ms+)。
- 结果:40 秒内 12 个 Pod 的 readiness 全部转 NotReady,Endpoint 里一个就绪实例都没有,网关直接 502/503,5xx 占比峰值 68%,持续了 75 秒——比 Redis 实际故障的 6 秒长了 12 倍。
- 最反直觉:
kubectl get events里没有 OOM、没有 CrashLoop,Pod 进程都活着,jstack也没死锁;Redis 监控显示 14:22:06 起 RT 恢复正常。服务明明没挂,却自己把自己摘光了。
二、根因:健康判定里混进了"别人的死活",形成正反馈
把机制拆成一条因果链,这是理解雪崩的关键:
- Spring Boot 的
/actuator/health(默认端点)默认聚合所有 HealthIndicator。我们没做端点拆分,/health里同时包含RedisHealthIndicator、DataSourceHealthIndicator、DiskSpaceHealthIndicator等。只要任意一个下游不健康,整个/health就返回 503。 - Redis 抖 6 秒 → 所有 Pod 的
RedisHealthIndicator报 DOWN →/health全量 503 → K8s 每 5s 探一次、failureThreshold=3,约 15~20 秒内12 个 Pod 先后被判 NotReady,全部摘出 Endpoint。 - 摘光 → 过载:1.2w QPS 的流量并没有消失,而是全部砸向"还没来得及摘"的那一两个 Pod(摘流量是逐个进行的,中间状态是 11/12/11 个就绪),这几个 Pod 的 CPU 从 40% 飙到 95%+,GC 频繁,接口 RT 上升。
- 过载 → 探针更慢 → 正反馈:探针
timeoutSeconds=2,而此刻 Pod 高负载,/actuator/health里那个DataSourceHealthIndicator要真的去连一次 DB,连接池被业务占满 → 探针请求排队 → 2 秒超时 → 又一批 Pod 被判失败。 - 正反馈闭环:过载 → 探针超时 → 摘更多 Pod → 更过载……直到全部摘光。这就是雪崩:它不是被 Redis 杀死的,是被"摘流量这个动作本身"反噬的。
打个比方:一家餐厅,厨房(下游)只是打了个 6 秒的嗝,但你的"接待标准"写的是"厨房不能有任何咳嗽才算能开门"。于是厨房一咳,服务员立刻把所有桌子的客人全赶出去;客人全挤到门口那家还没关的店,那家店又忙不过来开始咳嗽,于是……整条街一起关门。厨房其实早就好了,街已经塌了。
三、具体做法:把"我活没活"和"下游活没活"拆开,再加熔断兜底
第 1 步:端点拆分,readiness 只判"自己",别判"下游"。
Spring Boot 2.3+ 已内置两个分组端点,直接用它,别再用裸 /health:
# 应用里 actuator 配置
management:
endpoint:
health:
group:
liveness:
include: livenessState # 只回答进程活没活
readiness:
include: readinessState # 只回答"我能不能接单"
# 故意不 include redis / db —— 下游死活不决定我的摘流
# K8s 探针分别打这两个端点
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 3
关键:readiness 的 handler 里不做任何下游 I/O。readinessState 是一个纯内存状态位,判断它不需要连 Redis、不需要连 DB,探针响应稳定在 5ms 内,不会被过载拖死。
第 2 步:下游的抖动交给"熔断 + 降级"管,而不是交给探针管。
下游不可用时的正确姿势是单个请求快速失败 + 降级,而不是"把整个 Pod 摘掉"。用 Resilience4j(或 Hystrix/Sentinel)给 Redis 调用加超时 + 熔断:
@CircuitBreaker(name = "redis", fallbackMethod = "queryFromCache")
public Trade query(long id) {
return redisRepo.get(id); // 抖动时快速失败,触发 fallback
}
public Trade queryFromCache(long id, Throwable t) {
return localCache.getIfPresent(id); // 降级读本地缓存,不 5xx
}
这样 Redis 抖 6 秒时,Pod 依然 Ready、依然接流量,只是这 6 秒内的请求走降级/快速失败,而不是整个服务消失。故障被"吸收"在请求层,而不是"扩散"到实例层。
第 3 步:探针 timeout 给足,且探针 handler 与业务线程池隔离。
readinessProbe:
timeoutSeconds: 3 # 按「GC 停顿 + 探针本身 + 网络」给足,别贴着 1~2s
探针走独立端口 + 独立轻量 handler(Go 侧独立 goroutine / Java 侧不占业务 Tomcat 线程),避免"业务把探针饿死"这条隐藏反噬路径。
第 4 步:观测兜底,把"摘流动作"本身变成告警。
盯 kube_pod_container_status_ready 的翻转:1 分钟内同一 Service 就绪实例数骤降 ≥30%(如 12→8)直接 P1 告警——这正是"摘流量反噬"的指纹,比等 5xx 告警早 20 秒。
四、踩过的坑 & 反直觉之处
- 坑 1:以为"健康检查越全越稳"。 恰恰相反——把下游塞进 readiness,是把"局部故障"升级成"全局摘流"的开关。健康检查越窄越好:readiness 只该回答"我能不能接单",不该回答"世界好不好"。
- 坑 2:
timeoutSeconds=2太紧。 高负载下/actuator/health要真连一次 DB,2 秒不够,探针被业务拖超时 → 误摘。timeout 要按"最坏 GC 停顿 + 探针 I/O + 网络"算,别拍脑袋给 1~2s。 - 坑 3:摘流量是"逐个"的,中间态最危险。 12 个 Pod 摘到第 5 个时,剩下 7 个扛 100% 流量,正是过载峰值——雪崩往往在"摘到一半"时最严重,而不是摘完之后。
- 反直觉:这次服务不是被 Redis 杀死的。Redis 6 秒就恢复了,服务却瘫了 75 秒。真正杀死的,是"下游一抖就摘全部流量"这个正反馈机制。故障的时长 = 机制的放大倍数 × 实际故障时长,机制不改,故障再小也会放大成大事故。
五、结论与可复用清单
- 端点必拆分:liveness / readiness 用 Spring Boot 2.3+ 的分组端点(
/actuator/health/liveness、/actuator/health/readiness),绝不用裸/health;readiness 只 include 状态位,不 include 任何下游 Indicator。 - 下游故障交给熔断降级,不交给探针:给每个外部依赖加超时 + 熔断 + fallback,让"下游抖"表现为"个别请求降级",而不是"整个 Pod 摘流"。
- 探针 handler 与业务隔离:独立端口 / 独立线程,不占业务连接池;timeoutSeconds 按最坏停顿给足(Java 服务 ≥3s)。
- 把"摘流"本身当告警:监控就绪实例数骤降(≥30%/分钟)作为 P1,比 5xx 告警更早捕获雪崩起点。
- 记住正反馈:任何"故障 → 摘流量 → 剩余过载 → 更多故障"的闭环都是雪崩隐患。设计健康判定时问一句:"这个判定失败,会不会让别的健康实例也遭殃?" 会,就该拆出去。
一句话:健康探针只该量"我的体温",不该替"整条街"做体检——你替别人体检,别人一感冒,整条街就关门了。