容器里 curl 100% 超时、宿主机同一命令秒回:BGP 多线主机上被 MASQUERADE 吃掉的 ip rule
现场:同一台机器,两个 netns,两种结果
机器是轻云互联的 BGP 三线主机,eth0/eth1/eth2 分别上联电信、联通、移动,各挂一个 /29 公网段,自己压静态默认路由,上游做 BGP 宣告。系统 Rocky 8,内核 5.14,Docker 24.0.7。
业务是一个跑在 bridge 网络里的采集容器,需要 HTTPS 回传数据到三家 SaaS。值班群报的现象非常整齐:
- 宿主机上
curl -w '%{time_total}' https://api-a.example.com→ 稳定 0.18s - 同一个 URL 进容器
docker exec执行 → 100% timeout,30 秒挂断 - 换成
https://api-b.example.com,容器里又是秒回
“100% 挂”和“100% 通”这种规律本身就是线索。真随机丢包不会这么整齐,说明这不是链路质量问题,是选路问题。
第一轮:把常规嫌疑挨个排掉
- DNS:容器里
getent hosts api-a.example.com和宿主机返回的 IP 完全一致,排除。 - MTU:
ip link show docker0是 1500,三个物理口上ping -M do -s 1472全通,路径 MTU 没问题,排除。 - DOCKER-USER 链:
iptables -L DOCKER-USER -n -v是空的,没有 ACL 拦截,排除。 - conntrack 打满:
conntrack -C才 2000 多,net.netfilter.nf_conntrack_max是 262144,排除。
第二轮:三块网卡各抓一次,出口跑偏了
容器里抓包:
docker exec -it collector tcpdump -nn -i eth0 'host 1.2.3.4 and port 443'
# 只有 SYN 重传:172.17.0.2.41234 > 1.2.3.4.443: Flags [S]
# 一直没有 SYN-ACK
宿主机 docker0 抓到的也是同一个 SYN,正常。于是分头去三块物理网卡上抓:
tcpdump -nn -i eth0 'host 1.2.3.4' -c 5 # 干干净净,一个包都没有
tcpdump -nn -i eth1 'host 1.2.3.4' -c 5 # 干干净净,一个包都没有
tcpdump -nn -i eth2 'host 1.2.3.4' -c 5
# 192.0.2.10.41234 > 1.2.3.4.443: Flags [S]
# 全是单向出包,没有回程
源地址 192.0.2.10 是 eth2 那块移动口的地址。容器流量全从 eth2 走了。
第三轮:两条 ip route get 一对比,根因就出来了
先看宿主表和策略路由:
# ip -4 rule show
1000: from 203.0.113.10 lookup 100
1001: from 198.51.100.10 lookup 101
1002: from 192.0.2.10 lookup 102
32765: from all lookup local
32766: from all lookup main
32767: from all lookup default
# ip -4 route show table main
default
nexthop via 203.0.113.9 dev eth0 weight 1
nexthop via 198.51.100.9 dev eth1 weight 1
nexthop via 192.0.2.9 dev eth2 weight 1
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1
关键就在下面这两条命令的对比:
# 宿主机自己发起
# ip route get 1.2.3.4 from 203.0.113.10
1.2.3.4 from 203.0.113.10 via 203.0.113.9 dev eth0 uid 0
# 还原容器转发时的查表上下文
# ip route get 1.2.3.4 from 172.17.0.2 iif docker0
1.2.3.4 from 172.17.0.2 via 192.0.2.9 dev eth2 uid 0
同一个目的 IP,宿主机走 eth0,容器走 eth2。
原因只有一个:内核转发路径里,路由查找发生在 netfilter 的 NAT 之前。容器包进内核时源地址还是 172.17.0.2,而 ip rule 里压根没有 from 172.17.0.0/16 的规则,直接落到 32766 那条 from all lookup main。main 表里三条等价默认路由是 ECMP,内核按 flow hash 挑了一条,挑中的是 eth2。
等进 POSTROUTING 执行 MASQUERADE 时,出口设备早就定死了。MASQUERADE 只是“跟着已定的出口设备,把源地址换成那块网卡的地址”。它是选路的结果,不是选路的依据。方向搞反了,整条 from <公网IP> 的策略路由根本没机会参与。
conntrack 里跑一遍就实锤了:
# conntrack -L -s 172.17.0.2 | head -3
tcp 6 117 SYN_SENT src=172.17.0.2 dst=1.2.3.4 sport=41234 dport=443
[UNREPLIED] src=192.0.2.10 dst=1.2.3.4 sport=41234 dport=443 use=1
SNAT 后的源地址就是 192.0.2.10,跟 eth2 上的抓包完全对上。
为什么偏偏是这几个目的 IP 100% 挂
如果只是 ECMP 随机,现象应该是“时好时坏”。这里还有个隐藏开关:
# sysctl net.ipv4.fib_multipath_hash_policy
net.ipv4.fib_multipath_hash_policy = 0
0 是 L3 hash,哈希输入只有 (src_ip, dst_ip)。容器的 src 固定是 172.17.0.x,dst 固定是 SaaS 的 IP,对 (容器, 目标) 这个组合,哈希结果永远一样,永远挑中同一条线。只要那条线有问题,就是 100% 挂,不是 1/3 挂。
改成 fib_multipath_hash_policy=1 把端口也算进去,熵确实变大、表现会“随机化”,但那是把确定性故障变成概率故障,不解决问题,只能让排查更难。
顺藤摸瓜:eth2 那条线的 BGP 邻居早就 Down 了
问题到这一步已经不是“为什么走 eth2”,而是“eth2 为什么不通”。上上游那台路由器看会话:
/routing bgp session print
# peer=192.0.2.9 remote-as=64512 state=active
# 不是 established,已经 Active 三天了
对端根本不知道 192.0.2.8/29 这个段往哪送。但机器上的默认路由是运维手写死在 /etc/sysconfig/network-scripts/route-eth2 里的,BGP 会话状态和静态路由表之间没有任何联动,ECMP 就继续老老实实往这条死线上分发 1/3 的流量。
宿主机为什么一直没暴露?因为宿主上跑的业务为了合规早就把出向绑在电信 IP 上了,走的是 rule 1000,压根不碰 eth2。只有容器这条路是裸的 MASQUERADE,把三条线无差别混在一起。
修复:分三层,从止血到根治
第一层:立刻摘掉死线路(对存量连接无效)
ip route del default nexthop via 192.0.2.9 dev eth2
# 上游 BGP 恢复后再加回去
只对新连接生效。存量 conntrack 条目源地址还是旧的,需要 conntrack -D -s 172.17.0.0/16 清一遍,不然容器里的长连接还是会卡死。
第二层:给容器流量单独一张表,别让它走 main 的 ECMP
# 容器网段的转发流量走独立表
ip rule add iif docker0 lookup 200 priority 900
# 表 200 里只放经过健康检查的下一跳
ip route replace default \
nexthop via 203.0.113.9 dev eth0 weight 1 \
nexthop via 198.51.100.9 dev eth1 weight 1 \
table 200
配上 systemd timer 每 10 秒探一次,每块网卡用自己的探测目标,别共用一个,不然探不出线路差异:
#!/bin/bash
# /usr/local/bin/bgp-hc.sh
set -u
declare -A GW=( [eth0]=203.0.113.9 [eth1]=198.51.100.9 [eth2]=192.0.2.9 )
declare -A SRC=( [eth0]=203.0.113.10 [eth1]=198.51.100.10 [eth2]=192.0.2.10 )
declare -A PROBE=([eth0]=202.101.0.1 [eth1]=221.6.4.6 [eth2]=211.136.192.6)
HOPS=()
for i in eth0 eth1 eth2; do
ping -c 2 -W 1 -I "${SRC[$i]}" "${PROBE[$i]}" >/dev/null 2>&1 \
&& HOPS+=("nexthop via ${GW[$i]} dev $i weight 1")
done
[ ${#HOPS[@]} -eq 0 ] && { logger -t bgp-hc "ALL UPLINKS DOWN"; exit 1; }
ip route replace default "${HOPS[@]}" table 200
logger -t bgp-hc "table200: ${HOPS[*]}"
注意 ping -I 必须绑源地址,不要绑接口名。绑接口名在某些内核上会走成“从该接口发出但源地址仍由路由决定”,探的就不是那条线,这个坑踩过。
第三层:让容器持有真实公网 IP,策略路由自然生效
上面两层本质上都是补丁。真正干净的方案是取消 MASQUERADE,让容器的源地址就是公网地址,这样转发时 from <公网IP> 的 ip rule 就能命中,跟宿主机上的业务走同一套逻辑。
轻云互联的 BGP 多线产品一般能给到一个 /29 或更大的可用段,正好够用:
docker network create -d ipvlan \
--subnet=203.0.113.16/29 \
--gateway=203.0.113.17 \
-o ipvlan_mode=l3 \
-o parent=eth0 \
pubnet
docker run -d --network pubnet --ip 203.0.113.19 \
--name collector your-image:tag
ipvlan l3 模式下容器不和宿主抢 MAC,路由由内核代理,容器出向直接查 main 表或者 ip rule 里单独加的规则。容器里 ip route get 出来的源地址就是 203.0.113.19,这时候在宿主上补一条 ip rule add from 203.0.113.16/29 lookup 200 priority 900,三线冗余和健康检查可以复用同一套表,从“打补丁”变成“架构”。
复盘清单:BGP 多线主机上跑容器,先过这五条
- 别指望 MASQUERADE 会跟着 ip rule 走。它永远跟着“路由查找已经定好的出口设备”,转发场景下源地址策略路由在它之前就已经错过了窗口。
- main 表里的多条等价默认路由 = 随机出口。要么用
nexthop weight收敛成一条,要么用独立路由表把容器流量隔离出来。 - L3 hash 在 SNAT 环境下熵极低。
fib_multipath_hash_policy=0时同一容器访问同一目的地永远走同一条线,故障表现是 100% 而不是百分比。 - 静态默认路由必须和 BGP 会话状态联动。用 ExaBGP、BIRD 之类把健康检查结果推进内核路由表,别让死线路留在 ECMP 里继续吸流量。
- 排查时先对比“宿主机发起”和“容器转发”两条
ip route get,十有八九差异就在这里,比盲抓包快十倍。