标签: 丢包排查

  • 深入 K8S veth pair 丢包排查:高 PPS 触发的 SoftIRQ 单核瓶颈与 macvlan 卸载实战

    在 K8S 容器网络中,高并发(PPS > 30万)场景下 veth pair 极易因单队列架构触发宿主机单核 SoftIRQ (NET_RX) 100% 饱和,导致严重丢包与网络抖动。临时止血方案需在宿主机端开启 RPS(Receive Packet Steering)将软中断打散;而彻底解决该类 I/O 密集型业务瓶颈,应引入 macvlan 或 SR-IOV 进行网络栈卸载,直接旁路宿主机内核的复杂转发路径。

    故障现场:Redis 容器的神秘丢包与 99 线飙升

    近期排查了一起 K8S 集群内 Redis 响应毛刺问题。环境基础信息如下:

    • OS: Ubuntu 22.04 (Kernel 5.15.0-76-generic)

    • K8S 版本: v1.25.9

    • CNI: Calico v3.25.0 (BGP 路由模式)

    • 业务表现: 压测期间 Redis 实例的 QPS 达到 8 万时,p99 延迟从 2ms 突变至 150ms 以上,客户端频繁报 Read timed out

    首先登入 Redis 所在宿主机,直接通过 mpstat 查看中断分布:

    # 每秒输出所有 CPU 核状态
    mpstat -P ALL 1
    
    09:41:01 AM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
    09:41:02 AM  all    8.23    0.00    6.11    0.00    0.00   12.15    0.00    0.00    0.00   73.51
    09:41:02 AM    0    4.00    0.00    3.00    0.00    0.00    1.00    0.00    0.00    0.00   92.00
    ...
    09:41:02 AM   12    2.00    0.00   10.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
    

    如上所示,CPU 12 的 %soft 已经被彻底打满(100%)。进一步通过 /proc/softirqs 定位具体的中断类型:

    watch -d -n 1 "cat /proc/softirqs | grep NET_RX"
    

    确认是 NET_RX 软中断风暴。接着查看容器对应的宿主机端 veth 网卡(假设为 cali9a3b2c1)的丢包统计:

    # 确认网卡 rx_dropped 指标疯狂上涨
    ip -s link show cali9a3b2c1
    

    现象明确:宿主机单核处理软中断能力达到极限,导致网卡接收队列(Backlog)溢出,底层协议栈开始大面积丢弃数据包。

    为什么 veth pair 会成为高吞吐场景的性能毒药?

    要搞清楚这个问题,必须深入 veth pair 在内核中的数据流转机制。

    veth pair 是一对虚拟以太网设备。在 Calico 网络下,数据包从物理网卡(如 eth0)进入宿主机,经过内核路由判决后,发往对应的宿主机端 veth 设备(caliXXX),然后再进入容器的网络命名空间。

    对于物理网卡,现代网卡均支持多队列(RSS, Receive Side Scaling),可以通过 Hash 算法将不同数据流的硬件中断(HardIRQ)分发到多个 CPU 核上,进而触发多核并发处理 NET_RX 软中断。

    但 veth pair 是纯软件模拟的虚拟网卡,默认只有单队列(rx-0/tx-0)。 当数据包从物理网卡路由到 caliXXX 时,内核调用 dev_forward_skb,最终触发 netif_rxskb(套接字缓冲区)压入特定 CPU 的 softnet_data->input_pkt_queue 中。 由于 veth 没有硬件多队列支撑,所有发往该容器的数据包,其软中断处理逻辑通常只能由单核(通常是触发调用的源 CPU,或者被网卡中断绑定的固定 CPU)串行执行。当流量达到几十万 PPS 时,这个单 CPU 很快就会触及 100% 的瓶颈,导致后续包因为 Backlog 队满而被丢弃。

    实战破局:从软件调优到硬件卸载

    针对上述瓶颈,我们在实战中通常采用两个阶段的方案:快速止血与架构重构。

    第一阶段:软件层面开启 RPS 打散软中断

    RPS(Receive Packet Steering)是 RSS 的软件实现。它能在 netif_rx 接收到包后,利用四元组 Hash 软计算,将包投递到其他 CPU 的积压队列中,强制触发跨核的软中断处理。

    找到 Redis 对应的宿主机网卡 cali9a3b2c1,为其配置 RPS(假设宿主机为 16 核,我们将掩码设为 ffff,允许打散到所有核):

    # 将 16 进制掩码写入对应接收队列的 rps_cpus 中
    echo ffff > /sys/class/net/cali9a3b2c1/queues/rx-0/rps_cpus
    
    # 同步调大内核层面的 backlog 队列深度,防止缓冲击穿
    sysctl -w net.core.netdev_max_backlog=10000
    

    开启后,再次观察 mpstat,CPU 12 的 %soft 迅速下降至 30% 左右,其他 CPU 的 %soft 开始均衡上升,Redis 响应延迟立刻恢复到 2ms 的水平。

    注意: 这种方案有代价。RPS 带来了额外的 CPU 周期消耗(计算 Hash、跨核 Cache Miss),整体 CPU 负载(Load Average)会显著升高。这是典型的“空间换时间”策略。

    第二阶段:引入 macvlan / SR-IOV 卸载网络栈

    对于此类极致 I/O 的业务,经过多次踩坑,最终的防线必须是绕过复杂的宿主机网络栈。通过 Multus CNI 引入 macvlanSR-IOV,是当前主流的解法。

    macvlan 桥接模式为例,它的底层原理是直接在宿主机物理网卡(eth0)上虚拟出一个具有独立 MAC 地址的子接口。数据包到达物理网卡后,底层驱动通过匹配 MAC 地址,直接将包送入容器的 Network Namespace,彻底跳过了宿主机内核的路由查找、Netfilter (iptables/IPVS) 过滤以及 veth pair 的设备中转。 且 macvlan 继承了物理主网卡的 RSS 特性,天然支持多核并发接收。

    在 K8S 中配置 Multus 与 Macvlan 混合网络示例 (NetworkAttachmentDefinition):

    apiVersion: "k8s.cni.cncf.io/v1"
    kind: NetworkAttachmentDefinition
    metadata:
      name: macvlan-conf
      namespace: default
    spec:
      config: '{
          "cniVersion": "0.3.1",
          "type": "macvlan",
          "master": "eth0",
          "mode": "bridge",
          "ipam": {
            "type": "host-local",
            "subnet": "192.168.100.0/24",
            "rangeStart": "192.168.100.100",
            "rangeEnd": "192.168.100.200",
            "routes": [
              { "dst": "0.0.0.0/0" }
            ],
            "gateway": "192.168.100.1"
          }
        }'
    

    随后在 Redis Pod 中声明注解:

    metadata:
      annotations:
        k8s.v1.cni.cncf.io/networks: macvlan-conf
    

    改造后,Redis Pod 获得了直通物理网络的 eth1 网卡,单机压测极限 PPS 提升了近 3 倍,且宿主机的 CPU sys/soft 占用极低。

    常见问题 (FAQ)

    Q1:为什么使用 macvlan (bridge 模式) 后,宿主机反而 ping 不通该容器了? 这是 macvlan 驱动设计的经典防线。macvlan 拦截了进出物理网卡的流量,但根据 802.1q 规范,从物理网卡发出的包默认不会回流到自己。宿主机发送的报文直接从底层网卡出去了,无法通过 MAC 匹配路由回该网卡上的 macvlan 子接口。 解法: 在宿主机上再创建一个同网段的 macvlan 接口(例如叫 macvlan-host),将宿主机对该网段的路由指向 macvlan-host,利用 bridge 模式下的内部交换机制实现通信。

    Q2:SR-IOV 与 macvlan 相比优势在哪里,什么时候必须上 SR-IOV? macvlan 仍经过宿主机的物理网卡驱动和内核协议栈底层;而 SR-IOV(Single Root I/O Virtualization)是 PCIe 硬件级别的虚拟化。它通过 PF(Physical Function)虚拟出多个 VF(Virtual Function),VF 直接映射给容器。 如果是搞 DPDK 等用户态网络协议栈,或者极低延迟(微秒级)的 HFT (高频交易) 场景,必须用 SR-IOV 彻底 Bypass 内核。普通的高性能 Redis/MySQL,macvlan 已经足够。

    Q3:开启了 RPS,但有些网卡的 rps_cpus 修改后提示 “Permission denied” 或无效? 如果是针对容器内的 veth 设备修改,受限于 NetNS 权限,需在宿主机端的对端网卡(如 Calico 的 calixxx、Flannel 的 vethxxx)操作。另外,务必确保宿主机系统服务(如 irqbalance)不要与你手动的 RPS 掩码逻辑发生冲突,排查过程中发现两者打架是常态,针对极端优化的节点,通常建议关闭 irqbalance 并手动绑核。

  • 深入 nf_conntrack 满载丢包排查:SNAT 端口耗尽引发的 SYN 阻断与 nftables Flowtable 旁路加速实战

    高并发网关常遇 nf_conntrack: table full 导致 SYN 丢包。盲目调大 nf_conntrack_max 只会加剧内核自旋锁争用与内存开销。根本解法是排查 SNAT 端口耗尽,并从 iptables 彻底迁移至 nftables,利用 Flowtable 机制开启流量卸载(Offload),让 ESTABLISHED 状态报文旁路跳过 Netfilter 核心链,实测可降低 40% 的 sys CPU 并彻底消除连接跟踪瓶颈。

    案发现场:诡异的 99 线毛刺与超时

    排查过程中,某承载了上万并发连接的 K8s Egress NAT 网关节点(Kernel 5.15.0)频繁出现请求超时,监控大盘显示 TCP 99线延迟出现规律性毛刺,Load Average 中的 sys CPU 间歇性飙升到 80% 以上。

    直接上机器看内核日志:

    $ dmesg -T | tail -n 20 | grep conntrack
    [Thu Oct 26 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    [Thu Oct 26 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    

    经典的连接跟踪表爆满导致丢包。看一下当前连接数与上限:

    $ sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
    net.netfilter.nf_conntrack_count = 262144
    net.netfilter.nf_conntrack_max = 262144
    

    为什么盲目调大 nf_conntrack_max 是一剂毒药?

    遇到 table full,很多人的第一反应是无脑加大 nf_conntrack_max。在低并发场景下这确实管用,但在高吞吐 NAT 网关上,这是一剂毒药。

    nf_conntrack 是基于哈希表实现的。它的核心数据结构由 Hash buckets(桶)和链表组成。当你只调大 nf_conntrack_max 而不调整 nf_conntrack_buckets 时,每个 Hash bucket 下挂载的链表会变得极长。 内核在进行包过滤或 NAT 时,需要遍历链表来匹配五元组。链表越长,查询的开销越大;加上 Hash bucket 的自旋锁(spinlock)争用,在多核高 PPS(Packet Per Second)场景下,CPU 会被 __nf_conntrack_find_get 等函数吃干抹净(表现为软中断 si 和内核态 sy CPU 极高)。

    正确的临时缓解姿势必须是联动调整(保持桶大小为最大连接数的 1/4):

    # 1. 调大 Hash 桶大小(立即生效,不可通过 sysctl 修改)
    $ echo 262144 > /sys/module/nf_conntrack/parameters/hashsize
    # 2. 调大最大连接数
    $ sysctl -w net.netfilter.nf_conntrack_max=1048576
    # 3. 缩短 TIME_WAIT 和 ESTABLISHED 状态的超时时间,加速条目回收
    $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=300
    $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
    

    但这只是治标。抓包发现,该节点作为 SNAT 网关,真实存在的活跃连接并没有达到 26 万,导致表满的真凶是 SNAT 端口耗尽引发的僵尸连接积压。由于 iptables 的 MASQUERADE 规则,多个内网 Pod 访问外部同一个目标 IP:Port 时,由于源端口池(默认 1024-65535)被快速消耗殆尽,新的 SYN 包在进行 NAT 转换时无法分配到 free tuple,导致连接状态卡死并在 conntrack 表中滞留。

    iptables 时代的穷途末路与 nftables 破局

    只要你还在用 iptables,每一个数据包都要不可避免地穿透 PREROUTING -> FORWARD -> POSTROUTING 整条链。即使是已经建立连接(ESTABLISHED)的报文,也要每次去走一遍 Rule 解析和 Conntrack 状态机。

    Kernel 4.16+ 引入了 nftables 的杀手锏功能:Flowtable (Fast-path Offload)。 它的底层原理极其优雅:对于已经建立连接的 TCP/UDP 流量,Flowtable 会在网卡的 ingress hook 点(非常靠前的位置)直接进行路由转发和 NAT 替换,完全绕过传统的 Netfilter 过滤链和 Conntrack 查询

    实战:将 iptables NAT 迁移至 nftables Flowtable

    不要再用 iptables-nft 这种套壳工具了,直接写原生的 nftables 配置。以下是我们在网关节点上的落地配置,实现内网到外网的 SNAT 并开启 Flowtable 硬件/软件卸载。

    清除老旧规则:

    $ iptables -F && iptables -t nat -F
    $ systemctl stop iptables
    

    编写 /etc/nftables.conf

    flush ruleset
    
    table inet filter {
        # 定义 Flowtable 开启卸载
        flowtable f {
            # 挂载在非常靠前的 ingress 钩子,优先级 0
            hook ingress priority 0;
            # 绑定内外网网卡(根据实际情况修改)
            devices = { eth0, eth1 };
        }
    
        chain forward {
            type filter hook forward priority 0; policy drop;
    
            # 核心逻辑:允许 ESTABLISHED 流量,并将新流量加入 flowtable 'f'
            ip protocol { tcp, udp } flow add @f
    
            # 允许内网 (10.0.0.0/8) 到外网的初始包通过
            iifname "eth0" oifname "eth1" ip saddr 10.0.0.0/8 accept
    
            # 允许已建立连接的回包
            ct state established,related accept
        }
    }
    
    table ip nat {
        chain postrouting {
            type nat hook postrouting priority 100; policy accept;
            # 传统 SNAT/Masquerade,只对首包生效
            oifname "eth1" ip saddr 10.0.0.0/8 masquerade random
        }
    }
    

    应用配置并验证:

    $ nft -f /etc/nftables.conf
    $ nft list ruleset
    

    注意:masquerade random 的加入是为了缓解 SNAT 端口分配的哈希碰撞冲突,配合 Flowtable 能最大程度压榨网关性能。

    性能表现对比

    迁移至 nftables Flowtable 后,使用 perf top 观察内核函数调用:

    • 迁移前ipt_do_tablenf_conntrack_in 长年霸占 Top 3,软中断消耗极大。

    • 迁移后:由于首包建立连接后,后续几十个甚至成百上千个数据包直接从网卡 ingress 进入 nft_flow_offload_eval 后被路由发出,ipt_do_table 直接消失,sys CPU 占用率暴降 40% 以上,dmesg 中再无 table full 报错。

    常见问题 (FAQ)

    Q1:为什么我明明清空了 iptables,用 iptables -L 还能看到一些莫名其妙的规则? 因为较新的 OS(如 Debian 11+, RHEL 8+)默认将 iptables 软链接到了 iptables-nft。这是兼容层,你在 iptables 敲的命令,其实被转换成了 nftables 的内置表。要查看纯正的 iptables 规则,请使用 iptables-legacy -L。在系统层面彻底向 nftables 演进时,强烈建议干掉所有 legacy 和兼容层,统一用 nft 命令行管理。

    Q2:开启 nftables Flowtable 之后,为什么 tcpdump 抓不到部分数据包了? 这是预期行为。Flowtable 提供了 Software Offload 和 Hardware Offload (NIC HW offload)。如果是 Hardware offload(需要网卡驱动支持 tc 卸载),数据包在物理网卡层面就被转发了,根本不会进入内核网络栈,挂在 AF_PACKET 上的 tcpdump 自然抓不到。即使是 Software offload,由于绕过了常规的 Netfilter RX 路径,抓包结果也会呈现“只看到 SYN 包,看不到后续数据流”的现象。排查网络问题时,需要临时禁用 flowtable 规则。

    Q3:在 K8s 中使用 IPVS 模式的 kube-proxy,也会受 nf_conntrack 限制吗? 会。虽然 IPVS 维护了自己的连接管理哈希表,但它仍然深度依赖 Netfilter 框架做底层的包拦截和 NAT 协调(尤其是 nf_conntrack)。K8s 场景下大量短连接(如探针、微服务间 RPC)极易打满 conntrack。除文中提到的调优手段外,建议通过 kube-proxy 启动参数 --conntrack-max-per-core 来合理规划容量,而非手动修改 sysctl,防止被 Kubelet 重置。

  • 深入 IPVLAN L3 模式丢包排查:非对称路由引发的 rp_filter 拦截与网络黑洞实战

    结论先行:在将容器底层网络从 Macvlan 规模化迁移至 IPVLAN L3 模式时,跨网段 RPC 调用偶尔会出现 3s 超时重传。根本原因是 IPVLAN L3 绕过了宿主机的二层协议栈直接在网络层路由,导致出入站流量路径不一致(非对称路由),触发了内核(以 Linux 5.15 为例)严格模式下的 rp_filter (反向路径过滤) 拦截丢包。修复方案:将宿主机物理网卡及 all 级别的 rp_filter1(严格校验)降级为 2(松散校验)或 0

    故障现场与指标异动

    排查过程中,某业务线反馈部分 Pod 在跨可用区调用时存在偶发性的连接超时,99线从 15ms 剧增至 3s(触发 TCP 默认初始 RTO)。 登录宿主机排查基础指标:Load Average 正常,物理网卡 eth0 的 RX/TX 并没有达到硬件瓶颈,ksoftirqd 软中断也没有打满。

    在宿主机和 Pod 内分别双向抓包,发现了一个经典的“网络黑洞”现象:

    1. Pod 内发出的 SYN 包正常离境。

    2. 远端的 SYN+ACK 响应包已经到达了宿主机的物理网卡 eth0

    3. 但这个 SYN+ACK 并没有被投递到 Pod 内的虚拟网卡 eth0,直接在宿主机内核网络栈中“消失”了。

    既然包到了宿主机却没进 Pod,且没有被 iptables/Netfilter 的 DROP 规则拦截(通过 iptables -t filter -nvL 确认),第一反应是内核底层的静默丢包。直接通过 nstat 抓取网络层异常统计:

    # 持续观察 IPReversePathFilter 计数器
    nstat -az | grep -i filter
    IpExtIPReversePathFilter        124505             0.0
    

    每当业务出现超时,IpExtIPReversePathFilter 指标就会暴增。这直接锁定了凶手:Linux 内核的反向路由过滤机制(Reverse Path Filter)。

    为什么 IPVLAN L3 会触发非对称路由拦截?

    要理解这个报错,必须先搞懂 IPVLAN 的工作机制以及 L3 模式的特殊性。

    不同于 veth pair 会创建成对的虚拟网卡,也不同于 Macvlan 会为每个容器生成独立的 MAC 地址。IPVLAN 的核心特征是“MAC 地址复用”:所有的 IPVLAN 子接口都与宿主机的物理网卡共享同一个 MAC 地址。

    IPVLAN 支持三种模式:L2、L3、L3S。

    • L2 模式:行为类似交换机,处理 ARP 广播,同网段二层互通。

    • L3 模式:行为类似路由器,完全丢弃所有广播/多播包(包括 ARP)。容器向外发包时,依赖三层路由表,出站流量直接借用宿主机物理网卡的 MAC 地址发出去;入站流量到达宿主机物理网卡后,内核根据目的 IP 在宿主机的路由表中查找,将其转发给对应的 IPVLAN 子接口。

    触发 rp_filter 丢包的条件,恰恰就在 L3 模式的“非对称路由”特性上。 在复杂的 K8S 生产环境中,通常会配置多块网卡或复杂的策略路由(Policy Routing)。当远端响应包从宿主机的 eth0 进入时,内核的 Netfilter 系统会执行源地址有效性校验(fib_validate_source)。

    如果 rp_filter=1(严格模式),内核会假设:“如果我现在要给这个发件人(源 IP)回包,根据我当前的路由表,最优出站网卡是哪一个?” 如果最优出站网卡不是当前收到包的网卡(例如收到包是 eth0,但默认路由指向了 eth1,或者由于 CNI 注入了策略路由导致反向查找不匹配主路由表),内核就会判定这是一个源地址欺骗(Spoofing)的非法数据包,直接调用 kfree_skb 丢弃。

    底层原理:内核源码级丢包追踪

    为了拿到最硬核的证据,我们可以用 eBPFperf 捕获内核网络栈的丢包点(Drop Point)。

    使用 perf 挂载 kfree_skb 跟踪点:

    perf record -g -a -e skb:kfree_skb --filter 'skbaddr != 0' sleep 10
    perf script
    

    捕获到的调用栈如下,清晰地指向了 fib_validate_source

    ksoftirqd/0  [000] 12345.678901: skb:kfree_skb: skbaddr=0xffff888123456700 protocol=2048 location=ip_rcv_core+0x2f0
        ffffffff817a1234 kfree_skb+0x74 ([kernel.kallsyms])
        ffffffff817c5678 ip_rcv_core+0x2f0 ([kernel.kallsyms]) # 丢包点
        ffffffff817c6789 ip_rcv+0x59 ([kernel.kallsyms])
        ffffffff817e890a fib_validate_source+0x12a ([kernel.kallsyms]) # 反向路由校验失败
        ...
    

    在 Linux 内核源码 net/ipv4/fib_frontend.c 中,fib_validate_source 的逻辑极其严苛。更坑的是,Linux 的 sysctl 配置中,rp_filter 的最终生效值是由 all 和具体网卡(如 eth0)的值取 最大值 (MAX) 决定的。

    // 内核宏定义:取 MAX(conf/all/rp_filter, conf/dev/rp_filter)
    #define IN_DEV_RPFILTER(in_dev) \
        (max(IN_DEV_CONF_GET((in_dev), RP_FILTER), \
             IN_DEV_CONF_GET((in_dev)->cnf.all, RP_FILTER)))
    

    这意味着,哪怕你把 net.ipv4.conf.eth0.rp_filter 设成了 0,只要 net.ipv4.conf.all.rp_filter 是 1,系统依然会执行严格校验。很多排查人员在这里踩了坑,改了单网卡配置却发现不生效。

    破局与防御性配置实践

    解决此类问题,最稳妥的方式是将物理网卡和全局的 rp_filter 设置为 2(松散模式:Loose Mode)。在松散模式下,只要内核路由表中有任何一条路由可以到达该源 IP(无论通过哪个网卡),校验就会放行。

    1. 动态修复(立即生效)

    # 必须同时修改 all 和对应的物理网卡
    sysctl -w net.ipv4.conf.all.rp_filter=2
    sysctl -w net.ipv4.conf.default.rp_filter=2
    sysctl -w net.ipv4.conf.eth0.rp_filter=2
    
    # 验证当前生效值
    cat /proc/sys/net/ipv4/conf/all/rp_filter
    

    2. 自动化配置(防御性编程)

    在 DevOps 体系中,不能指望人工救火。应该在主机初始化 Ansible 剧本或者 K8S CNI 的 initContainers 中强制声明此状态。 编辑 /etc/sysctl.d/99-kubernetes.conf

    # IPVLAN L3 Mode prerequisite
    net.ipv4.conf.all.rp_filter=2
    net.ipv4.conf.default.rp_filter=2
    net.ipv4.conf.eth0.rp_filter=2
    

    执行 sysctl --system 加载,确保机器重启后不会复发。

    常见问题 (FAQ)

    Q1:高并发场景下,veth pair 和 IPVLAN 该怎么选? 如果你的业务特点是超高 PPS(每秒包数),坚决选 IPVLAN(或 Macvlan)。veth pair 的通信路径需要穿越宿主机的虚拟网卡,对端的 rx_handler 会触发大量的软中断(softirq),在单核高 PPS 下极易打满 CPU 导致限流丢包。IPVLAN 直接挂载在物理网卡的 rx_handler 上,跳过了中间的虚拟网络层开销,网络吞吐和延迟性能无限逼近物理机。

    Q2:既然 Macvlan 也能提升性能,为什么还要花精力迁移到 IPVLAN? Macvlan 的致命缺陷是“MAC 地址泛滥”。它会为每个 Pod 申请一个 MAC 地址,当单台宿主机上运行上百个 Pod,集群规模达到数千台时,底层物理交换机的 CAM 表(MAC 地址表)极易被打爆,导致整个机房的网络瘫痪。IPVLAN 共享宿主机物理网卡的 MAC 地址,彻底根绝了 CAM 表溢出问题。

    Q3:IPVLAN 容器内可以和宿主机直接通信吗? 默认不行。由于流量被直接截获投递给虚拟子接口,宿主机的协议栈和 IPVLAN 子接口处于隔离状态。如果需要互通,经典解法是在宿主机上再创建一个 IPVLAN 接口并配置 IP,将宿主机的通信也接入到这个虚拟网络层中。

    Q4:IPVLAN L3 模式下,外部请求是怎么找到容器的? 因为 L3 模式没有 ARP,外部请求找不到容器的 MAC。生产实践中,必须结合 BGP 路由协议(如 Calico BGP)或者在上一级路由器/网关配置静态路由,将容器子网的下一跳(Next Hop)直接指向宿主机物理网卡的 IP。包到达宿主机后,宿主机的路由表再负责将包“导”入 IPVLAN 子接口。

  • 深入 eBPF/XDP 丢包雪崩排查:Hash Map 满载引发的 XDP_DROP 风暴与 ksoftirqd 饱和实战

    近期排查了一起极其诡异的边缘节点网络雪崩事故。业务表现为随机的 TCP 建连超时,API 网关 P99 延迟阶段性飙升至 3 秒以上(典型的 SYN 丢包重传)。经过链路排查,最终定位于一个新上线的基于 XDP (eXpress Data Path) 的防 DDoS 阻断程序。 核心结论:开发在编写 eBPF 代码时,错误地使用基础的 BPF_MAP_TYPE_HASH 来记录源 IP 访问频率,且未配置任何过期清理逻辑;当 Map 满载后 bpf_map_update_elem 调用失败,异常处理分支居然默认返回了 XDP_DROP。更致命的是,部署脚本未校验网卡驱动兼容性,在部分节点回退到了 Generic XDP (SKB 模式),不仅没起到加速作用,反而直接打爆了 ksoftirqd 软中断。

    这不仅仅是代码 Bug,更是对生产环境缺乏敬畏之心的体现。写底层网络逻辑,不带防御性编程思维,等同于在主干道上埋雷。

    案发现场:消失的 SYN 包与哀嚎的软中断

    监控告警最先在部分 BGP 边缘节点触发,现象非常割裂:

    1. 网络层面:外部监控探测大面积报 TCP Timeout。ss -s 发现节点 SYN-RECV 极少,但外部抓包显示 SYN 已经到达物理机网卡。

    2. 系统层面:出问题的节点 Load Average 飙高,top -1 发现个别 CPU 核心的 si(Soft Interrupt)使用率长时间顶在 100%,进程 ksoftirqd/x 霸榜。

    3. 传统排查失效:内核 dmesg 无任何报错,conntrack -S 表项未满,iptables/nftables 的 drop 计数器毫无波澜。

    包进来了,但在进入内核协议栈(Netfilter)之前就凭空消失了。结合软中断被打满的现象,直觉告诉我:有人在网卡底层动了手脚,极大概率是 tc 或者 XDP。

    现场拆解:用 bpftool 扒掉“黑盒”的底裤

    既然怀疑是底层 Hook,直接上 bpftool 查户口。

    执行 bpftool net show,果不其然,网卡 eth0 上挂着东西:

    # bpftool net show
    xdp:
    eth0(2) generic id 142 act XDP_DROP
    

    这里立刻暴露了两个致命问题:

    1. 模式不对:显示为 generic 而不是 driver。Native XDP 是在网卡驱动层(Ring Buffer 刚分配完)处理数据,性能极高;而 Generic XDP 是内核为了兼容不支持 XDP 的网卡做的妥协,它是在 sk_buff 已经分配,甚至包已经进入网络协议栈入口后才执行 eBPF 字节码。此时拦截毫无性能优势,反而因为额外的 BPF 执行逻辑增加了 NET_RX 软中断的开销,直接导致 ksoftirqd 饱和。

    2. 阻断风暴:当前挂载的程序 ID 是 142。

    接着查看具体挂载了什么程序和它的 Map 状态:

    # bpftool prog show id 142
    142: xdp  name xdp_ddos_block  tag 3b185187f1855c4c  gpl
            loaded_at 202X-XX-XXT10:00:00+0800  uid 0
            xlated 528B  jited 312B  memlock 4096B
            map_ids 45
    

    提取关联的 Map ID 45,查看 Map 容量与元素数量:

    # bpftool map show id 45
    45: hash  name ip_stat_map  flags 0x0
            key 4B  value 8B  max_entries 65536  memlock 5242880B
    # bpftool map dump id 45 | grep "Found"
    Found 65536 elements
    

    破案了。max_entries 是 65536,当前已经存了 65536 个元素。Map 被彻底打满了。

    源码处刑:无脑的 XDP_DROP 与缺失的驱逐机制

    把开发叫来,翻开 eBPF C 源码,看到如下逻辑片段时,我血压直接上来了:

    struct bpf_map_def SEC("maps") ip_stat_map = {
        .type = BPF_MAP_TYPE_HASH, // 致命错误1:普通的 Hash Map
        .key_size = sizeof(__u32),
        .value_size = sizeof(struct ip_stat),
        .max_entries = 65536,
    };
    
    SEC("xdp")
    int xdp_ddos_block(struct xdp_md *ctx) {
        // ... 解析 IP 头 ...
        __u32 src_ip = iph->saddr;
    
        struct ip_stat *stat = bpf_map_lookup_elem(&ip_stat_map, &src_ip);
        if (!stat) {
            struct ip_stat new_stat = { .count = 1, .last_time = bpf_ktime_get_ns() };
            // 致命错误2:未判断更新失败的情况,直接放任后续逻辑或采取错误假设
            int ret = bpf_map_update_elem(&ip_stat_map, &src_ip, &new_stat, BPF_ANY);
            if (ret != 0) {
                // 致命错误3:更新 Map 失败(如满了),直接当做异常流量 Drop 掉!
                return XDP_DROP; 
            }
        } else {
            // ... 频率检测逻辑 ...
        }
        return XDP_PASS;
    }
    

    灾难逻辑剖析: 开发者的本意是:“如果连记录状态都失败了,说明系统可能在被严重攻击,为了安全起见,宁可杀错不可放过,直接丢弃(XDP_DROP)”。 但他们忽略了网络世界的复杂性:互联网每天有大量的扫描器、僵尸网络发起一次性连接。使用 BPF_MAP_TYPE_HASH,这些单次访问的源 IP 会永远占据坑位。没有用户态进程去定时清理,也没有内核级的 LRU (Least Recently Used) 淘汰机制,不到几个小时,65536 的容量必然耗尽。 Map 满载后,后续所有正常用户的全新 IP 访问,在执行 bpf_map_update_elem 时都会返回 -E2BIG。代码捕获到这个错误,果断执行了 XDP_DROP。 最终结果就是:防 DDoS 的程序自己变成了一个完美的高性能 DDoS 攻击器,对所有新访客实施无差别静默丢包。

    技术结论与重构方案

    XDP 的极强性能来源于其极其底层的执行位置,但这也意味着它完全脱离了 Linux 协议栈成熟的异常处理、垃圾回收和可观测性体系。能力越大,越需要防御性编程。

    针对该故障,我们进行了彻底整改:

    1. 废弃标准 HASH,强制使用 LRU HASH: 将 Map 类型修改为 BPF_MAP_TYPE_LRU_HASH。当 Map 容量达到 max_entries 时,内核会自动淘汰最久未访问的元素,腾出空间给新连接。永远不要在没有外部 GC 守护进程的情况下,在网络数据面使用不具备自动淘汰机制的 Map。

    2. 修正 Fail-Open (容错放行) 逻辑: 监控程序自身的异常不应导致业务中断。如果 Map 更新失败,正确的做法是打印 BPF Trace 日志或增加异常统计 Counter,并返回 XDP_PASS 交给上层内核协议栈处理,而不是傲慢地返回 XDP_DROP

    3. 强制 Native 模式加载,彻底告别 Generic 软中断陷阱: 修改加载程序(或使用 ip link set dev eth0 xdp obj xxx.o sec xdp 时明确指定模式)。我们重写了加载工具,如果在给定的网卡上 XDP_FLAGS_DRV_MODE (Native 模式) 挂载失败,应当直接终止部署并告警,绝对不允许静默回退到 XDP_FLAGS_SKB_MODE

    同类问题速查排查清单 (eBPF/XDP 故障急救)

    1. 确认 XDP 是否介入及运行模式bpftool net showip link show。重点看接口后是否带有 xdp 标记,以及是 xdpgeneric 还是 xdpdrv。如果是 xdpgeneric 且系统软中断高,直接拔掉 XDP 恢复业务。

    2. 检查 eBPF Map 的满载情况: 使用 bpftool map show 获取所有 Map 的 max_entries,再用 bpftool map dump id | grep "Found" 检查当前元素量。接近或等于满载的,必定会引发 bpf_map_update_elem 返回 -E2BIG,需立即排查代码异常分支逻辑。

    3. 定位静默丢包(Drop)统计: XDP 丢包不会体现在 iptables 里。除了在代码里自建 BPF Perf Event 或 Ring Buffer 输出丢包日志外,可以通过 ethtool -S | grep xdp_drop (依赖具体网卡驱动支持)来观测底层拦截量。

    4. 内核 BPF 调试日志探测: 如果开发在代码中使用了 bpf_printk,可通过 cat /sys/kernel/debug/tracing/trace_pipe 查看实时内核 eBPF 打印的报错信息,往往能一针见血发现诸如 Map Update 失败的错误。