首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >离谱!提前 120s 从 Nacos 摘流,线上依旧疯狂报 Connection refused

离谱!提前 120s 从 Nacos 摘流,线上依旧疯狂报 Connection refused

原创
作者头像
锡东
修改2026-08-14 21:38:50
修改2026-08-14 21:38:50
1230
举报
文章被收录于专栏:踩坑集踩坑集

摘要

谁能想到,一套经过线上多轮验证、自带双层优雅下线 + 120s 流量缓冲窗口的微服务销毁方案,会在一次常规节点缩容中彻底失效。线上检索服务大批量商品索引重建任务抛出Connection refused连接拒绝异常,监控显示故障 Pod 正在正常执行优雅关闭、文件句柄平缓下降;明明 Pod 销毁前会通过 PreStop 主动注销注册中心实例,预留两分钟等待调用方刷新缓存,可上游请求依旧源源不断打过来。

完整排查后发现,问题根源并非下线框架存在设计缺陷,而是运维缩容缺少节点封锁步骤,引发 Pod 跨节点二次驱逐,出现诡异时序倒置:注销接口在应用还未注册 Nacos 时就提前执行,注销操作完全作废。再叠加客户端负载均衡缓存、POST 接口无跨实例重试、Agent 日志未落地等多重因素,最终引发线上故障。本文完整还原故障时序、拆解双层下线底层原理、分层定位根因,同时给出运维规范、容器脚本、业务容错多层级落地解决方案。

脱敏说明:业务平台名称、自研框架标识、注册中心、业务服务名、内网 IP、监控指标图仅保留逻辑描述,敏感标识统一脱敏处理。

一、故障现象

1.1 业务报错

商品检索服务异步索引重建任务 Feign 调用商品基础服务批量查询接口持续抛出异常,核心堆栈关键信息:

代码语言:javascript
复制
getGoodsByIdList error goodsIdList:[753122130018366]
feign.RetryableException: Failed to connect to /172.19.115.10:8080 executing POST http://goods-service/queryGoodsInfo
Caused by: java.net.ConnectException: Connection refused
Suppressed: java.io.IOException: unexpected end of stream on http://172.19.115.10:8080/...
Caused by: java.io.EOFException: \n not found: limit=0 content=…
  1. 调用链路:检索服务(消费方)→ 商品基础服务(提供方,实例 172.19.115.10:8080)
  2. 请求类型:POST 接口,用于商品全量索引构建异步任务
  3. 异常特征:同时存在EOFException抑制异常 + Connection refused根异常

1.2 监控指标佐证

找到服务提供方实例监控,发现该实例生命周期很短,启动后即停止。

故障实例文件句柄监控
故障实例文件句柄监控

1.3 业务影响

  1. 单商品 ID 索引构建失败,商品完整信息无法写入检索引擎;
  2. 无自动重试兜底,任务直接失败,需人工补发重建任务。

二、系统内置优雅下线底层机制

平台自研微服务框架设计双层独立下线体系,分为 Java Agent 层、Spring 业务框架层,配套 K8s PreStop 标准销毁流程。

2.1 两层下线组件能力对比

表格

分层

服务载体

监听端口

核心能力

触发方

Java Agent 层

随 JVM 字节码增强启动,独立于 Spring 容器

60199

反射调用注册中心接口,主动注销当前实例;提供健康探针/health

K8s PreStop 钩子、就绪探针

Spring 框架层

SideServer 轻量 Web 服务,容器启动后初始化

8686

仅内存标记应用状态;执行业务资源清理 Hook(MQ、定时任务、线程池等)

SIGTERM 信号、运维手动调用、容器关闭事件

2.2 Agent 层核心端点逻辑

  1. /offline:幂等执行,调用注册中心deregister注销实例,标记实例状态为 OFFLINE;执行后/health探针持续返回 400,K8s Service 不再转发新流量。
  2. /health:K8s 就绪探针依赖,实例在线返回 200,下线返回 400。

2.3 K8s 标准正常销毁流程(无故障场景)

代码语言:javascript
复制
Pod标记Terminating
        ↓
PreStop脚本执行:curl 127.0.0.1:60199/offline(Agent注销实例)
        ↓
脚本sleep 120s 缓冲窗口
        ↓
