分析一次由PMTU黑洞导致的TCP Send-Q堆积问题

TLDR

这几天在一台源端机器上的内网长连接服务经常遇到一个网络问题:收包正常,发包卡住。卡住时43112长连接的Send-Q持续堆积,抓包能看到IP Total Length=1500的TCP包不断重传。

ping -M do -s 1472探测目标机器时,Linux收到mtu=1476的错误并更新本地PMTU缓存。缓存更新后,业务连接发包变小,服务恢复。

这说明原因不在应用没有读socket,也不是本机网卡丢包。更合理的解释是:源端机器到部分10.x目标的路径实际只能通过1476字节IP包,但TCP连接一开始仍按1500字节IP包发送。长期看,要么让路径上的PMTU发现稳定工作,要么在主机或网关上明确限制这条路径的MTU策略。

现象

问题发生时,这个长连接服务表现得很直接:收包正常,发包卡住。先看43112端口的TCP连接:

1
sudo netstat -nt | grep ESTABLISHED | grep 43112

当时看到:

1
2
tcp 0 191103 10.74.110.20:43112 10.168.209.211:36502 ESTABLISHED
tcp 0 127405 10.74.110.20:43112 10.168.216.119:50842 ESTABLISHED

这里第一列0Recv-Q,第二列191103 / 127405Send-Q。也就是说,本机接收没堆积,从本机发往对端的数据已经进入发送队列,但迟迟没有被对端确认。

抓包看同一类连接,可以看到1500字节IP包持续重传:

图里几个字段比较关键:

字段 含义
Frame Length 1514 以太网帧长度,包含14字节Ethernet头
IP Total Length 1500 真正的IP包长度
TCP Len 1448 TCP payload长度
Don't fragment DF置位,中间设备不能分片

也就是:

1
2
IP 1500 = Frame 1514 - Ethernet 14
TCP payload 1448 = IP 1500 - IP/TCP/Timestamp头 52

其中52字节来自这次TCP包里的IP/TCP头部和TCP Timestamp option:

1
2
3
4
5
IP header        20
TCP header 20
TCP Timestamp 12
-------------------
合计 52

52不是永远固定的。如果没有TCP Timestamp option,常见头部开销会是40字节;如果还有其他TCP option,开销可能更大。

这一节先只看现象:源端发送方向正在持续重传IP length 1500且DF置位的TCP包。后文统一用IP length / MTU讨论,关键证据就是这个IP length 1500的DF包在持续重传。

定位和处理

临时恢复

当时的临时恢复方式,是用DF ping构造一个IP length 1500的包去探测目标机器。ping -s填的是ICMP payload长度,所以入参要从目标IP包长度里减出来:

1
ICMP payload 1472 = IP 1500 - IP header 20 - ICMP header 8

实际执行:

1
ping -M do -s 1472 10.168.209.211

输出:

1
2
3
PING 10.168.209.211 (10.168.209.211) 1472(1500) bytes of data.
ping: local error: message too long, mtu=1476
ping: local error: message too long, mtu=1476

所以这条命令是在测试:到10.168.209.211的路径能不能通过IP length 1500且DF置位的包。返回mtu=1476说明路径上某个设备告诉Linux:1500过不去,这条路径最大只能过1476

再看路由缓存:

1
ip route get 10.168.209.211

能看到:

1
2
10.168.209.211 via 10.74.104.1 dev enp105s0 src 10.74.110.20 uid 0
cache expires 48sec mtu 1476 advmss 1400

这个输出里真正解释恢复的是mtu 1476:它限制的是本机发往这个目标时的IP包总长度。

这就是为什么执行一次ping后服务会恢复:ping触发了Linux的PMTU学习,内核临时记住到这个目标的路径MTU是1476。后续业务TCP发包不再尝试IP length 1500,而是被限制到1476以内;包变小后能通过中间路径,连接就恢复了。

为什么会卡住

Linux自动学习路径MTU依赖一种ICMP错误:

1
ICMP Destination Unreachable: fragmentation needed

中间设备收到一个过大的DF包时,如果它能正常返回这个ICMP,Linux就能知道:

1
这个目标方向最大只能发mtu=1476。

然后它会更新PMTU缓存,后续TCP包变小。

问题在于,这个ICMP返回并不总是稳定。实际抓包时能看到机器收到了一部分这类ICMP:

1
2
10.74.105.69 > 10.74.110.20:
ICMP 10.168.209.211 unreachable - need to frag (mtu 1476)

但也能看到一些正在卡住的TCP连接仍然持续重传IP length 1500的DF包。这说明网络不是完全不回ICMP,而是ICMP返回不稳定,可能被过滤、限速,或者不同目标经过的路径不完全一致。没收到对应ICMP的连接,就无法及时学习mtu=1476,只能持续发1500字节IP包,然后RTO重传。

这也是为什么有时候卡几分钟自己好了,有时候能卡半小时:一旦某个目标方向收到了有效ICMP,PMTU缓存就会更新;如果一直没收到,对应连接就只能靠TCP超时和重传慢慢耗。

