香港 CN2 服务器上 Docker 网络的三个真实损耗:netns 不吃你的 sysctl、MASQUERADE 换掉了 GIA 出口 IP、veth 把 10G 压成单核
上个月一台港机上的跨境 API 网关从裸机构架迁进 Docker,CPU、内存、镜像完全一致,跨境点查 P99 从 42ms 涨到 190ms。宿主上 bbr 开着、tcp_rmem 拉到 32MB、ip_local_port_range 扩到 6 万端口,进容器一看——全是内核默认值。下面这三处损耗全部不报错,全都不在 docker logs 里。
一、netns 不继承 /etc/sysctl.d 里的任何一行
Docker 建容器时会 unshare(CLONE_NEWNET),新的 netns 从内核编译期默认值初始化,宿主 /etc/sysctl.d/99-tuning.conf 只作用于 init_net。先看现象:
# 宿主
$ sysctl net.ipv4.tcp_congestion_control net.ipv4.ip_local_port_range
net.ipv4.tcp_congestion_control = bbr
net.ipv4.ip_local_port_range = 10240 65000
# 容器
$ docker run --rm --network appnet alpine sysctl net.ipv4.tcp_congestion_control net.ipv4.ip_local_port_range
net.ipv4.tcp_congestion_control = cubic
net.ipv4.ip_local_port_range = 32768 60999
挂在 netns_ipv4 表里的参数都是 per-netns 的,随着容器一起重置。香港 CN2 这种 30~40ms RTT 的链路,cubic + 6 万端口 + 4MB 写缓冲,基本等于把宿主上做的所有跨境调优全部作废。高发的几个:
net.ipv4.tcp_congestion_control— 容器内还是 cubicnet.ipv4.tcp_rmem/tcp_wmem— 三元组,容器内是4096 87380 6291456net.ipv4.ip_local_port_range— 短连接密集型业务,容器内 28232 个端口先耗尽,TIME_WAIT堆在容器 netns 里net.ipv4.tcp_tw_reuse、tcp_mtu_probing、tcp_slow_start_after_idle— 全部归零
注意 net.core.rmem_max 这类不属于 netns 的全局参数,容器读到的确实是宿主调过的值,不用管。**要盯的是 net.ipv4.* 这一批。**
--sysctl 只解决单值参数,三元组它接不住
docker run --sysctl net.ipv4.tcp_congestion_control=bbr 能过,因为它是单值且在白名单内。但 --sysctl net.ipv4.tcp_rmem="4096 262144 33554432" 带空格的三元组会被 runc 的 sysctl 解析器拦掉或者静默丢弃,别在这上面浪费时间。
可靠的办法:nsenter 打进 netns 直接写
关键在于 /proc/sys/net 是 per-netns 视图,取决于**打开它的那个进程**的 netns,而不是 mount ns。所以只切 -n、不切 -m,用的就是宿主可写的 /proc,写进去的却是容器 netns 的参数:
#!/usr/bin/env bash
# /usr/local/bin/netns-tune.sh
set -euo pipefail
name="$1"
pid=$(docker inspect -f '{{.State.Pid}}' "$name")
[ "$pid" -gt 0 ] || { echo "$name not running" >&2; exit 1; }
while read -r k v; do
nsenter -t "$pid" -n -- sysctl -qw "$k=$v"
done <<'EOF'
net.ipv4.tcp_congestion_control=bbr
net.ipv4.tcp_rmem=4096 262144 33554432
net.ipv4.tcp_wmem=4096 262144 33554432
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_mtu_probing=1
net.ipv4.tcp_slow_start_after_idle=0
net.ipv4.ip_local_port_range=10240 65000
EOF
echo "[ok] netns of $name tuned, pid=$pid"
用 systemd 钩住,注意容器重启后 PID 会变,别只跑一次:
# /etc/systemd/system/netns-tune@.service
[Unit]
Description=Tune netns sysctl for container %i
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/netns-tune.sh %i
[Install]
WantedBy=multi-user.target
改完 tcp_congestion_control 只对新连接生效,已经建立的长连接仍然是老算法,跨境长连业务建议随容器一起重建连接池。另外别想着进容器里 sysctl -w,容器的 /proc/sys 是 read-only 挂载,会直接甩你一个 Read-only file system。
二、MASQUERADE 把你的出站流量踢出了 CN2 回程
这是港机上最贵、也最难发现的一个损耗。现象很诡异:宿主上 curl 到目标 35ms,进容器 180ms,中间没有任何资源瓶颈。
# 宿主
$ curl -s -o /dev/null -w '%{time_connect}\n' https://api.target.com
0.034
# 容器
$ docker exec web curl -s -o /dev/null -w '%{time_connect}\n' https://api.target.com
0.181
第一反应是查出口 IP。很多香港 CN2 GIA 机器是双 IP 甚至多 IP:一个走 AS4809 回程,一个是普通国际线路。而 Docker 默认那条 MASQUERADE 规则不带 --to-source,iptables 在 SNAT 时会取**出接口上的主地址**(inet_select_addr 的结果),跟你 /etc/network/interfaces 里的顺序、甚至跟 ip addr 的 secondary 标记有关。结果就是容器流量被 SNAT 到了一个非 CN2 的 IP 上。
$ iptables -t nat -L POSTROUTING -n -v --line-numbers
num pkts bytes target prot opt in out source destination
1 84213 5.9M MASQUERADE all -- * * 172.17.0.0/16 0.0.0.0/0 ADDRTYPE match src-type LOCAL
2 920441 812M MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
第 2 条就是元凶。验证很简单,容器里直接打一下:
docker exec web curl -s https://ifconfig.co/json | jq -r '.ip, .country'
如果这里出来的 IP 不是你期望的那个 CN2 地址,跨境延迟翻 5 倍就是必然的。修正方式是插一条优先级更高的精确 SNAT:
iptables -t nat -I POSTROUTING 1 \
-s 172.17.0.0/16 -d 0.0.0.0/0 \
-j SNAT --to-source 149.x.x.x
如果两个 IP 在不同网卡、需要按回程线路分流,再补一层策略路由,光靠 SNAT 不够:
ip rule add from 149.x.x.x lookup 100
ip route add default via 149.x.x.254 dev eth0 table 100
更彻底的做法是在 daemon 层关掉 Docker 自己的 NAT,自己接管:
docker network create \
-o com.docker.network.bridge.enable_ip_masquerade=false \
appnet
然后由你的 iptables 规则集统一处理源地址。代价是 NAT 规则要自己维护,但对多线港机来说是值得的——线路是你花钱买的,不该被一条默认规则悄悄改掉。
三、绕过 bridge 的第二跳:macvlan 直通,顺手解开 veth 单队列
Bridge 模式下容器出站的路径是 容器 eth0 → veth pair → br_handle_frame → eth0,多一次二层查找和一次 netfilter 遍历。更麻烦的是 **Docker 创建的 veth 默认单队列**,`/sys/class/net/vethXXXX/queues/` 下只有一个 `rx-0` / `tx-0`,所有软中断压在一个 CPU 核上。top 里一个核 100% 的 ksoftirqd,剩下 15 个核闲着。
# 看软中断分布,如果只有 CPU0/CPU1 的 NET_RX 在涨,就是这个病
watch -n1 'cat /proc/softirqs | grep -E "NET_RX|NET_TX"'
同一台机器、同一对端,两种模式实测:bridge + NAT 跑到 5.8Gbps 时单核 softirq 打满;换成 macvlan 后 8.9Gbps,softirq 摊在 4 个核上。不是带宽上不去,是路径上多了一跳。
如果机器上有多个同段可用 IP,macvlan 是最干净的解——容器直接拿到公网地址,既干掉了第二跳,也顺手解决了第二节的 SNAT 劫持问题(根本没有 NAT)。我们这批港机选的是轻云互联的香港 CN2 GIA,每台带 4 个同段 IP 并且可以自由绑定,第三节这套方案才真正跑得起来;单 IP 机器只能退回 bridge + 精确 SNAT 那条路。
docker network create -d macvlan \
--subnet=199.19.x.0/24 \
--gateway=199.19.x.1 \
--ip-range=199.19.x.128/26 \
-o parent=eth0 \
-o macvlan_mode=bridge \
cn2pub
docker run -d --name web --net cn2pub --ip 199.19.x.130 nginx:alpine
三个必须提前知道的坑:
- 宿主连不上 macvlan 容器。这是 macvlan 的固有缺陷,宿主 eth0 和 macvlan 子接口在二层不通。解法是给宿主自己再挂一个 macvlan 接口专门用于管理:
ip link add mvh0 link eth0 type macvlan mode bridge
ip addr add 199.19.x.129/24 dev mvh0
ip link set mvh0 up
ip route add 199.19.x.130/32 dev mvh0
- 机房的 MAC 反欺骗。部分香港机房对同一物理口上的多 MAC 有端口安全策略,上 macvlan 前先跟服务商确认是否放行,轻云互联这边是默认放行的,其他家不一定。
- 广播 / 组播受限。macvlan 容器收不到广播包,DHCP、mDNS、某些服务发现的广播模式会失效,业务用单播注册中心就没问题。
如果不想动 IP 规划,ipvlan L3 模式是另一个方向,但它跨网段需要额外路由配置,跨境场景下反而不如 macvlan 省心。
四、上线前跑这四条命令,能挡掉 90% 的隐性回退
docker exec web sysctl net.ipv4.tcp_congestion_control net.ipv4.ip_local_port_range— 确认不是内核默认值docker exec web curl -s https://ifconfig.co/json | jq -r .ip— 出口 IP 必须是你预期的那条 CN2 线路conntrack -S | grep insert_failed— 非 0 说明nf_conntrack_max不够,配合dmesg -T | grep conntrack看丢包时间点ip -s link show vethXXXX— 看dropped是否在涨,涨了就是 txqueuelen 或软中断单核瓶颈
这三处损耗的共同点是:不报错、不进日志、docker stats 里也看不出来。容器编排工具默认假设网络是透明的,但香港 CN2 这种跨境链路对路径、对 netns 参数、对单队列极其敏感——参数看着都在,实际一个都没生效。