ZeroTier MTU设置导致的请求超时

2026-08-15T11:27:00

昨天下午联调网关,服务 A 调得好好的,服务 B 一调就是 java.net.SocketTimeoutException。更奇怪的是,服务 B 那边的日志干干净净,一条请求都没进来。

环境是什么样的

本地开发机没有公网 IP,所以部署了 ZeroTier,把本地服务挂给公网服务器用。

  • Nacos:跑在公网服务器
  • Spring Cloud Gateway:跑在公网服务器的 Docker 容器里
  • 业务服务:跑在我本地开发机
  • 本地服务注册到 Nacos 时,填的是 ZeroTier 分配的虚拟 IP

链路很短:

客户端
  ↓
公网服务器(Nacos + Gateway 容器)
  ↓  lb://xxx-audit
ZeroTier
  ↓
本地开发机 10.x.x.238:9306

服务 A 和服务 B 注册的是同一个 IP,只是端口不同。

现象凑在一起,很不讲道理

先把手上的线索摊开。

一,Gateway 调服务 B 超时,服务 B 没有任何日志。请求像是凭空消失了。

二,从公网服务器直接访问服务 B,正常。具体业务接口也能调通。

三,进 Gateway 容器里测,也正常。

nc -vz 10.x.x.238 9306
# 10.x.x.238 (10.x.x.238:9306) open

curl http://10.x.x.238:9306

返回:

{"timestamp":"2026-08-11 11:44:31","status":404,"error":"Not Found","path":"/"}

这个 404 是好消息。它说明 TCP 通了、HTTP 也通了,服务端确实收到了请求,只是 / 没有对应接口而已。

四,服务 A 一切正常。它和服务 B 用的是同一个 ZeroTier IP。

四条凑在一起就很不讲道理:网络看着是通的,路由看着是对的,同一条链路上的另一个服务好好的,偏偏这一个不行。

我唯一没敢下的结论是"网络没问题"。因为 curl 打的是一个几百字节的裸请求,而真正走 Gateway 的请求里塞着 Authorization、Cookie、一堆 X-Forwarded-*,后面还挂着 POST Body。

小包能过,不代表大包能过。这个念头当时只是随手记了一下,后来它是整件事的答案。

第一步:请求到底发给谁了

先把日志开到底:

logging:
  level:
    org.springframework.cloud.gateway: TRACE
    org.springframework.cloud.loadbalancer: TRACE
    com.alibaba.cloud.nacos: DEBUG

再调一次 POST /audit/template

Pattern "/audit/**" matches against value "/audit/template"
Route matched: xxx-audit
ReactiveLoadBalancerClientFilter url before: lb://xxx-audit/template
LoadBalancerClientFilter url chosen: http://10.x.x.238:9306/template
NettyRoutingFilter - outbound route ...

路由匹配、StripPrefix、Nacos 服务名解析、LoadBalancer 选实例,一路都对,最后选中的 IP 和端口也对。

范围一下子缩到很小:Gateway 选对了人,但东西没送到。

第二步:抓包,看到了重传

在公网服务器上开 tcpdump:

tcpdump -nn -i any 'host 10.x.x.238 and tcp port 9306'

调一次接口,抓到这么几行:

13:36:34.503220 IP 10.x.x.15.47156 > 10.x.x.238.9306: Flags [.], seq 1149:2597, ack 290, length 1448
13:37:31.933114 IP 10.x.x.238.9306 > 10.x.x.15.47156: Flags [F.], seq 290, ack 1149, length 0
13:37:37.414959 IP 10.x.x.15.47156 > 10.x.x.238.9306: Flags [.], seq 1149:2597, ack 291, length 1448

发出去的是 seq 1149:2597,长度 1448。对端的 ACK 一直停在 1149。

如果这段数据收到了,ACK 就该往后走。它没有往后走,然后同一个 seq 又发了一遍。

这是重传。连接是活的,但这个 1448 字节的段,怎么也过不去。

顺带说一句,抓包里会同时看到 172.21.0.910.x.x.15 两个源地址,这是正常的——前者是 Gateway 容器地址,后者是公网服务器的 ZeroTier 地址,中间隔了一层 Docker 的 SNAT。tcpdump -i any 会把 NAT 前后的流量都收进来,同一个包出现两次不用慌。

第三步:量一下这条路能过多大的包

ping -M do 会禁止 IP 分片,包太大就直接丢,正好拿来试探。

ping -M do -s 1472 10.x.x.238   # 1472 + 8 ICMP + 20 IP = 1500
# 8 packets transmitted, 0 received, 100% packet loss

ping -M do -s 1400 10.x.x.238   # 实际 1428 字节
# 2 packets transmitted, 0 received, 100% packet loss