sleep期间Readiness探针持续返回400,K8s摘除Service流量;同时给调用方缓存刷新时间
        ↓
缓冲结束,K8s向JVM发送SIGTERM信号
        ↓
Spring框架捕获信号,切换应用为offline状态,关闭各类中间件连接、线程池
        ↓
资源清理完成,进程退出,Pod销毁

设计初衷:先从注册中心全局摘除实例,预留充足时间让全量调用方刷新本地服务实例缓存,理论上无新 RPC 请求打入待销毁 Pod。

三、全链路时序排查与反常证据

3.1 故障 Pod 业务日志时间线(商品提供方实例)

代码语言:txt
复制
00:13:30 日志初始化,加载生产环境配置文件(Pod 第一条业务日志)
00:14:08 Spring 应用完全启动,耗时 44.66s,完成注册中心实例注册
00:15:24 JVM 收到 SIGTERM 信号,框架打印下线日志,切换应用状态 online→offline,依次销毁 XXL-JOB、MQ 发布器、缓存等组件
00:15:51 最后一条业务日志:缓存统计任务取消,进程开始逐步释放资源

3.2 关键时序矛盾推导

  1. PreStop 脚本逻辑:执行 curl 注销接口后 sleep 120s,sleep 结束才发送 SIGTERM;
  2. SIGTERM 触发时间 00:15:24,倒推 PreStop 调用 Agent /offline 时间为 00:13:24
  3. 注销接口执行时间 00:13:24,早于 Pod 第一条业务日志 00:13:30,远早于应用注册完成时间 00:14:08。

3.3 Agent 日志观测佐证

Agent 标准输出仅打印至容器 stdout,未落地业务日志文件,无法直接抓取/offline调用日志;但容器标准输出可观测:Agent 启动日志早于 Spring 业务应用,PreStop 钩子可在应用未就绪时正常调用 60199 端口下线接口。

3.4 反常流程完整还原(故障场景时序倒置)

  1. Pod 创建完成,Agent 优先启动,60199 端口可正常响应 HTTP 请求;
  2. K8s 调度标记 Pod 为 Terminating,立即执行 PreStop 脚本,调用 Agent /offline
  3. 当前 Spring 应用未启动、未向注册中心完成注册,deregister注销无任何效果;
  4. curl 执行完毕,脚本进入 120s 休眠;
  5. 休眠窗口内,Spring 应用启动完成,正常向注册中心注册实例,注册中心、调用方负载均衡缓存识别该实例为健康节点;
  6. 120s 休眠结束,K8s 发送 SIGTERM 信号;
  7. 应用进入优雅关闭流程,文件句柄持续下降,8080 端口逐步停止监听;
  8. 调用方本地缓存未过期,持续路由请求至该实例:
    • OkHttp 复用连接池残留长连接,通道已关闭,抛出EOFException
    • OkHttp 新建 TCP 套接字连接,端口无监听,抛出根异常Connection refused

9. 补充限制:调用接口为 POST 请求,负载均衡默认关闭跨实例重试,失败后直接抛出异常,无容错兜底。

四、分层根因定位

4.1 表层直接原因

PreStop 钩子执行 Agent 注销接口时,应用尚未完成注册中心注册,注销操作无效;后续应用正常注册,实例重新对外提供服务,随即进入销毁流程,出现 “刚注册就下线” 的矛盾状态。

4.2 中层放大因素

  1. 客户端负载均衡本地实例列表存在缓存 TTL,调用方无法实时感知实例下线;
  2. POST 接口默认关闭全局重试策略,不会自动切换其他健康实例重试;
  3. Agent 日志未持久化至业务日志文件,故障初期缺少直观排查依据。

4.3 底层根本根因(RootCause of RootCause)

运维执行集群节点缩容操作,未提前对目标节点执行cordon调度封锁:

  1. 节点资源回收,调度器驱逐存量 Pod 至其他宿主机;
  2. 新宿主机同步执行下线回收策略,Pod 刚完成创建就被再次标记为 Terminating(二次驱逐);
  3. K8s 只要 Pod 进入 Terminating 状态,会立刻执行 PreStop 钩子,不会等待应用启动、注册、就绪;
  4. 正常滚动发布无二次驱逐,Pod 完整生命周期为「启动 - 注册 - 稳定运行 - 销毁」,下线逻辑生效;缩容调度异常击穿设计边界。

