裸金属跑 LVS-DR 的 5 个静默坑:bond0 的 arp_ignore 在 sysctl -p 时压根没写进去、ip_vs 默认 4096 个哈希桶把 25G 网卡锁在 3 万 PPS

先说清楚场景,不然下面全是空谈:两台裸金属物理机做 DS(Director Server),Keepalived 主备,LVS 走 DR 模式,后端是几十台物理机 RS 跑 nginx/PHP-FPM。25G 双口 bond4 接交换机,VIP 是公网段。

这个架构的问题在于——它在你压测到 30% 负载之前,看起来是完全正常的。ipvsadm -Ln 有输出、curl 能通、keepalived 状态是 MASTER。所有坑都在你翻倍流量之后才露头,而且每一个都能让你怀疑人生一天。

坑一:arp_ignore 写进了 sysctl.conf,但那个文件执行时 bond0 还不存在

现象:每隔几小时全站 502 或超时 5~15 秒,DS 上 ipvsadm 一切正常,RS 上 nginx 日志里能看到本该走 DS 的请求直接打在了某一台 RS 上。抓包能看到同一个 VIP 对应了三个不同的 MAC。

根因:Linux 是 weak host model——只要某个 IP 属于本机,任何一张网卡收到针对这个 IP 的 ARP 请求,默认都会回(arp_ignore=0)。你在 RS 的 lo 上挂了 VIP,但 bond0 照样会替这个 VIP 应答 ARP,把你的 MAC 报给交换机。DS 也在应答,于是交换机 MAC 地址表在两个端口之间反复学习、震荡,流量在 DS 和 RS 之间来回漂。更糟的是某台 RS 进程挂了但机器还活着,ARP 照样应答,流量继续往死机器上打。

你以为写了这行就完事了:

net.ipv4.conf.bond0.arp_ignore = 1
net.ipv4.conf.bond0.arp_announce = 2

