LANUNION ARCHIVELANUNION蓝色札记

← 档案索引

ARCHIVE NO. 003

Docker 自定义网桥间歇性丢包:一次 MTU 收敛排查记录

CLASSIFICATIONNETWORK
DATE2026.09.14
状态 STATUSDECLASSIFIED
READING TIME6 MIN
REVISION1.3

故障现象

周一上午收到告警:对象存储前置的 ingest-api 容器向同网桥的 redis-cache 容器写入 大 key 时超时。有意思的是这个故障并不稳定——redis-cli ping 永远是 PONGcurl 拉一个 2 KB 的配置接口也次次成功,但只要 payload 超过 1.4 KB, 连接就会在数秒到四十秒之间随机僵死,最后抛出 i/o timeout

最初怀疑是 Redis 的 client-output-buffer-limit 或者连接池泄漏,但把 ingest-api 换成 nc 手动灌数据后,问题依旧按 payload 大小复现:

TERMINAL / BASHLINE 01–07
# 小报文:稳定通过
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 复测:

TERMINAL / BASHLINE 01–06
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 的报文:

TERMINAL / BASHLINE 01–02
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

MTU隧道=MTU物理HWireGuard=150060=1440\mathrm{MTU}_{\text{隧道}} = \mathrm{MTU}_{\text{物理}} - H_{\text{WireGuard}} = 1500 - 60 = 1440

问题在于 docker0 与所有自定义网桥在被创建时只读取了物理接口的 MTU (1500),并不知道路由实际上会绕进隧道。容器 veth 对又是从网桥继承的, 于是整条链路都以 1500 为准。单个 IPv4 报文的头部开销是 20+8=2820 + 8 = 28 字节, 所以真正能过隧道的有效负载是:

可通负载=MTU隧道HIPHTCP=14402020=1400 字节\text{可通负载} = \mathrm{MTU}_{\text{隧道}} - H_{\text{IP}} - H_{\text{TCP}} = 1440 - 20 - 20 = 1400\ \text{字节}

往上一叠就更难看了——如果哪天在隧道里再套一层 VXLAN(外层开销 50 字节), 容器的可用 MTU 会掉到:

MTU容器=15006050=1390\mathrm{MTU}_{\text{容器}} = 1500 - 60 - 50 = 1390

而容器仍然按 1500 发包,于是任何超过 1400 字节的写入都会在隧道入口被丢掉。 短连接之所以正常,是因为它的整个请求响应都能塞进一个不分片的报文里。

验证假设

用一个不分片的 ping 直接测出路径 MTU,这是最省事的确认方式:

TERMINAL / BASHLINE 01–06
# -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 选项,只对新创建的网桥生效。把它写进配置, 避免以后新建网络时再踩一次:

TERMINAL / BASHLINE 01–11
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 是怎么来的:

TERMINAL / YAMLLINE 01–05
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 头):

TERMINAL / BASHLINE 01–02
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

  1. 60 字节 = 外层 IPv4 头 20 + UDP 头 8 + WireGuard 自己的报文头 32 (4 字节 type、4 字节 receiver index、8 字节 counter、16 字节认证标签)。 这是无 keepalive、无分片时的稳态开销;握手的第一个报文还要多一个 148 字节的 initiation。