朕喜技术(ZhenxiJS)
2026.10.01
1 分钟阅读

UDP 大报文「静默丢失」MTU 黑洞:服务端网卡 MTU 大于路径 MTU,小配置秒回、大配置 2 秒超时,而 TCP 健康检查全绿、agent 退出码 0

第94题 | 日期:2026-10-01 08:00 场 | 分支:2026-10-01-0800 关键词:MTU 黑洞、MTU black hole、PMTUD、路径 MTU 发现、DF 位、IP_MTU_DISCOVER、IP_PMTUDISC_DO、UDP 静默丢包、EMSGSIZE、Message too long、ip link set mtu、Overlay/VXLAN 隧道 MTU、TCP MSS 协商、sendto 返回成功但收不到、无 ICMP fragmentation needed

一、问题背景

某配置中心用 UDP 把配置快照下发给各业务节点的 agent:

配置体积
cfg:small10 B
cfg:large1623 B

上线后 大配置一直下不去,但监控面板全绿:

  • agent 打印 [agent] round OK (self_reported_ok=True),report.json 里 ok: true
  • agent 进程退出码 0
  • TCP 健康检查 /small 永远 200 OK
  • 服务端 configd.py 日志没有任何报错
  • 把超时调大才看到:large 一直 2000 ms 超时、0 字节

拓扑:client(eth0 MTU 1400)── mesh 网络(路径 MTU 1400)──► api(eth0 MTU 1500)。

二、复现步骤

cd scenario/2026-10-01-0800
docker compose up -d --build
bash ops/repro.sh
docker compose exec -T client bash /srv/ops/verify.sh

问题态输出:

{
  "udp": {
    "small": {"bytes": 10,   "ms": 0.1,    "received": true},
    "large": {"bytes": 0,    "ms": 2001.8, "received": false}
  },
  "tcp_health": true,
  "ok": true
}
[agent] round OK (self_reported_ok=True)
--- agent 退出码: 0 ---
  UDP 收到: small=10B large=0B
  [FAIL] A2 大配置静默丢失(MTU 黑洞)
  [FAIL] D1 report.ok 与真实可达性不一致(report=True, real=False)—— 自检恒绿/撒谎
PASS=4 FAIL=2 / RESULT: BAD

三、根本原因

3.1 主因:发送侧网卡 MTU(1500)> 路径 MTU(1400)→ MTU 黑洞

docker-compose.yml 里 api 启动时把网卡 MTU 写死成 1500:

command: >
  sh -c "python3 /srv/app/configd.py & python3 /srv/app/httpd.py &
         sleep 1; ip link set eth0 mtu 1500;"

而 mesh 网络(代表 VXLAN/Overlay/隧道收敛后的真实路径 MTU)只有 1400:

networks:
  mesh:
    driver_opts:
      com.docker.network.driver.mtu: 1400

于是服务端发出 1616 B(1623 payload + 20 IP + 8 UDP)的 UDP 帧 > 1400,被链路层静默丢弃。

3.2 关键证据(三步锁定)

① 比对两侧 MTU 与路径契约 —— api 比路径多 100 字节,首个疑点:

api    : mtu 1500
client : mtu 1400
MESH_PATH_MTU=1400

② sendto() 竟然返回成功 —— 这是最容易骗过排查的一点:

docker compose exec -T api python3 - <<'PY'
import socket
for n in (1372, 1373, 1500, 1623):
    s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(1.0)
    s.setsockopt(socket.IPPROTO_IP, 10, 2)   # IP_PMTUDISC_DO → DF=1
    try: s.sendto(b"A"*n, ("10.90.0.2", 9999)); print(f"  send {n}B -> sendto OK")
    except OSError as e: print(f"  send {n}B -> OSError {e}")
PY
  send 1372B -> sendto 成功
  send 1373B -> sendto 成功          # ← 1373~1500 区间内核直接放行(它以为 MTU=1500)
  send 1500B -> OSError Message too long
  send 1623B -> OSError Message too long

1373~1500 B 区间 sendto() 成功,但帧出了网卡就消失。应用层得不到任何错误 —— 这就是「黑洞」。

③ 内核计数器证明「只到 1 个包、0 个 ICMP」(镜像无 tcpdump,用 /proc/net/snmp 替代):

