高防CDN边缘节点容器化:docker0 的 MTU 在 daemon 启动那一刻就焊死了,GRE 隧道起来也不会改,大文件全卡在 98%

先说现象,别急着改配置

边缘节点容器化,Docker 里跑 Nginx 做缓存层+回源层。高防清洗中心回注到你这台机器走的是一条 GRE/IPIP 牵引隧道,ipip0 的 MTU 1280~1400。

然后业务侧的反馈会很迷惑:

  • 静态 API、几 KB 的 JS、小图 全部正常,压测 QPS 也上得去;
  • 但只要单文件超过 1MB,下载进度条就卡在 98% 或者 60%~99% 之间来回横跳,最终超时;
  • POST 上传稍微大一点的表单,偶尔 502;
  • 换一个源站就好了,换一台机器(裸机跑 nginx)也好了 —— 于是所有人第一反应是"源站问题"、"CDN 清洗问题",一查三天。

这是 MTU 问题,但根子不在隧道上,在 Docker 的 bridge MTU 继承时机上。

第一现场:三个命令定位

先确认容器实际 MTU 和 daemon 的初始值:

# 容器 eth0 的 MTU
for c in $(docker ps -q); do
  printf "%-24s %s\n" \
    "$(docker inspect -f '{{.Name}}' $c)" \
    "$(nsenter -t $(docker inspect -f '{{.State.Pid}}' $c) -n ip -o link show eth0 | awk '{print $5}')"
done

# docker0 与牵引隧道
ip -o link show docker0 ipip0 | awk '{print $2, $5}'

典型输出是 docker0 mtu 1500、ipip0 mtu 1400。容器内的 eth0 也是 1500。

再抓 SYN,看协商出来的 MSS:

tcpdump -i any -nn -vv -c 10 'tcp[tcpflags] & tcp-syn != 0 and port 80' 2>/dev/null | grep -i mss

会看到 mss 1448(带 timestamp option)。1448 + 20 + 20 + 12 = 1500。也就是说,两端都以为链路是 1500,然后每个 1500 字节的 TCP 段进了 1400 的隧道口,DF 位置位,直接丢。

验证是不是"被丢了而不是被分片了":

# 隧道口有没有 ICMP Fragmentation Needed 回来
tcpdump -i ipip0 -nn 'icmp and icmp[icmptype] == 3 and icmp[icmpcode] == 4' -c 20

# 内核有没有缓存到 PMTU
ip route get 203.0.113.10 from 172.17.0.5

# 重传计数
ss -tin state established '( sport = :80 )' | grep -oE 'retrans:[0-9/]+' | head

实际情况八成是:一条 ICMP 都抓不到,但 retrans 在疯涨。PMTUD 黑洞。

根因一:docker0 的 MTU 是 daemon 启动那刻定死的

dockerd 启动时通过默认路由接口取 MTU 塞给 docker0,之后再也不会更新。你的机器开机顺序是这样的:

  1. systemd 拉起 network / 物理口 eth0(1500),此时默认路由指向 eth0;
  2. docker.service 启动,docker0 吃到 1500,veth 全部继承;
  3. 几十秒后 BGP 牵引脚本 / keepalived / 你自己的隧道服务才把 ipip0 建起来并切默认路由。

docker0 的 1500 就这么一直留着,容器重建一百次也还是 1500。

对齐时间线确认一下:

systemctl show docker -p ExecMainStartTimestamp
ip -o link show ipip0 | grep -oE 'state [A-Z]+'
stat -c '%y' /etc/docker/daemon.json

根因二:清洗中心把 ICMP Type 3 Code 4 吃了

正常情况下,隧道口丢包会回一个 ICMP frag needed, mtu 1400,源端收到后自动降 PMTU、重传、继续跑。但高防线路上的 ICMP 大概率被:

  • 清洗中心的抗反射策略直接 drop;
  • 或者牵引侧只放行 TCP/UDP,ICMP 全灭;
  • 或者源站前置的硬件防火墙把 ICMP 限速到 10pps,高峰期等于没有。

于是 PMTUD 完全失效,小包通、大包死,而且是那种"大包也能过一部分,但速度只有理论值的 1/3"的慢性病 —— 因为每次重传能过一个 1400 以内的小窗口,然后又被下一个 1500 的段卡住。

根因三:在容器里 clamp MSS 会打错地方

很多人第一反应是进容器 iptables -t mangle -A FORWARD -j TCPMSS ...——没用。容器的 FORWARD 链根本不参与自己的出站流量:容器发出的包走自己的 OUTPUT → veth → 宿主机的 FORWARD → POSTROUTING(MASQUERADE)→ ipip0。

要 clamp,要么在容器 netns 的 OUTPUT 链(需要 cli 工具和 NET_ADMIN),要么在宿主机 FORWARD 链上按隧道口限定方向。后者更干净。

修复清单(按侵入性从低到高)

1. 先止血:宿主机两面 clamp,不停服

modprobe xt_TCPMSS

# 出向:容器 → 源站,SYN 出去时压 MSS
iptables -t mangle -A FORWARD -o ipip0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360

# 回向:源站 → 容器,SYN-ACK 回来时压 MSS
iptables -t mangle -A FORWARD -i ipip0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360