但 systemd-sysctl 执行 /etc/sysctl.d/*.conf 的时机在 NetworkManager 拉起 bond0 之前,这时候 /proc/sys/net/ipv4/conf/bond0 根本不存在,sysctl 会报 cannot stat,而大部分人的封装脚本后面跟了个 2>/dev/null,报错被吞得干干净净。上机第一件事就是验证,别信配置文件:

sysctl net.ipv4.conf.bond0.arp_ignore net.ipv4.conf.bond0.arp_announce
# 期望 arp_ignore = 1, arp_announce = 2

# 从 RS 本机反向探测,看应答的 MAC 是不是自己
arping -I bond0 -c 3 -S 10.0.0.1 <VIP>
# 或者在 DS 上听
tcpdump -i bond0 -e -n arp and arp[24:4] == <VIP 的十六进制>

我的做法是干脆不用 lo。lo 接口有 MAC(全零)且受 weak host model 影响,用 dummy 接口更干净——dummy 不会响应任何 ARP,从协议层就没这个能力:

ip link add vip0 type dummy
ip link set vip0 up
ip addr add <VIP>/32 dev vip0

如果业务方坚持要 lo 挂 VIP,那就用 NetworkManager dispatcher 兜底,保证 bond 起来之后再重放一次:

#!/bin/bash
# /etc/NetworkManager/dispatcher.d/90-vip-arp
[ "$1" = "bond0" ] && [ "$2" = "up" ] || exit 0
sysctl -w net.ipv4.conf.bond0.arp_ignore=1 \
         net.ipv4.conf.bond0.arp_announce=2 \
         net.ipv4.conf.all.arp_ignore=1 \
         net.ipv4.conf.all.arp_announce=2
ip route replace <VIP>/32 dev lo scope host

交换侧还能再补一道:VIP 的 MAC 做静态绑定 + port-security,谁再抢就 err-disable。丢一台总比全站抖强。

坑二:ip_vs 的连接哈希表默认只有 4096 个桶,25G 网卡卡在 3 万 PPS

现象:25G 双口 bond,业务是短连接 API,压到 3 万 PPS 左右上不去。mpstat 看 %si 有几个核 100%,但 ipvsadm -L --stats 的 Conns/s 才几万,远没到网卡的线速能力。业务侧 P99 从 8ms 飙到 400ms。

根因:ip_vs 的内核参数 conn_tab_bits 默认是 12,也就是 2^12 = 4096 个哈希桶。10 万并发连接散在 4096 个桶里,平均链表长度 25,最坏情况上百。每次包进来都要遍历链表做五元组比对,每次比对都在自旋锁保护下。哈希链一长,软中断就全耗在这上面了。

# 先看当前值,别猜
cat /sys/module/ip_vs/parameters/conn_tab_bits
# 12  ← 大多数发行版默认就是这个

# 看真实的连接表规模
ipvsadm -Lcn | wc -l
cat /proc/net/ip_vs_stats

修复不能热改。这是 ip_vs 模块的加载参数,模块一旦被 ip_vs_rr / ip_vs_wrr 引用就 rmmod 不掉,只能改内核 cmdline 后重启。而这一步,恰恰是很多云主机给不了你的——你改不了 grub,改不了内核参数,只能提工单等排期。这也是我在选裸金属时必查的一项:能不能拿到 IPMI、能不能直接编辑 /etc/default/grub。轻云互联那边的裸金属给的是完整 root + IPMI 直通,grub 参数自己改自己重启,这种内核层调优才落得了地。

# /etc/default/grub 里追加
GRUB_CMDLINE_LINUX="... ip_vs.conn_tab_bits=20"

# 或者退而求其次,走 modprobe(前提是 ip_vs 还没被加载)
echo 'options ip_vs conn_tab_bits=20' > /etc/modprobe.d/ip_vs.conf

# 重新生成并重启
grub2-mkconfig -o /boot/grub2/grub.cfg
reboot

# 验证
cat /sys/module/ip_vs/parameters/conn_tab_bits
# 20  → 1048576 个桶,指针数组约占 8MB 内存,完全值

改完这一项,同压测脚本、同机器,短连接 PPS 从 3 万拉到 180 万。不是玄学,就是哈希链长度的问题。顺带说我一般顺手把这几个也一起改:net.ipv4.vs.expire_nodest_conn=1(RS 挂了立刻剔)、net.ipv4.vs.expire_quiescent_template=1。

坑三:ipvsadm --set 默认值会把 WebSocket 长连接"漂"到别的后端

现象:长连接业务(WebSocket、gRPC streaming)每隔一段时间断一次,重连后又正常,日志里查不到任何后端错误。或者更诡异:连接建立后,后续请求打到了一台完全没建立过会话的 RS 上。

根因:IPVS 的默认超时是 tcp=900s, tcpfin=120s, udp=300s。DR 模式下 RS 的回包不经过 DS,DS 只能靠单向流量和超时来维护连接表项。如果一条长连接空闲超过 900 秒,DS 会把这条表项老化掉;之后客户端再发数据,DS 收到的是一个"不属于任何连接"的 TCP 包——而 IPVS 默认不做 SYN 校验,会按调度算法重新选一台 RS 发过去。会话上下文当然全丢了。

# 看当前超时
ipvsadm -L --timeout
# Timeout (tcp tcpfin udp): 900 120 300

# 长连接业务按最长空闲时间往上调,不是往下调
ipvsadm --set 3600 30 60

这里还有个新手最容易踩的认知陷阱:ipvsadm -Ln 里的 ActiveConn / InActConn 不是真实连接数。DR 模式下只统计单向状态,InActConn 的数字取决于超时老化速度。你拿这个数字去做容量规划,会把 20 万实际在线判断成 5 万。要准的数据,看 /proc/net/ip_vs_stats 的 ConnS 并配合 RS 侧 ss -s 交叉验证。

坑四:conn_reuse_mode 与客户端端口复用,导致连接被错投到已下线的 RS

现象:偶发 502,量不大但要命。DS 上 ipvsadm -Lcn 能看到某条连接的去向是一台已经摘除的 RS。

根因:DS 前面有 SNAT 网关,或者客户端本身是上游 LB,大量短连接会复用同一个源端口。同一个五元组在 TIME_WAIT 期间又来一个 SYN,IPVS 的处理策略由 net.ipv4.vs.conn_reuse_mode 决定:默认是复用已有表项,把新 SYN 丢给旧 RS。如果旧 RS 刚被摘、或者进程刚崩,这个连接就废了。

# 先确认你内核上的默认值,不同发行版差异很大
sysctl net.ipv4.vs.conn_reuse_mode

# 高并发短连接 + 端口复用场景
sysctl -w net.ipv4.vs.conn_reuse_mode=0
sysctl -w net.ipv4.vs.expire_nodest_conn=1

切成 0 之后一定要盯 24 小时,看两个指标:ipvsadm -Lcn | wc -l 是否异常膨胀(因为每次 SYN 都新建表项),以及 dmesg 里有没有 ip_vs 的告警。这个参数是"二选一的毒药",没有银弹,要么牺牲一点表项内存换正确性,要么反过来。我的经验是短连接业务选 0,长连接业务保持默认。

坑五:主备切换只要 3 秒,业务恢复花了 5 分钟——GARP 被交换机吃了

现象:Keepalived 日志显示 VRRP 状态 3 秒内就切完了,VIP 也能 ping 通,但业务从 DS 恢复要 5 分钟,正好是交换机 MAC 地址表的老化时间。

根因:keepalived 切到 MASTER 后默认只发 5 个免费 ARP(garp_master_repeat 5),间隔 5 秒一次。如果交换机开了 DAI(Dynamic ARP Inspection)或者 IP Source Guard,而静态绑定表没更新,这 5 个 GARP 会被静默丢弃——keepalived 不知道,你也不知道。交换机的 MAC 表还指着老 DS 的端口,直到 300 秒老化才重新学习。

vrrp_instance VI_1 {
    state BACKUP
    interface bond0
    virtual_router_id 51
    priority 100
    advert_int 1

    # 关键三行,默认值在物理机环境里不够用
    garp_master_repeat 20
    garp_master_refresh 30
    garp_master_refresh_repeat 3

    virtual_ipaddress {
        <VIP>/24 dev bond0
    }
}

garp_master_refresh 30 的意思是当 MASTER 期间每 30 秒重发一次 GARP,防止中间设备表项过期。这个在跨机箱 LACP 或者中间隔着防火墙的环境里是救命参数。如果交换机侧确实不让过 GARP,那就别指望二层了,直接在 DS 上跑 BIRD 用 BGP 向交换机通告 VIP/32,路由撤销比 MAC 学习快一个数量级。

最后:上机前的 5 条自检命令

裸金属的好处是新机器随便你怎么折腾,坏处是烧了就是真烧了,没有"重建一台"的退路。LVS-DR 这套东西上生产前,把下面五条跑一遍,能省掉 80% 的深夜电话:

sysctl net.ipv4.conf.bond0.arp_ignore net.ipv4.conf.bond0.arp_announce  # 全 1 / 全 2
cat /sys/module/ip_vs/parameters/conn_tab_bits                          # ≥ 18
ipvsadm -L --timeout                                                    # 长连接业务 tcp ≥ 业务空闲上限
sysctl net.ipv4.vs.conn_reuse_mode net.ipv4.vs.expire_nodest_conn      # 按业务类型定
grep -E 'garp_master' /etc/keepalived/keepalived.conf                   # repeat ≥ 10, refresh 开着

DR 模式的本质是"让 DS 只做单向转发,靠二层把回包抄近路送回去"。所有坑都源于这个设计里 DS 是个半盲的状态——它看不到回包,就看不到真实连接状态,也看不到 RS 到底死没死。你得替它把这几处补上。