- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 小请求能通,只有大响应卡住
有一类故障的症状格外特别。
- 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发现。顺序如下。
- Linux 默认会给 TCP 数据包置上 DF(Don't Fragment)位。
- 中间路由器收到比自己下一跳 MTU 更大的 DF 数据包时,因为无法分片,就把包丢掉。
- 作为替代,它向发送方发出 ICMP 类型 3 代码 4,也就是 Fragmentation Needed。这条消息里带着下一跳的 MTU 值。
- 发送方内核把这个值存进按路由的缓存,把段切小后重新发送。
正常工作时看起来是这样。
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 钳制值 |
|---|---|---|---|
| PPPoE | 8 字节 | 1492 | 1452 |
| GRE | 24 字节 | 1476 | 1436 |
| VXLAN (IPv4 外层) | 50 字节 | 1450 | 1410 |
| Geneve (无选项) | 50 字节以上 | 1450 以下 | 1410 以下 |
| WireGuard (IPv4 外层) | 60 字节 | 1440 | 1400 |
| WireGuard (IPv6 外层) | 80 字节 | 1420 | 1380 |
| IPsec ESP 隧道模式 | 最多约 73 字节 | 1427 | 1387 |
| IPsec ESP + NAT-T | 最多约 81 字节 | 1419 | 1379 |
有几点需要注意。
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 钳制写进设计。等到后来才发现,症状实在太模糊,会白白搭进去好几天。