1. 一次“半小时就能上线”的需求,为什么最后折腾了一整天
先说结论:我从 ERR_CONNECTION_REFUSED 入手排查,最后靠的既不是重装服务,也不是重启机器,而是把“调用链上每一个环节”都验证了一遍。这个报错看着只是端口不通,但在微服务架构里,它对应的可能是注册中心失效、负载均衡探活误判、容器端口绑定错位、连接跟踪表溢出,甚至只是防火墙规则没放行。任何一个环节错位,表象都一样:连接被拒绝。
这次事故的背景很简单,某订单服务要从单体里拆出来独立部署,由网关统一转发。按原计划,注册中心里加入新节点,网关改一下路由规则,再由负载均衡做后端摘除和上线,整体半小时以内就能搞定。当时线上拓扑大概是这样的:入口网关 → 负载均衡集群 → 用户服务、订单服务等多个微服务节点,服务之间的调用走注册中心做服务发现,容器部署在主机集群上,服务对外暴露端口通过宿主机的端口映射完成。
升级过程其实很顺利:订单服务的新实例启动成功,日志显示监听端口正常,注册中心里也能看到新实例。可当我准备做一次真实调用验证时,第一个请求就失败了,浏览器返回 ERR_CONNECTION_REFUSED,后端日志里同步出现 connect refused 的异常。那一刻我意识到,这个“半小时就能上线”的判断过于乐观了。
1.1 事故背景:一张“应该早就通了”的网络拓扑
我先把这个系统的真实拓扑补完整,方便后面讲排查路径。整套系统分为三层:最外层是统一入口网关,负责接收外部 HTTP 请求;中间层是负载均衡集群,把请求分发到后端的用户服务、订单服务等节点;最底层是若干无状态微服务实例,跑在容器里,实例之间的互相调用通过注册中心做动态发现。
问题就出在订单服务拆分的这次变更上。它在原来的单体里已经能通过网关访问,但拆分以后,网络路径变了:请求要先到网关,网关把路径转发到负载均衡,负载均衡再从后端池里选一个节点,最后节点内部还要去调用用户服务拿数据。任何一个节点只要信息不一致,整条链路就会断。
我排障前先做了个分类,这个动作很关键。同样一个“连接拒绝”,至少在三层会出现:第一层是浏览器直接访问网关,页面提示 ERR_CONNECTION_REFUSED,说明网关对外暴露的端口没有正常应答;第二层在负载均衡往后端转发时,日志出现 connect to upstream 127.0.0.1:8080 failed: Connection refused;第三层在服务之间相互调用时,订单服务调用用户服务直接抛连接异常。三层指向同一个现象,根因却未必相同。
1.2 第一现场:同一个报错,三种不一样的位置
我把第一现场的信息归档成了下面这张表,每一步排查命令都会对照这个表来定位:
| 现象位置 | 报错示例 | 初步判断方向 |
|---|---|---|
| 浏览器访问网关 | ERR_CONNECTION_REFUSED | 网关对外监听端口异常 |
| 负载均衡转发后端 | connect to upstream ... refused | 负载均衡与后端节点链路异常 |
| 服务间调用 | Connection refused (order → user) | 注册中心地址与真实地址不一致 |
这三类报错在日志里的字面意思几乎一样,都包含 connection refused,但排查入口完全不同。我见过不少同事看到这个报错就直接跑到目标机器上重启服务,结果常常是服务本来活着,问题在别处。所以我没有急着改任何配置,而是先用命令把“到底是谁在拒绝谁”给钉死。
1.3 为什么我首先怀疑的是“新节点还没就绪”,而不是网络
这里需要先讲清楚一个容易被忽略的事实:ERR_CONNECTION_REFUSED 与请求超时有本质区别。前者是目标端口主动返回了 RST,说明网络路径大概率是通的,只是目标主机上对应端口没有进程在监听,或者监听地址不对;后者才是真的丢包,或者被防火墙静默丢弃,路径上根本没有人应答。
这个区别决定了排查顺序应该反过来:先怀疑“服务进程状态不对”,再怀疑“网络配置不对”。如果用 ping 去测,目标机器能通,但业务报连接拒绝,那问题大概率出在端口这一层,而不是链路这一层。带着这个判断,我把排查主线定成了“服务是否真的就绪”“监听地址是否真的对外”“流量经过的每一跳是否知道这个节点的存在”。
2. 把“连接被拒绝”和“连接超时”拆开看,排障方向才不会跑偏
很多新手一看到 ERR_CONNECTION_REFUSED 就去 ping 对方机器,ping 通了就以为网络没问题,然后陷入死循环。实际上这个报错根本不代表“网络不通”,它代表的是“目标主机找到了,但目标端口上没有进程在监听”。把这句话理解透,排障效率能提升一半。
2.1 三次握手视角:RST 包已经把答案写在里面
我从 TCP 三次握手说起。客户端发出 SYN 后,如果目标端口有服务监听,目标会回 SYN+ACK;如果目标端口上根本没有进程监听,操作系统协议栈会直接回 RST;如果中间路由不可达或防火墙丢弃,客户端只会反复重发 SYN,直到超时。所以 ERR_CONNECTION_REFUSED 的潜台词是:我到了你家门口,但敲门没人应。这时候责怪“马路修得不好”没有任何意义。
这个理解还带来另一个好处:看到 RST,就说明从客户端到目标机器的三层网络基本是通的。问题收窄到“端口/进程”这一层。如果看到的是超时,才需要往防火墙、安全组、路由策略这个方向查。两种报错的关键词不同,处理路径完全不同。
2.2 我用这几条命令,一分钟锁定了被拒端口
我在报错发生的客户端机器上,先看和目标地址的连接状态:
ss -ant | grep -E 'SYN|SYN-RECV|ESTAB' | head -50这一步能看到连接是处于建立中、已建立还是被重置。接着用 nc 直接探测目标端口:
nc -vz 192.168.10.20 8080返回Connection refused,说明目标 IP 可达,但 8080 端口没人监听。再用 curl 看完整输出:
curl -v http://192.168.10.20:8080/actuator/health如果得到Failed to connect to 192.168.10.20 port 8080: Connection refused,问题就基本锁定在目标进程本身,而不是网络设备。
这里还有个细节:我会在目标主机上执行ss -lntp看监听端口对应的 PID,然后ps -ef | grep PID看这个 PID 到底是不是当前服务的进程。有一次排查时发现端口在监听,但监听进程是一个没有退出干净的旧实例,新服务反而因为端口冲突一直没起来,光看端口状态根本发现不了。
2.3 两种“看似正常”但其实已经坏掉的状态
排障里最容易踩的坑,是遇到这两种“假正常”状态。第一种是端口被占用,但属于僵尸进程。比如旧服务没有退出干净,新进程启动时端口冲突,新进程日志显示“端口已占用”,但你在宿主机上用 netstat 又能看到端口在 LISTEN,于是误判成“服务是好的”。这种状态必须看 PID 和启动时间,确认监听进程是不是当前发布的新版本。
第二种是健康检查接口返回 200,但核心依赖没连上。比如服务把数据库连接池打满,对外的 HTTP 端口还在监听,健康检查接口也返回 200,但真实业务请求进来后马上就报错。这一点后面会专门说探针设计,这里想强调的教训是:排障不能只看“端口有没有”,还要看“进程是谁”“健康检查探的是什么”。
3. 顺着调用链挖:注册中心、负载均衡、网关三者的配合失效才是真凶
当端口监听本身没有问题时,报错依然出现,就得立刻把视角从“单机进程”切换到“调用链上的协作关系”。微服务架构下,单个节点活着不代表链路是通的,关键在于每一个转发节点拿到的“目标地址”是否真实有效。
3.1 注册中心里看到的节点,可能已经“僵尸化”
我们这次遇到的情况就是典型例子。注册中心里能看到新实例,日志也提示注册成功,但真正到了网关转发时,负载均衡拿到的后端列表已经过期:新节点没有被正确纳入,旧节点的 IP 还在列表里。于是转发请求打到一个已经下线的主机,自然变成 Connection refused。
这种情况最常见的诱因是强杀进程。服务停止时如果进程被直接 kill -9,没来得及发送注销请求,注册中心只能等到心跳超时才剔除节点。而从“进程死掉”到“注册中心剔除”,中间存在一个不小的窗口期。在这个窗口期内,负载均衡还会把流量转发给死节点,表现就是间歇性的连接拒绝。时间越接近发布完成,出现频率越高,特别迷惑人。
我把各环节的检查点整理成一个清单,发布之后照着核对一遍,能省掉后面大部分弯路:
| 检查位置 | 需要确认的信息 | 常用方式 |
|---|---|---|
| 注册中心 | 实例是否在线,最近心跳时间 | 控制台查看实例列表 |
| 负载均衡 | 后端池里的节点 IP 是否等于注册中心列表 | 管理端导出后端配置做 diff |
| 网关路由 | 路由规则指向的服务名是否匹配 | 路由配置对比 |
| 目标主机 | 端口监听与进程 PID 是否一致 | ss -lntp加ps -ef |
3.2 健康检查探活策略:把“刚启动还没就绪”的下游判成死节点
除了强杀进程,还有一次我踩过更隐蔽的坑:新服务进程起来了,端口也在监听,但服务内部还需要加载一段缓存,启动初期接口会大量超时。负载均衡的主动健康检查正好在这个窗口期发起了几次探测,发现响应不健康,就把节点标记为 down,并从后端池摘除。等服务完成初始化,它已经不会再被纳入转发列表,外面看到的表现就是:明明服务活着,但请求一直 Connection refused。
这个问题的本质是健康检查太“急”。常规做法是把容器编排里的就绪探针和存活探针分开配置,分别承担“能不能接流量”和“要不要重启”两个职责。如果只有一个探针,并且用它来判断存活,那启动慢的服务会被反复重启,陷入“永远起不来”的循环。
我后来在配置里把两个探针分开写,就绪探针的检测路径直接指向服务内部的真实依赖状态,而不是一个写死的 200 页面。这个改动虽然小,但它保证了“流量进入的时机”和“服务真正可用的时机”对齐。
3.3 网关到容器的真实路径:主机、容器、防火墙三层逐个排除
如果每个服务单独访问都没问题,但经过网关就失败,那重点看转发路径上的三层。第一层是宿主机上的规则,容器端口映射依赖地址转换规则,一旦发布过程中规则被重建,旧的转发规则丢失,新规则又没生效,就会出现“容器里正常,宿主机端口不通”的诡异现象。
第二层是负载均衡与后端之间的安全策略。微服务节点变更后,如果安全策略没有把新节点 IP 加进白名单,探测和转发都会被丢弃。这时从负载均衡机器上用 nc 探测后端端口,可以迅速定位,不通就是安全策略或路由问题。
第三层是容器网络自身的路由表。跨主机通信依赖网络插件维护路由,网络插件异常时,同网段的容器可能互不相通。这时进到容器里 ping 对端地址,看是通还是超时,基本能判断是哪一层的锅。这三层必须一层一层排除,不能因为某一层看着“好像没问题”就跳过。
4. 容器与主机层的两个暗坑,很容易让 ERR_CONNECTION_REFUSED 变成“灵异事件”
排查到链路每一跳都通,报错依然偶发出现,那就要考虑系统和容器层的一些隐藏状态。这一部分的坑之所以“灵异”,是因为它们不改变任何配置,但会随着时间或流量规模忽好忽坏。
4.1 连接跟踪表溢出带来“间歇性拒绝”
连接跟踪表是 Linux 内核里维护网络连接状态的一块哈希表。当表被占满时,新连接无法建立,内核会直接丢弃或拒绝。此时服务本身完全健康,端口也在监听,但高并发下的表现就是“大量连接拒绝,过一会儿又自动恢复”,非常像网络抖动。
检查命令是:
cat /proc/sys/net/netfilter/nf_conntrack_count cat /proc/sys/net/netfilter/nf_conntrack_max如果 count 长期接近 max,就要考虑调大参数,或者排查是否存在大量未被回收的连接。我当时把 max 调大后,并没有急着坐等结果,而是继续观察是什么连接把表打满。果然发现是某个调用方没有正确复用连接池,每个请求都新建短连接,短时间内把表占满。这个问题不改代码,单纯调大内核参数只是延缓爆炸。
4.2 服务把端口绑定到 127.0.0.1,容器外当然连不进来
这个坑看着低级,实际特别常见。服务配置里写了监听地址为回环地址,本地开发时一切正常,但部署到容器后,宿主机通过端口映射访问时,连接请求到达容器网络接口,容器内进程却只监听回环地址,于是连接被拒绝。日志里不会报任何异常,因为服务自己认为“我明明在监听”。
排查方法很简单,在容器内执行:
ss -lntp看监听地址是0.0.0.0:8080还是127.0.0.1:8080。如果是后者,把配置改成全网卡监听,重新发布即可。这个坑特别容易出现在“从虚拟机部署迁到容器部署”的过程中,原来在虚拟机里绑定回环地址也能被本机访问,但容器化之后,外部请求从虚拟网卡进来,回环监听就完全不可达了。
4.3 滚动发布后遗留的旧容器与端口映射残影
还有一种脏状态来自滚动发布:新容器起来了,旧容器还没完全销毁,端口映射规则同时指向了两个容器。这时候负载均衡探活可能从旧容器拿到一次成功、从新容器拿到一次失败,表现为“时好时坏”。
我处理这类问题的方法是:发布流程里强制先“摘流量→等存量请求结束→确认旧容器退出→再建新容器”,而不是直接原地替换。同时发布结束后,人工比对一次端口映射规则和实际容器列表,确认没有残留。一开始觉得这步是多余的,直到亲眼看到“服务只有一半流量正常”,才明白发布过程的顺序和清理机制一样重要。
5. 从单点修复到全线贯通:我的验证顺序与探针配置
把根因一个个钉死后,我并不急着把所有服务一起改掉,而是采用“逐条链路验证、最后再统一放量”的方式,避免修好一个环节,又把另一个环节打挂。这一步拼的不是技术,而是耐心。
5.1 先恢复被误杀的节点,再做一次全链路回放
第一步是把负载均衡里被误判的下游节点重新启用,并强制触发一次健康检查。这时候我会用一个占比很小的临时流量去验证,比如把权重临时设为 1,让少量请求经过,观察错误率是否瞬间下降。如果错误率降了,说明刚才的判断方向对了,再逐步放大权重。
第二步是回放真实请求链路:浏览器访问网关、负载均衡转发、服务 A 处理、服务 A 调用服务 B、返回结果。每经过一个环节,我都检查日志里是否有新的异常,直到最终响应码全部正常。用一个 curl 命令可以从入口直接验证整条链路的可用性:
curl -s -o /dev/null -w "code:%{http_code} time:%{time_total}s\n" \ http://gateway.example.com/api/order/check这一步最有价值的地方在于,它把“网络通不通”和“业务通不通”区分开了。网络通只能说明 TCP 层可以握手,业务通才说明整条调用链真的连起来了。
5.2 就绪探针和存活探针,职责不能混淆
这里再展开说一下探针配置。很多故障其实源于探针设计不合理。就绪探针的作用是告诉负载均衡:这个节点可以接流量了;存活探针的作用是告诉容器平台:这个节点该重启了。两者职责完全不同,不能混用。
如果只有一个探针并且用来判断存活,服务启动慢时,平台会反复重启容器。如果只有一个探针并且用来判断就绪,那服务内部分支逻辑已经坏掉时,负载均衡不会摘除节点,流量照样进来,用户还是会感受到异常。我给出一个通用配置模板,其中就绪探针的检测路径指向服务内部真实依赖状态,存活探针的检测路径只负责确认进程还活着:
containers: - name: order-service ports: - containerPort: 8080 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /live port: 8080 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 3这里的关键点是:就绪探针的失败不会杀掉容器,它只负责把节点从负载均衡后端池里摘除,让流量绕开;存活探针的失败才会触发重启。两者配合,才能在“服务还没就绪”时不被打死,在“服务彻底卡死”时及时自愈。
5.3 端到端验证不能只看 200,还要看业务成功标识
在最后打通所有服务节点后,我又做了一次完整的业务验证,而不是只看 HTTP 状态码。比如订单接口返回 200 并不等于下单成功,还需要检查数据库里是否真的生成了订单记录,以及后续消息有没有被正确写入消息队列。
这一步让排障结论得到了业务侧的认可,也意味着真正的“全线贯通”不只是在技术层面连上了,而是业务链路里每个环节都按预期协作。我后来把这段验证脚本做成了自动化巡检的一部分,每次变更后都会跑一遍,避免下次再用人工方式逐条点。
6. 把这次经历沉淀下来:监控、告警和变更清单,缺一不可
故障修完,工作才算完成了一半。如果不把监控和告警补上,下次换个服务升级,同样的问题还会以另一种形式冒出来。这次经历给我的最大教训就是:排障做得再漂亮,如果没有沉淀成制度和工具,下一次还是会熬夜。
6.1 监控四件套:端口、状态码、调用成功率、注册中心一致性
我给这次涉及的微服务链路补了四类监控,覆盖了从端口到业务的完整链路:
| 监控对象 | 监控内容 | 采集方式 |
|---|---|---|
| 端口监听 | 每台实例的监听端口是否在 LISTEN | 定时探测ss -lntp |
| HTTP 健康 | 就绪接口与存活接口状态码 | 主动探活 |
| 调用成功率 | 网关到下游、服务到服务之间的成功率 | 链路追踪采样 |
| 注册中心一致性 | 注册中心列表与实际实例列表是否一致 | 定时对比 |
其中注册中心一致性的监控很多人会漏掉。这次如果没有它,我们可能直到下一次发布才会再次遇到“僵尸节点”。在微服务架构里,注册中心只是“最终一致”的,它不等于实时真相,所以必须有独立工具定时对比。
6.2 告警阈值怎么定,才不会变成“狼来了”
告警规则不是越多越好。我见过太多团队把端口探活告警设成“失败 1 次就告警”,结果网络抖动一天几十条,最后大家直接把告警群屏蔽,重要故障反而被淹没。经验做法是设置一个“连续失败 N 次且持续 M 分钟”的窗口,让告警有容忍度,又有灵敏性。
我当时定的规则大概是这样:端口不可达连续失败 3 次,持续 1 分钟,触发普通告警;调用成功率低于 99.9%,持续 5 分钟触发普通告警,持续 15 分钟升级为严重告警;注册中心不一致只要检测到差异就触发低级别告警,留出人工确认时间。这些阈值要根据自己业务的容忍度调整,不能照搬,但“连续+N+持续+M”的框架比“一次就报警”靠谱得多。
6.3 一份真正管用的变更检查清单
以后每次涉及微服务新增、下线、升级,我都会按这份清单过一遍:
- 先确认服务端口监听地址是全网卡,而不是回环地址。
- 确认注册中心里新实例心跳正常,旧实例已彻底摘除。
- 在负载均衡配置里核对后端池节点,和注册中心列表做差异对比。
- 从网关入口发真实请求,验证整条调用链,而不是只发单点请求。
- 发布完成后观察连接跟踪表占用和健康检查日志,确认没有持续增长。
- 预留回滚方案,包括旧版本镜像、回滚后的路由规则和摘除新节点的步骤。
这几条看起来简单,但每一条都在实际的微服务故障里出现过。把它们做成了流程之后,我再也没有在类似问题上熬到深夜。最后再分享一个小经验:排障时的每一个判断,都要能用一句具体的话写下来,比如“谁在什么时候访问哪个地址,得到了什么结果”。多数连接拒绝问题到最后复盘时,都能发现是某个“假设”错了,而把假设显式写下来,就是最快找到错误假设的方法。