ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

K8s就绪探针实战:解决Java应用数据库故障时的服务假活问题

K8s就绪探针实战:解决Java应用数据库故障时的服务假活问题 去年做 MySQL 主备切换演练我盯着监控屏看到了一个很典型的场景数据库已经切到备库连接闪断了大概 40 秒按理说数据库恢复后业务应该很快恢复正常。但实际却是客户端在随后一分多钟里持续报 502/504应用日志里全是连接池超时、Communications link failure。打开 kubectl get pods 一看Java 服务所有 Pod 都显示 READY 1/1K8s 觉得服务“还活着”流量照常往里灌可应用根本连不上数据库每个请求进来都是白等。那一刻我彻底理解了K8s 的“容器存活”和用户眼中的“业务可用”之间隔着一个 Readiness Probe 的距离。这篇文章就围绕 Readiness Probe就绪探针展开解决一个 Java 应用在 K8s 上特别常见的真实问题数据库故障或恢复期间K8s 仍然把流量打进应用导致请求大面积失败。配置好 Readiness Probe 之后K8s 会在数据库不可用时自动把 Pod 从 Service 的 Endpoints 里摘掉等数据库恢复、应用真正准备好之后再重新挂回流量。这篇文章适合刚把 Java 应用迁到 K8s 的团队也适合长期被“数据库一抖、服务就假活”折磨的运维和开发同学。1. 服务“假活”数据库挂了的几十秒流量还在往 Pod 里灌1.1 故障现场READY 1/1 干着 0/1 的活先还原一下当时的故障表现。数据库主备切换的窗口大概 40 秒切换完成后 MySQL 恢复可用。但就在这 40 秒加上恢复后的近一分钟里业务方反馈所有接口大面积超时网关层监控显示 502、504 比例急剧上升。我这边看 K8s 集群所有应用 Pod 的状态都是 RunningREADY 列显示 1/1没有任何异常Service 对应的 Endpoints 列表里Pod IP 一个不少全在里面Pod 的日志显示大量“HikariPool-1 - Connection is not available, request timed out”。这里的问题非常典型Pod 的进程活着、端口在监听但应用的核心依赖数据库已经断了。K8s 的默认行为只检查“容器是否在运行”并不关心“业务是否真的能处理请求”。所以它继续把流量分发给这些实际上已经不具备服务能力的 Pod用户请求打进 TomcatTomcat 里的线程去连接池拿连接连接池里全是失效连接请求只能排队超时最终导致线程池被占满。数据库恢复后这些堆积的请求还在慢慢消化新的连接池也还没完全重建服务恢复的时间远远晚于数据库恢复的时间。我在很多团队里都见过类似的情况。第一反应通常是调大连接池、加长 timeout、增加 Pod 副本数但这些都是治标不治本。真正的原因只有一个K8s 不知道这个应用“其实不能干活”。它判断“健康”的标准太粗了需要我们把“业务就绪”的信号显式地告诉它。1.2 为什么“启动成功”不等于“业务就绪”Java 应用进程起来、Spring 容器初始化完成、8080 端口开始监听很多人觉得这就叫“服务可用了”。但站在业务角度这只是“进程没死”。业务就绪要求的是数据源可用、消息队列连接可用、注册中心能连通、依赖的配置服务能拿到配置。数据库是 Java 应用最核心的依赖只要数据库不可用就算 Tomcat 端口每毫秒都在响应 TCP 握手进来的每个 HTTP 请求也注定会失败。传统部署模式下这个矛盾不太容易被放大因为负载均衡后端一般不会主动感知业务依赖运维通过监控发现故障后人工介入。K8s 不一样它把“调度”和“流量管理”当作自动化系统的基础设施。Service 后面的 Endpoints 列表是所有流量的分发依据Endpoints 里有什么 IP流量就打到什么 IP。K8s 没有能力判断这个 IP 背后的应用到底能不能干活除非你告诉它。Readiness Probe 就是那个“告诉它”的机制。1.3 那条要命的恢复时间窗口数据库挂掉再恢复应用并不是立刻就能恢复服务中间存在一条时间窗口由几个因素叠加而成HikariCP 默认的连接超时时间是 30 秒数据库不可用时应用线程会阻塞等待拿连接等到超时才报错HikariCP 默认的 maxLifetime 是 30 分钟数据库断开期间连接池里的旧连接不会立刻被销毁应用可能要反复拿到失效连接才知道要重建Tomcat 默认线程池 200 个线程当这些线程都被慢请求占住后新的请求只能排队排队时间被拉长超时率暴增数据库恢复后连接池需要按需重建连接但如果旧的失效连接还在池子里没有被逐出应用会先“尝试”旧连接失败后再新建这个过程会拖慢整个恢复节奏。所以数据库恢复后应用还需要几秒甚至几十秒才能真正恢复服务。这个时间窗口内如果流量一直打进来就会造成“数据库好了应用还躺着”的尴尬局面。Readiness Probe 的价值就在这里它让 K8s 在发现应用未就绪时先摘流量等应用真正恢复、连接池完成重建后再恢复流量分配。流量不会早到也不会堵在应用门口。这也是为什么我始终坚持K8s 上跑 Java 应用Readiness Probe 不是可选项而是必选项。2. 先分清两位探针Liveness 管“死没死”Readiness 管“能不能用”2.1 两种探针的分工边界很多刚开始接触 K8s 的同学会把 Liveness Probe 和 Readiness Probe 搞混甚至把两者配置成完全一样的检查方式这会在故障时引发更严重的雪崩。我一般用一句话来区分它们Liveness Probe 回答“这个容器还活着吗”如果检测失败Kubelet 会根据 restartPolicy 把容器杀掉再重建。它解决的是死循环、死锁、OOM 这类让进程陷入僵死状态的问题Readiness Probe 回答“这个容器准备好接收流量了吗”如果检测失败Kubelet 不会杀 Pod而是把该 Pod 从 Service 的 Endpoints 中摘除不再往这里分发流量。为什么要区分因为两者失败后的处理动作完全不同。比如数据库出现 10 秒抖动如果是 Readiness 检测失败K8s 只是暂时不往这个 Pod 打流量数据库恢复后探针重新通过Pod 自动回到 Endpoints 列表整个过程对 Pod 本身零伤害。但如果 Liveness 也配置成同样的检测Kubelet 会把 Pod 杀掉重启重启一个 JVM 的成本至少是几十秒起步而且副本重启期间可用容量直接减少严重时还能引发连环雪崩。2.2 为什么 TCP 探针解决不了数据库依赖问题有些同学喜欢图省事Readiness Probe 直接配一个 TCP 探针检查 8080 端口。这种写法对基础组件还可以对业务应用来说几乎等于没配。原因很简单端口在监听不代表应用“能用”。数据库挂了Tomcat 的 8080 端口照样 accept 连接TCP 探针永远成功Pod 永远 Ready流量继续打进一个注定失败的请求。K8s 里 TCP 探针的检测逻辑只是判断“能不能建立 TCP 连接”它不做任何应用层的逻辑判断。业务应用要判断“就绪”与否最可靠的方式是直接给一个 HTTP 接口由应用自己汇报健康状态。这也是 Spring Boot Actuator 的健康检查端点成为主流选择的原因。2.3 三种探针方式怎么选K8s 的 Probe 机制支持三种检测方式我把它们的适用场景整理了一张表探针类型检测原理适用场景典型局限HTTP GET请求指定 HTTP 端点根据响应码判断200-399 为成功业务应用、有 HTTP 接口的服务配合 /actuator/health 最合适依赖应用暴露健康端点TCP Socket尝试与指定端口建立 TCP 连接成功即判定正常Redis、MySQL 等中间件或没有 HTTP 接口的组件只能反映端口连通性无法感知应用内部依赖Exec在容器内执行命令根据退出码判断0 为成功容器内没有 HTTP 服务需要通过脚本探测的场景每次探测都会启动进程开销相对大一些业务应用优先用 HTTP GET纯中间件优先用 TCP或者直接交给 K8s 上层服务网格的 mTLS 健康检查特殊场景才用 Exec。我见过不少团队给 MySQL 容器本身也配了 HTTP 探针这是没必要的MySQL 又没有 HTTP 接口老老实实用 TCP 探针就好。3. Java 侧的配合让 Actuator 的健康检查反映真实业务依赖3.1 引入 actuator 并暴露健康端点如果 Java 应用用的是 Spring Boot那 Readiness Probe 的最佳搭档就是 Actuator。它是 Spring Boot 自带的监控端点组件能提供 /actuator/health 之类的接口。引入方式很简单Maven 项目在 pom.xml 里加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency然后配置暴露 health 端点management: endpoints: web: exposure: include: health endpoint: health: show-details: when_authorized probes: enabled: true这里有几个细节容易踩坑。第一Spring Boot 2.x 之后actuator 的端点默认只暴露 health 和 info所以通常不用额外加 include但如果用了旧版或者自定义过暴露策略一定要确认 health 被暴露出来。第二show-details 建议设置成 when_authorized这样匿名访问时只返回总体状态UP / DOWN不会泄露数据库地址这类内部信息如果设置成 always探针能看到细节但安全风险也更大。第三Spring Boot 2.3 支持 Kubernetes 探针专用端点/actuator/health/liveness 和 /actuator/health/readiness这是官方解决 Liveness 和 Readiness 分离的推荐方式。打开了 probes.enabled 之后这两个端点就会生效。3.2 health 端点如何感知数据源断连Actuator 的健康检查会把多个 HealthIndicator 的结果聚合起来比如 DataSourceHealthIndicator、RedisHealthIndicator、KafkaHealthIndicator 等。默认情况下只要数据源健康检查失败/actuator/health 就会返回 503聚合状态的 DOWN 会传导到端点结果中。DataSourceHealthIndicator 的检测逻辑并不神秘它会从连接池里拿一个连接执行一条验证 SQL。对 MySQL 来说默认就是 SELECT 1如果这个查询失败数据源健康状态就变成 DOWN。这个机制意味着当数据库不可用时应用自己能立即感知到并且通过健康端点把这个状态暴露出去。Readiness Probe 请求这个端点时拿到的就是 503从而触发摘流量动作。这里有一个重要的时间差问题。Spring Boot 默认对健康检查结果做了缓存比如 health 端点的缓存时间默认在几秒级别。如果 Readiness Probe 的探测周期设得比缓存短探针拿到的是旧结果。生产环境我一般把探针周期设在 5-10 秒之间同时调整 health 缓存时间避免探针打过来时看到的还是上一轮的“健康”状态。具体配置management: endpoint: health: show-details: when_authorized cache: time-to-live: 5s3.3 连接池参数与健康检查的联动Readiness Probe 只负责“门卫”真正决定应用能不能快速恢复服务的是连接池行为。我用的是 HikariCPSpring Boot 2.x 的默认连接池有几个参数值得在生产环境刻意调整spring: datasource: hikari: connection-timeout: 3000 validation-timeout: 3000 minimum-idle: 5 maximum-pool-size: 20 connection-test-query: SELECT 1 idle-timeout: 600000 max-lifetime: 1800000connection-timeout 我建议从默认 30 秒缩短到 3-5 秒。核心原因是数据库不可用时探针会摘流量但可能还有少量在途请求和刚恢复流量瞬间的请求如果应用线程在连接池上阻塞 30 秒线程池会被快速占满恢复期会被无限拉长。把拿连接超时控制在 3 秒线程能快速失败把线程资源释放出来后续探针一旦恢复应用能更快进入正常状态。connection-test-query 对应的是连接池在拿到连接后做一次连通性测试我显式指定为 SELECT 1。虽然 HikariCP 会自动探测 JDBC 驱动的 validationQuery但显式配置能保证不同数据库类型下行为一致。还有一个容易忽略的细节当 Readiness Probe 第一次从失败转成功时探针请求本身就会触发一次数据源连接获取这相当于强制连接池重建了一次连接。所以探针恢复成功的那一刻数据库连接大概率已经是可用的了流量的进入顺序和连接池的就绪顺序是吻合的。这是整个方案最优雅的地方。4. 完整的 YAML 配置从模板到生产级参数4.1 一个能直接用的 Deployment 示例说了这么多原理来看一个完整的可运行配置。下面这个 Deployment 示例把 StartupProbe、ReadinessProbe、LivenessProbe 三种探针都配上了并且分别使用 Spring Boot Actuator 提供的独立端点互不干扰apiVersion: apps/v1 kind: Deployment metadata: name: order-service labels: app: order-service spec: replicas: 3 selector: matchLabels: app: order-service template: metadata: labels: app: order-service spec: containers: - name: order-service image: registry.example.com/order-service:1.4.2 ports: - name: http containerPort: 8080 env: - name: SPRING_PROFILES_ACTIVE value: prod startupProbe: httpGet: path: /actuator/health/readiness port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 30 successThreshold: 1 readinessProbe: httpGet: path: /actuator/health/readiness port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 2 successThreshold: 1 livenessProbe: httpGet: path: /actuator/health/liveness port: http initialDelaySeconds: 15 periodSeconds: 20 timeoutSeconds: 5 failureThreshold: 3 successThreshold: 1对应的 Service 配置apiVersion: v1 kind: Service metadata: name: order-service spec: selector: app: order-service ports: - port: 80 targetPort: http4.2 每个探针参数背后的计算逻辑很多人拿到 YAML 就往上套但参数如果拍脑袋配上线后反而会踩坑。我把几个关键参数的计算逻辑展开讲一下initialDelaySeconds容器启动后探针要等多久才开始第一次探测。Java 应用的初始化时间波动比较大JVM 启动、Spring 容器创建、配置中心拉取配置这些步骤通常在 10 到 40 秒之间。如果 initialDelaySeconds 设得太小应用还没起来探针就打了Pod 会被频繁标记为未就绪设得太大启动慢的应用会长时间没有探针状态流量可能被提前打进来。我一般先看应用真实启动日志找到端口监听完成、ApplicationStartedEvent 触发的时间点再往上加 5 到 10 秒余量。如果用了 startupProbereadiness 的 initialDelaySeconds 可以设 0因为 startupProbe 会先“挡”一段时间。periodSeconds 和 failureThreshold 的组合这两个参数共同决定“从依赖故障到流量摘除需要多久”。比如 periodSeconds5、failureThreshold2那么最坏情况下 10 秒左右就会把 Pod 摘掉。这个时间如果太长故障瞬间用户还是会碰到几次错误太短则可能在网络抖动时误摘影响可用性。对于数据库这类核心依赖我推荐 5 秒周期加上 2 次失败阈值也就是 10 秒内完成摘流量。如果业务对瞬时错误容忍度极低可以缩到 3 秒周期加 2 次失败代价是探针请求频率变高应用承担的压力也变大。timeoutSeconds单次探针请求的超时时间。如果 /actuator/health 因为数据库故障或线程池占满而迟迟不返回探针请求就会一直挂着整个探针周期被拉长摘流量的速度变慢。建议控制在 2 到 3 秒。successThreshold从失败转成功时需要连续成功多少次才算就绪。默认值 1 通常就够用不需要调大。因为摘流量之后应用恢复就绪的瞬间如果只有一次成功就挂流量确实存在概率正好碰到抖动假恢复但调大这个值意味着恢复后要等待更长时间才挂流量业务恢复速度变慢得不偿失。4.3 startupProbe 解决 Java 启动慢的问题Java 应用在 K8s 上有一个老生常谈的痛点启动慢。如果只配置 liveness 和 readiness而且 liveness 的 failureThreshold 不够大启动慢的 Pod 可能 Spring 容器还在初始化就被 liveness 判定为“僵死”直接被 Kubelet 杀掉进入 CrashLoopBackOff 的循环。这个坑我踩过一次当时某个服务的启动时间在 60 秒左右liveness 的 failureThreshold 设成 3、periodSeconds 10也就是 30 秒没成功就杀结果那一次发版直接把服务搞崩了整整 15 分钟才恢复。startupProbe 就是专门解决这个问题的。它的语义是“容器启动过程是否完成”在 startupProbe 成功之前liveness 和 readiness 都不会生效。所以给启动慢的 Java 应用配置一个充足的启动窗口很重要。上面例子里periodSeconds5、failureThreshold30总共 150 秒的窗口绝大多数 Java 服务都能完成启动。启动完成后容器运行期的健康管理就完全交给 liveness 和 readiness。这也是为什么我强烈建议上 K8s 的 Java 应用三个探针要一起配缺一个都不完整。5. 实弹演习模拟数据库宕机、恢复与流量切换5.1 演练准备光说不练假把式。我在本地用 kind 搭了一个单节点集群部署了一个 Spring Boot 应用order-service和一个 MySQLMySQL 也跑在集群里便于直接控制它的启停。应用镜像里已经引入了 actuator 并配置了健康端点探针配置和上面示例一致。先把环境跑起来kind create cluster --name probe-demo kubectl apply -f mysql.yaml kubectl apply -f order-service.yaml kubectl rollout status deploy/order-service确认服务都起来后用一个循环请求脚本持续打接口while true; do code$(curl -s -o /dev/null -w %{http_code} http://localhost:8081/order/1) echo $(date %H:%M:%S) HTTP $code sleep 1 done这里的 8081 是本地端口到 Service 的映射实际环境可以换成 NodePort 或者直接请求 Service IP。5.2 宕机阶段摘流量到底有多快开始模拟数据库故障直接把 MySQL 的副本缩到 0kubectl scale deploy/mysql --replicas0然后同时观察两边的输出。请求脚本那一侧刚开始还会出现少量 200因为 Readiness Probe 的检测周期是 5 秒数据库故障后最多有 5 到 10 秒左右的时间窗口探针还没有连续失败到阈值流量仍在进入。大约 8 到 10 秒之后脚本输出全部变成 502 或 504但和之前那个“数据库挂了流量还在进”的故障不同这时候网关层的错误来自“Service 后面已经没有可用 Endpoint”而不是应用本身处理不过来。再看 K8s 侧kubectl get pods -w kubectl get endpoints order-servicePod 列表里 order-service 的 READY 会从 1/1 变成 0/1Endpoints 列表里的 Pod IP 会消失。此时再执行 kubectl describe podEvents 里能看到 Readiness probe failed 的记录失败原因指向数据库连接超时。从数据库故障发生到流量完全摘除整个过程在 10 秒左右和对应的 failureThreshold * periodSeconds 吻合。这个过程中最核心的体验是应用没有重启Pod 也没有被删掉只是从流量分发列表中被摘除。数据库恢复后这个 Pod 重新回到 Endpoints 列表整个过程不需要人工介入。5.3 恢复阶段重新挂流量的微妙节奏恢复数据库kubectl scale deploy/mysql --replicas1观察时会发现一个细节MySQL Pod 变成 Running 之后order-service 并不会立刻变成 Ready。这是因为 Readiness Probe 的成功需要连续 1 次成功successThreshold1而这次成功的前提是应用能从连接池拿到一个可用的数据库连接。在数据库恢复初期HikariCP 正在逐出旧连接、建立新连接可能要等几秒甚至十几秒才能完全就绪。这个“延迟”不是坏事它保证了流量重新进入时连接池已经具备处理能力避免了“数据库好了但应用还没好”的尴尬窗口。等到 order-service 的 READY 重新变为 1/1Endpoints 里重新出现 Pod IP请求脚本的输出会在 1 到 2 秒内恢复 200。恢复过程中如果你盯着 kubectl get pods -w 看可能还会看到 READY 在 0/1 和 1/1 之间抖动一次这是正常的探针刚恢复成功时如果某一次请求恰好遇到连接池重建状态会反复两个周期后就稳定了。这套演练做完实际的收获是从数据库故障到流量摘除再到数据库恢复后流量重新接入整个过程完全自动化应用侧不需要人为干预。这也是我后面在多个生产集群上推广 Readiness Probe 方案的底气。6. 上线后被问得最多的坑与排查手段6.1 七类高频问题汇总配置 Readiness Probe 本身不复杂但生产环境跑起来后各种边界问题才会浮出水面。我整理了几个高频问题问题现象根因解决方法Liveness 和 Readiness 共用同一端点数据库抖动时 Pod 被反复重启Liveness 失败会触发重启误判业务故障为僵死用 /actuator/health/liveness 和 /actuator/health/readiness 分开initialDelaySeconds 太小启动慢的 Java 应用一直处于未就绪状态探针在应用完成初始化前就开始探测根据启动日志设置合理值或依赖 startupProbe只配 Liveness 没配 Readiness数据库挂后 Pod 被重启而不是摘流量没有不上流量管理能力补上 ReadinessLiveness 只管进程僵死TCP 探针当业务探针用数据库不可用但 Pod 一直 Ready端口监听不代表业务就绪换成 HTTP GET 探针指向 ActuatorActuator 被安全框架拦截探针请求返回 401Pod 一直未就绪Spring Security 拦截了 /actuator/health放行健康检查端点或用 Actuator 的 additional-pathshealth 端点结果缓存过旧数据库故障后探针过了一段时间才失败健康检查结果被缓存探针拿到旧状态调整 management.endpoint.health.cache.time-to-liveMySQL 启动慢应用探针过早成功数据库还没真正可用Pod 就 Ready应用启动时数据库可能还在初始化给数据库也配置 Readiness 探针应用侧适当加大延迟6.2 快速排查工具链如果探针行为不如预期我一般按下面的顺序排查kubectl describe pod 先看 Events失败的探针会有详细记录kubectl get endpoints -o yaml确认当前流量挂到了哪些 IP 上kubectl exec -it -- curl -s http://localhost:8080/actuator/health手动触发一次健康检查看返回状态码和 bodykubectl logs --previous如果容器被重启过上一轮的日志会保留在这里确认请求链路如果经过了 Ingress 或网关还要额外排查这些组件的健康检查配置它们不一定和 K8s 的探针共用一套体系。这套工具链帮我定位过很多案例。有一次同事反馈“Pod 明明 Ready 了但流量一直报错”最后排查发现是 Ingress Controller 上游健康检查的频率比 K8s 探针低Pod 被摘除后 Ingress 还要一段时间才能感知。这个问题不在 K8s 探针本身但也要纳入考虑范围。6.3 我自己的实践心得配置 Readiness Probe 这事做得比不做强但做得“细”比做得“有”更重要。有三个经验我一直在坚持第一探针参数一定要针对应用实际调整不要抄网上现成的模板。Java 应用的启动时间、数据库连不上的容错能力、业务对瞬时错误的容忍度每个团队都不一样。我每次上线新服务都会先看两样东西应用启动日志里从进程启动到事件监听完成的时间以及压测时数据库抖动下应用恢复的时间曲线。这两个数据决定了探针参数的大致范围。第二Readiness Probe 不是银弹它只解决流量摘除和挂回的问题。像数据库连接池参数、线程池大小、慢 SQL 这类业务侧问题该优化还是要优化。探针做得再好也救不了一个连接池配置全是默认值的应用。第三探针本身也有成本。HTTP 探针每次都要走一遍 HTTP 请求如果周期设成 1 秒每个 Pod 每秒都会产生一次额外的应用层请求。高流量集群里大量 Pod 同时高频探测健康端点本身也可能成为一个小热点。生产环境我一般不会把周期设到 3 秒以下除非业务对故障探测要求极高。后续如果服务规模再上去还可以考虑用 Service Mesh 的流量治理能力做更细粒度的灰度发布和故障注入演练配合 Readiness Probe 做更极端的弹性测试。但不管上层工具怎么演进Readiness Probe 作为 K8s 原生机制永远是保障 Java 应用服务质量的第一道防线。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进