本文来自一次真实事故,但服务名、Pod、节点、时间、接口、容量和组织信息均已泛化。系列导航:Kubernetes 重建迁移教程系列。

现象很像平台故障:多项 Spring Boot 服务在相近时间重启,日志里同时出现 Nacos gRPC 重连、JDBC 获取连接失败、Feign 超时和探针失败。

如果只截取其中一行,很容易得出三个不同结论:

  • Kubernetes 节点故障。
  • Nacos 升级导致全站异常。
  • JDK 或内存问题。

最终证据表明,节点和中间件没有同步重启;多个业务线程被慢依赖和数据库连接等待占住,liveness 又把“暂时无法及时响应”判成“进程永久失活”,从而放大成重启风暴。

事故判断链路

flowchart LR
  A["多 Pod 重启"] --> B["Last State / Exit Code"]
  B --> C["Events 与探针"]
  C --> D["previous logs"]
  D --> E["节点分布"]
  E --> F["共同依赖"]
  F --> G["线程与连接池阻塞"]
  G --> H["代码超时和降级修复"]

第 1 步:不要先看当前日志

Pod 重启后,kubectl logs 默认看到的是新容器。先收集:

kubectl -n <namespace> get pod <pod> -o jsonpath='
restartCount={.status.containerStatuses[0].restartCount}{"\n"}
reason={.status.containerStatuses[0].lastState.terminated.reason}{"\n"}
exitCode={.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}
startedAt={.status.containerStatuses[0].lastState.terminated.startedAt}{"\n"}
finishedAt={.status.containerStatuses[0].lastState.terminated.finishedAt}{"\n"}'

kubectl -n <namespace> logs <pod> --previous --timestamps \
  > reports/<pod>-previous.log

--previous 是事故证据,不应等 Pod 再次重启才想起来保存。

第 2 步:理解 Exit 137 和 143

Exit Code常见信号不能直接证明什么
137SIGKILL,可能是 OOM 或超过 grace period 后被强杀不能仅凭 137 断言 JVM 堆溢出
143SIGTERM,常见于探针重启、滚动更新或人工删除不能仅凭 143 断言正常发布

继续看 reason:

kubectl -n <namespace> describe pod <pod>
kubectl -n <namespace> get events --sort-by=.lastTimestamp

如果是 OOMKilled,再检查容器 limit、JVM Native Memory 和节点压力。如果事件持续出现 Liveness probe failed,则要查清探针为什么超时。

第 3 步:比较 Pod 是否集中在同一节点

kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,NODE:.spec.nodeName,RESTARTS:.status.containerStatuses[0].restartCount' \
  | sort

如果故障 Pod 分散在多台 Ready 节点,而同一节点上的其他工作负载正常,单节点 kubelet、containerd、磁盘或网络故障的可能性下降。

同时检查节点:

kubectl get nodes
kubectl describe node <node> | sed -n '/Conditions:/,/Addresses:/p'
kubectl get events -A --field-selector involvedObject.kind=Node --sort-by=.lastTimestamp

第 4 步:建立时间线,而不是搜索一个关键词

将所有 previous logs 的关键事件按时间排序:

rg -n -i 'liveness|readiness|timeout|refused|connection|pool|nacos|shutdown|killed' reports/

这次事件呈现的顺序是:

远程接口响应持续变慢
→ Feign 调用占住业务线程
→ 数据库连接池等待增加
→ 健康端点无法在 timeout 内返回
→ liveness 连续失败
→ kubelet 发送 SIGTERM
→ 部分进程未在 grace period 内退出
→ 容器被重启

日志中同时存在 Nacos 重连,但时间线显示 Nacos StatefulSet 的滚动升级发生在更早的维护窗口,不能把相邻日志自动当成同一根因。

第 5 步:区分“共同出现”和“共同根因”

建立对照表:

组件是否重启是否健康与故障同一时间
Kubernetes Node否Ready否
Nacos未同步重启三节点可用否
Redis否PING 正常否
消息中间件否成员正常否
多个业务 Pod是liveness 超时是

这张表排除了“所有底层组件同时崩溃”的可能,把范围收敛到业务共享依赖和探针设计。

第 6 步:找到没有边界的远程调用

问题调用是一个非核心附件查询。它位于主业务查询内部,但没有独立的短超时;下游变慢后,请求会长时间占住线程。

