分析一次由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 | tcp 0 191103 10.74.110.20:43112 10.168.209.211:36502 ESTABLISHED |
这里第一列0是Recv-Q,第二列191103 / 127405是Send-Q。也就是说,本机接收没堆积,从本机发往对端的数据已经进入发送队列,但迟迟没有被对端确认。
抓包看同一类连接,可以看到1500字节IP包持续重传:
图里几个字段比较关键:
| 字段 | 含义 |
|---|---|
Frame Length 1514 |
以太网帧长度,包含14字节Ethernet头 |
IP Total Length 1500 |
真正的IP包长度 |
TCP Len 1448 |
TCP payload长度 |
Don't fragment |
DF置位,中间设备不能分片 |
也就是:
1 | IP 1500 = Frame 1514 - Ethernet 14 |
其中52字节来自这次TCP包里的IP/TCP头部和TCP Timestamp option:
1 | IP header 20 |
但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 | PING 10.168.209.211 (10.168.209.211) 1472(1500) bytes of data. |
所以这条命令是在测试:到10.168.209.211的路径能不能通过IP length 1500且DF置位的包。返回mtu=1476说明路径上某个设备告诉Linux:1500过不去,这条路径最大只能过1476。
再看路由缓存:
1 | ip route get 10.168.209.211 |
能看到:
1 | 10.168.209.211 via 10.74.104.1 dev enp105s0 src 10.74.110.20 uid 0 |
这个输出里真正解释恢复的是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 | 10.74.105.69 > 10.74.110.20: |
但也能看到一些正在卡住的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 | 10.168.209.211.36502 > 10.74.106.52.38940: |
但源端机器侧收到同一个SYN-ACK时,MSS已经变成1400:
1 | 10.168.209.211.36502 > 10.74.106.52.38940: |
这说明路径上确实有网关侧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 | ss -tn state established | awk ' |
TCP连接状态
挑一个本地端口看TCP细节:
1 | ss -tin state established | grep -A1 '10.74.110.20:43112' |
典型可疑状态是:
1 | Send-Q > 0 |
如果已经学到正确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 | sudo tcpdump -i enp105s0 -s 0 -w /tmp/mtu_blackhole_with_icmp.pcap \ |
这里要包含icmp,因为ICMP Fragmentation Needed的源地址通常是中间网关,不一定是目标机器。如果只抓:
1 | host <peer> and tcp port <port> |
可能看不到PMTU学习依赖的ICMP。
这次抓包里重点看两类包:
| 包 | 看到的内容 | 含义 |
|---|---|---|
| TCP重传包 | IP Total Length=1500、Don't fragment |
本机还在发不能分片的IP length 1500包 |
| ICMP回包 | fragmentation needed、mtu 1476 |
中间设备告诉本机:这条路径最大只能过1476字节IP包 |