ARCHIVE NO. 003
Docker 自定义网桥间歇性丢包:一次 MTU 收敛排查记录
故障现象
周一上午收到告警:对象存储前置的 ingest-api 容器向同网桥的 redis-cache 容器写入
大 key 时超时。有意思的是这个故障并不稳定——redis-cli ping 永远是 PONG,
curl 拉一个 2 KB 的配置接口也次次成功,但只要 payload 超过 1.4 KB,
连接就会在数秒到四十秒之间随机僵死,最后抛出 i/o timeout。
最初怀疑是 Redis 的 client-output-buffer-limit 或者连接池泄漏,但把
ingest-api 换成 nc 手动灌数据后,问题依旧按 payload 大小复现:
# 小报文:稳定通过
docker exec ingest-api sh -c 'head -c 1000 /dev/urandom > /tmp/small.bin'
docker exec ingest-api sh -c 'nc -w 3 redis-cache 6379 < /tmp/small.bin; echo "exit=$?"'
# 大报文:挂起,最终 exit=124 (timeout)
docker exec ingest-api sh -c 'head -c 60000 /dev/urandom > /tmp/big.bin'
docker exec ingest-api sh -c 'nc -w 3 redis-cache 6379 < /tmp/big.bin; echo "exit=$?"'“小包通、大包死”几乎可以直接把应用层排除掉。真正要回答的是:包在哪一跳被丢的。
先排除 DNS 与 iptables
同网桥容器之间按 IP 直连,不经过 Docker 内嵌 DNS;iptables -t nat -L DOCKER
里也没有异常规则。为了确认不是 NAT 表把分片吃了,直接在容器里用 IP 复测:
docker network inspect app-net \
--format '{{range .Containers}}{{.Name}}={{.IPv4Address}} {{end}}'
# 输出:redis-cache=172.20.0.4/16
docker exec ingest-api sh -c 'nc -w 3 172.20.0.4 6379 < /tmp/big.bin; echo "exit=$?"'
# 仍然是 exit=124绕过 DNS 后行为完全一致,所以问题在数据面,不在名字解析。
tcpdump:证据在分片上
在宿主机的网桥接口上抓包,同时灌一个 60 KB 的报文:
timeout 15 tcpdump -ni br-$(docker network inspect app-net \
--format '{{.Id}}' | cut -c1-12) -vv 'host 172.20.0.4' -c 40抓包结果里能看到完整的三次握手、以及携带 DF 标志、长度恰好 1514 字节的
初始数据帧,但 后续的 IP 分片一个都没出现。对端只在重传第一个数据段,
重传次数耗尽后连接被中断——这就是那个”随机”的四十秒。
这一条把范围收得很窄:路径上某一段的 MTU 小于容器自认为可以发送的长度,
中间设备因为 DF 位置位而无法分片,只能静默丢弃。
根因:MTU 未随隧道向下继承
机房的物理出口是 1500,但宿主机的默认路由走了一条 WireGuard 隧道到对端机房, 两台宿主机上的容器实际上是通过隧道互通的。WireGuard 会在每个报文外面 再封一层 IPv4 与 UDP 头,外层开销不参与内层计数1:
问题在于 docker0 与所有自定义网桥在被创建时只读取了物理接口的 MTU
(1500),并不知道路由实际上会绕进隧道。容器 veth 对又是从网桥继承的,
于是整条链路都以 1500 为准。单个 IPv4 报文的头部开销是 字节,
所以真正能过隧道的有效负载是:
往上一叠就更难看了——如果哪天在隧道里再套一层 VXLAN(外层开销 50 字节), 容器的可用 MTU 会掉到:
而容器仍然按 1500 发包,于是任何超过 1400 字节的写入都会在隧道入口被丢掉。 短连接之所以正常,是因为它的整个请求响应都能塞进一个不分片的报文里。
验证假设
用一个不分片的 ping 直接测出路径 MTU,这是最省事的确认方式:
# -M do 禁止分片,-s 指定负载长度
docker exec ingest-api ping -M do -s 1372 -c 2 172.20.0.4
# 1400 负载 = 1372 + 28 字节 ICMP/IP 头 —— 通过
docker exec ingest-api ping -M do -s 1472 -c 2 172.20.0.4
# 1500 负载 —— 100% packet loss,符合预期1372 通、1472 不通,与上面算出的 1400 完全吻合,根因确认。
修复
第一层:daemon.json 固化默认 MTU
Docker 守护进程支持 mtu 选项,只对新创建的网桥生效。把它写进配置,
避免以后新建网络时再踩一次:
sudo tee /etc/docker/daemon.json >/dev/null <<'JSON'
{
"mtu": 1400,
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" }
}
JSON
sudo systemctl restart docker
# 重建受影响的网络;已有容器需要重新创建才会拿到新 MTU
docker network rm app-net && docker compose up -d注意 mtu 对已经存在的网桥不生效,必须删掉重建。这一点在滚动发布的
宿主机上要格外小心,重建网络意味着该网络上所有容器短暂断连。
第二层:compose 里显式声明,让配置自解释
守护进程级默认值是个隐式约定,值班的人未必知道。更稳的做法是在 compose 文件里显式写出来,并附上注释说明 1400 是怎么来的:
networks:
app-net:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1400第三层:应用侧收敛 TCP MSS
MTU 只约束二层帧长,TCP 仍然可能按自己的 MSS 协商。稳妥起见在
ingest-api 所在主机上把走隧道的流量做一次 MSS clamping,
把协商值钳到 1360(1400 减去 40 字节的 IP/TCP 头):
sudo iptables -t mangle -A FORWARD -o wg0 -p tcp \
--tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360三层同时加上之后,60 KB 与 5 MB 的写入都稳定通过,且没有出现新的性能回退。
复盘
这次故障真正花时间的不是修复,而是确认”小包通、大包死”这个模式。 把结论固化成几条巡检项:
- 任何叠加了隧道的宿主机,
docker network inspect里的Options必须能看到 显式的mtu,而不是空对象。 - 新上线容器后跑一次
ping -M do -s $((MTU - 28)),把路径 MTU 当作冒烟测试。 DF位被置位时中间设备不会分片、也不会回 ICMP,只表现为重传。 看到”三次握手成功但首个大报文后全是重传”,应第一时间怀疑 MTU, 而不是先去看应用日志。- 隧道两端如果由不同团队维护,MTU 一定要写进交接文档; 它对上层是隐式的,但对排障的人是决定性的。
Footnotes
-
60 字节 = 外层 IPv4 头 20 + UDP 头 8 + WireGuard 自己的报文头 32 (4 字节 type、4 字节 receiver index、8 字节 counter、16 字节认证标签)。 这是无 keepalive、无分片时的稳态开销;握手的第一个报文还要多一个 148 字节的 initiation。 ↩