Skip to content

필사 모드: Connection refused 与 timeout 的区别 — 在 TCP 层面收敛原因

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 同样是"连接失败",消息却有两种

刚部署完健康检查就失败了。一个 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 refusedECONNREFUSED目标回了 RST发出去了并收到应答目标主机上的进程与绑定地址
Connection timed outETIMEDOUT直到耗尽全部 SYN 重传都没有应答发出去了但没有应答防火墙、安全组、路由、accept 队列
No route to hostEHOSTUNREACH收到 ICMP host unreachable,或者没有 ARP 应答只走到同一子网目标主机电源、ARP 表、REJECT
Network is unreachableENETUNREACH路由表里没有通往该目标的路径根本发不出去ip route,尤其是有没有 IPv6 路径
Name or service not knownEAI_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 秒这个数字,就能立刻分清是内核放弃了,还是应用超时先触发了。

把两条消息笼统合并成"连接失败"的那一刻,诊断时间就会翻好几倍。反过来,只要把这一行读准,需要确认的地方一开始就消失了一半以上。

현재 단락 (1/114)

刚部署完健康检查就失败了。一个 Pod 这样说。

작성 글자: 0원문 글자: 7,059작성 단락: 0/114