容器里 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 完全一致,排除。
  • MTUip 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十有八九差异就在这里,比盲抓包快十倍。