为单个 Feign Client 配置边界:

spring:
  cloud:
    openfeign:
      client:
        config:
          attachment-api:
            connectTimeout: 3000
            readTimeout: 5000
            loggerLevel: basic

Spring Cloud OpenFeign 支持按 Feign Client 配置 URL、连接和读取参数,具体属性应按项目的 Release Train 对照官方文档。

只对幂等请求做有限重试。下游已经变慢时,无限制重试会把一次拥塞放大成更多并发。

第 7 步:让可选依赖真正可降级

附件不是付款、订单或报表主数据。目标行为应是:

附件查询成功 → 返回附件
附件超时 → 记录带业务键的告警,返回空附件
主业务数据 → 正常返回

伪代码:

try {
    return attachmentClient.findByBusinessKey(businessKey);
} catch (RetryableException ex) {
    log.warn("attachment lookup degraded, businessKey={}", businessKey, ex);
    return List.of();
}

降级逻辑不能吞掉核心数据写入失败,也不能把所有异常都当作“没有附件”。应只捕获明确的远程连接或超时异常,并对降级进行计数和告警。

第 8 步:检查连接池和线程池

远程调用阻塞可能导致:

  • Web 请求线程耗尽。
  • Spring Batch 分区线程等待。
  • Druid/Hikari 获取连接超时。
  • 健康端点与业务共享资源时一起超时。

需要同时监控:

HTTP active threads
Feign latency/error
JDBC active/pending connections
Kafka/Rabbit listener lag
GC pause
readiness/liveness latency

看到 CannotCreateTransactionException 不代表数据库一定宕机。若底层异常是 InterruptedException,可能是 Pod 正在终止时线程被中断。

第 9 步:探针调宽只能临时止血

临时将 liveness 调整为更宽松的时间窗口,可以避免依赖的短暂抖动立即杀死进程:

livenessProbe:
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 12

但这只把重启窗口延后到约 120 秒。若线程永久阻塞,进程仍会重启;若没有代码超时,流量仍会持续排队。

正确顺序是:

临时降低重启爆炸半径
→ 给依赖设置超时和隔离
→ 实现业务降级
→ 验证线程和连接池恢复
→ 再按数据校准探针

第 10 步:验证修复

在测试环境中人为让附件服务的延迟超过 read timeout:

主业务接口在规定时间内返回
附件字段进入明确降级结果
Feign 超时指标增加
Web 线程不持续累积
数据库连接池等待恢复
liveness 始终成功
Pod restartCount 不增加

部署后观察:

kubectl -n <namespace> get pods -w
kubectl -n <namespace> logs deployment/<app> --since=30m \
  | grep -Ei 'attachment lookup degraded|liveness|timeout|shutdown'
kubectl top pod -n <namespace>

如何识别 Nacos 滚动升级日志

Nacos Client 出现:

RST_STREAM closed stream
HTTP/2 error code: CANCEL
reconnect

先比较:

kubectl -n <nacos-namespace> get pod -o wide
kubectl -n <nacos-namespace> get controllerrevision
kubectl -n <nacos-namespace> rollout history statefulset/nacos

若日志与 Nacos Pod 的逐个替换严格对应,随后客户端自动重连且实例恢复,这更像是升级过程中的连接切换。只有时间、实例状态和业务失败一致,才能判定为事故根因。

最终根因与改进

根因

  • 可选远程依赖没有独立短超时。
  • 慢调用占用业务线程并诱发连接池等待。
  • liveness 健康检查对暂时性资源拥塞过于敏感。

放大因素

  • 多项服务使用相同探针模板。
  • 日志中同时存在 Nacos 重连,干扰了初始判断。
  • previous logs 没有在第一时间集中保存。

改进

  • 为每个外部依赖明确超时、重试、隔离和降级。
  • liveness 只检查进程是否可自愈,readiness 表示接流能力。
  • 统一采集 Last State、Events 和 previous logs。
  • 为发布与中间件升级建立时间线和 Revision 记录。

结论

多个 Pod 同时重启是现象,不是根因。最可靠的排查方式是:

终止状态 → 事件 → 前一容器日志 → 节点分布 → 公共依赖 → 线程/连接池 → 探针放大效应

平台层通常负责最后一次“杀死进程”,但真正让进程无法及时响应的原因,可能藏在一条没有超时边界的普通业务调用里。