WEBKT

MetalLB L2 模式下 ARP/NDP 表溢出的根因分析与实战解决

29 0 0 0

先说结论

如果你在 Kubernetes Bare Metal 环境中跑着几十个以上节点的集群,发现某些节点突然丢包、服务可达性抖动,而重启 kube-proxy 或重启节点能短暂恢复——很可能正遭受 ARP(IPv4)或 NDP(IPv6)表项溢出 的折磨。根本原因不是硬件故障,而是 Linux 内核邻居表的容量被大量来自 MetalLB Speaker 的 "我是这个 IP" 的 announcement 给撑爆了。

下面从原理到排查到根治,说个通透。


一、Bare Metal 环境为什么非要用 MetalLB

云厂商的 Kubernetes(GKE、EKS、AKS)自带 LoadBalancer Service 支持,靠的是云平台的网络插件。但在裸机上跑 K8s,这块能力是缺失的。

MetalLB 就是干这个活的——它给集群注入一层虚拟 IP(ExternalTrafficPolicy=Cluster 时尤其依赖),让 LoadBalancer 类型 Service 能正常工作。目前主流玩法两种:

模式 工作方式 推荐场景
L2(ARP/NDP) Speaker 通过 ARP(IPv4)或 NDP(IPv6)宣告 ownership,抢占目标 IP 的响应权 小规模集群、多网卡环境、不支持 BGP 的网络
BGP 与外部路由器建立 BGP 会话,宣告路由 中大规模集群、需要 ECMP 多路径、需要精确流量控制

L2 实现简单,但代价也明显——它本质上是个 ARP hijack / NDP hijack,每个 Speaker 都可能对外发gratuitous ARP/NA,把整个二层广播域搅得很热闹。这正是溢出的导火索。


二、MetalLB L2 Announcement 的工作原理

IPv4 — Gratuitous ARP

当某个 Service 被分配了 External IP 后,持有该 IP 的 Speaker 会周期性地广播 gratuitous ARP 包,大致长这样:

Sender MAC: the-speaker's-mac
Sender IP:   the-service-ip
Target MAC:  the-speaker's-mac  
Target IP:   the-service-ip

交换机会学到这条映射,其他节点收到后更新自己的 ARP cache,此后发往该 Service IP 的流量会送到持有者节点。

IPv6 — Unsolicited Neighbor Advertisement (UNA)

逻辑类似,用 NA 包宣告:

Target Link-Layer Address: the-speaker's-mac
Target Address:            the-service-ip (on-link)

所有收到这个消息的节点会在自己的 NDP neighbor table 中创建或刷新对应条目。

问题来了——周期性 + 大规模 = 无效堆积

正常情况下这是无害的。但有几个变数会让事情失控:


三、根因拆解:从哪来的这么多表项?

直接原因:邻居表容量有限 + 新旧条目竞争

Linux 内核维护邻居表,有硬上限:

# 查看当前邻居表大小上限和用量
cat /proc/sys/net/ipv4/neigh/default/gc_thresh1   # 最小区区触发垃圾回收阈值,默认128
cat /proc/sysv/neigh/default/gc_thresh2            # 超这个数强制回收,默认512  
cat /proc/sys/net/ipv6/neigh/default/gc_thresh3    # 最大值,达到后不再新建,默认1024甚至更低

ip neigh show | wc -l    # 当前实际条目数,快速摸底用法

Bare Metal 环境里,每个物理节点的网卡直接接入交换机,多个 K8s Node 通过同一个 VLAN 或 flat network互联。当以下条件叠加时,问题就爆发了:

触发条件一:Service IP 与 Node 子网高度重合

假设你用 172.16.x.x/16 做 Pod CIDR,10.x.x.x/24 做 Node 网络,Service IPs 用 192.168.x.x/24。每创建一个 LoadBalancer Service,就会有一个 ExternalIP 进入 Announcement。如果你的集群有 200 个 LoadBalancer Service,理论上每个 Speaker 每隔 announce interval(在配置中通常默认 interval: "10s")就会向全网广播 gratuitous ARP/NA。

更关键的是:每个新 announcer 加入、旧的离开、旧主机重启网卡都会触发大量额外的 gratuitous 包,导致邻居表快速抖动和重排。

