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:small | 10 B |
cfg:large | 1623 B |
上线后 大配置一直下不去,但监控面板全绿:
agent打印[agent] round OK (self_reported_ok=True),report.json里ok: trueagent进程退出码 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 全绿、监控没告警(三重掩护)
- TCP 有 MSS 协商:三次握手交换 MSS,服务端主动把分段压到对端能收的大小(≈1360),所以
/small永远 200 OK —— MTU 问题在 TCP 上不暴露。 - agent 成功口径错:
any(v["received"] ...)—— 任意一份配置拿到就算成功。10 B 小配置始终能过,大配置失败被掩盖。 - 退出码写死 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 都没有说明报文在链路层被无声丢掉。
排查方法论
- 先比对两侧 MTU 与路径契约:
ip link show eth0+conf/mesh.env里的MESH_PATH_MTU。 - 做方向性二分探测:分别从两端试探不同大小的 UDP 包。本案 client→api 正常(client 认知正确)、api→client 黑洞,一步就把范围缩到「回程」。
- 计算边界值验证认知:
1400 - 20(IP) - 8(UDP) = 1372。client 侧发 1372 成功、1373 得EMSGSIZE,说明它的 MTU 认知正确 —— 对照正确的一侧,反推错误的一侧。 - 没有
tcpdump就用内核计数器:/proc/net/snmp的Udp InDatagrams/Icmp InMsgs前后差值,同样能证明「只到了 1 个包、0 个 ICMP」。
踩坑清单
- 镜像没装
tcpdump:docker compose exec client tcpdump→No such file or directory。改用/proc/net/snmp计数器,效果等价。 ip link set mtu不持久:ops/apply_fix.sh --apply只是运行时改,docker compose restart api会把 MTU 打回启动命令里写死的值。真正的修复必须落在docker-compose.yml,否则一重启就复发。- compose 变量转义:
"${MESH_PATH_MTU}"会在宿主被 compose 展开(通常为空);要写$${MESH_PATH_MTU}才能延迟到容器内 shell 展开。 - 别只看
sendto返回码判断「发得出去」:api 发 1373~1500 B 时竟然成功。必须验证对端收到了没有。 app/是挂载/COPY 进镜像的:改了agent.py后restart用的是旧代码,要up -d --build(本场景app/是 volume 挂载,改动即时生效,但养成--build习惯更稳)。- 别把「调大
FETCH_TIMEOUT」当修复:它只让失败更慢(2000 ms → 更大),包依然丢;且会把 MTU 问题长期埋在「偶尔超时」的表象下。 - 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。
