Skip to content
Published on

ping 能通但只有大请求卡住 — 解剖 MTU、MSS 与 PMTUD 黑洞

分享
Authors

引言 — 小请求能通,只有大响应卡住

有一类故障的症状格外特别。

  • ping 百分之百成功,往返时间也正常。
  • 健康检查端点应答良好。
  • 可是像列表查询 API 这样响应稍微变大一点,请求就那么卡住了。不是报错,就是卡住。
  • ssh 能连上,但在大目录里敲 ls -la 屏幕就冻住。
  • git clone 的进度停在某个点上再也走不完。
  • 有些站点会卡在 TLS 握手阶段。

看到这个组合就不用看别的了。这是路径 MTU 问题。而且大多出现在刚接上 VPN、隧道、覆盖网络之后,或者路径刚变过之后。

本文讲清为什么结果会按大小分化,以及如何在几分钟内确定下来。

MTU 与 MSS — 40 字节的算术

MTU 是一个接口一次能发出的 IP 数据包的最大尺寸。以太网的默认值是 1500 字节。

TCP 从这个值里减去首部,算出自己能装多少数据。减掉 20 字节的 IPv4 首部和 20 字节的 TCP 首部,得到 1460 字节。这个值就是 MSS,它作为选项装在握手的 SYN 数据包里传给对端。

ip link show ens5
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
    link/ether 06:1f:2a:3b:4c:5d brd ff:ff:ff:ff:ff:ff
sudo tcpdump -nn -i any 'tcp[tcpflags] & tcp-syn != 0' -c 2
14:22:03.104512 IP 10.0.1.20.51244 > 10.20.0.5.443: Flags [S], seq 2841029371, win 62727, options [mss 1460,sackOK,TS val 33012 ecr 0,nop,wscale 7], length 0
14:22:03.106880 IP 10.20.0.5.443 > 10.0.1.20.51244: Flags [S.], seq 991044820, ack 2841029372, win 65160, options [mss 1460,sackOK,TS val 88103 ecr 33012,nop,wscale 7], length 0

这里得出一个决定性的事实。两端都只看自己接口的 MTU 来决定 MSS。路径中间有没有更窄的区段,谁都不知道。上面的抓包里两台主机约好互相收发 1460 字节的段,但如果中间有一条 1420 字节的隧道,这个约定就无法兑现。

小请求能成功的原因就在这里。健康检查的响应一个 200 字节的段就结束了,所以能平安通过窄区段。ping 的默认负载是 56 字节,因此总是能通过。只有尺寸越过阈值的数据包才会消失,所以症状按大小分化

卡在 TLS 握手也是同样的原因。ClientHello 很小,但服务器发来的证书链有三千到五千字节,立刻就变成最大尺寸的段。于是就出现连接建立得起来、握手却结束不了的样子。

PMTUD 黑洞形成的确切过程

