你在 K8s 里抓包抓了半天,发现丢包发生在 netfilter 这一层。可是你连 netfilter 在整个收包路径里排第几都说不清楚。
这不怪你。Linux
网络栈横跨硬件中断、软中断、协议栈、netfilter、socket
五六个层次,代码分散在
net/core/、net/ipv4/、drivers/net/
十几个目录里,光 net_rx_action
这一个函数就有几百行。很少有人能把一个包从网线到
read() 系统调用的完整路径讲清楚。
本文就干这件事:把一个包从物理网卡到用户态
read()
返回的完整路径走一遍。不画概念图糊弄,而是深入到具体的内核函数、sk_buff
的字段变化、softirq 的调度时机。最后用 bpftrace
在虚拟机里实测,打出每一跳的纳秒级时间线。
本文基于 Linux 6.x 内核源码。部分函数签名在不同版本间可能有差异。 实验环境:Ubuntu 22.04, kernel 6.5, QEMU/KVM 虚拟机
一、全景图:一个 RX 包的八层旅程
先看全貌。一个从网线进来的包,在 Linux 内核中要经过以下层次:
从下往上:
- NIC 硬件:网卡通过 DMA 把包写入 RX Ring Buffer,触发硬件中断(HW IRQ)
- 驱动 / NAPI:硬件中断处理函数关闭中断,调度 NAPI softirq
- NET_RX_SOFTIRQ:软中断上下文调用
napi_poll(),从 Ring Buffer 取包,构造sk_buff - GRO / TC /
RPS:
netif_receive_skb()做 Generic Receive Offload 合并、流量控制、Receive Packet Steering - IP 层:
ip_rcv()做基本校验,ip_rcv_finish()查路由表决定本地交付还是转发 - Netfilter:PREROUTING / INPUT / FORWARD 等 hook 点,iptables/nftables 在这里执行
- 传输层:
tcp_v4_rcv()/udp_rcv()/icmp_rcv()把包放进对应 socket 的接收队列 - Socket / 用户态:
read()/recvmsg()系统调用从 socket 接收队列取出数据,拷贝到用户空间
这八层,就是本文要逐一拆解的内容。
一个关键认知:中断上下文 vs 进程上下文
在开始之前,有一个认知必须先建立:一个包在内核中经历的代码,不是在同一个执行上下文里跑完的。
- 硬件中断上下文(hardirq):网卡中断触发后,CPU 立即执行中断处理函数。这个上下文不能睡眠、不能调度、不能做复杂处理。驱动在这里做的事极其有限——关闭中断、调度 NAPI。
- 软中断上下文(softirq):
NET_RX_SOFTIRQ在中断返回后被调度执行。NAPI poll、GRO、IP 层处理、netfilter 规则匹配,全部在 softirq 上下文中完成。不能睡眠,但可以被硬中断打断。 - 进程上下文:用户态进程调用
read()/recvmsg()进入内核,从 socket 接收队列取数据。可以睡眠等待。
这意味着:从网卡到 socket 接收队列,整个过程都是在 softirq 里完成的,不是在你的应用进程里。你的进程只负责最后一步——从队列里把数据拿走。理解这一点,对后面分析性能问题至关重要。
二、NIC 与驱动:从电信号到 sk_buff
硬件收包:DMA 与 Ring Buffer
现代网卡收包不走 CPU。网卡内部有一个 RX Ring Buffer(环形描述符数组),每个描述符指向一块预先分配好的 DMA 内存区域。当一个包到达时:
- 网卡硬件做 L2 校验(CRC check),如果开启了硬件 checksum offload,还会校验 L3/L4 校验和
- 网卡通过 DMA(Direct Memory Access) 把包数据写入 Ring Buffer 中下一个空闲描述符指向的内存
- 更新描述符状态为「已完成」
- 触发 MSI-X 中断(现代网卡基本都用 MSI-X,一个队列一个中断向量)
Ring Buffer 的大小直接影响抗突发流量的能力。查看和调整方法:
# 查看当前 Ring Buffer 大小
ethtool -g eth0
# 输出示例:
# Ring parameters for eth0:
# Pre-set maximums:
# RX: 4096
# TX: 4096
# Current hardware settings:
# RX: 256
# TX: 256
# 调大 RX Ring Buffer
ethtool -G eth0 rx 4096一个常见的丢包场景:Ring Buffer 太小,突发流量来了,CPU
来不及在 softirq 里把包取走,Ring Buffer
被填满,新到的包直接被网卡丢弃。这种丢包在
ethtool -S eth0 | grep rx_dropped 或
rx_no_buffer_count 里能看到。
RSS:多队列网卡的负载均衡
现代网卡通常有多个 RX 队列(比如 8 个或 16 个),每个队列有独立的 Ring Buffer 和中断向量。RSS(Receive Side Scaling) 是网卡硬件层面的负载均衡机制——网卡根据包头的五元组(src IP、dst IP、src port、dst port、protocol)计算一个 hash,然后用 indirection table 把包分配到不同的队列。
# 查看 RSS indirection table
ethtool -x eth0
# 查看各队列的中断统计
cat /proc/interrupts | grep eth0RSS 的好处是:不同队列的中断可以分配到不同的 CPU 核心,实现硬件级别的并行收包。
NAPI:从中断驱动到轮询
早期 Linux 网卡驱动是纯中断驱动的——每收一个包触发一个中断。在高速网络下(10Gbps+),每秒几百万个包,每个包一个中断,CPU 光处理中断就忙不过来了,这就是所谓的 中断风暴(interrupt storm)。
NAPI(New API)是解决方案。核心思想:高负载时从中断模式切换到轮询模式。
- 第一个包到达,触发硬件中断
- 中断处理函数 关闭该队列的中断,调用
napi_schedule()把 NAPI poll 结构体挂到当前 CPU 的 softirq poll list 上 - 中断返回后,
NET_RX_SOFTIRQ被调度,调用napi_poll()一次性从 Ring Buffer 轮询取出多个包(budget 默认 64) - 如果一次 poll 没取完(说明包还在持续到来),继续 poll
- 如果一次 poll 取完了所有包(Ring Buffer 空了),重新打开中断,回到中断驱动模式
/* 简化的 NAPI poll 逻辑 (drivers/net/virtio_net.c 等) */
static int my_driver_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb = fetch_from_ring_buffer(ring);
if (!skb)
break;
/* 构造 sk_buff,设置协议等元数据 */
skb->protocol = eth_type_trans(skb, netdev);
/* 交给上层协议栈 */
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
/* Ring Buffer 已空,退出轮询模式 */
napi_complete_done(napi, work_done);
/* 重新打开中断 */
enable_irq(ring->irq);
}
return work_done;
}这里的 budget 参数控制一次 softirq
最多处理多少个包,默认 64。可以通过 sysctl 调整:
# 查看当前 NAPI budget
sysctl net.core.netdev_budget
# net.core.netdev_budget = 300
# 每次 softirq 最多处理的包数
sysctl net.core.netdev_budget_usecs
# net.core.netdev_budget_usecs = 2000netdev_budget 是所有 NAPI
结构体的总预算(默认 300),netdev_budget_usecs
是单次 softirq 的时间上限(默认
2ms)。两个条件任一满足就退出 softirq。
三、sk_buff:内核网络栈的通用货币
一个包从 Ring Buffer
取出后,驱动要做的第一件事就是分配一个 sk_buff
结构体。这个结构体伴随着包走完内核中的整个旅程——从驱动层到
socket 层,每一层都通过修改 sk_buff
的指针和字段来「剥洋葱」。
核心字段:四个指针
sk_buff
里最关键的是四个指针,它们定义了包数据在内存中的位置:
struct sk_buff {
/* ... 省略大量字段 ... */
unsigned char *head; /* 分配的内存块起始位置 */
unsigned char *data; /* 当前层协议头的起始位置 */
unsigned char *tail; /* 当前有效数据的结束位置 */
unsigned char *end; /* 分配的内存块结束位置 */
/* 协议相关 */
__be16 protocol; /* L3 协议类型 (ETH_P_IP, ETH_P_IPV6, ...) */
__u16 transport_header; /* L4 头相对 head 的偏移 */
__u16 network_header; /* L3 头相对 head 的偏移 */
__u16 mac_header; /* L2 头相对 head 的偏移 */
/* socket 关联 */
struct sock *sk; /* 关联的 socket(可能为 NULL) */
/* 设备信息 */
struct net_device *dev; /* 收到/发出此包的网络设备 */
/* 引用计数 */
refcount_t users; /* 引用计数 */
};内存布局如下:
head data tail end
| | | |
v v v v
+-------------------+--+---------+----+-----------------+
| headroom |L3| payload |L4 | tailroom |
+-------------------+--+---------+----+-----------------+
|<-- skb_headroom ->|<- skb_len() -->|<- skb_tailroom ->|
|<------------------ skb_end_offset() ----------------->|
- head 到 data(headroom):预留空间,给上层协议添加头部用。比如在转发时需要在前面加一个新的 L2 头。
- data 到 tail:当前层看到的有效数据。
- tail 到 end(tailroom):尾部预留空间。
每一层处理时,通过移动 data
指针来「剥掉」当前层的协议头:
/* IP 层处理时 */
skb_pull(skb, ip_hdrlen(skb)); /* data 指针后移,跳过 IP 头 */
/* 此时 data 指向 TCP/UDP 头 */skb_clone vs skb_copy
当一个包需要被多个地方使用时(比如 tcpdump 要抓包,同时正常协议栈也要处理),内核不会复制整个包数据。
skb_clone():只复制sk_buff结构体本身,包数据(head 到 end 那块内存)通过引用计数共享。非常快。skb_copy():完整复制,包括包数据。很慢,尽量避免。
/* skb_clone 的本质 */
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp)
{
struct sk_buff *n = kmem_cache_alloc(skb_cache, gfp);
/* 复制 sk_buff 元数据 */
/* 共享底层数据,增加引用计数 */
atomic_inc(&skb_shinfo(skb)->dataref);
return n;
}tcpdump 能以几乎零开销抓包,就是因为它用的是
skb_clone()——只要你不修改包数据,共享就是安全的。
sk_buff 的分配与回收
sk_buff 分配用的是 slab
缓存(skbuff_head_cache),包数据用
kmalloc 或 page allocator。频繁的分配回收在高
PPS(packets per second)场景下会成为瓶颈。这也是 XDP 和
AF_XDP 绕过 sk_buff
直接操作原始包数据能获得巨大性能提升的原因之一。
# 查看 skbuff slab 缓存的状态
cat /proc/slabinfo | grep skbuff
# 或者用 slabtop
slabtop -o | grep skbuff四、softirq 收包:从 napi_poll 到 ip_rcv
NET_RX_SOFTIRQ 的触发
当驱动的中断处理函数调用 napi_schedule()
后,当前 CPU 的 softirq pending 位图中
NET_RX_SOFTIRQ 被置位。中断返回时,内核检查
pending 位图,发现有 softirq 需要处理,就调用
__do_softirq()。
调用链:
硬件中断 → irq_handler (驱动注册的)
→ napi_schedule() // 把 napi_struct 挂到 per-cpu poll_list
→ raise_softirq(NET_RX_SOFTIRQ)
中断返回 → do_softirq() → __do_softirq()
→ net_rx_action() // NET_RX_SOFTIRQ 的处理函数
→ napi_poll() // 调用驱动注册的 poll 回调
→ driver_poll() // 从 Ring Buffer 取包,构造 sk_buff
→ napi_gro_receive()
→ netif_receive_skb()
netif_receive_skb():分发中心
netif_receive_skb()
是收包路径的关键枢纽。它做几件事:
- 送给 ptype_all 处理器:注册了
ETH_P_ALL的协议处理器会收到每一个包。tcpdump就是通过AF_PACKETsocket 注册了ptype_all来抓包的。 - GRO(Generic Receive Offload):如果开启了 GRO,会尝试把多个属于同一个 TCP 流的小包合并成一个大包再往上送。减少上层处理的包数量。
- RPS/RFS 处理:如果配置了 Receive Packet Steering,可能把包转发到其他 CPU 处理。
- 送给对应 L3 协议处理器:根据
skb->protocol(ETH_P_IP、ETH_P_IPV6等),调用对应的处理函数。对于 IPv4,就是ip_rcv()。
/* 简化的 netif_receive_skb 核心逻辑 */
static int __netif_receive_skb_core(struct sk_buff *skb, ...)
{
/* 1. 送给 tcpdump 等 raw socket */
list_for_each_entry(ptype, &ptype_all, list) {
deliver_skb(skb, ptype, orig_dev);
}
/* 2. 查找对应的 L3 协议处理器 */
type = skb->protocol; /* 如 ETH_P_IP (0x0800) */
ptype = ptype_base[hash(type)];
/* 3. 调用协议处理函数 */
ret = deliver_skb(skb, ptype, orig_dev);
/* 对于 IPv4, 这会调用 ip_rcv() */
return ret;
}ip_rcv():IP 层入口
ip_rcv() 是 IPv4 包进入网络层的入口:
int ip_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
struct iphdr *iph;
/* 基本校验 */
if (skb->pkt_type == PACKET_OTHERHOST)
goto drop; /* 不是发给我的,丢弃 */
iph = ip_hdr(skb);
/* 版本号必须是 4 */
if (iph->version != 4)
goto drop;
/* 头部长度校验 */
if (iph->ihl < 5)
goto drop;
/* IP checksum 校验(如果硬件没做的话) */
if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
goto drop;
/* Netfilter PREROUTING hook */
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
net, NULL, skb, dev, NULL,
ip_rcv_finish);
}注意最后一行:NF_HOOK 宏。这是 netfilter
的入口。包在进入 ip_rcv_finish() 之前,会先经过
netfilter 的 PREROUTING 链。如果 iptables
规则在 PREROUTING 里 DROP
了这个包,ip_rcv_finish() 根本不会被调用。
ip_rcv_finish():路由决策
过了 PREROUTING 之后,ip_rcv_finish()
查路由表,决定这个包的去向:
- 目标 IP 是本机:调用
ip_local_deliver()→ 送往传输层 - 目标 IP 不是本机,但开启了 IP
转发:调用
ip_forward()→ 从另一个网卡发出 - 目标 IP 不是本机,未开启 IP 转发:丢弃
# 查看路由缓存命中情况
cat /proc/net/stat/rt_cache
# 查看 IP 转发是否开启
sysctl net.ipv4.ip_forward五、Netfilter:iptables 到底在哪里执行
Netfilter 是 Linux 内核的包过滤框架,iptables/nftables 是它的用户态工具。理解 netfilter 的 hook 点在收包路径中的位置,对 K8s 网络调试至关重要——因为 kube-proxy 的 iptables 模式就是在这些 hook 点上挂规则。
五个 Hook 点
PREROUTING
|
routing decision
/ \
INPUT FORWARD
| |
local process POSTROUTING
| |
OUTPUT [out]
|
POSTROUTING
|
[out]
对于一个发给本机的包,经过的 hook 点是:
PREROUTING → INPUT → 本地进程
对于一个需要转发的包:
PREROUTING → FORWARD → POSTROUTING
对于一个本机发出的包:
OUTPUT → POSTROUTING
Netfilter 在 K8s 中的角色
在 K8s 中,kube-proxy 的 iptables 模式会在这些 hook 点上挂大量规则:
- PREROUTING / OUTPUT 的 nat 表:做 Service ClusterIP 到 Pod IP 的 DNAT
- FORWARD 的 filter 表:控制 Pod 之间的网络策略
- POSTROUTING 的 nat 表:做 SNAT/MASQUERADE,让 Pod 访问外部网络
当 Service 数量达到几千上万时,iptables 规则可能有几万条。每个包都要线性匹配这些规则,性能急剧下降。这也是为什么 K8s 后来引入了 IPVS 模式和 eBPF(Cilium)来替代 iptables。
关于 eBPF 如何绕过 netfilter 提升性能,详见 eBPF 系列。
用 iptables 的 trace 功能观察包的路径
Netfilter 有一个极其有用但鲜为人知的 trace 功能,可以打印出一个包经过了哪些表和链:
# 开启 trace(在 raw 表的 PREROUTING 链上标记要追踪的包)
iptables -t raw -A PREROUTING -p icmp -j TRACE
iptables -t raw -A OUTPUT -p icmp -j TRACE
# 加载 nf_log_ipv4 模块
modprobe nf_log_ipv4
# 然后 ping 一下,看 dmesg
ping -c 1 10.0.0.1
dmesg | grep TRACE
# 输出类似:
# TRACE: raw:PREROUTING:policy:2 IN=eth0 ...
# TRACE: mangle:PREROUTING:policy:1 IN=eth0 ...
# TRACE: nat:PREROUTING:policy:1 IN=eth0 ...
# TRACE: mangle:INPUT:policy:1 IN=eth0 ...
# TRACE: filter:INPUT:policy:1 IN=eth0 ...
# TRACE: nat:INPUT:policy:1 IN=eth0 ...这个输出清晰地展示了包经过的每一个表和链。调试 K8s 网络问题时,这比盲猜有用一百倍。
# 用完记得清理
iptables -t raw -D PREROUTING -p icmp -j TRACE
iptables -t raw -D OUTPUT -p icmp -j TRACE六、传输层:tcp_v4_rcv 与 socket 的相遇
从 IP 层到传输层
ip_local_deliver() 在经过 netfilter 的
INPUT 链之后,调用
ip_local_deliver_finish(),根据 IP 头中的
protocol 字段(6=TCP, 17=UDP,
1=ICMP)找到对应的传输层处理函数:
/* net/ipv4/ip_input.c */
static int ip_local_deliver_finish(struct net *net, struct sock *sk,
struct sk_buff *skb)
{
int protocol = ip_hdr(skb)->protocol;
/* 剥掉 IP 头 */
skb_pull(skb, ip_hdrlen(skb));
/* 根据协议号找处理函数 */
ipprot = rcu_dereference(inet_protos[protocol]);
/* TCP: tcp_v4_rcv, UDP: udp_rcv, ICMP: icmp_rcv */
ret = ipprot->handler(skb);
return ret;
}tcp_v4_rcv():TCP 的入口
以 TCP 为例,tcp_v4_rcv() 大致做这些事:
- 查找 socket:根据四元组(src IP:port,
dst IP:port)在 hash table 中查找对应的
struct sock - SYN 包:如果是 SYN 包且找到了 LISTEN 状态的 socket,进入三次握手流程
- 数据包:如果找到了 ESTABLISHED 状态的
socket,把
sk_buff放入 socket 的接收队列(sk_receive_queue或sk_backlog) - 唤醒等待的进程:如果有进程在
read()/epoll_wait()上阻塞等待,唤醒它
/* 极度简化的 tcp_v4_rcv */
int tcp_v4_rcv(struct sk_buff *skb)
{
struct tcphdr *th = tcp_hdr(skb);
/* 查找对应的 socket */
sk = __inet_lookup_skb(&tcp_hashinfo, skb,
th->source, th->dest);
if (!sk)
goto no_tcp_socket; /* 没找到,发 RST */
/* 如果 socket 被用户进程锁住了(正在 read),放入 backlog */
if (sock_owned_by_user(sk)) {
sk_add_backlog(sk, skb);
} else {
/* 直接处理:放入 receive queue,唤醒等待进程 */
tcp_rcv_established(sk, skb);
/* 内部会调用 sk->sk_data_ready() 唤醒 epoll */
}
return 0;
}这里有一个很重要的细节:backlog
队列。如果用户进程正在持有 socket 锁(比如正在
read() 中间),softirq 不能直接操作 socket
的接收队列(会有锁竞争)。这时候 softirq 把包放到 backlog
队列,等用户进程释放锁时再处理 backlog。
socket 接收队列到用户态
当用户进程调用 read() 或
recvmsg() 时:
- 进入内核,调用
sock_recvmsg()→tcp_recvmsg() - 从
sk_receive_queue中取出sk_buff - 用
skb_copy_datagram_msg()把数据从内核空间拷贝到用户空间 - 释放
sk_buff - 返回用户态
# 查看 socket 接收队列的积压情况
ss -tnp | head
# Recv-Q 列就是接收队列中未被应用读取的字节数
# 如果 Recv-Q 持续增长,说明应用消费速度跟不上七、中断亲和性与收包调优
到这里,我们已经把完整的收包路径走了一遍。但在生产环境中,仅仅理解路径是不够的——你还需要知道如何调优。收包调优的核心就是:让包的处理负载均匀分布在多个 CPU 核心上。
IRQ 亲和性:smp_affinity
每个硬件中断都可以绑定到特定的 CPU 核心。这是最基础的负载均衡手段:
# 查看 eth0 各队列的中断号
cat /proc/interrupts | grep eth0
# 28: 1234567 0 0 0 IR-PCI-MSI eth0-TxRx-0
# 29: 0 2345678 0 0 IR-PCI-MSI eth0-TxRx-1
# 30: 0 0 3456789 0 IR-PCI-MSI eth0-TxRx-2
# 31: 0 0 0 4567890 IR-PCI-MSI eth0-TxRx-3
# 查看中断 28 的 CPU 亲和性
cat /proc/irq/28/smp_affinity
# 00000001 (只有 CPU 0)
# 把中断 28 绑定到 CPU 2
echo 4 > /proc/irq/28/smp_affinity # 4 = 二进制 100 = CPU 2
# 或者用 smp_affinity_list(更直观)
echo 2 > /proc/irq/28/smp_affinity_listirqbalance:自动中断均衡
手动设置中断亲和性很繁琐。irqbalance
是一个守护进程,会自动根据负载把中断分配到不同的 CPU
核心。
但在高性能场景下,irqbalance
可能不够好——它的调整频率较低(默认 10
秒一次),而且不了解你的应用架构。很多高性能场景会关闭
irqbalance,手动绑定。
# 查看 irqbalance 状态
systemctl status irqbalance
# 高性能场景:关闭 irqbalance,手动绑定
systemctl stop irqbalance
systemctl disable irqbalanceRPS:软件层面的 RSS
如果你的网卡只有一个 RX 队列(虚拟机里很常见),或者网卡的 RSS 队列数少于 CPU 核心数,怎么办?
RPS(Receive Packet Steering)
是内核在软件层面实现的收包负载均衡。它在
netif_receive_skb() 阶段,根据包头 hash
把包转发到其他 CPU 的 backlog 队列处理。
# 查看 eth0 第 0 个 RX 队列的 RPS 配置
cat /sys/class/net/eth0/queues/rx-0/rps_cpus
# 00000000 (未启用)
# 启用 RPS,让所有 CPU 参与处理(假设 8 核)
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# 同时需要增大 backlog 队列大小
sysctl -w net.core.netdev_max_backlog=10000RPS 的代价是:跨 CPU 转发包会产生 IPI(Inter-Processor Interrupt),有一定开销。但对于单队列网卡来说,这比所有包都在一个 CPU 上处理要好得多。
RFS:让包在「对的」CPU 上处理
RPS 把包随机分散到各个 CPU,但有一个问题:处理包的 CPU 可能不是运行应用进程的 CPU。这意味着数据要跨 CPU 传递,cache miss 严重。
RFS(Receive Flow Steering) 解决这个问题:它记录每个网络流(五元组 hash)最近是被哪个 CPU 上的进程消费的,然后把属于同一个流的包引导到那个 CPU 处理。
# 启用 RFS(设置全局流表大小)
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
# 设置每个 RX 队列的流表大小
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cntXPS:发包侧的队列选择
XPS(Transmit Packet Steering) 是发包方向的优化。它控制每个 CPU 核心优先使用哪个 TX 队列发包,减少多个 CPU 竞争同一个 TX 队列的锁。
# 把 TX 队列 0 绑定到 CPU 0
echo 1 > /sys/class/net/eth0/queues/tx-0/xps_cpus
# 把 TX 队列 1 绑定到 CPU 1
echo 2 > /sys/class/net/eth0/queues/tx-1/xps_cpus总结对比
| 机制 | 层面 | 方向 | 原理 | 适用场景 |
|---|---|---|---|---|
| RSS | 硬件 | RX | 网卡按五元组 hash 分配到不同 RX 队列 | 多队列物理网卡 |
| smp_affinity | 内核 | RX | 把中断绑定到指定 CPU | 手动调优 |
| irqbalance | 用户态 | RX | 自动均衡中断分配 | 通用场景 |
| RPS | 内核 | RX | 软件层面按 hash 分散到多 CPU | 单队列/少队列网卡 |
| RFS | 内核 | RX | 把包引导到消费进程所在的 CPU | 减少 cache miss |
| XPS | 内核 | TX | 让 CPU 优先用绑定的 TX 队列 | 减少 TX 队列锁竞争 |
八、实验:用 bpftrace 追踪一个 ICMP 包的完整旅程
理论讲完了,该上手验证了。我们用 bpftrace 追踪一个 ICMP
echo request 包(ping)从进入内核到送达 socket
的完整路径,打印每一跳的时间戳。
关于 eBPF 和 bpftrace 的基础知识,参见 eBPF 系列。 关于 Network Namespace 的基础知识,参见 网络命名空间详解。
准备工作
# 安装 bpftrace(Ubuntu 22.04+)
apt-get install -y bpftrace
# 确认内核支持 kprobe
cat /proc/kallsyms | grep -c ip_rcv
# 应该输出 1 或更多bpftrace 脚本:追踪 ICMP 收包路径
下面这个脚本追踪 ICMP 包经过的关键内核函数,打印纳秒级时间线:
#!/usr/bin/env bpftrace
/*
* icmp_rx_trace.bt - 追踪 ICMP 包的完整 RX 路径
*
* 用法:
* bpftrace icmp_rx_trace.bt &
* ping -c 3 <target_ip>
*/
BEGIN
{
printf("%-18s %-30s %-6s %s\n",
"TIMESTAMP_ns", "FUNCTION", "CPU", "INFO");
printf("---\n");
}
/* 1. 驱动层 NAPI poll 入口 */
kprobe:napi_gro_receive
{
$skb = (struct sk_buff *)arg1;
/* 只追踪 ICMP (IP protocol 1) */
if ($skb->protocol == 0x0008) { /* ETH_P_IP in network byte order */
@start[tid] = nsecs;
printf("%-18llu %-30s %-6d %s\n",
nsecs, "napi_gro_receive", cpu, "skb entered GRO");
}
}
/* 2. netif_receive_skb:分发中心 */
kprobe:netif_receive_skb
{
$skb = (struct sk_buff *)arg0;
if ($skb->protocol == 0x0008) {
printf("%-18llu %-30s %-6d %s\n",
nsecs, "netif_receive_skb", cpu, "dispatching to L3");
}
}
/* 3. ip_rcv:IP 层入口 */
kprobe:ip_rcv
{
$skb = (struct sk_buff *)arg0;
printf("%-18llu %-30s %-6d %s\n",
nsecs, "ip_rcv", cpu, "IP validation + PREROUTING");
}
/* 4. ip_rcv_finish:路由决策 */
kprobe:ip_rcv_finish
{
printf("%-18llu %-30s %-6d %s\n",
nsecs, "ip_rcv_finish", cpu, "route lookup done");
}
/* 5. ip_local_deliver:送往本地传输层 */
kprobe:ip_local_deliver
{
printf("%-18llu %-30s %-6d %s\n",
nsecs, "ip_local_deliver", cpu, "-> INPUT chain");
}
/* 6. icmp_rcv:ICMP 处理 */
kprobe:icmp_rcv
{
$elapsed = @start[tid] > 0 ? nsecs - @start[tid] : 0;
printf("%-18llu %-30s %-6d total=%llu ns\n",
nsecs, "icmp_rcv", cpu, $elapsed);
}
END
{
clear(@start);
}运行实验
# 终端 1:运行 bpftrace
bpftrace icmp_rx_trace.bt
# 终端 2:发送 ICMP 包
ping -c 3 127.0.0.1预期输出类似:
TIMESTAMP_ns FUNCTION CPU INFO
---
8234567890123456 napi_gro_receive 0 skb entered GRO
8234567890124100 netif_receive_skb 0 dispatching to L3
8234567890124800 ip_rcv 0 IP validation + PREROUTING
8234567890125200 ip_rcv_finish 0 route lookup done
8234567890125600 ip_local_deliver 0 -> INPUT chain
8234567890126100 icmp_rcv 0 total=2644 ns
在一台典型的 VM 上,一个 ICMP 包从
napi_gro_receive 到 icmp_rcv 大约
2-5 微秒。在物理机上可以更快。如果你看到超过 10 微秒,可能是
netfilter 规则太多或者有其他 softirq 在竞争。
进阶:用 perf probe 追踪发包路径
除了 bpftrace,perf probe
也是追踪内核网络路径的利器。它可以在任意内核函数上动态设置追踪点:
# 在 ip_rcv 上设置追踪点
perf probe --add ip_rcv
# 在 tcp_v4_rcv 上设置追踪点
perf probe --add tcp_v4_rcv
# 在 netif_receive_skb 上设置追踪点
perf probe --add netif_receive_skb
# 录制追踪数据(同时在另一个终端制造流量)
perf record -e probe:ip_rcv -e probe:tcp_v4_rcv \
-e probe:netif_receive_skb -a sleep 5
# 查看结果
perf script
# 清理
perf probe --del ip_rcv
perf probe --del tcp_v4_rcv
perf probe --del netif_receive_skbperf probe
的好处是开销极低,适合在生产环境使用。缺点是输出没有
bpftrace 那么灵活。
完整的发包 + 收包时间线
如果你想同时追踪发包路径(TX),可以扩展 bpftrace 脚本:
#!/usr/bin/env bpftrace
/*
* net_timeline.bt - 同时追踪 TX 和 RX 路径
*/
/* TX 路径关键函数 */
kprobe:dev_queue_xmit { printf("%-18llu TX dev_queue_xmit cpu=%-3d\n", nsecs, cpu); }
kprobe:sch_direct_xmit { printf("%-18llu TX sch_direct_xmit cpu=%-3d\n", nsecs, cpu); }
kprobe:dev_hard_start_xmit { printf("%-18llu TX dev_hard_start_xmit cpu=%-3d\n", nsecs, cpu); }
/* RX 路径关键函数 */
kprobe:netif_receive_skb { printf("%-18llu RX netif_receive_skb cpu=%-3d\n", nsecs, cpu); }
kprobe:ip_rcv { printf("%-18llu RX ip_rcv cpu=%-3d\n", nsecs, cpu); }
kprobe:tcp_v4_rcv { printf("%-18llu RX tcp_v4_rcv cpu=%-3d\n", nsecs, cpu); }
/* 使用 tracepoint 也可以 */
tracepoint:net:net_dev_xmit
{
printf("%-18llu TP net_dev_xmit cpu=%-3d dev=%s len=%d\n",
nsecs, cpu, args->name, args->len);
}
tracepoint:net:netif_receive_skb
{
printf("%-18llu TP netif_receive_skb cpu=%-3d len=%d\n",
nsecs, cpu, args->len);
}这个脚本同时使用了 kprobe 和 tracepoint。tracepoint 是内核源码里预定义的追踪点,比 kprobe 更稳定(不会因为函数改名而失效),但数量有限。kprobe 可以挂在任意函数上,灵活性更强。
九、常见问题与调试实战
问题一:softirq 占用率过高
# 查看 softirq 统计
cat /proc/softirqs
# CPU0 CPU1 CPU2 CPU3
# NET_RX: 123456789 12345 1234 123
# 如果某个 CPU 的 NET_RX 远高于其他 CPU,说明收包集中在一个核上
# 检查 RSS 是否生效、RPS 是否配置如果 softirq 处理时间过长,内核会把 softirq 工作转交给
ksoftirqd 内核线程(每个 CPU 一个)。用
top 看到 ksoftirqd/0 CPU
占用率很高,基本就是网络 softirq 忙不过来了。
问题二:Ring Buffer 溢出丢包
# 查看网卡统计
ethtool -S eth0 | grep -E 'rx_dropped|rx_missed|rx_no_buffer'
# 查看内核网络统计
cat /proc/net/softnet_stat
# 每行对应一个 CPU,各列含义:
# col1: 已处理的包数
# col2: 因 backlog 满而丢弃的包数
# col3: time_squeeze 次数(softirq 用完预算被强制退出)time_squeeze(第三列)不为零说明 softirq
一直在忙,但被强制限时退出了。这时候可以考虑: - 增大
net.core.netdev_budget - 增大
net.core.netdev_budget_usecs - 启用 RPS
分散负载
问题三:用 ss 诊断 socket 队列积压
# 查看 TCP socket 的接收和发送队列
ss -tnpi
# 各字段含义:
# Recv-Q: 接收队列中等待应用读取的字节数
# Send-Q: 发送队列中等待对端确认的字节数
# 如果 Recv-Q 持续非零且增长,说明:
# 1. 应用 read() 速度跟不上
# 2. 可能需要优化应用的事件循环
# 3. 或者 epoll 的用法有问题问题四:确认中断是否均匀分布
# 实时监控中断分布(每秒刷新)
watch -n 1 'cat /proc/interrupts | grep eth0'
# 如果只有一个 CPU 在涨,其他都是 0:
# 1. 检查 RSS 是否启用
# 2. 检查 irqbalance 是否运行
# 3. 检查 smp_affinity 设置十、从 sk_buff 到 XDP:为什么需要绕过协议栈
走完整个收包路径后,你应该能感受到一个现实:这条路径很长。
一个包要经过硬件中断 → softirq → NAPI poll → GRO →
netif_receive_skb → ip_rcv → netfilter → tcp_v4_rcv → socket
队列 → 用户态 read()。每一步都有 CPU 开销,每一步都涉及
sk_buff 的分配、修改、释放。
在 10Gbps 线速下(大约 14.88 Mpps 对于最小包),每个包只有大约 67 纳秒的处理预算。走完整个协议栈是不够用的。
这就是为什么需要 XDP(eXpress Data Path)和 AF_XDP:
- XDP:在驱动层(NAPI poll
之前)直接处理包,不分配
sk_buff,不进入协议栈。适合做高速包过滤、DDoS 防御、负载均衡。 - AF_XDP:在驱动层直接把包通过共享内存映射到用户态,彻底绕过内核协议栈。适合高性能用户态网络栈。
- DPDK:更激进——直接绕过内核驱动,用户态轮询网卡。性能最高但放弃了内核协议栈的所有功能。
| 方案 | 绕过层次 | 性能 | 保留内核功能 |
|---|---|---|---|
| 标准协议栈 | 无 | 基线 | 全部保留 |
| XDP | netfilter 之前处理 | 高 | 可选择性送入协议栈 |
| AF_XDP | 绕过整个协议栈 | 很高 | 仅保留驱动层 |
| DPDK | 绕过内核驱动 | 极高 | 无,纯用户态 |
在 K8s 场景中,Cilium 使用 eBPF/XDP 来替代 kube-proxy 的 iptables 规则,在 netfilter 之前就完成 Service 的负载均衡,性能提升显著。
十一、总结:一张图回顾完整路径
让我们最后把完整的 RX 路径用函数调用链串起来:
[NIC Hardware]
DMA write → RX Ring Buffer → MSI-X IRQ
[Hard IRQ Context]
irq_handler()
→ napi_schedule()
→ raise_softirq(NET_RX_SOFTIRQ)
[Soft IRQ Context - NET_RX_SOFTIRQ]
net_rx_action()
→ napi_poll()
→ driver_poll() // 从 Ring Buffer 取包
→ alloc_skb() // 分配 sk_buff
→ eth_type_trans() // 设置 skb->protocol
→ napi_gro_receive() // GRO 尝试合并
→ netif_receive_skb()
→ ptype_all 处理 (tcpdump)
→ RPS 转发 (如果配置了)
→ deliver_skb() // 根据 protocol 分发
→ ip_rcv()
→ NF_HOOK(PREROUTING)
→ ip_rcv_finish()
→ route lookup
→ ip_local_deliver()
→ NF_HOOK(INPUT)
→ ip_local_deliver_finish()
→ tcp_v4_rcv() / udp_rcv() / icmp_rcv()
→ sk_lookup()
→ sock_queue_rcv_skb()
→ sk->sk_data_ready() // 唤醒 epoll
[Process Context]
read() / recvmsg() / epoll_wait()
→ sock_recvmsg()
→ tcp_recvmsg()
→ skb_copy_datagram_msg() // 拷贝到用户空间
→ kfree_skb() // 释放 sk_buff
→ return to userspace
这条路径中的每一个函数,你都可以用 bpftrace 或 perf probe 挂追踪点。当你在 K8s 中遇到网络问题时,知道包在哪一层被丢弃或者卡住,就知道该往哪个方向去查。
本文覆盖的是 Linux 网络栈的「纵向」路径。在后续文章中,我们会深入每一层的「横向」细节:
- netfilter 的五链四表如何与 kube-proxy 配合
- eBPF 如何在各个 hook 点上做自定义处理
- veth pair 和 bridge 如何连接不同的 Network Namespace
- Overlay 网络(VXLAN/Geneve)如何封装和解封装
掌握了这条完整路径,你就有了一张地图。后面的文章都是在这张地图上标注细节。
附录 A:快速参考命令
# === 网卡和驱动 ===
ethtool -i eth0 # 驱动信息
ethtool -g eth0 # Ring Buffer 大小
ethtool -l eth0 # 队列数量
ethtool -x eth0 # RSS indirection table
ethtool -S eth0 # 网卡统计(含各种丢包计数)
ethtool -k eth0 # offload 特性开关
# === 中断 ===
cat /proc/interrupts | grep eth0 # 中断计数
cat /proc/irq/<N>/smp_affinity # 中断亲和性
cat /proc/irq/<N>/effective_affinity # 实际生效的亲和性
# === softirq ===
cat /proc/softirqs # softirq 计数
cat /proc/net/softnet_stat # 收包统计(丢包、time_squeeze)
# === RPS/RFS/XPS ===
cat /sys/class/net/eth0/queues/rx-0/rps_cpus
cat /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
cat /sys/class/net/eth0/queues/tx-0/xps_cpus
# === socket ===
ss -tnpi # TCP socket 详情
ss -unlp # UDP listening sockets
cat /proc/net/sockstat # socket 统计
# === netfilter ===
iptables -t raw -A PREROUTING -j TRACE # 开启 TRACE
conntrack -L # 连接追踪表
conntrack -C # 连接追踪条目数
# === bpftrace ===
bpftrace -l 'kprobe:*ip_rcv*' # 列出可追踪的函数
bpftrace -l 'tracepoint:net:*' # 列出 net 相关 tracepoint附录 B:关键内核源码文件索引
| 文件 | 内容 |
|---|---|
net/core/dev.c |
netif_receive_skb()、net_rx_action()、RPS/RFS
逻辑 |
net/core/skbuff.c |
sk_buff 的分配、释放、clone、copy |
net/ipv4/ip_input.c |
ip_rcv()、ip_rcv_finish()、ip_local_deliver() |
net/ipv4/tcp_ipv4.c |
tcp_v4_rcv() |
net/ipv4/udp.c |
udp_rcv() |
net/ipv4/icmp.c |
icmp_rcv() |
net/netfilter/core.c |
nf_hook_slow()、netfilter 框架核心 |
net/ipv4/netfilter/ |
iptables 各表的实现 |
include/linux/skbuff.h |
sk_buff 结构体定义 |
include/linux/netdevice.h |
net_device、NAPI 结构体定义 |
kernel/softirq.c |
softirq 调度、__do_softirq() |
附录 C:推荐阅读
- Linux 内核源码
Documentation/networking/目录下的文档 - 「Linux Network Internals」(Christian Benvenuti) —— 虽然老了但底层逻辑没变
- Memory Layout 部分参考
include/linux/skbuff.h头部的注释,那里有最权威的说明 - Brendan Gregg 的 BPF Performance Tools —— bpftrace 用法的权威参考