在 Nacos 点了下线,为什么流量还是打到了停机的机器上?
前两天面试问了一个非常日常的问题❝你们线上发版的时候怎么保证旧服务下线时用户的请求不报错他挺自信地回答这很简单啊去 Nacos 控制台找到那个实例点一下下线按钮。或者在发布脚本里直接kill -15杀进程Nacos 收不到心跳自然就把节点剔除了网关就不会往这台机器发流量了。额在真实的生产环境下如果发布流水线真的只是这么干那每次发版的一两分钟里你们的网关日志里一定会刷出一堆Connection Refused或者Read Timeout的报错。如果刚好赶上晚高峰用户的直观体验就是点了一下按钮界面弹出一个醒目的网络异常。1. 流量为什么停不住很多同学对注册中心有一个误解觉得它是一个绝对实时的系统只要 Nacos 知道某个节点下线了所有依赖的微服务就瞬间都知道了。但在分布式系统里信息的传递是需要时间的。服务调用方比如 Gateway 网关在发起 HTTP 请求前需要知道目标服务的 IP。但为了性能网关不可能每来一个请求都去 Nacos 查一次网络。会用像 Ribbon 或者 SpringCloud LoadBalancer 这样的客户端负载均衡组件会在自己的内存里维护一个ServerList 服务 IP 列表缓存。坑就踩在这个缓存的更新机制上调用方默认会启动一个后台定时任务每隔一段时间去 Nacos 拉取最新的实例列表。虽然 Nacos 服务端也有 UDP 实时推送变更的机制但在复杂的生产网络里UDP 推送是有概率丢失的。这就导致了一个长达几十秒的时间差。在这几十秒里虽然 Nacos 服务端已经把那个节点标记为下线了但网关本地的缓存还没刷新网关手里拿着的依然是旧名单。现在回想一下你在控制台点下线后的操作你调用了下线紧接着就触发了重启进程被杀掉。但此时网关还没更新缓存依然把用户的请求往这台已经死掉的机器上送。操作系统一看端口都没人监听了直接回一个 RST 包网关马上就报Connection Refused了。2. 中小团队的解法知道了原因解决思路也就有了。很多中小团队的做法是在 CI/CD 发布流水线里加一行sleep 40。流程变成了这样主动摘流,脚本先调 Nacos 的 OpenAPI把机器标记为下线。脚本强行sleep 40秒。在这 40 秒里机器还在正常运行依然在处理请求只是我们在这 40 秒里等待全网所有的网关更新完缓存。优雅停机40 秒一过没人往这发请求了再发送kill -15杀进程。这套方案确实管用。但如果你拿着这个方案去大厂面试依然是不及格的。原因很简单太慢了且不具备普适性。如果一个核心服务有 500 个节点滚动发布时每个节点都要干等 40 秒发一次版要好几个小时。而且单纯靠等根本无法应对物理机突然宕机、网络抖动等突发情况因为机器意外宕机时你根本没机会去 sleep。3. 大厂的无损下线方案第一步编排层摘流正规一点的公司下线动作不该由 Jenkins 脚本控制而应该与 K8s 的 Pod 生命周期深度绑定。当 K8s 决定缩容或更新一个 Pod 时它不会立刻发送kill -15而是会先触发一个生命周期前置钩子PreStop Hook。在 K8s 的 yaml 里这么写lifecycle: preStop: exec: command: - /bin/sh - -c - | # 1. 立即向 Nacos 发送下线请求主动摘除自己 curl -X PUT http://nacos-server:8848/nacos/v1/ns/instance?serviceNamemy-serviceip${POD_IP}port8080enabledfalse # 2. 象征性地短暂停顿 3~5 秒给 Nacos 的 UDP 主动推送一点时间 sleep 5这一步把主动下线的逻辑封装在了容器内部只要 Pod 要死临死前第一件事就是去注册中心销户。第二步客户端重试即使有 PreStop依然无法 100% 避免在那几秒钟里有残余的请求顺着网关的旧缓存打过来。当这台机器停止接收新请求时网关打过来的请求会立刻收到操作系统的Connection Refused。注意这个极其关键的细节ConnectException发生在 TCP 三次握手阶段此时 HTTP 业务数据根本还没有发出去既然业务没执行那这个请求就是绝对安全、绝对幂等的。所以我们在网关或上游微服务里必须开启底层的重试机制。以 SpringCloud 为例只要加上spring: cloud: loadbalancer: retry: enabled: true # 开启客户端重试此时发生的奇迹是网关拿着旧 IP 发请求 - 碰壁报错Connection Refused-网关底层的 LoadBalancer 捕获到连接异常默默把它掐掉然后自动从缓存里换另外一个健康的 IP 重新发起请求。整个过程在几毫秒内完成最终用户在手机上看到的就是一个正常的 200 OK用户是无感知的。第三步应用的优雅停机有了前面两步进入这台机器的新请求已经被完美挡住并重试了。最后是处理那些已经接进来了正在工作的老请求。在 Spring Boot 项目的application.yml里加上这两行配置server: shutdown: graceful # 开启优雅停机 spring: lifecycle: timeout-per-shutdown-phase: 30s # 最多等老请求 30 秒配合 K8s 的停机机制Java 进程在等待一段时间后然后彻底释放资源。4. 无损下线全流程为了让你看得更直观我画了这张涵盖了K8s Nacos 客户端重试的联动时序图。