ping -M do -s 1350 10.x.x.238   # 实际 1378 字节
# 4 packets transmitted, 3 received, 25% packet loss
Ping Payload实际 IP 包结果
14721500全丢
14001428全丢
13501378能通,但还丢

1428 都过不去,抓包里那个 1448 字节的段自然也过不去。两边对上了。

为什么会这样

MTU 是网络接口在不分片的前提下能发出的最大 IP 报文。以太网常见是 1500。

但端到端真正受限的不是某一张网卡,而是整条路径上最小的那个值,也就是 Path MTU。我这条路是这样的:

Docker Bridge      1500
服务器物理网络      1500
ZeroTier Overlay
公网 / NAT / ISP    实际只稳定过 ~1380
本地网络           1500

ZeroTier 是 Overlay,业务数据外面还要再套一层封装,底层实际跑的包比业务看到的更大。中间只要有一段吃不下,就会开始丢。

正常情况下这不该是死局。中间设备该发一个 ICMP 回来说"包太大",发送端收到后通过 PMTUD 自己降下来。

现实是 ICMP 经常被防火墙吃掉、被 NAT 处理错、被运营商丢弃。于是就变成:

发大包 → 中间丢掉 → 发送端收不到任何反馈 → 继续发同样大小的包 → TCP 重传 → 继续丢 → SocketTimeoutException

这就是 PMTU 黑洞。它的症状很有迷惑性:连接能建、小请求正常、大请求卡死,抓包里反复出现同一个 seq。

回头看服务 A 为什么正常,也是同一个原因。它和服务 B 走的是同一条链路,只是请求大小不同,一个没踩到临界点,一个踩到了。

为什么是调小,不是调大

MTU 不是越大越好,只有整条链路都撑得住的时候,大 MTU 才有意义。

实际只能过 1380,你还按 1500 发,结果就是丢包加重传,来回耗着,最后超时。

我最后把 ZeroTier 的网络 MTU 设成了 1300,没有卡在 1380 那个边界上,留了点余量。

1300 MTU - 20 IP Header - 20 TCP Header ≈ 1260 MSS

发送端不再生成 1448 那种大段,包数量多了一点点,但不丢了。重新建连之后,Gateway 调服务 B 就恢复了。

Legacy Central 没有直接改 MTU 的入口,得走 API:

export ZT_TOKEN='你的 Token'
export NWID='你的 Network ID'

curl -H "Authorization: token $ZT_TOKEN" \
     -H "Content-Type: application/json" \
     -X POST "https://api.zerotier.com/api/v1/network/$NWID" \
     --data '{"config":{"mtu":1300}}'

改完记得让应用重新建 TCP 连接。Gateway 用的是 Reactor Netty 连接池,旧连接还会拿着旧的 MSS 继续用,联调环境我直接 docker restart 了事。

验证也简单,按新 MTU 反推 payload:

ping -M do -s 1272 -c 20 10.x.x.238   # 1272 + 8 + 20 = 1300

应该接近 0% 丢包。然后再抓一次包,确认没有大段重传。

下次可以照着走的顺序

同类场景——VPN、ZeroTier、WireGuard、Docker Overlay,TCP 连得上但部分 HTTP 请求超时——我把顺序记在这里:

  1. nc -vz 确认端口
  2. 真正的调用端测,应用在容器里就进容器测,别只在宿主机上试
  3. 打开 Gateway / LoadBalancer 的 TRACE,确认最终选中的 IP:Port
  4. tcpdump 看 seq、ack、payload length、有没有重传
  5. 小包正常大包重传,就用 ping -M do 逐级探 Path MTU
  6. 设一个留了余量的 MTU,别贴着临界值
  7. 重新建连再复测,注意连接池

几个容易想当然的地方

常见判断实际情况
nc 能连,所以网络没问题SYN 很小,能建连不代表大包能过
curl / 正常,业务接口也该正常真实请求的 Header、JWT、Body 大得多
服务端没日志,所以请求没发出去TCP 已经连上了,只是完整的 HTTP Request 没传完
服务 A 正常,所以 ZeroTier 没问题服务 A 可能只是没踩到那个临界点
MTU 越大越好只在整条链路都支持时成立
看 Java 异常栈就能定位网络超时得靠 TRACE + tcpdump + DF Ping 一起

整件事表面是一个 SocketTimeoutException,最开始怀疑的是 Nacos、路由、端口、Docker 网络、服务本身,一个都没猜中。

真正有用的动作只有三个:把日志开到 TRACE 确认请求去了哪,抓包确认数据有没有到,用 ping 量出这条路能过多大的包。三条证据串起来,答案就只剩一个。

我踩过这一次才意识到,多层网络叠在一起的时候,"能连上"和"能用"是两件事。以后再看到 TCP 通、小请求正常、部分接口超时,我会把 MTU 放到排查列表更靠前的位置。

当前页面是本站的「Baidu MIP」版。发表评论请点击:完整版 »