更完整的确认命令放在附录,核心判断是:Send-Q堆积、连接还没学到MTU 1476、抓包看到DF置位的大包持续重传。

修复方向

机器侧路由配置

原本路由里的advmss 1400是运维提前配置的静态路由属性,不是临时执行ping后Linux通过PMTU学习出来的。ip route show能看到它已经在路由上:

1
10.0.0.0/8 via 10.74.104.1 dev enp105s0 advmss 1400

这次要补的是发包方向的mtu 1476

1
ip route replace 10.0.0.0/8 via 10.74.104.1 dev enp105s0 mtu 1476 advmss 1400

两者限制的是不同的数据流:

  • advmss 1400约束本机对外声明的TCP MSS,主要影响对端发回本机的一侧。
  • mtu 1476才限制本机发往10.0.0.0/8的IP包总长度,避免本机继续发IP length 1500

这次的问题在于:之前只有advmss 1400,少了发包方向的mtu 1476,所以本机发包方向仍会出问题。

网关侧 MSS clamp

MSS clamp是网关在转发TCP SYN/SYN-ACK时改MSS选项,和本机ip route mtu是两类配置。它不改变路由表里的MTU,也不直接告诉Linux某条路径只能过多大的IP包;它只是把TCP握手里双方声明的payload上限压低,让后续TCP数据段更容易落在路径可承载的范围内。

这次用模拟服务端做了一次验证。对端服务侧抓到自己发出的SYN-ACK还是mss 1460

1
2
10.168.209.211.36502 > 10.74.106.52.38940:
Flags [S.], options [mss 1460,sackOK,TS val ...,wscale 11]

但源端机器侧收到同一个SYN-ACK时,MSS已经变成1400

1
2
10.168.209.211.36502 > 10.74.106.52.38940:
Flags [S.], options [mss 1400,sackOK,TS val ...,wscale 11]

这说明路径上确实有网关侧MSS clamp。它对TCP连接有帮助,但只能约束TCP payload,不影响ICMP/UDP。

不过这也不表示UDP组件天然更危险。裸UDP是应用自己决定每个datagram多大,但很多成熟协议库会在协议层主动控制包长:

  • QUIC要求初始包至少1200字节,常见实现也会把普通发送包控制在一个保守范围内。
  • ngtcp2里默认的NGTCP2_MAX_UDP_PAYLOAD_SIZE就是1200,PMTUD打开后才逐步探测更大的UDP payload,上限默认是1452

也就是说,MSS clamp管不到UDP,但QUIC这类跑在UDP上的协议通常不会像随手sendto(4000)那样把一个大datagram直接丢给网络。

附录:确认和验证命令

发送队列

看有没有发送队列堆积:

1
2
3
4
ss -tn state established | awk '
NR > 1 && $2 + 0 > 0 && $4 ~ /^10\./ {
print $2, $3, $4
}' | sort -nr | head -20

TCP连接状态

挑一个本地端口看TCP细节:

1
ss -tin state established | grep -A1 '10.74.110.20:43112'

典型可疑状态是:

1
2
3
Send-Q > 0
mss:1448 pmtu:8801
retrans:1/x lost:x backoff:x

如果已经学到正确PMTU,会更像:

1
mss:1424 pmtu:1476

这里的mss指的是TCP payload大小,和正文里的IP length / MTU不是同一个层级。按截图里的52字节IP/TCP/Timestamp头部开销,可以直接对应成下面这个表:

TCP payload IP length / MTU 含义
1448 1500 还在按 IP length 1500 发包
1424 1476 已经学到路径 MTU 1476

所以mss:1424来自当前发送方向已经学到MTU 1476,不是从advmss 1400算出来的。52只是这次抓包里的头部开销,不是永远固定的常数。

整台机器的网络计数

看整台机器的网络计数是否仍在增长:

1
nstat -az | egrep 'TcpRetransSegs|TCPTimeouts|TCPLostRetransmit|IcmpInDestUnreachs'

重点看:

计数 含义
TcpRetransSegs TCP重传segment数
TcpExtTCPTimeouts TCP RTO超时次数
TcpExtTCPLostRetransmit 重传包本身也被认为丢失
IcmpInDestUnreachs 收到ICMP Destination Unreachable,Frag Needed也在这里

网卡层的dropped/errors不一定涨,因为包对本机来说已经发出去了,真正丢在中间路径上。

抓包

抓目标TCP连接和所有ICMP:

1
2
sudo tcpdump -i enp105s0 -s 0 -w /tmp/mtu_blackhole_with_icmp.pcap \
'icmp or (host 10.168.209.211 and tcp port 43112)'

这里要包含icmp,因为ICMP Fragmentation Needed的源地址通常是中间网关,不一定是目标机器。如果只抓:

1
host <peer> and tcp port <port>

可能看不到PMTU学习依赖的ICMP。

这次抓包里重点看两类包:

看到的内容 含义
TCP重传包 IP Total Length=1500Don't fragment 本机还在发不能分片的IP length 1500
ICMP回包 fragmentation neededmtu 1476 中间设备告诉本机:这条路径最大只能过1476字节IP包