触发条件二:"死亡" 条目没有被及时清理

Linux GC 算法是保守的。在高频率 Announcement 时,同一个 target IP 在多个 MAC 之间来回横跳,内核认为它们都是"活跃"的,不会轻易删除任何一个。这导致无效条目长期占据空间,直到真正需要的条目因为达到阈值而无法创建。

# 查看邻居状态,正常应该是 REACHABLE/STALE/DELAY,死掉的叫 FAILED 或者干脆不在表里但流量异常  
ip neigh show nud all        # nud = neighbour state display filter 可选 perm/reachable/noarp/stale/delay/probe/failed/incomplete 

特别容易出现的情况是:大量 INCOMPLETE 条目——即发出了请求但没收到响应的半成品状态,说明本机正在尝试解析某个目标的 MAC 但失败了,通常意味着对端已经不存在于网络中但本地缓存还没过期。此时内核会反复重试,占据 gc_thresh3 中的名额但不释放,直到 GC 运行才清理。

触发条件三:多播/广播泛洪被低估

Gratuitous ARP 是 broadcast (Ethernet destination = ff:ff:ff:ff:ff:ff),虽然交换机通常做了优化(CISCO portfast、STP bypass),但在某些老旧交换机或未正确配置的 VLAN 里,大量 GARP 会造成 CPU overload 和 CAM table 打满。更隐蔽的是,有些交换机的 ACL 或 storm-control 设置不当,会悄悄丢弃部分包,导致发送端以为发了但接收端根本没收到,于是继续重试,加剧混乱循环。


四、如何确认自己中招了?

第一步:本机观察邻居表水位和增长趋势

#!/bin/bash
# watch-neighbour-growth.sh — 在症状节点上持续监控5分钟采样,每秒记录一次条目数变化趋势
  
for i in {1..300}; do 
    count=$(ip -4 neigh show | wc -l) 
    echo "$(date '+%H:%M:%S') IPv4 neighbours: $count"
    sleep 1 
done > neighbour_log.txt 

# 分析结果:如果数字持续增长且接近 gc_thresh3,说明GC来不及回收  
awk '{print $NF}' neighbour_log.txt | sort -n | tail -5   # 看最高水位出现在什么时候  
awk 'NR>1{print $1,$prev}{prev=$1}' neighbour_log.txt       # 看有没有突变跳跃点  

同样的方法测 IPv6:ip -6 neigh show

第二步:抓包验证 Gratuitous ARP 来源和频率是否异常高企

tcpdump -i eth0 'arp' -nnvv         # IPv4 用这个,过滤掉普通 arp 请求,只看 reply 和 gratuitous   
tcpdump -i eth0 'icmp6 && (ip6[40] == 136)'   # IPv6,看 ICMPv6 type=136 (NA) 

# 把 tcpdump 结果保存下来统计一下每秒多少个 GARP 包  
tcpdump -i eth0 'arp' -c10000 > arp_capture.pcap & 
sleep 60 && kill %1 
python3 <<'EOF'
from scapy.all import rdpcap, Ether, ARP  

pkts = rdpcap("arp_capture.pcap")
for pkt in pkts:
    if pkt.haslayer(ARP):
        arp = pkt[ARP]
        if arp.op == 2 or (arp.psrc == arp.pdst):      # op=2 is who-has reply; gratuitous self-match trick 
            print(f"GARP from {arp.hwsrc} claiming {arp.psrc}")
EOF 

正常情况单个大型集群每秒不应该超过几十个 GARP。如果发现某个时间段集中爆发数百个,很可能说明有配置变更或者多个 Speaker 在竞争同一批地址段(比如启用了 address pool 自动扩缩)。

第三步:对齐时间和事件相关性排查因果链

典型错误序列往往是这样的时间线:

T+0m   新增第N个 LoadBalancer Service,MetalLB 开始分配新地址并 announcement  
T+30s ~ T+5m 之间,该批次地址首次被大量 host 学习到,产生 spike  
T+10m 左右,部分节点的 ip neigh show 开始出现 REACHABLE -> STALE -> FAILED 连环降级,最终 FAILCOUNT 清零后被 evict,导致 service failover 到另一个 speaker,那个 speaker 又开始新一轮 flood   
T+15m+,运维告警 SLA 不达标,开始怀疑 metalb 配置错误,但其实问题出在内核参数和网络设备侧的限制组合拳上。