docker compose exec -T client sh -c '
before=$(awk "/^Udp:/{getline; print \$2}" /proc/net/snmp)
before_icmp=$(awk "/^Icmp:/{getline; print \$2}" /proc/net/snmp)
python3 -c "
import socket
for k in (\"cfg:small\",\"cfg:large\"):
    s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(2)
    s.setsockopt(socket.IPPROTO_IP,10,2)
    s.sendto(k.encode(),(\"10.90.0.3\",9999))
    try: d,_=s.recvfrom(65535); print(f\"  {k}: got {len(d)}B\")
    except socket.timeout: print(f\"  {k}: TIMEOUT\")
"
after=$(awk "/^Udp:/{getline; print \$2}" /proc/net/snmp)
after_icmp=$(awk "/^Icmp:/{getline; print \$2}" /proc/net/snmp)
echo "  Udp InDatagrams: Δ=$((after-before))"
echo "  Icmp InMsgs    : Δ=$((after_icmp-before_icmp))"
'
  cfg:small: got 10B
  cfg:large: TIMEOUT
  Udp InDatagrams: 5 -> 6  (Δ=1)     # 只有小配置那 1 个包到达
  Icmp InMsgs     : 0 -> 0  (Δ=0)    # 零 ICMP → 不是正常 PMTUD,是黑洞

判据:正常 PMTUD 失败应回 ICMP fragmentation needed 或本机得到 EMSGSIZE; 两者皆无、报文凭空消失 = MTU 黑洞。

3.3 为什么 TCP 全绿、监控没告警(三重掩护)

  1. TCP 有 MSS 协商:三次握手交换 MSS,服务端主动把分段压到对端能收的大小(≈1360),所以 /small 永远 200 OK —— MTU 问题在 TCP 上不暴露。
  2. agent 成功口径错:any(v["received"] ...) —— 任意一份配置拿到就算成功。10 B 小配置始终能过,大配置失败被掩盖。
  3. 退出码写死 0:return 0 # 无论成败都退出 0。编排/探活看到的是「进程正常退出」。

三个「绿」没有一件指向真相。

四、解决办法

4.1 治本:让服务端网卡 MTU 对齐路径契约(改 docker-compose.yml)

+    env_file: [./conf/mesh.env]
     command: >
       sh -c "python3 /srv/app/configd.py & python3 /srv/app/httpd.py &
-             sleep 1; ip link set eth0 mtu 1500;
-             echo '[api] eth0 mtu set to 1500 (problem state)';
+             sleep 1;
+             ip link set eth0 mtu \"$${MESH_PATH_MTU:-1400}\";
+             echo \"[api] eth0 mtu set to $${MESH_PATH_MTU:-1400} (aligned with path MTU)\";
              wait"

compose 里 $$ 是转义,让 $MESH_PATH_MTU 延迟到容器内 shell 展开。

为什么这是治本而不是「调大超时」:黑洞成因是「发出的帧 > 路径能过的 MTU」。把发送侧 MTU 降到路径 MTU,1616 B 帧就成了合法单帧。调大超时只会让失败更慢,包照样丢。

4.2 顺手修掉 agent 的假成功(改 app/agent.py)

-    any_udp = any(v["received"] for v in report["udp"].values())
-    report["ok"] = bool(any_udp and report["tcp_health"])
+    all_udp = all(v["received"] for v in report["udp"].values())
+    report["ok"] = bool(all_udp and report["tcp_health"])
+    report["missing"] = [k for k, v in report["udp"].items() if not v["received"]]

-    print("[agent] round OK (self_reported_ok=%s)" % report["ok"])
-    return 0  # 无论成败都退出 0
+    if report["ok"]:
+        print("[agent] round OK (self_reported_ok=True)")
+        return 0
+    print("[agent] round FAILED (self_reported_ok=False, missing=%s)"
+          % ",".join(report["missing"]))
+    return 1  # 退出码必须如实反映失败

五、验证

docker compose down -v
docker compose up -d --build
docker compose exec -T client bash /srv/ops/verify.sh
  UDP 收到: small=10B large=1623B
  [PASS] A2 大配置可达(1623B)
  [PASS] D1 report.ok 与真实可达性一致(ok=True)
  [PASS] D2 退出码与报告一致(ok=True, rc=0)
PASS=6 FAIL=0 / RESULT: OK

延迟达标(要求 < 100 ms):cfg:large: 1623B in 0.05ms,Udp InDatagrams Δ=2。

持久性验证(证明治本):

docker compose restart api && sleep 6
docker compose exec -T api ip link show eth0 | head -1
# mtu 1400   ← 重启后仍是 1400(修复前会打回 1500)

假成功修复的反向验证(回到问题态 MTU 看 agent 是否如实报错):

bash ops/apply_fix.sh --restore
docker compose exec -T client python3 /srv/app/agent.py; echo "rc=$?"
#   "ok": false, "missing": ["large"]
#   [agent] round FAILED (self_reported_ok=False, missing=large)
#   rc=1

六、经验与避坑

核心认知

  • sendto() 返回成功 ≠ 对端收得到。UDP 无连接、无确认,sendto 只保证「交给本机协议栈」。唯一可信的判据是「对端有没有收到」,不是返回值。
  • 「小包通、大包不通」是 MTU 问题的教科书指纹。看到它就立刻比对 发送侧网卡 MTU 与 路径 MTU。
  • TCP 全绿不能证明网络没问题:TCP 有 MSS 协商会主动适配,MTU 黑洞在 TCP 上几乎不暴露。用 TCP 健康检查去监控 UDP 业务是无效监控。
  • 「无 ICMP 反馈」才是黑洞的标志。正常 PMTUD 会回 ICMP fragmentation needed;有 ICMP 说明路径在正常工作,连 ICMP 都没有说明报文在链路层被无声丢掉。

排查方法论

  1. 先比对两侧 MTU 与路径契约:ip link show eth0 + conf/mesh.env 里的 MESH_PATH_MTU。
  2. 做方向性二分探测:分别从两端试探不同大小的 UDP 包。本案 client→api 正常(client 认知正确)、api→client 黑洞,一步就把范围缩到「回程」。
  3. 计算边界值验证认知:1400 - 20(IP) - 8(UDP) = 1372。client 侧发 1372 成功、1373 得 EMSGSIZE,说明它的 MTU 认知正确 —— 对照正确的一侧,反推错误的一侧。
  4. 没有 tcpdump 就用内核计数器:/proc/net/snmp 的 Udp InDatagrams / Icmp InMsgs 前后差值,同样能证明「只到了 1 个包、0 个 ICMP」。

踩坑清单

  1. 镜像没装 tcpdump:docker compose exec client tcpdump → No such file or directory。改用 /proc/net/snmp 计数器,效果等价。
  2. ip link set mtu 不持久:ops/apply_fix.sh --apply 只是运行时改,docker compose restart api 会把 MTU 打回启动命令里写死的值。真正的修复必须落在 docker-compose.yml,否则一重启就复发。
  3. compose 变量转义:"${MESH_PATH_MTU}" 会在宿主被 compose 展开(通常为空);要写 $${MESH_PATH_MTU} 才能延迟到容器内 shell 展开。
  4. 别只看 sendto 返回码判断「发得出去」:api 发 1373~1500 B 时竟然成功。必须验证对端收到了没有。
  5. app/ 是挂载/COPY 进镜像的:改了 agent.py 后 restart 用的是旧代码,要 up -d --build(本场景 app/ 是 volume 挂载,改动即时生效,但养成 --build 习惯更稳)。
  6. 别把「调大 FETCH_TIMEOUT」当修复:它只让失败更慢(2000 ms → 更大),包依然丢;且会把 MTU 问题长期埋在「偶尔超时」的表象下。
  7. agent 的「自检」是写死的成功口径:any(...) + return 0 让致命故障恒绿。自检必须是「算出来的」,退出码必须如实反映业务结果。

同类影响面

  • VXLAN / Overlay / IPsec / GRE / WireGuard 隧道:封装头会吃掉 40~100+ 字节,必须调小发送侧 MTU 或写死 MSS clamp。
  • K8s CNI(Calico/Flannel/Cilium):Pod MTU 通常需设为 物理 MTU - 封装开销(Flannel VXLAN 典型 1450)。
  • 云厂商 VPC / 跨区域对等连接:路径 MTU 可能小于 1500,尤其跨 VPN 时。
  • 任何「网卡 MTU 写死 1500」的旧镜像在现代虚拟网络中都是隐患。
  • 监控自查:只做 TCP 探活的服务,UDP/大报文的故障是监控盲区;应加端到端业务探针(比对「实际拿到的内容」而非「连接是否建立」)。

一句话自查:「有没有一个大包能走通?」 —— 如果只有小包通、大包丢,且 sendto 不报错、没有 ICMP,就是 MTU 黑洞,去比对发送侧 MTU 与路径 MTU。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-01-0800-udpmtu-mtumtu

分享这篇文章