1360 = 1400 - 20(IP) - 20(TCP)。别用 --clamp-mss-to-pmtu:它读的是路由缓存的 PMTU,PMTUD 黑洞场景下缓存里压根没有 1400,回退到出接口 MTU 就又变回 1500,白干。

注意不要写成不带 -i/-o 的全局规则 —— 同机房内网如果跑 9000 MTU 的存储流量,会被一起压到 1360,得不偿失。规则只对新连接生效,老连接得等 net.ipv4.tcp_fin_timeout 走完,或者直接 ss -K 干掉。

2. 治本:给网络显式指定 MTU

不要指望 daemon.json 的全局 mtu 立刻生效 —— 改完 systemctl restart docker 会导致所有容器网络重建、docker0 上的 conntrack 全清、边缘节点瞬断。生产上更稳的做法是新建一个专用网络,滚动迁移:

docker network create cdn-edge \
  --driver bridge \
  --opt com.docker.network.driver.mtu=1400 \
  --subnet 172.28.0.0/16

compose 里对应:

networks:
  cdn-edge:
    driver: bridge
    driver_opts:
      com.docker.network.driver.mtu: "1400"

daemon.json 里也顺手锁死,防止有人 docker network create 不带参数:

{
  "mtu": 1400,
  "default-address-pools": [
    { "base": "172.30.0.0/16", "size": 24 }
  ]
}

关键运营动作:把 Restart= 和隧道服务的启动顺序反过来,或者干脆在隧道 up 之后加一个 docker network disconnect/connect 的钩子。我们后来边缘这批机器换成轻云互联的高防线路,物理口是 25G,但流量牵引仍然走 GRE,MTU 1280 是死规矩,所以部署脚本里第一件事就是 ip link set ipip0 mtu 1280,然后才拉容器 —— 顺序错了,后面几小时的排障全白费。

3. 容器内兜底(host network 场景必须做)

如果边缘节点用 --network host 跑 Nginx 抢性能,上面的 FORWARD 规则根本不经过(流量走 OUTPUT),必须在宿主机 mangle OUTPUT 上加:

iptables -t mangle -A OUTPUT -o ipip0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360

验证:别只看"能下载了"

# 1. SYN 里的 MSS 应该是 1360(或 1348 带时间戳)
tcpdump -i ipip0 -nn -vv 'tcp[tcpflags] & tcp-syn != 0' -c 5 | grep -i mss

# 2. 已建立连接的协商值
ss -tinm state established '( dport = :80 )' | grep -E 'mss|retrans' | head -20

# 3. 大文件吞吐
curl -o /dev/null -w 'size=%{size_download} speed=%{speed_download}\n' \
  --resolve cdn.example.com:443:127.0.0.1 \
  https://cdn.example.com/100m.bin

# 4. 重传率应该掉到 <0.5%
nstat -az TcpRetransSegs TcpOutSegs | awk 'NR>1{print $1,$2}'

顺带一刀:HTTP/3 的静默降级

CDN 边缘开 QUIC 之后你会发现一个诡异现象:Alt-Svc 通告了,HTTP/3 握手也成功了,但下载速度只有 HTTP/2 的一半,而且 ss -u 上看不到什么异常。

原因是 QUIC 按 RFC 9000 做 PMTU 探测时会置 DF 位,探测从 1200 起步往上爬。容器 eth0 是 1500,QUIC 会探到 1450+ 然后包在隧道口被静默丢弃,探测超时后回退,在 1200~1450 之间反复试探,吞吐直接在锯齿上抖。日志里啥也没有,因为丢包发生在隧道侧,Nginx 只看到"客户端重传"。

把容器 MTU 压到 1400 以下,QUIC 的探测上限自然收敛,问题消失。用 curl --http3 -v 看 quic 相关的时间轴,或者直接对比两个协议在大文件上的 speed_download 差值,超过 30% 就该怀疑 MTU 了。

再顺带一刀:滚动发布时的 conntrack 残留

容器重建后 IP 会被复用(172.17.0.x 池子就那么点),宿主 conntrack 表里还挂着老容器的 ESTABLISHED 表项和 MASQUERADE 端口映射。新容器用同一个 IP 发起回源,SYN 可能匹配到旧 tuple,回包被内核判成"unexpected data",直接 RST。表现就是发布后 2~3 分钟内,新节点的回源 502 率飙到几个点,然后自己好了。

# 发布前手动清
conntrack -D -s 172.28.0.15 2>/dev/null

# 事后确认
netstat -s | grep -iE 'unexpected|reset'

更省事的做法是滚动发布时别复用 IP,用固定 IP 池 + 递增编号,或者干脆每次 docker network disconnect 后重建网络段。反正边缘节点本来就是按节点编号排布的,IP 递增成本几乎为零。

一句话总结

高防牵引隧道把 MTU 从 1500 砍到 1400/1280,这件事对裸机部署是"改个接口 MTU 就完了",对 Docker 部署是"三处状态各有各的时机":daemon 启动时的默认路由、bridge 网络的创建参数、以及每个已建立连接的 MSS 协商结果。任何一处没对齐,症状都长成"小请求全好,大文件卡 98%"这种把所有人带偏的样子。