资讯动态

Nacos点下线为何流量仍打到停机Pod?K8S场景全链路排查与优雅停机方案

发布时间:2026/10/16 16:51:17 来源:尧图企业网站定制
先说我遇到的实际场景。某个下午某服务做滚动发布K8S里旧Pod已经进入Terminating状态我在Nacos控制台也手动点了下线实例状态都变成灰的了结果监控里还是很清楚看到那个IP在贡献错误率调用方批量出现Connection refused。当时我整个人是懵的注册中心都下线了怎么流量还往死机器上打而且这不是偶发是每次发布几乎都会碰到只是报错比例高低不同。后来把整条链路捋了一遍才发现原因根本不是某一个单一环节出错而是Nacos下线动作、消费者本地缓存、K8S的Endpoint摘除、Pod终止顺序这几个环节叠加出来的结果。这篇文章就围绕“Nacos点了下线为什么流量还是打到停机Pod”这个问题把那段时间踩过的坑和最终沉淀出的解决方案完整整理一遍适合正在用K8S跑Spring Cloud Alibaba或Dubbo等微服务体系的同学参考。1. 现象复盘一场“下线”没生效的发布事故1.1 事故现场还原整个故障链路是这么走的某后端服务做滚动更新Deployment的strategy是默认的RollingUpdate旧Pod被K8S标记为Terminating。发布前监听群里有人提醒“把旧实例从Nacos下线免得流量打过去”于是我在Nacos控制台找到对应服务把那个旧IP点成了下线。不到两分钟调用方那边开始飘错误大量Connection refused异常部分请求超时链路追踪里能看到目标IP就是刚下线的那个旧Pod地址错误率从0%猛增到8%~15%持续了大概一两分钟才开始回落更奇怪的是监控看板显示旧Pod的IP在进入Terminating后的前30秒里QPS不降反升好像下线操作完全没有被调用方感知到。1.2 初步排查动作当时第一反应是查注册中心状态和K8S状态Nacos控制台服务详情里旧实例状态确实显示下线健康状态为false用kubectl get endpoints看Service后端旧Pod IP已经从addresses中移除到节点上看iptables规则DNS解析出来的ClusterIP转发规则也已经没有旧Pod IP但是调用方日志里的目标IP明确就是旧Pod IP这就很有意思了基础设施层面的路由已经切走调用方的流量还在往旧地址打。说明问题出在“调用方的服务发现缓存”上而不是K8S转发层。我把调用方的一个测试实例打开调试日志发现它本地缓存的服务实例列表中旧IP仍然存在并且被负载均衡算法选中。这时候基本定位了Nacos服务端虽然下线了实例但是调用方客户端本地缓存没有及时刷新还在用旧的实例列表做负载均衡。1.3 为什么这个故障让人头疼这一类问题最麻烦的地方在于它“飘忽不定”有时候下线后一两秒流量就切干净有时候能持续报错三四十秒完全取决于调用方数量、Nacos版本、客户端配置、K8S删除Pod的节奏。如果运维团队没有一套统一的下线流程每次发版都会像开盲盒。而且影响范围往往被低估。单个实例下线的瞬间调用方如果还没刷新缓存请求会持续打到Terminating Pod直到连接被拒绝。像Feign、Dubbo这类框架都有连接池或者长连接复用连接建立后不会每请求做一次服务发现这种场景下流量打到垂死机器的概率会比想象中大得多。2. 为什么“下线”失效注册中心、客户端、K8S三方各有各的延迟要解决这个问题必须先把整条链路拆开。一个服务从“在Nacos点下线”到“调用方完全不往这个实例打流量”中间至少经过三层Nacos服务端、调用方客户端、K8S网络层。每一层都有自己的缓存和延迟机制任何一层慢了流量都会打到已停机的机器上。2.1 Nacos的“下线”到底做了什么先明确Nacos控制台点“下线”这个动作的本质。它调用的服务端接口会把实例的enabled字段置为false同时实例进入不健康状态。这一步完成了两件事服务端不会再主动把该实例作为健康实例返回给新订阅的消费者服务端会向已经订阅该服务的客户端推送一条变更通知但这里就有第一个坑Nacos服务端推送通知的可靠性跟你用的Nacos版本强相关。Nacos 1.x时代的核心推送是基于UDP的。服务端变更后往客户端注册时的监听端口发一个UDP数据包告诉它“服务列表变了快来拉取”。容器化环境里UDP丢包是家常便饭比如K8S Pod经过NAT转发、多网卡环境、防火墙策略不一致UDP通知经常悄无声息就丢了。客户端一旦没收到通知只能等兜底的轮询逻辑而轮询周期默认在30秒左右。Nacos 2.x版本改成了gRPC长连接服务端主动推送的可靠性大幅提升变更通知能秒级到达客户端。但即使升级到2.x下面的客户端缓存问题依旧存在。2.2 消费者本地的“过期服务列表”这是整个问题里贡献最大的一个点。调用方在启动时会从Nacos拉取目标服务的全量实例列表缓存在本地。之后每次发起远程调用负载均衡器都是从本地缓存里挑选实例而不是每次都去Nacos查。这个本地缓存其实有两层Nacos客户端SDK本身维护了一份服务实例缓存Spring Cloud LoadBalancer为了性能还会再包一层缓存默认TTL在几十秒量级两份缓存的刷新周期叠加起来最坏情况下一个实例从Nacos下线之后调用方在几十秒内依然会把它当作健康实例来路由。我自己测试过一个比较典型的组合Nacos 1.4.xSpring Cloud Alibaba 2.2.xSpring Cloud LoadBalancer默认配置在Nacos控制台把实例下线后调用方本地缓存里的旧实例过了大约30秒左右才消失。这30秒内如果发请求有概率命中下线实例。如果刚好赶上那个Pod已经被K8S强杀表现就是Connection refused。更隐蔽的是很多调用方没有配置重试或者负载均衡策略是“同实例优先”一旦选中了死实例这次请求直接失败。即使配置了重试重试间隔通常几百毫秒如果缓存一直不刷新多次重试也都是在同一个死实例上撞墙。2.3 K8S的Endpoint和转发规则更新慢半拍Nacos层处理完K8S层也有自己的节奏。当你删除一个Pod时流程是这样的Pod进入Terminating状态Endpoint Controller异步将该Pod IP从对应的Service Endpoints中移除每个节点上的kube-proxy watch到Endpoints变化更新本机的iptables或ipvs转发规则这三步在绝大多数集群里都是秒级完成的但不是瞬间完成。在那个时间窗口内Service ClusterIP的转发规则仍然包含旧Pod IP请求依然会被DNAT到停机中的Pod。还有一个特别容易忽略的点conntrack连接跟踪表。即使iptables规则已经更新如果调用方和Service之间已经建立了长连接这条连接在conntrack表项的过期时间之前依然会继续被转发到旧的Pod IP上。也就是说网络层的“残留连接”会持续一小段时间。这就是为什么有时候你看到kubectl get endpoints里旧IP已经没了但线上仍然有请求打到旧IP需要再等一段时间才能彻底干净。K8S本身没办法做到“规则更新瞬间掐断存量连接”这是内核连接跟踪机制决定的。2.4 Pod“猝死”优雅终止没做好还有一个经常被忽视的问题Pod收到SIGTERM后怎么退出。默认情况下K8S向容器主进程发送SIGTERM信号后会等一个terminationGracePeriodSeconds时间默认30秒超时后直接SIGKILL强杀。很多Java应用压根没有优雅停机配置收到SIGTERM立刻退出存量请求还没来得及处理完就断了。即使你想让应用处理SIGTERM如果没有preStop钩子配合应用进程退出瞬间Nacos里的实例也还没来得及彻底摘除调用方的本地缓存可能还记录着这个实例新请求照样打过来。这时候Pod已经死了流量自然直接报错。所以在故障复盘时不要把锅都甩给Nacos。K8S的Pod生命周期管理、应用自身的优雅停机能力、调用方的缓存刷新策略这三者任何一个不配合都可能导致“下线后流量继续打过来”。3. 解决实操从快速止血到全链路根治定位清楚了接下来就是怎么把问题解决掉。我按“先止血、再优化、后固化”的顺序来写每一步都有可以直接抄走的配置和命令。3.1 快速止血重启调用方清缓存如果线上已经在报错最快的动作不是改配置而是让调用方实例重启把本地缓存的过期实例列表清掉。这个操作听起来很粗暴但确实是最有效的临时方案。重启之后调用方会从Nacos重新拉取服务列表已经下线的实例不会出现在列表里流量立刻恢复正常。缺点是影响面大尤其在调用方很多、无法批量操作的时候这个方案不现实。另一个止血手段是调用Nacos OpenAPI把实例直接删除而不仅仅是标记下线curl -X DELETE http://nacos-server:8848/nacos/v1/ns/instance?serviceNamemy-serviceip192.168.1.100port8080ephemeraltrue注意这里的ephemeral参数如果是临时实例默认填true如果是持久实例填false。这个接口比控制台点下线更彻底直接从Nacos服务端把实例摘掉不再返回给任何消费者。还要检查一件事这个服务在Nacos里是否配置了保护阈值。如果保护阈值大于0服务端健康实例比例低于阈值时会把不健康实例也返回给消费者防止服务雪崩。这种配置下即使实例下线了调用方依然可能拿到它的地址。正常情况下保护阈值建议保持为0。# 查询服务详情确认保护阈值 curl -X GET http://nacos-server:8848/nacos/v1/ns/service?serviceNamemy-service3.2 给Pod配置preStop钩子走优雅下线流程止血完成一定要把优雅下线做成标准流程。第一步就是在K8S的Deployment里配置preStop生命周期钩子。preStop的核心作用让Pod在收到SIGTERM之前先执行一段脚本把当前实例从Nacos下线并等待一段时间让调用方的本地缓存过期、K8S Endpoints摘除、conntrack连接自然老化然后再让应用进程退出。具体Deployment配置参考如下spec: terminationGracePeriodSeconds: 60 containers: - name: my-service image: my-service:latest ports: - containerPort: 8080 env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP lifecycle: preStop: exec: command: - /bin/sh - -c - | echo preStop started, pod IP: ${POD_IP} # 调用Nacos OpenAPI将当前实例标记为下线 curl -s -X PUT http://nacos-server:8848/nacos/v1/ns/instance/update?serviceNamemy-serviceip${POD_IP}port8080enabledfalseephemeraltrue || true # 等待Nacos通知传播、LoadBalancer缓存过期、Endpoint摘除 sleep 15 echo preStop finished exit 0几点实际操作经验curl命令在镜像里不一定存在。很多精简的Java运行镜像里没有curl连wget都没有。如果preStop脚本执行失败K8S会直接发SIGTERM等于没有优雅下线。所以要么在镜像里装上curl要么改成用/dev/tcp或者应用自带的健康检查接口来触发下线逻辑。实测下来在基础镜像里装一个静态编译的curl最省心。enabledfalse只是标记下线不是删除实例。如果你希望实例彻底从服务列表消失可以改成DELETE请求。但标记下线已经能阻止Nacos把实例返回给消费者删除更彻底。我倾向于用DELETE彻底摘干净。sleep 15这个时间不是拍脑袋定的需要根据调用方的LoadBalancer缓存TTL来算。如果把消费者侧的缓存TTL调整到5秒Nacos推送按秒级算K8S Endpoints摘除按秒级算15秒基本够。如果调用的下游特别多或者网络环境复杂可以再往上加到20秒甚至30秒。terminationGracePeriodSeconds必须大于preStop执行时间加上应用本身优雅停机时间。preStop花了15秒Spring Boot优雅停机配置了20秒那Pod级别的这个参数至少要50秒以上留点余量。否则preStop还在sleepGrace Period就到了Pod直接SIGKILL前面的努力全白费。3.3 消费者侧优化缩短缓存TTL与主动订阅刷新光改服务提供方一侧还不够调用方也要配合。最直接的办法是把Spring Cloud LoadBalancer的缓存TTL调小spring: cloud: loadbalancer: cache: enabled: true ttl: 5sTTL设置为5秒意味着调用方在最坏情况下5秒内就会重新拉取服务列表比默认的几十秒好很多。TTL再往小调意义不大因为会增加每次请求的缓存穿透反而影响性能。如果希望做到“变更秒级感知”可以在调用方订阅Nacos服务变更事件收到事件后手动清空LoadBalancer缓存。核心思路是这样的Component public class NacosServiceChangeListener { Resource private NacosNamingService nacosNamingService; Resource private LoadBalancerCacheManager loadBalancerCacheManager; PostConstruct public void init() throws NacosException { nacosNamingService.subscribe(my-service, new EventListener() { Override public void onEvent(Event event) { if (event instanceof NamingEvent) { // 服务列表变化清空该服务的负载均衡缓存 loadBalancerCacheManager.evictCache(my-service); } } }); } }这段代码思路是通用的具体API根据你使用的Spring Cloud Alibaba版本和Nacos客户端版本会有差异。重点是让调用方在服务列表变化时主动淘汰本地缓存而不是被动等TTL过期。Nacos 1.x用户如果想彻底摆脱UDP推送丢包问题最推荐的做法就是升级到Nacos 2.x或更高版本。2.x的gRPC长连接推送机制可靠得多服务端变更能秒级通知到所有客户端。如果暂时不能升级就通过上面的主动订阅和缩短TTL兜底。3.4 应用侧优雅停机与K8S就绪探针配合preStop负责“延迟”应用侧还要负责“排空存量请求”。Spring Boot应用建议开启优雅停机server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 20s这样配置后应用收到SIGTERM不会立即退出会等待存量请求处理完毕最多等20秒超时再强制退出。整个过程和preStop是衔接的preStop期间应用还在正常运行并处理存量请求等preStop结束、SIGTERM发出后应用进入优雅停机把最后一批请求处理完再退出。K8S这一侧还有一个容易被忽略的配合项就绪探针。很多团队的就绪探针只是启动时检查一次运行期间基本不干活。实际上就绪探针的作用是持续告诉K8S“这个Pod能不能接流量”如果探针失败K8S会自动把Pod IP从Endpoints摘除比任何preStop里的sleep都更快生效。建议把就绪探针配置得积极一点readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 2当应用准备退出时可以在SIGTERM处理逻辑里主动把就绪状态切换为不通过K8S会在1~2个探针周期内把Pod摘除这个速度比等待Endpoint Controller处理要快。摘除之后外部新流量不会再进到这个Pod存量流量则通过优雅停机继续处理完。4. 常见问题排查与避坑实录4.1 同类问题怎么快速定位再遇到“点了下线流量还打过来”的问题我建议按下面这个表格逐层排查不要一上来就怀疑Nacos。排查层命令/操作预期结果Nacos服务端实例状态控制台看实例列表或OpenAPI查询下线实例enabledfalse健康状态false没有被心跳重新拉回Nacos服务端保护阈值查询服务详情里的protectThreshold阈值必须为0否则不健康实例也会被返回给消费者调用方本地缓存查看调用方日志或抓包确认目标IP候选列表缓存中不应再包含已下线实例的IPK8S Endpointskubectl get endpoints -n {ns} {svc} -o yaml已下线Pod IP不在subsets.addresses中K8S转发规则到节点执行iptables-save或ipvsadm -Ln集群IP的DNAT规则里已没有旧Pod IP残留连接到节点执行 conntrack -L -ngrep {podIP}顺带说一个经验排这类问题最好的手段不是看监控而是看调用方实际的负载均衡器日志。把调用方日志级别调到DEBUG能看到它每次选中的目标IP列表如果列表里还有旧IP问题一定出在本地缓存的刷新上。排查命令建议这样用# 检查端点 kubectl get endpoints -n my-ns my-service -o yaml # 检查Pod状态 kubectl get pod -n my-ns -o wide | grep my-service # 到被下线Pod所在节点上观察iptables规则 iptables-save | grep {clusterIP} # 查看残留连接 conntrack -L -n | grep {podIP}4.2 最容易踩的几个坑第一个坑临时实例控制台点下线会自动“复活”。Nacos里SDK注册的临时实例健康状态是靠客户端心跳维持的。控制台点了下线实例的enabled被置为false但只要提供者进程还在运行心跳还在续约过一会儿服务端可能又把实例置为健康状态。你会在控制台上看到实例刚点下线又变绿了。这是很多团队绕不过去的坑。临时实例真正可靠的下线方式是停止进程让心跳停止服务端自动剔除或者在preStop里调用OpenAPI DELETE把实例删掉。控制台点下线只适合临时摘流量不适合作为发布流程的依赖。第二个坑镜像里没有curlpreStop静默失败。我在一个内部项目里遇到过preStop脚本写了调用Nacos下线但因为基础镜像里没有curl脚本执行直接报错K8S没有反馈任何告警Pod照常进入SIGTERM流程。等到问题复盘才发现preStop从来没成功执行过。建议在镜像里装好curl或者在应用启动时先诊断一下preStop会用到的命令是否存在。第三个坑保护阈值配置了不为0。某团队为了“防止健康实例过少导致雪崩”把保护阈值设成了0.5。结果发布时新实例还没完全起来健康比例低于阈值Nacos就把已经下线的不健康实例也发给了消费者流量大量打到死机器上。保护阈值这个参数是双刃剑不是所有服务都适合开。第四个坑conntrack残留连接导致的“幽灵流量”。即使Nacos、K8S Endpoints、iptables规则全部更新完毕已经建立的连接在conntrack表中短暂残留仍然会把流量导入旧Pod。这个窗口通常几秒到几十秒取决于连接状态和超时时间。解决办法是接受这个窗口的存在在preStop里多sleep几秒或者在消费者侧配置连接失败重试。4.3 我现在沉淀下来的发布流程经历这几轮排查和整改之后我这边现在的新服务发布标准配置是三层同时落地提供方preStop里调用Nacos OpenAPI删除实例sleep 15~30秒terminationGracePeriodSeconds设为60秒应用侧开启优雅停机调用方LoadBalancer缓存TTL调至5秒关键服务订阅Nacos变更事件主动清缓存基础设施就绪探针设置成主动探测让K8S快速摘除不健康Pod这套组合下来滚动发布时再也没出现过“点了下线流量还打过来”的情况。偶尔有一两个连接因为conntrack残留正好落在旧Pod上也会因为消费者侧配置了重试在下一个实例上成功返回对业务无感知。最后分享一个小技巧preStop脚本里一定要打日志。很多人以为preStop执行了就是成功了实际上K8S不会把preStop的stdout/stderr展示在Pod事件里需要到容器日志里看。我在脚本里echo了开始和结束日志排查问题时能快速确认preStop是否真的跑完这个习惯帮我节省了不少定位时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