- Published on
Connection refused 与 timeout 的区别 — 在 TCP 层面收敛原因
- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 同样是"连接失败",消息却有两种
刚部署完健康检查就失败了。一个 Pod 这样说。
dial tcp 10.0.3.11:8080: connect: connection refused
旁边的 Pod 这样说。
dial tcp 10.0.3.11:8080: i/o timeout
很多团队把这两者笼统地当成"反正就是连不上"来处理。而且两种情况都从安全组和防火墙规则开始翻。这正是最浪费时间的地方。两条消息在 TCP 层面陈述的是完全相反的事实,因此该看的地方也完全相反。
本文讲清这两个错误分别是哪种数据包交换的结果,以及从这个事实出发能把候选原因裁剪到什么程度。命令和输出都按真实形态给出,方便你直接复制对照。
Connection refused 意味着收到了 RST
connect() 系统调用返回 ECONNREFUSED 的条件只有一个:对端针对我发出的 SYN 回了一个带 RST 标志的 TCP 段。
当 SYN 到达一个没有监听者的端口时,内核会与应用无关地立刻构造并发回 RST。这是 TCP 规范要求的行为,即使进程已经死掉,只要内核还活着就会持续发生。
# 没有监听者的端口 — 在一个往返时间内立刻结束
time nc -vz 10.0.3.11 8080
nc: connect to 10.0.3.11 port 8080 (tcp) failed: Connection refused
real 0m0.004s
user 0m0.001s
sys 0m0.002s
这里重要的不是失败本身,而是耗时。4 毫秒是一个往返时间。我的 SYN 抵达了 10.0.3.11,那台主机构造了应答并回给了我。也就是说,这就是数据包已经通过了路由、安全组、网络策略、NAT 等路径上所有关卡的证据。
于是剩下的候选原因就收敛到目标主机内部了。
- 进程死了,或者还没启动。
- 进程活着,但绑定在别的端口上。
- 进程绑定在 127.0.0.1 而不是 0.0.0.0 上。
- 目标是负载均衡器或 kube-proxy,而它后面一个正常后端都没有。
这里必须纠正一个常见误解。"出现 connection refused 说明是防火墙拦了"这种解释大多是错的。实务中使用的防火墙几乎总是 DROP 策略。AWS 安全组、Kubernetes NetworkPolicy、云防火墙都会静默丢弃规则之外的流量。被丢弃的数据包没有应答,因此表现为 timeout 而不是 refused。看到 refused 就去翻防火墙,等于重新确认一个数据包已经通过的关卡。
例外是显式配置成回应拒绝的情况。
# 这条规则会返回 RST,所以看起来像 refused
sudo iptables -A INPUT -p tcp --dport 8080 -j REJECT --reject-with tcp-reset
# 这条规则没有应答,所以会变成 timeout
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP
如果是自己直接管理本机 iptables/nftables 的环境,只要确认一次有没有 REJECT 规则就够了。除此之外,把 refused 当成进程问题来处理几乎总是对的。
Timeout 意味着没有人应答 SYN
超时是信息量少得多的失败。我发出了 SYN,按既定次数做了重传,在这期间既没有 SYN-ACK,也没有 RST,也没有 ICMP 回来。内核能知道的事实只有"没有应答"这一条。
# 数据包被静默丢弃的端口 — 会耗尽内核默认的重试次数
time nc -vz 10.0.3.11 9090
nc: connect to 10.0.3.11 port 9090 (tcp) failed: Connection timed out
real 2m7.213s
user 0m0.002s
sys 0m0.003s
2 分 7 秒这个数字后面会算一下。现在重要的是候选原因。
- 路径上的防火墙或安全组正在丢弃数据包。
- 目标 IP 写错了,根本没有主机使用那个地址。
- 路由不对,数据包去了别的地方。
- 返回路径不对称,SYN-ACK 回不到我这里。
- 目标活着,监听者也在,但 accept 队列溢出导致 SYN 被丢弃。
最后一项是最常被漏掉的情况。
有监听者却依然 timeout 的情况
握手完成的连接会堆在 accept 队列里,由应用用 accept() 取走。如果应用很慢导致这个队列被填满,Linux 在默认配置下会静默丢弃新的 SYN。从客户端看来,这和端口关闭无法区分,只不过表现为 timeout 而不是 refused。
ss -ltn 'sport = :8080'
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 128 0.0.0.0:8080 0.0.0.0:*
在监听套接字上,Recv-Q 是当前 accept 队列长度,Send-Q 是队列的最大容量。129 超过了 128,说明队列已经饱和。用累计计数器确认会更加确定。
nstat -az | grep -E 'ListenOverflows|ListenDrops'
TcpExtListenOverflows 18437 0.0
TcpExtListenDrops 18437 0.0
如果这些值在增长,原因就是应用的吞吐能力而不是网络。防火墙翻到底也翻不出来。
其余错误消息各自指向的位置
connect 一族的失败其实还有四种。它们分别发生在不同的层,所以只要把消息读准,一半的活就干完了。
| 消息 | errno | 实际发生的事 | 数据包发出去了吗 | 先看哪里 |
|---|---|---|---|---|
| Connection refused | ECONNREFUSED | 目标回了 RST | 发出去了并收到应答 | 目标主机上的进程与绑定地址 |
| Connection timed out | ETIMEDOUT | 直到耗尽全部 SYN 重传都没有应答 | 发出去了但没有应答 | 防火墙、安全组、路由、accept 队列 |
| No route to host | EHOSTUNREACH | 收到 ICMP host unreachable,或者没有 ARP 应答 | 只走到同一子网 | 目标主机电源、ARP 表、REJECT |
| Network is unreachable | ENETUNREACH | 路由表里没有通往该目标的路径 | 根本发不出去 | ip route,尤其是有没有 IPv6 路径 |
| Name or service not known | EAI_NONAME | 在名字解析阶段就失败,连套接字都没打开 | 朝目标一个都没发出去 | nsswitch.conf、resolv.conf、/etc/hosts |
有两点要强调。
第一,Network is unreachable 是内核路由表里没有路径、数据包连接口都没出去的状态。如今这个错误绝大多数都来自 IPv6。如果 DNS 返回了 AAAA 记录,而主机没有 IPv6 默认路由,应用会先尝试 IPv6 并立刻失败。
ip -6 route show default
# 如果输出为空,就无法用 AAAA 结果发起连接
getent ahosts api.example.com
2606:4700:4700::1111 STREAM api.example.com
1.1.1.1 STREAM
第二,Name or service not known 不是 TCP 问题。朝目标服务器一个数据包都没有发出去。这种情况下,防火墙日志里怎么看都什么也没有,才是正常的。
用 tcpdump 亲眼确认 SYN 与 RST
推断没把握的时候,直接看数据包是最快的办法。只需要看握手,所以过滤器要收窄。
sudo tcpdump -nn -i any "host 10.0.3.11 and tcp port 8080"
这是被拒绝时的输出。
14:02:11.104512 IP 10.0.2.7.54312 > 10.0.3.11.8080: Flags [S], seq 1829301744, win 62727, options [mss 8961,sackOK,TS val 913442 ecr 0,nop,wscale 7], length 0
14:02:11.104698 IP 10.0.3.11.8080 > 10.0.2.7.54312: Flags [R.], seq 0, ack 1829301745, win 0, length 0
两行就结束了。[S] 出去了,[R.] 回来了。0.2 毫秒就收到应答,说明是同一台主机,或者非常近。
超时的情况看起来是这样。
14:05:31.220114 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1113209 ecr 0,nop,wscale 7], length 0
14:05:32.235880 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1114225 ecr 0,nop,wscale 7], length 0
14:05:34.251874 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1116241 ecr 0,nop,wscale 7], length 0
14:05:38.315876 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1120305 ecr 0,nop,wscale 7], length 0
同一个序列号的 SYN 以 1 秒、2 秒、4 秒的间隔重复。完全没有应答行。
如果环境里没有抓包权限,只看套接字状态也能判别。
ss -tanp state syn-sent
Recv-Q Send-Q Local Address:Port Peer Address:Port Process
0 1 10.0.2.7:54390 10.0.3.11:9090 users:(("curl",pid=31544,fd=5))
如果在 SYN-SENT 状态停留数秒以上而且 Send-Q 是 1,说明有一个没收到应答的 SYN 悬在那里。refused 时这个状态短到肉眼捕捉不到。
用 SYN 重传间隔预测超时长度
前面的 2 分 7 秒不是偶然的数字。Linux 会按 net.ipv4.tcp_syn_retries 的值重传 SYN,并且每次把间隔翻倍。
sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6
首次发送之后按 1、2、4、8、16、32 秒的间隔重传 6 次,最后一次重传之后再等 64 秒才放弃。合计是 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127 秒。与前面测得的 2 分 7 秒完全吻合。
这个计算在实务中有用的理由有两个。
其一,正好在 127 秒附近断掉的失败意味着内核放弃了 SYN,也就是说根本没有收到任何应答。如果应用日志里的超时值是 30 秒而实际上是 127 秒才失败,那也是超时设置没有生效的信号。
其二,如果想把连接超时调短,比起改内核参数,按应用或按套接字指定更安全。
# 对整台主机生效 — 重传 3 次,约 15 秒
sudo sysctl -w net.ipv4.tcp_syn_retries=3
# 按工具限制副作用更小
curl --connect-timeout 3 -sS http://10.0.3.11:8080/health
nc -w 3 -vz 10.0.3.11 8080
macOS 的这一行为不同。默认连接超时约为 75 秒,sysctl 的名字也不一样,所以不能把本地 Mac 上测到的时间按 Linux 服务器的标准来解读。
容器与 Kubernetes 里的 127.0.0.1 绑定事故
容器环境中一半的 connection refused 都是同一个原因:进程只绑定在了回环地址上。
本地开发时用 127.0.0.1:8080 起得好好的,连也连得上。原样放进容器再挂上 Service,Pod 是 Running,进程也活着,可是从别的 Pod 访问就出现 connection refused。
原因在目标地址上。kube-proxy 把 Service 的 ClusterIP 做 DNAT 到 Pod IP 之后,到达的数据包的目标地址是 10.244.1.23 这样的 Pod IP。套接字只在 127.0.0.1 上打开,因此内核找不到匹配这个目标的监听者,于是回了 RST。
kubectl exec -it deploy/api -- ss -ltnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:* users:(("api",pid=1,fd=7))
正常的话应该是这样。
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("api",pid=1,fd=7))
LISTEN 0 4096 [::]:8080 [::]:* users:(("api",pid=1,fd=8))
让这类事故拖很久的,是在 Pod 内部健康检查会成功这一点。用 kubectl exec 进去执行 curl localhost:8080 就很顺利。目标地址是 127.0.0.1,当然如此。必须用 Pod IP 来确认。
POD_IP=$(kubectl get pod -l app=api -o jsonpath='{.items[0].status.podIP}')
kubectl exec -it deploy/api -- curl -sS --connect-timeout 2 "http://${POD_IP}:8080/health"
curl: (7) Failed to connect to 10.244.1.23 port 8080 after 1 ms: Connection refused
要改的地方是应用配置。Node 的 Express 写成 app.listen(8080) 会绑到所有地址,而 app.listen(8080, '127.0.0.1') 则不会。Go 里 http.ListenAndServe(":8080", ...) 是对的,"localhost:8080" 是错的。Spring Boot 是 server.address 属性,Rails 是 -b 0.0.0.0,Gunicorn 是 --bind 0.0.0.0:8000,Postgres 是 listen_addresses 配置。在 Docker 里做了端口映射却连不上的案例,原因也是一样的。
把同样的逻辑反过来就成了判别法。如果用 Pod IP 是 refused,而在 Pod 内用 localhost 却成功,那么原因是绑定地址而不是网络策略。反过来,如果用 Pod IP 也 timeout,那才轮到去看 NetworkPolicy 和 CNI。
还有一种只在经过 Service 时才出现 refused 的情况,也值得记住。如果 Endpoints 为空,kube-proxy 没有地方可发,就会立即拒绝。
kubectl get endpoints api
NAME ENDPOINTS AGE
api <none> 12m
这时要么是 Service 的 selector 与 Pod 标签对不上,要么是 readinessProbe 失败导致一个就绪的 Pod 都没有。
结语 — refused 并不是坏消息
概括起来,判断的骨架很短。
看到 Connection refused,说明数据包到达了目标又回来了。路径已经得到验证,所以把防火墙和路由从候选里划掉,去看目标主机上的进程与绑定地址、端口号、后端是否存在就行。这意味着问题的范围已经缩小到一台主机之内,因此是个值得高兴的信号。
看到 timeout,说明什么信息都没回来。要把防火墙、安全组、路由、非对称路径,以及 accept 队列溢出统统敞开作为候选,先用 tcpdump 确认 SYN 是否真的发出去了、重传是否在反复。在这里记住 127 秒这个数字,就能立刻分清是内核放弃了,还是应用超时先触发了。
把两条消息笼统合并成"连接失败"的那一刻,诊断时间就会翻好几倍。反过来,只要把这一行读准,需要确认的地方一开始就消失了一半以上。