你有没有遇到过这个场景线上服务跑在 K8S 里某个 Pod 眼看要出问题你在 Nacos 控制台找到了对应实例果断点了“下线”控制台也显示标记成功了。本想着流量下一秒就会绕开它结果监控看板上的请求量一点没减甚至继续涨再一看这台 Pod 已经处于 Terminating 状态了流量还是打在它头上。这问题不是个例几乎每个把 Nacos 和 K8S 一起用的人都会踩上一次。很多人第一反应是“Nacos 的下线是不是没用”其实不是没用而是“下线”这个动作的含义和我们直觉里想的不一样。Nacos 的下线只是给实例打了一个 disabled 标记流量能不能马上绕开它取决于消费者何时刷新本地缓存、K8S 的 Endpoint 何时摘除这个 Pod、以及 Pod 在停止过程中的健康检查状态。这三条链路是独立工作的任何一条慢半拍都会出现“已经下线了还有流量”的现象。我花了大半天排查过一次类似问题从消费者日志一路查到 kube-proxy 的转发规则最终定位到是缓存刷新时序和实例类型共同作用导致的。这篇文章就把整个排查思路和原理讲透既有能直接照抄的复现步骤也有踩过坑之后沉淀下来的优雅下线方案。1. 先捋清链路流量到底是怎么找到那台机器的1.1 服务发现拿到的地址决定了后面的排查方向微服务调用发起后消费者的负载均衡器会向 Nacos 要一份服务提供者的实例列表然后从中选一个地址发起请求。这里有个容易忽略的关键点Nacos 返回的地址到底是什么。多数 Spring Cloud 应用会把 Pod IP 注册进 Nacos也就是说消费者拿到的是类似192.168.x.x:8080这样的一串直连地址不经过 K8S Service。这种情况下Nacos 的下线标记是影响流量的唯一入口。消费者如果没刷新列表它依然会拿着旧地址去连那个已经停机的 Pod。还有一些项目会把 K8S Service 的 ClusterIP 注册进 Nacos或者通过网关转发。这时候请求路径会变成“消费者 → Service ClusterIP → kube-proxy → 后端 Pod”流量能不能避开停机 Pod取决于 K8S Endpoint 控制的摘除速度和 Nacos 已经没有直接关系了。1.2 K8S 在服务发现这个环节里到底管什么K8S 从来不管 Nacos 里注册了谁它只管 Pod 的调度、健康检查和流量转发规则。如果注册的是 Pod IPK8S 连流量经过它都不知道所谓“下线保护”更是无从谈起。如果涉及 ClusterIPkube-proxy 会根据 Endpoint 列表生成转发规则而这个列表由 Endpoint 控制器维护它依据 Pod 的 Ready 状态决定是否把 Pod 的 IP 放进列表。问题来了Nacos 点的“下线”和 K8S 的“Endpoint 摘除”是两个完全独立的系统互不感知。你在 Nacos 下线了实例K8S 依然认为这个 Pod 是健康的、应该接收流量反过来K8S 把 Pod 删了Nacos 也可能因为心跳超时没到而把它保留在列表中。两套系统的信息不同步流量就有了漏进去的窗口。2. Nacos 的“下线”核心机制只是一个标记不是删除2.1 控制台点“下线”实际改了什么在 Nacos 控制台把某个实例下线底层其实是调用接口把该实例的 enabled 字段设为 false。服务端并不会把这个实例从服务列表中移除实例仍然存在甚至心跳照样续约只是对外标记为“不参与服务发现”。Nacos 实例分两种临时实例ephemeraltrue和持久实例ephemeralfalse。临时实例依赖心跳维持状态客户端停止心跳后超过一定时间默认 15 秒左右服务端会自动将它移除持久实例则不会因为心跳超时而被清理只能通过主动注销接口或控制台手动删除才能拿掉。这个区别非常关键。我用过的某套系统里服务注册时把 ephemeral 设置成了 false后来 Pod 被杀掉Nacos 里那个实例却一直显示健康流量持续打到已经停机的 IP 上最后查了很久才发现是实例类型埋的雷。2.2 消费者什么时候才能感知“下线”Nacos 服务端感知到实例状态变化后会通过 UDP 向订阅了该服务的客户端推送变更消息。客户端拿到变更后更新本地缓存再触发负载均衡器刷新服务列表。这中间有多个容易出问题的环节第一UDP 推送本身不可靠。网络抖动或者客户端所在机器的防火墙丢包推送就没了这条通道默认没有重传机制客户端只能等下一次定时拉取兜底兜底周期通常按秒到几十秒量级计。第二客户端本地缓存有自己的生命周期。Nacos 客户端会在本地磁盘保存一份服务列表的快照文件用于容灾恢复。如果推送和定时拉取都失败了客户端会继续使用这份快照里的旧数据包括已经停机的 IP。第三下游的负载均衡组件还有一层缓存。Ribbon 默认会周期性刷新 ServerListSpring Cloud LoadBalancer 也有缓存 TTL。即便 Nacos 客户端拿到了最新列表负载均衡器没刷新照样会选到旧实例。我在排查中见过一个更隐蔽的情况某些老版本的 Spring Cloud Alibaba 对 disabled 实例的处理存在兼容问题导致实例被标记为下线后消费者依然能在本地缓存里看到它甚至被负载均衡器选中。这个不是 Nacos 的锅而是客户端版本对新字段支持不完善。2.3 下线与注销的边界在哪里很多人把“下线”和“注销”混为一谈其实它们应对的场景完全不同。下线是把实例标记为不可用但保留记录适合 Pod 还要重启恢复、临时要摘流量的场景。注销是直接从服务列表里删掉实例适合 Pod 即将永久销毁、不再回归的场景。在 K8S 环境里一个 Pod 将要被删除时正确做法通常是先下线或注销→ 等待消费者刷新列表和 K8S 摘除 Endpoint → 再让 Pod 退出。如果只做下线不做注销临时实例会在心跳停止后被自动清理但持久实例会永远躺在列表里直到有人手动删掉它。3. K8S 停机的真实时序为什么 Endpoint 摘不掉3.1 Pod 从删除到销毁中间经历了什么当用户或发布系统删除一个 PodK8S 并不会立刻把流量全部断开。完整的流程如下Pod 被标记 deletionTimestamp进入 Terminating 状态。如果配置了 preStop HookK8S 会先执行 preStop 命令。preStop 执行完毕后K8S 向主容器发送 SIGTERM 信号。容器处理 SIGTERM进行优雅退出在 terminationGracePeriodSeconds 宽限期内结束进程。如果超时还没退出K8S 会发送 SIGKILL 强制杀死。在此期间Endpoint 列表不会立即移除这个 Pod。Endpoint 控制器根据 Pod 的 Ready 状态来决定是否保留它。如果应用没有配置 readinessProbe或者应用收到 SIGTERM 后仍然能响应健康检查那么这个 Pod 就会被一直视为 ReadyEndpoint 里的对应 IP 自然也不会消失流量继续被转发到它头上。3.2 Endpoint 摘除与 kube-proxy 更新存在时间差即使 Pod 的 Ready 状态变为 FalseEndpoint 控制器也需要时间更新 EndpointSlicekube-proxy 从 EndpointSlice 到转发规则又有一层同步延迟。所以从“Pod 应被摘除”到“转发规则真正生效”中间会有一段几十毫秒到几秒的窗口期流量依然可能打进正在 Terminating 的进程。还有存量连接的问题。已经建立好的 TCP 连接会记录在 conntrack 表里即使 iptables / IPVS 规则已经更新这些存量连接在超时之前依然会继续转发到原 Pod。如果业务里有长连接比如 WebSocket 或 gRPC Stream这个问题会持续到连接自然断开看起来就像“怎么下线了还在通”。3.3 直连 Pod IP 的情况下 K8S 无能为力如果你的服务把 Pod IP 直接注册到了 Nacos且没有经过 K8S Service那么 K8S 层面完全不存在拦截流量的机制。消费者通过旧缓存拿到已删除 Pod 的 IP发出去的请求只在网络层收到“连接失败”或“超时”的结果期间不会有任何中间层去改写目的地址或拒绝请求。这也是我遇到最多的情况大家以为 K8S 会兜底实际上 K8S 根本不知道流量在找谁。4. 一晚上的排查实录四层检查法4.1 第一层确认流量真的打到了停机 Pod排查这种问题第一步不是看 Nacos而是先把事实坐实。打开监控系统找到故障时间段的调用链日志看请求落在的 IP 和端口是否就是那台已停机 Pod 的地址。如果这台 Pod 进程已经不存在那么请求基本表现为超时或连接重置如果 Pod 还在 Terminating 状态进程可能还活着请求会正常返回只是延迟比平时高。我当时的做法是在网关和消费者两端分别开启访问日志抓取请求的实际目标 IP并和 Nacos 中那个禁用实例的 IP 对比确认一致后再往下查。这一步不能跳否则后面所有推断都可能是错的。4.2 第二层查 Nacos 服务端视角的实例状态登录 Nacos 控制台进入对应服务详情页重点看三个信息实例的 enabled 状态是否已经变为 false。如果没有说明下线操作根本没生效检查操作调用的 API 和权限。实例的 healthy 状态是否仍是 true。如果开启了健康检查即便 enabledfalse健康状态也会持续更新。实例的 lastBeat 时间。通过它判断客户端是否还在继续心跳。同时要看实例类型。持久实例不会因为 Pod 停止而自动消失这是很多故障的根源。4.3 第三层查消费者本地的“记忆”Nacos 客户端会在本地目录默认用户目录下的 nacos/naming 或通过配置指定保存服务快照文件。直接打开快照文件看里面是否包含已经停机的实例 IP。如果包含说明消费者拿到的还是旧数据问题出在客户端缓存和推送链路。另外一个更直接的办法在消费者代码里临时加一个接口返回当前负载均衡器拿到的所有服务实例列表包括 IP、端口、状态。请求一次这个接口就能看到最后被选中的实例集合。加上日志里的负载均衡选路结果很容易判断消费者持有的是哪一份列表。多数时候到这里就能定位问题消费者本地缓存里依然有那个 disabled 实例负载均衡器也没触发刷新。NacosNamingService订阅推送是异步的推送丢失后要等下一次定时拉取这期间旧列表一直在生效。4.4 第四层查 K8S Endpoint 和转发规则如果请求经过了 ClusterIP就需要查 K8S 侧。用 kubectl 查看 Service 对应的 Endpoint 和 EndpointSlice对比哪些地址还在、哪些已经不在。检查 Pod 的状态和 Ready 条件如果 Pod 显示 Terminating 但 Ready 条件仍为 True说明 Endpoint 控制器认为它还能接收流量需要从健康检查入手解决。再看 kube-proxy 的实际转发规则。iptables 模式下用 iptables-save 查看该 Service 的 DNAT 规则IPVS 模式下用 ipvsadm -Ln 查看 RealServer 列表。如果规则里仍存在旧 IP说明同步还没完成。如果规则里没有旧 IP 但流量仍能打通则大概率是 conntrack 表里的存量连接在起作用。5. 常见原因速查表与踩坑心得5.1 故障原因速查表现象原因排查方向Nacos 已标记 disabled流量仍打到该 IP消费者缓存未刷新UDP 推送丢失、定时拉取未到、负载均衡器缓存未更新查看消费者机器 Nacos 快照、负载均衡器当前服务列表Pod 处于 Terminating请求还在进来没有 readinessProbe 或健康检查未失败Endpoint 未摘除kubectl get endpoints、检查 Pod Ready 条件Pod 已被删除Nacos 中实例仍存在且健康持久实例未主动注销临时实例心跳超时未到查看实例类型、lastBeat 时间、调用注销接口Endpoint 已摘除请求仍成功存量长连接、conntrack 老化未到、kube-proxy 规则更新延迟查看 conntrack 表、等待或主动断连5.2 三个我踩过的坑第一个坑把“下线”当成灵丹妙药。第一次遇到这个故障时我以为在 Nacos 点下线流量就应该立刻停。结果业务侧请求还在持续进来当时完全没考虑消费者刷新也是有周期的。实际生产环境中从下线操作到所有消费者感知必须预留几十秒甚至更长的缓冲时间发布系统应当把这一步当做一个等待阶段处理。第二个坑实例类型选错。某个项目注册服务的时候为了“避免临时实例频繁抖动”把 ephemeral 设成了 false。结果 Pod 被淘汰后Nacos 里的持久实例一直存续健康检查也显示通过。当时花费了大量时间在客户端缓存排查上最后发现是实例类型的问题。持久实例需要显式注销如果注册时没有明确理由建议保持临时实例默认行为。第三个坑preStop 只注销不等待。我在 preStop 里调用了 Nacos 注销接口但没有加任何延时。K8S 执行完 preStop 后立刻发送 SIGTERM业务进程瞬间退出很多在途请求直接失败。后来改成先注销、再 sleep 一段时间给健康检查和流量排空留出窗口问题才得到解决。5.3 现场应急处置三板斧如果事故已经发生别慢慢排查先止血再说。第一板斧直接把问题 Pod 杀掉或把 Deployment 副本数调成 0从物理上断绝一切打到这个实例的流量。虽然粗暴但最有效。第二板斧如果是持久实例导致 Nacos 列表残留立刻通过 Nacos 控制台或调用注销接口把对应实例手动移除。第三板斧保留现场。把消费者端当时的缓存文件、Nacos 服务端日志、kube-proxy 规则都留存下来再进入详细原因分析避免事故复现时没有证据。6. 从根上解决优雅下线三件套6.1 三件套健康检查失败 注册中心下线 优雅停机要彻底解决“下线了流量还打进来”的问题需要让 K8S 和 Nacos 协同工作而不是指望某一个动作覆盖全部场景第一步让新流量绕开这个实例应用在停止前主动把 Nacos 中的实例下线或注销同时让 /health 接口返回失败K8S 的 readinessProbe 探测到失败后将 Pod 从 Endpoint 中摘除。第二步处理存量请求应用收到 SIGTERM 后执行优雅停机线程池停止接收新任务等待已提交任务在合理时间内完成Spring Boot 中对应 graceful shutdown 配置。第三步确认完毕再杀进程保证 terminationGracePeriodSeconds 足够覆盖 preStop 脚本执行时间、健康检查失败时间、优雅停机时间之和。6.2 一份可以抄的配置示例Spring Boot 应用的优雅停机配置server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30sK8S Deployment 中的生命周期与宽限期配置spec: terminationGracePeriodSeconds: 60 template: spec: containers: - name: app lifecycle: preStop: exec: command: - /bin/sh - -c - | curl -X PUT http://nacos-server:8848/nacos/v1/ns/instance?serviceNameyour-serviceip${POD_IP}port8080enabledfalseephemeraltrue || true sleep 15这段配置做的事情是在容器被终止前先调用 Nacos OpenAPI 把当前 Pod IP 对应的实例下线然后 sleep 15 秒。sleek 的作用是等待 readinessProbe 检测到应用已退出、Endpoint 完成摘除同时也给消费者一段刷新缓存的时间。sleep 的时长需要根据实际环境调整如果健康检查周期是 10 秒15 秒基本够用但最好结合监控数据验证。需要注意如果 Nacos 服务开启了鉴权curl 命令里还要带上 accessToken 参数。preStop 里尽量加|| true避免因为注销接口调用失败导致 Pod 无法终止。6.3 消费者侧兜底配置消费者侧也要做一些设置避免因缓存刷新慢而放大故障。Ribbon 场景下调短服务列表刷新间隔并降低超时阈值ribbon: ServerListRefreshInterval: 5000 ConnectTimeout: 500 ReadTimeout: 3000 MaxAutoRetries: 1 MaxAutoRetriesNextServer: 1Spring Cloud LoadBalancer 场景下调短缓存 TTL 并开启重试spring: cloud: loadbalancer: cache: ttl: 5s retry: enabled: trueFeign 的默认超时和重试策略在生产环境往往不够用建议单独配置连接超时和读取超时。重试开启后请求可能被多次执行所以消费者的业务接口必须具备幂等性否则一次失败触发重试会把脏数据写进库。6.4 发布流程层面堵住漏洞比起在代码和配置里反复打补丁更推荐把优雅下线做成发布流程的固定步骤。比如滚动发布时先启动新实例等待新实例 readiness 通过再让旧实例进入终止流程旧实例在 preStop 中完成下线、排空、退出。不要用“手动删 Pod”这种粗暴方式每次手动操作都有概率打错顺序把故障窗口拉长。一点个人体会这个问题折磨过我很长时间后来把 Nacos 下线、K8S 健康检查、应用优雅停机三件事串成一条线才算真正走出来。现在每次上线前我都会先确认注册实例的类型再看一遍 preStop 脚本里的 sleep 时长和宽限期配得够不够最后在监控上盯一遍旧的实例 IP 是否从请求日志里消失。线上环境里意外总是出在最不起眼的配置组合上提前把这条链路验证扎实比故障发生时熬夜排查要划算得多。
阅读完成 · 觉得有帮助?