本文来自一次真实事故,但服务名、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 | 常见信号 | 不能直接证明什么 |
|---|---|---|
| 137 | SIGKILL,可能是 OOM 或超过 grace period 后被强杀 | 不能仅凭 137 断言 JVM 堆溢出 |
| 143 | SIGTERM,常见于探针重启、滚动更新或人工删除 | 不能仅凭 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 同时重启是现象,不是根因。最可靠的排查方式是:
终止状态 → 事件 → 前一容器日志 → 节点分布 → 公共依赖 → 线程/连接池 → 探针放大效应
平台层通常负责最后一次“杀死进程”,但真正让进程无法及时响应的原因,可能藏在一条没有超时边界的普通业务调用里。
DISCUSSION
讨论与反馈