TCP 本来是有解决这个问题的机制的,那就是路径MTU发现。顺序如下。

  1. Linux 默认会给 TCP 数据包置上 DF(Don't Fragment)位。
  2. 中间路由器收到比自己下一跳 MTU 更大的 DF 数据包时,因为无法分片,就把包丢掉。
  3. 作为替代,它向发送方发出 ICMP 类型 3 代码 4,也就是 Fragmentation Needed。这条消息里带着下一跳的 MTU 值。
  4. 发送方内核把这个值存进按路由的缓存,把段切小后重新发送。

正常工作时看起来是这样。

ip route get 10.20.0.5
10.20.0.5 via 10.0.1.1 dev ens5 src 10.0.1.20 uid 1000
    cache expires 588sec mtu 1420

缓存里出现 mtu 1420,说明路径MTU发现起作用了。

问题出在第 3 步。如果有把 ICMP 全部拦掉的防火墙、不回应 ICMP 的路由器,或者无法把 ICMP 送回原始发送方的非对称路径,这条消息就消失了。于是发送方什么都不知道,继续发大包,而这些包继续被丢弃。重传也是同样大小,照样被丢。这个状态就叫 PMTUD 黑洞。

黑洞的特点是不报错。连接一直是 ESTABLISHED,应用一直等响应,直到自己的超时到了才结束。于是就导出了"网络是好的,只是应用慢"这种错误结论。

这里必须纠正一个流传甚广的误解。"为了安全把 ICMP 全部封掉"这种策略本身就是故障源。可以封的大概只有 echo request 和 echo reply,而类型 3 代码 4 必须放行。在 IPv6 里,Packet Too Big 也就是 ICMPv6 类型 2 承担同样的角色,而且那里连选择的余地都没有。

确认 ICMP 是否真的收到了。

sudo tcpdump -nn -i any 'icmp[icmptype] == 3 and icmp[icmpcode] == 4'
14:31:02.884411 IP 10.0.1.1 > 10.0.1.20: ICMP 10.20.0.5 unreachable - need to frag (mtu 1420), length 556

看到这一行就说明 PMTUD 还活着。如果一直在发大包却一次都看不到这一行,那就是黑洞。

Linux 有自行探测黑洞的功能,默认是关闭的。

sysctl net.ipv4.tcp_mtu_probing
net.ipv4.tcp_mtu_probing = 0
# 1: 只在怀疑有黑洞时探测,2: 始终探测
sudo sysctl -w net.ipv4.tcp_mtu_probing=1

把值设为 1 之后,当重传反复发生时,内核会自行缩小段的尺寸,直到找到能通过的大小。这不是根治,但在还没找到原因时用来保住服务很有效。

隧道拿走的字节

路径变窄的原因大多是封装。原始数据包被另一层首部包起来,里面能用的空间就变少了。把数字记住能让诊断更快。

封装方式额外首部大小以 1500 为基准的实效 MTU建议的 MSS 钳制值
PPPoE8 字节14921452
GRE24 字节14761436
VXLAN (IPv4 外层)50 字节14501410
Geneve (无选项)50 字节以上1450 以下1410 以下
WireGuard (IPv4 外层)60 字节14401400
WireGuard (IPv6 外层)80 字节14201380
IPsec ESP 隧道模式最多约 73 字节14271387
IPsec ESP + NAT-T最多约 81 字节14191379

有几点需要注意。

wg-quick 之所以默认把 WireGuard 接口设为 1420,是因为这是把 IPv6 外层首部也算进去的安全值。如果只用 IPv4,1440 也可以,但端点一旦变成 IPv6 就会坏掉,所以维持 1420 更稳妥。

IPsec 因为加密算法和填充的关系,开销并不固定。所以要按最坏值来定。AES-CBC 因为块填充而可变,AES-GCM 则相对较小。

云上有个反方向的坑。AWS 大多数实例类型在 VPC 内部使用 MTU 9001。在同一个 VPC 内跑得好好的,可一旦经过互联网网关或 VPN,就会降到 1500 以下。于是就表现为"内部通信都正常,唯独外部 API 调用在大响应上卡住"。

用二分查找找出路径 MTU

最确定的办法是直接测。置上 DF 位,调节尺寸,看能不能通过。

# -M do: 置上 DF 位, -s: ICMP 负载大小
ping -M do -s 1472 -c 1 10.20.0.5
PING 10.20.0.5 (10.20.0.5) 1472(1500) bytes of data.
From 10.0.1.1 icmp_seq=1 Frag needed and DF set (mtu = 1420)

--- 10.20.0.5 ping statistics ---
1 packets transmitted, 0 received, +1 errors, 100% packet loss

运气好的话路由器会这样告诉你准确的 MTU。如果是 ICMP 被拦掉的路径,就只会没有应答,那就得自己去逼近。

for s in 1472 1420 1400 1392 1380 1372; do
  printf '%5d bytes payload (MTU %5d): ' "$s" "$((s + 28))"
  if ping -M do -s "$s" -c 1 -W 1 10.20.0.5 >/dev/null 2>&1; then
    echo OK
  else
    echo FAIL
  fi
done
 1472 bytes payload (MTU  1500): FAIL
 1420 bytes payload (MTU  1448): FAIL
 1400 bytes payload (MTU  1428): FAIL
 1392 bytes payload (MTU  1420): OK
 1380 bytes payload (MTU  1408): OK
 1372 bytes payload (MTU  1400): OK

这里的 28 是 8 字节的 ICMP 首部加 20 字节的 IPv4 首部。能通过的最大负载是 1392,所以路径 MTU 是 1420。那么 TCP 的 MSS 就应该是 1420 减去 40,即 1380。

tracepath 有时能一次就出结果,连在哪一跳变小都能看到。

tracepath -n 10.20.0.5
 1?: [LOCALHOST]                      pmtu 1500
 1:  10.0.1.1                                              0.412ms
 1:  10.0.1.1                                              0.318ms
 2:  10.0.1.1                                              0.286ms pmtu 1420
 2:  10.60.4.1                                             8.114ms
 3:  10.20.0.5                                             8.902ms reached
     Resume: pmtu 1420 hops 3 back 3

pmtu 1420 出现在第 2 跳。那个位置就是隧道入口。

也确认一下内核是否已经学到了值。

ip route get 10.20.0.5
ip route show cache

要清掉学错的值就清空缓存。

sudo ip route flush cache

为什么 MSS 钳制才是实务上的答案

已经知道路径很窄了,接下来要修。候选有三个。

第一,降低接口 MTU。

sudo ip link set dev ens5 mtu 1420

这个办法只对那台主机发出的数据包有效。它确实也会限制对端发来的数据包,因为我通告的 MSS 变小了。问题在于适用范围。如果窄区段是两台路由器之间的隧道,而两端有数百台服务器和数千台客户端,你不可能去改所有设备的 MTU。

第二,放行 ICMP 让路径MTU发现活过来。原则上这是最正确的解法,而且必须同时进行。只是路径中间的防火墙常常不在我们的掌控之内,互联网另一头某处把 ICMP 封掉这种事更是插不上手。

第三,MSS 钳制。让隧道经过的路由器直接改写路过的 SYN 数据包里的 MSS 选项值。

# 按路径 MTU 自动匹配
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --clamp-mss-to-pmtu

# 只对走隧道接口出去的流量按固定值限制
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
  -o wg0 -j TCPMSS --set-mss 1380

# nftables 写法
sudo nft add rule inet mangle forward tcp flags syn tcp option maxseg size set rt mtu

这种方式之所以成为实务标准,理由很清楚。

只需要动握手的 SYN 和 SYN-ACK,成本实际上等于零。而且 MSS 是通告"我能接收的最大尺寸"的值,所以把双向的 SYN 都改掉,两边就都只会发小段。最重要的是它完全不依赖 ICMP,因此在黑洞环境里也能确定地工作

应用之后用抓包确认。

sudo tcpdump -nn -i wg0 'tcp[tcpflags] & tcp-syn != 0' -c 2
15:02:11.104512 IP 10.60.1.20.51302 > 10.20.0.5.443: Flags [S], seq 118203993, win 62727, options [mss 1380,sackOK,TS val 41120 ecr 0,nop,wscale 7], length 0
15:02:11.112880 IP 10.20.0.5.443 > 10.60.1.20.51302: Flags [S.], seq 771102384, ack 118203994, win 65160, options [mss 1380,sackOK,TS val 90221 ecr 41120,nop,wscale 7], length 0

两边的 mss 都变成了 1380。

也有需要注意的地方。MSS 钳制只对 TCP 生效,基于 UDP 的协议得不到保护。基于 QUIC 的 HTTP/3、用 UDP 接收大响应的 DNS、WireGuard 里面套的另一条 UDP 隧道都属于这一类。QUIC 自己会做 DPLPMTUD 并把默认的 1200 字节当作安全值,所以大体上扛得住,但直接操作 UDP 的应用必须自己限制数据报大小。

Kubernetes CNI 与 IPv6 下的不同之处

在 Kubernetes 里,这个问题因为覆盖网络而定期复发。Flannel VXLAN、Calico VXLAN、Cilium 的 VXLAN 或 Geneve 模式都用到了封装。

症状很有特点。

  • Pod 之间 ping 通得了,小请求也没问题。
  • 只有发送大请求体的 POST 请求会卡住。
  • kubectl exec 能用,但日志很多的 Pod 执行 kubectl logs 会卡在半路。
  • 镜像拉取能开始,却在大层上卡住。

确认从比较接口 MTU 开始。

# 节点的物理接口
ip link show ens5 | head -1
# 覆盖网络接口
ip link show flannel.1 | head -1
# Pod 内部
kubectl exec -it deploy/api -- ip link show eth0 | head -1
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
5: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default
3: eth0@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default

这里就有错。作为 VXLAN 接口的 flannel.1,其 MTU 和节点一样是 1500。VXLAN 要拿走 50 字节,所以应该是 1450。Pod 的 eth0 也应该是 1450。

正常状态是这样。

2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
5: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
3: eth0@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...

在 CNI 里显式写明 MTU 最为稳妥。交给自动探测的话,一旦节点接口变了或者云上启用了巨帧就会对不上。

# Flannel: net-conf.json
{
  "Network": "10.244.0.0/16",
  "Backend": { "Type": "vxlan", "MTU": 1450 }
}
# Calico: 确认已配置的 MTU
kubectl get configmap -n kube-system calico-config -o jsonpath='{.data.veth_mtu}'

# Cilium: 确认探测到的值
kubectl exec -n kube-system ds/cilium -- cilium-dbg status --verbose | grep -i mtu

在 AWS 上,节点用 9001 而 CNI 取成 1500 不会出问题,但反过来节点是 1500 而 CNI 取成 9001,立刻就变成黑洞。必须遵守先确认节点 MTU、再从这个值里减去封装开销的顺序。

IPv6 的规则不同。记住三点就够了。

第一,IPv6 路由器不对数据包分片。在 IPv4 里没有 DF 位时路由器可以自行切开转发,而 IPv6 里根本没有这个选项。分片只有发送主机才能做。

第二,正因如此,ICMPv6 的 Packet Too Big 即类型 2 消息是必需的。把它封掉,IPv6 通信会比 IPv4 坏得更彻底。ICMPv6 的过滤指引整理在 RFC 4890 里,类型 2 必定属于放行对象。

第三,IPv6 的最小 MTU 是 1280 字节。任何路径都至少保证这个尺寸,所以在还没找到原因、需要临时保住服务时,1280 是安全的下限。

# 测量 IPv6 路径 MTU
ping6 -M do -s 1232 -c 1 2001:db8::5

# 确认 IPv6 的 ICMP 能否抵达
sudo tcpdump -nn -i any 'icmp6 and ip6[40] == 2'
15:18:44.201188 IP6 2001:db8:1::1 > 2001:db8:2::20: ICMP6, packet too big, mtu 1400, length 1240

结语 — 结果按大小分化就该怀疑 MTU

诊断的出发点是症状的形状。如果连接能建立,只有尺寸变大时才卡住,而且没有报错就那么卡着,那么在看别的之前先量一下路径 MTU。一条命令就够了。

ping -M do -s 1472 -c 1 目标地址

如果这条失败而小尺寸成功,就可以确定了。

要记住的有三点。TCP 只看自己的接口来决定 MSS,因此无法自行知道路径中间的瓶颈。唯一能告知它的机制是 ICMP 类型 3 代码 4,而封掉这条消息的那一刻,一个安静的黑洞就形成了。而且正因为我们无法控制整条路径,在隧道入口做 MSS 钳制才成了现实中的答案。

新引入隧道或覆盖网络时,最好从一开始就把开销计算和 MSS 钳制写进设计。等到后来才发现,症状实在太模糊,会白白搭进去好几天。