结合 kube-controller-manager 日志、金属架构拓扑、Kibana/Grafana 时间轴对照看,能准确定位是否是这种模式的问题,而不是其他因素(如 pod 网络丢包、i/o bottleneck等)。


五、系统性解决思路与方案矩阵

针对上述三类成因,分别给出可直接落地的方案,按优先级排序:

✅方案 A — 最推荐:从根本上减少 Announcement 量(BGP vs L2)

如果网络设备支持,BGP 是彻底绕过二层泛洪的根本解法。改用 BGP mode 后,Speaker 只跟路由器建立单播会话,对底层交换网络几乎没有额外负担,且支持 ECMP 多路径,性能更好、更稳定。迁移步骤一句话概括:修改 configmap 将 protocol 从 layer2 → bgp,给每台路由器配置 peer,重新分配 address pool。

具体示例配置片段参考官方文档,不展开细说了,重点是迁移完成后你会发现 gratuitous arp 完全消失,网络设备日志干净很多。对于规模超过50台机器、或对性能敏感的生产环境,这一步几乎必须做,早迁移早受益,避免以后头疼历史包袱难清零的问题升级窗口期难找等困难局面出现。所以建议在设计阶段就评估清楚,是否从一开始就应该选择 BGP 而非走弯路绕回老路上浪费资源学习成本高且后期还要改写代码进行适配测试验证等等工作量其实更大不如一次性规划好全局架构合理可行性和实施难度可控的前提下尽量一步到位避免重复建设现象发生才是最优策略!

当然现实情况可能不允许立刻换,因为涉及网络设备权限、团队知识储备、业务连续性保证等因素,如果只能继续玩 L2,那就进入方案 B 和 C 双管齐下的轨道上来综合治理才能见效,否则单纯靠修修补补只能缓解一时表面症状而不治本时间长了必然还会再次复发类似问题最终还是要走到架构重构这一步所以不如现在就把精力投入到更有价值的长期方向上去不要在老路上消耗太多沉没成本不划算!

好了接下来详细说两个具体可行的修复方法分别对应刚才分析的三个根本成因中的前两个以及配套工具链支撑才能形成完整闭环否则只有理论框架没有可操作落地手段等于没用纸上谈兵误导人!


结语:从一个问题看见的系统设计盲区

回过头来看这次故障,它的本质不是「MetalLB 有 bug」,而是两层边界认知错位:K8s 网络层认为只要声明式地创建 Service 就够了,不需要关心底下怎么路由;而网络层却默认二层环境只承载「少量已知终端」之间的通信,从未设计成容纳如此高频、海量、动态的二层宣告。一个看似简单的「让外部访问内部服务」的诉求,实际上横跨了应用编排层和基础设施层,中间缺少显式的容量规划契约,导致两侧都在按自己的隐含假设运行,终于在某个临界点相撞。

这类问题的共同特征是:跨越领域边界时,各自的安全余量假设不一致,最终汇聚在一个薄弱环节集中爆发。解题的关键不是打补丁,而是重新审视整个系统栈中每一层的容量约束,建立起跨层可见性,让应用侧的弹性伸缩能够感知到基础设施侧的极限在哪里,提前做好分舱隔离或者分层限流,而不是等到崩溃再救火。从这个角度看,解决这一个具体的故障其实收获了一个更大的工程原则:用的时候要想着它的最大负载场景是什么,不能光想 happy path,要模拟 chaos engineering 那样的压力测试提前暴露隐患,这才是真正的 SRE 做派而不是被动等待告警来了才手忙脚乱处理。希望这篇文章不只是帮你修好了一个功能,更是提供了一种思考这类复杂系统交互问题的方法论视角。下次遇到类似的跨层疑难杂症,可以试着用这套框架逐层拆解,往往能找到隐藏在表象之下的真正病因。对你有帮助的话,转发给你身边同样跑 Bare Metal K8s 的同事,大家少踩坑多睡觉 : )


netopswalker

评论点评