五、优化解决方案(分运维规范、容器脚本、业务层三类)

5.1 运维操作规范(根治方案,优先落地)

节点缩容 / 节点下线标准化流程,杜绝 Pod 二次驱逐:

  1. 执行kubectl cordon <节点名称>封锁目标节点,禁止调度器将新 Pod 分配至该节点;
  2. 分批驱逐节点内运行 Pod,每批完成后观测业务监控、RPC 报错指标无异常再执行下一批;
  3. 全部 Pod 迁移完成后,再执行节点关停、缩容操作。

5.2 PreStop 脚本防御改造(兜底防护,规避无效注销)

修改容器 PreStop 执行脚本,调用 Agent 下线接口前增加就绪探测循环,仅实例完成注册、探针返回 200 后才执行注销逻辑,避免空操作:

shell

代码语言:javascript
复制
#!/bin/sh
# 循环等待实例就绪
while true; do
  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" 127.0.0.1:60199/health)
  if [ "${HTTP_CODE}" = "200" ]; then
    break
  fi
  sleep 1
done
# 实例就绪后执行注销
curl http://127.0.0.1:60199/offline
# 保留120s流量缓冲窗口
sleep 120

5.3 微服务业务层容错优化(降低故障影响)

  1. 缩短客户端负载均衡实例缓存 TTL,减少调用方感知下线延迟;
  2. 针对具备幂等性的 POST 接口,开启负载均衡跨实例重试配置,失败自动切换节点;
  3. 异步索引重建任务增加异常捕获、延迟重试、告警埋点,避免线程池堆积失败任务。

5.4 日志观测优化(提升排查效率)

调整容器日志输出配置,将 Agent 60199 端口标准输出日志同步持久化至业务日志文件,完善下线动作观测链路。

六、总结

  1. 平台双层优雅下线机制、K8s PreStop+120s 缓冲窗口设计本身无缺陷,可覆盖正常滚动发布场景;
  2. 本次故障属于调度边界场景击穿组件逻辑,核心诱因是缩容运维流程缺失节点封锁步骤,引发 Pod 跨节点二次驱逐;
  3. 故障由多层因素叠加产生:时序倒置的无效注销、客户端缓存延迟、POST 无重试、日志缺失共同放大业务影响;
  4. 优化方案分为三层:运维流程根治、容器脚本前置防御、业务侧容错兜底,多层防护杜绝同类故障复现。

写在最后

本次故障踩了调度、容器、微服务三层大坑,看似完善的优雅下线机制,在不规范运维操作下直接失效。

你们线上是否遇到过 K8s 驱逐、Pod 时序错乱引发的 RPC 报错?有没有自研下线框架踩过诡异边界 case?欢迎在评论区留言交流排坑经验。

后续我会持续输出 K8s 调度避坑、微服务优雅下线、Feign 重试机制深度拆解系列干货,关注不迷路,遇到线上故障随时能拿来参考!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 摘要
  • 一、故障现象
    • 1.1 业务报错
    • 1.2 监控指标佐证
    • 1.3 业务影响
  • 二、系统内置优雅下线底层机制
    • 2.1 两层下线组件能力对比
    • 2.2 Agent 层核心端点逻辑
    • 2.3 K8s 标准正常销毁流程(无故障场景)
  • 三、全链路时序排查与反常证据
    • 3.1 故障 Pod 业务日志时间线(商品提供方实例)
    • 3.2 关键时序矛盾推导
    • 3.3 Agent 日志观测佐证
    • 3.4 反常流程完整还原(故障场景时序倒置)
  • 四、分层根因定位
    • 4.1 表层直接原因
    • 4.2 中层放大因素
    • 4.3 底层根本根因(RootCause of RootCause)
  • 五、优化解决方案(分运维规范、容器脚本、业务层三类)
    • 5.1 运维操作规范(根治方案,优先落地)
    • 5.2 PreStop 脚本防御改造(兜底防护,规避无效注销)
    • 5.3 微服务业务层容错优化(降低故障影响)
    • 5.4 日志观测优化(提升排查效率)
  • 六、总结
    • 写在最后
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档