你大概已经听过 XDP 的威力——在网卡驱动层丢包,单核 24 Mpps,吊打 iptables。但 XDP 只是 eBPF 在网络栈里的第一个挂载点。一个真正的可编程数据面,需要在从网卡到 socket 的整条路径上都能插入逻辑。
想想 Cilium 是怎么做的:它用 eBPF 替掉了 kube-proxy 的全部 iptables 规则,实现了 Service 负载均衡、Network Policy 执行、透明加密、四层连接追踪……这些功能不可能只靠 XDP 一个 hook 点完成。Cilium 的 BPF 程序挂在 TC ingress、TC egress、cgroup、socket 等多个位置,形成了一张覆盖整个网络栈的可编程网格。
这篇文章从全局视角出发,把 eBPF 在网络栈中的五大挂载点逐个拆解,然后重点讲 TC BPF——它是 XDP 之外最重要的网络 hook 点,也是 Cilium 数据面的核心。最后我们动手写一个 TC BPF 程序,在 veth 上做包计数和选择性丢弃。
如果你还没读过 XDP 的详解,建议先看 XDP:在网卡驱动层就把包丢掉。本篇会在 XDP 的基础上展开,不会重复基础概念。关于 eBPF 的整体架构,可以参考 eBPF 与 Cilium。
一、eBPF 在网络栈中的五大挂载点
在 Linux 网络栈里,eBPF 程序可以挂载在五个关键位置。每个位置看到的数据结构不同,能做的事情也不同。理解这五个 hook 点的差异,是写出高效 eBPF 网络程序的前提。
1. XDP(eXpress Data Path)
位置:网卡驱动的 NAPI poll
函数内,sk_buff 分配之前。
程序类型:BPF_PROG_TYPE_XDP
操作对象:xdp_buff——一个轻量结构体,直接指向
DMA ring buffer 中的包数据。
可用动作:
| 动作码 | 含义 |
|---|---|
XDP_DROP |
立即丢弃,不进协议栈 |
XDP_PASS |
交给正常协议栈处理 |
XDP_TX |
从同一网卡发回 |
XDP_REDIRECT |
转发到另一网卡或 AF_XDP socket |
XDP_ABORTED |
出错丢弃(触发 tracepoint) |
适合场景:DDoS 过滤、极速负载均衡、AF_XDP 零拷贝收包。
XDP
的优势在于”早”——早到内核还没为这个包花一分钱。但”早”也意味着”穷”:你拿不到
sk_buff,看不到 socket
信息,做不了需要协议栈上下文的复杂操作。
2. TC Ingress(流量控制入口)
位置:__netif_receive_skb()
之后,netfilter 之前。
程序类型:BPF_PROG_TYPE_SCHED_CLS
操作对象:__sk_buff——sk_buff
的 BPF 视图,字段经过验证器白名单过滤。
可用动作:
| 动作码 | 含义 |
|---|---|
TC_ACT_OK |
继续正常处理 |
TC_ACT_SHOT |
丢弃 |
TC_ACT_REDIRECT |
重定向到另一设备 |
TC_ACT_PIPE |
传递给下一个 filter |
TC_ACT_STOLEN |
包已被消费,不再处理 |
适合场景:Service 负载均衡(DNAT/SNAT)、Network Policy 执行、包头改写、容器间转发。
TC ingress 是 Cilium 数据面的主力 hook 点。它比 XDP
晚一步,但拿到了完整的 sk_buff,可以做 L2-L4
头部改写、FIB 路由查找、conntrack 状态查询——这些都是 XDP
做不到或者很难做的事。
3. TC Egress(流量控制出口)
位置:dev_queue_xmit()
路径上,在 qdisc 之前。
程序类型:同样是
BPF_PROG_TYPE_SCHED_CLS
操作对象:同样是
__sk_buff
TC egress 和 TC ingress 的程序类型、操作对象、返回值完全一样,区别只是挂载方向。一个 BPF 程序可以同时挂在 ingress 和 egress 上——虽然实际中很少这么做,因为逻辑通常不同。
适合场景:出口 SNAT、出口 Network Policy、包标记(mark)、流量整形辅助。
4. cgroup/skb
位置:cgroup 关联的 socket 收发路径上。
程序类型:BPF_PROG_TYPE_CGROUP_SKB
操作对象:__sk_buff
cgroup BPF 程序挂在 cgroup 级别,对该 cgroup 内所有进程的网络流量生效。它可以在 ingress 或 egress 方向执行。
适合场景:容器级别的带宽限制、按 cgroup 的包过滤策略、Kubernetes Pod 级别的网络策略。
5. sock_ops / sk_msg
位置:socket 操作回调(TCP 连接建立、状态变更等)。
程序类型:BPF_PROG_TYPE_SOCK_OPS、BPF_PROG_TYPE_SK_MSG
操作对象:bpf_sock_ops、sk_msg_md
这一层的 hook 最”高级”——它看到的不再是原始包数据,而是
socket 级别的事件和消息。sock_ops 可以在 TCP
握手、RTT 采样等关键点插入逻辑;sk_msg 可以在
sendmsg 路径上做消息级别的重定向。
适合场景:同节点 Pod 间通信加速(sockmap bypass)、透明代理(Envoy sidecar 替代)、TCP 参数动态调优。
五个 hook 点的关键差异
| 特性 | XDP | TC ingress/egress | cgroup/skb | sock_ops |
|---|---|---|---|---|
| 操作对象 | xdp_buff |
__sk_buff |
__sk_buff |
bpf_sock_ops |
| 有 sk_buff | 否 | 是 | 是 | 间接 |
| 可改写头部 | 有限 | 完整 L2-L4 | 有限 | 否 |
| 可做路由查找 | 否 | bpf_fib_lookup |
否 | 否 |
| 可做 conntrack | 否 | bpf_ct_lookup |
否 | 否 |
| 可重定向 | bpf_redirect |
bpf_redirect |
否 | bpf_msg_redirect |
| 性能 | 最高 | 高 | 中 | 中 |
| 典型用户 | XDP 防火墙 | Cilium datapath | Calico policy | Cilium sockmap |
二、XDP 回顾与 TC BPF 的差异:同源不同命
XDP 和 TC BPF 都是 eBPF 程序挂在网络栈上的用法,但它们操作的数据结构、能力边界、使用场景差异极大。理解这些差异,才能在实际工程中选对 hook 点。
数据结构的根本差异
XDP 程序操作 xdp_buff:
struct xdp_buff {
void *data; // 包数据起始
void *data_end; // 包数据结束
void *data_meta; // 元数据区域
void *data_hard_start;// headroom 起始
struct xdp_rxq_info *rxq; // 收包队列信息
};TC BPF 程序操作 __sk_buff——这是内核
sk_buff 的 BPF 可见视图,比
xdp_buff 丰富得多:
struct __sk_buff {
__u32 len; // 包长度
__u32 mark; // fwmark(可读可写)
__u32 protocol; // L3 协议
__u32 ingress_ifindex;// 入口设备索引
__u32 ifindex; // 当前设备索引
__u32 cb[5]; // 控制块,ingress/egress 间传数据
__u32 data; // 包数据偏移
__u32 data_end; // 包数据结束偏移
__u32 vlan_tci;
__u32 priority;
/* ... 更多字段 ... */
};__sk_buff 能看到
ingress_ifindex(包从哪个设备进来的)、mark(fwmark)、cb[](5
个 u32 的控制块,可以在 TC ingress 和 egress
之间传递数据)等关键元数据。
能力对比:TC BPF 能做什么 XDP 做不到
1. 完整的 L2-L4 头部改写
TC BPF 有一组专用的 helper 函数来改写包头:
// 改写 L3 字段(自动更新校验和)
bpf_l3_csum_replace(skb, offset, old, new, size);
// 改写 L4 字段(自动更新校验和)
bpf_l4_csum_replace(skb, offset, old, new, flags);
// 修改包大小(添加/删除头部空间)
bpf_skb_change_head(skb, len, flags);
bpf_skb_change_tail(skb, len, flags);
// VLAN 操作
bpf_skb_vlan_push(skb, vlan_proto, vlan_tci);
bpf_skb_vlan_pop(skb);XDP
也能改写包头,但必须手动计算校验和,而且调整包大小要用
bpf_xdp_adjust_head(),没有 L3/L4
级别的抽象。
2. FIB 路由查找
struct bpf_fib_lookup params = {
.family = AF_INET,
.ipv4_src = saddr,
.ipv4_dst = daddr,
.ifindex = skb->ingress_ifindex,
};
int rc = bpf_fib_lookup(skb, ¶ms, sizeof(params), 0);这让 TC BPF
程序可以在不经过内核路由子系统的情况下查询路由表,然后用
bpf_redirect 直接把包发走。Cilium
就是这么干的——绕过了整个 netfilter/routing 路径。
3. conntrack 查询
从 Linux 5.18 开始,TC BPF 程序可以直接查询内核的 conntrack 表:
struct bpf_ct_opts opts = { .netns_id = -1 };
struct nf_conn *ct = bpf_skb_ct_lookup(skb, &tuple, sizeof(tuple), &opts, sizeof(opts));
if (ct) {
// 连接已被跟踪,可以读取状态
bpf_ct_release(ct);
}这对实现有状态防火墙和 NAT 至关重要。
什么时候用 XDP,什么时候用 TC
| 需求 | 选择 | 原因 |
|---|---|---|
| DDoS 过滤 | XDP | 越早丢越好,不需要 sk_buff |
| 简单负载均衡(改 MAC 转发) | XDP | 性能优先,XDP_TX
直接发回 |
| Service DNAT/SNAT | TC | 需要 FIB lookup 和 conntrack |
| Network Policy | TC | 需要看到完整的包元数据 |
| 容器间转发 | TC | 需要
bpf_redirect_peer |
| 出口策略 | TC egress | XDP 只有 ingress |
| 包头添加/删除(隧道封装) | TC | bpf_skb_change_head |
一句话总结:XDP 干脏活快活(丢包、简单转发),TC 干细活精活(NAT、策略、改写)。
三、BPF Map 在网络编程中的典型用法
BPF map 是 eBPF 程序之间、以及 eBPF 程序与用户态之间共享数据的核心机制。在网络场景中,map 不只是”存个计数器”这么简单——它承担着连接追踪、负载均衡后端管理、策略下发等关键角色。
conntrack map:有状态防火墙的基石
在 Cilium 的数据面里,conntrack 信息存储在一个 BPF hash map 中:
struct ct_key {
__be32 saddr;
__be32 daddr;
__be16 sport;
__be16 dport;
__u8 proto;
__u8 dir; // 0 = ingress, 1 = egress
};
struct ct_value {
__u64 packets;
__u64 bytes;
__u64 lifetime;
__u16 rev_nat_id; // 反向 NAT 映射
__u8 state; // 0=new, 1=established, 2=related
__u8 flags;
};
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__type(key, struct ct_key);
__type(value, struct ct_value);
__uint(max_entries, 524288);
} cilium_ct4_global SEC(".maps");选用 LRU_HASH 是关键——当 map
满了,最久没被访问的条目自动被淘汰,不需要用户态垃圾回收。这比内核原生
conntrack 的 nf_conntrack_max
机制灵活得多。
LB backend map:Service 负载均衡
kube-proxy 的 iptables 模式用一堆 DNAT 规则做 Service 负载均衡,每个 Service 多一个 endpoint 就多几条 iptables 规则,性能线性下降。Cilium 用 BPF map 存后端列表:
struct lb4_key {
__be32 address; // Service VIP
__be16 dport; // Service port
__u16 backend_slot; // 0 = lookup, >0 = specific backend
};
struct lb4_backend {
__be32 address; // 后端 Pod IP
__be16 port; // 后端 Pod port
__u8 proto;
__u8 flags;
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct lb4_key);
__type(value, struct lb4_backend);
__uint(max_entries, 65536);
} cilium_lb4_backends SEC(".maps");查找是 O(1) 的 hash 查询,不管集群里有 10 个 Service 还是 10000 个,性能几乎不变。这是 eBPF 替代 iptables 最有说服力的论据之一。
policy map:Network Policy 执行
struct policy_key {
__u32 identity; // Cilium security identity
__u16 dport;
__u8 proto;
__u8 pad;
};
struct policy_entry {
__u8 allow; // 0 = deny, 1 = allow
__u8 flags;
__u16 proxy_port; // 非零则重定向到 L7 proxy
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct policy_key);
__type(value, struct policy_entry);
__uint(max_entries, 16384);
} cilium_policy SEC(".maps");用户态的 Cilium agent 监听 Kubernetes NetworkPolicy 变更,把策略编译成 map 条目写入。数据面的 BPF 程序只需要一次 hash 查询就能判断是否允许通过。
per-CPU map 与 map-in-map
对于需要频繁更新的计数器(比如每包 +1
的统计),BPF_MAP_TYPE_PERCPU_ARRAY 为每个 CPU
维护独立副本,更新无需加锁。我们的实战代码中就用了这种
map。
另外,BPF_MAP_TYPE_ARRAY_OF_MAPS
允许原子替换整个内层
map。这对无中断更新至关重要——新的负载均衡后端列表可以先构建在一个新
map 里,然后一次性替换,不会出现读到半新半旧数据的情况。
四、bpf_redirect 三兄弟:包转发的三种姿势
在 eBPF 网络编程里,把包从一个设备转发到另一个设备是最核心的操作之一。内核提供了三个 helper 函数来做这件事,它们的性能和适用场景各不相同。
bpf_redirect:基础版
long bpf_redirect(__u32 ifindex, __u64 flags);bpf_redirect
是最基础的转发函数。它把当前包重定向到 ifindex
指定的网络设备。对于 XDP 程序,配合
bpf_redirect_map()
使用效果更好(批量转发,减少锁竞争)。
工作流程:
- BPF 程序调用
bpf_redirect(target_ifindex, 0) - 返回
TC_ACT_REDIRECT(TC)或XDP_REDIRECT(XDP) - 内核把包送到目标设备的 ingress 路径
- 目标设备上的 TC ingress BPF 程序(如果有)会被执行
关键限制:包到达目标设备后,要走完目标设备的整个 ingress 路径——包括 XDP、TC ingress、netfilter 等。这在 veth pair 场景下会带来额外开销。
bpf_redirect_peer:跳过对端 ingress
long bpf_redirect_peer(__u32 ifindex, __u64 flags);Linux 5.10 引入的 bpf_redirect_peer
专门优化了 veth pair 的场景。在容器网络中,宿主机的 veth
端和容器内的 veth 端通过 veth pair 连接。传统的
bpf_redirect 把包发到宿主机侧的
veth,包还要走到对端(容器内的 veth),再走对端的 ingress
路径。
bpf_redirect_peer 直接把包送到 veth
对端的 ingress
路径起点,跳过了中间的设备切换开销:
bpf_redirect:
hostVeth [TC egress] -> peerVeth [XDP -> skb alloc -> TC ingress -> netfilter -> ...]
bpf_redirect_peer:
hostVeth [TC egress] -> peerVeth [TC ingress -> netfilter -> ...]
(跳过了 XDP 和 skb 重新分配)
性能提升:在 Cilium
的测试中,bpf_redirect_peer 比
bpf_redirect 快
15-25%,因为少了一次
__netif_receive_skb 的完整处理。
限制:
- 只能用于 veth pair(不是任意设备)
- 只能从 TC 程序调用(XDP 不行)
- 目标必须是 veth 的对端
bpf_redirect_neigh:带邻居解析的转发
long bpf_redirect_neigh(__u32 ifindex, struct bpf_redir_neigh *params, int plen, __u64 flags);Linux 5.10 同时引入的 bpf_redirect_neigh
解决了另一个问题:当你在 TC BPF
程序里做完路由查找(bpf_fib_lookup),想把包转发到另一个
L3 设备时,你需要填写正确的 L2 头部(目标 MAC 地址)。
bpf_redirect_neigh
会自动做邻居(ARP/NDP)查找,填好 L2
头部,然后把包送到目标设备的 egress
路径。如果邻居缓存里没有对应条目,它还会触发 ARP 请求。
// 典型用法:TC ingress 程序做完 DNAT 后转发
struct bpf_fib_lookup fib_params = {};
fib_params.family = AF_INET;
fib_params.ipv4_src = iph->saddr;
fib_params.ipv4_dst = new_daddr; // DNAT 后的目标地址
fib_params.ifindex = skb->ingress_ifindex;
int rc = bpf_fib_lookup(skb, &fib_params, sizeof(fib_params), 0);
if (rc == BPF_FIB_LKUP_RET_SUCCESS) {
// FIB lookup 成功,用 bpf_redirect_neigh 转发
// 它会自动处理 L2 头部
return bpf_redirect_neigh(fib_params.ifindex, NULL, 0, 0);
}适合场景:跨子网转发、L3 路由转发、需要自动 ARP 解析的场景。
三兄弟对比
| 特性 | bpf_redirect |
bpf_redirect_peer |
bpf_redirect_neigh |
|---|---|---|---|
| 最低内核版本 | 4.15 | 5.10 | 5.10 |
| 目标设备类型 | 任意 | 仅 veth 对端 | 任意 L3 设备 |
| L2 头部处理 | 手动 | 自动 | 自动(含 ARP) |
| 目标路径 | ingress | peer ingress | egress(含邻居解析) |
| XDP 可用 | 是(配合 redirect_map) | 否 | 否 |
| 典型用户 | 通用转发 | 容器 veth 优化 | L3 跨子网转发 |
| Cilium 用途 | XDP LB | Pod ingress | NodePort/HostFW |
Cilium 在不同场景下组合使用这三个函数:
- Pod 到 Pod(同节点):宿主机侧 TC
ingress 用
bpf_redirect_peer送到目标 Pod 的 veth 对端 - 外部到 Service:XDP 用
bpf_redirect做负载均衡后的快速转发 - 跨节点转发:TC 用
bpf_redirect_neigh送到 overlay/underlay 的出口设备
五、TC BPF 程序的生命周期
理解 TC BPF 程序的加载和挂载机制,才能正确地管理它。
加载步骤
# 1. 编译 BPF C 代码为 ELF 对象文件
clang -O2 -g -target bpf -c tc_filter.c -o tc_filter.o
# 2. 为目标设备创建 clsact qdisc(如果还没有)
tc qdisc add dev eth0 clsact
# 3. 挂载 BPF 程序到 TC ingress
tc filter add dev eth0 ingress bpf da obj tc_filter.o sec tc_ingress
# 4. 或者挂载到 TC egress
tc filter add dev eth0 egress bpf da obj tc_filter.o sec tc_egress这里的 clsact 是一个特殊的
qdisc(排队规则),专门为 BPF 分类器设计。它同时提供 ingress
和 egress 两个挂载点,不像老的 ingress qdisc
只有入方向。
da 参数的意思是 “direct-action”——让 BPF
程序的返回值直接作为 TC
动作码(TC_ACT_OK、TC_ACT_SHOT
等),而不是作为 classid 再去匹配 action。几乎所有现代 TC
BPF 用法都用 da 模式。
查看和卸载
# 查看已挂载的 BPF 程序
tc filter show dev eth0 ingress
tc filter show dev eth0 egress
# 卸载 ingress 上的所有 BPF 程序
tc filter del dev eth0 ingress
# 或者直接删除 clsact qdisc(同时卸载 ingress 和 egress)
tc qdisc del dev eth0 clsact用 bpftool 检查
# 查看系统中所有已加载的 BPF 程序
bpftool prog list
# 查看某个程序的详细信息
bpftool prog show id 42
# 导出程序的汇编指令
bpftool prog dump xlated id 42
# 查看 JIT 编译后的机器码
bpftool prog dump jited id 42在生产环境中,通常用 libbpf 的 C
API(bpf_tc_hook_create +
bpf_tc_attach)或 Go/Rust binding 来加载 BPF
程序,而不是调用 tc 命令行。libbpf 提供了完整的
TC hook 管理 API,可以在代码中创建 clsact qdisc、挂载和卸载
BPF 程序。
六、实战:TC BPF 包过滤器和计数器
理论讲够了,开始写代码。我们要实现一个 TC BPF 程序,挂在 veth pair 上,做两件事:
- 按协议类型(TCP/UDP/ICMP)统计包数量
- 选择性丢弃匹配特定目标端口的 TCP 包
BPF 程序
// tc_filter.c
#include <linux/bpf.h>
#include <linux/pkt_cls.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <linux/udp.h>
#include <linux/icmp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
// 协议统计计数器(per-CPU 避免锁竞争)
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 256); // 以协议号为 key
} proto_stats SEC(".maps");
// 丢弃端口集合
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, __u16); // 目标端口(网络序)
__type(value, __u8); // 非零 = 丢弃
__uint(max_entries, 1024);
} blocked_ports SEC(".maps");
// 丢弃计数器
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 1);
} drop_counter SEC(".maps");
static __always_inline void count_protocol(__u8 proto)
{
__u32 key = proto;
__u64 *count = bpf_map_lookup_elem(&proto_stats, &key);
if (count)
*count += 1;
}
static __always_inline void count_drop(void)
{
__u32 key = 0;
__u64 *count = bpf_map_lookup_elem(&drop_counter, &key);
if (count)
*count += 1;
}
SEC("tc_ingress")
int tc_pkt_filter(struct __sk_buff *skb)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
// 解析以太网头
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return TC_ACT_OK;
// 只处理 IPv4
if (eth->h_proto != bpf_htons(ETH_P_IP))
return TC_ACT_OK;
// 解析 IP 头
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end)
return TC_ACT_OK;
// 按协议类型计数
count_protocol(iph->protocol);
// 对 TCP 包检查是否需要丢弃
if (iph->protocol == IPPROTO_TCP) {
struct tcphdr *tcph = (void *)iph + (iph->ihl * 4);
if ((void *)(tcph + 1) > data_end)
return TC_ACT_OK;
// 查找目标端口是否在黑名单中
__u16 dport = tcph->dest; // 已经是网络序
__u8 *blocked = bpf_map_lookup_elem(&blocked_ports, &dport);
if (blocked && *blocked) {
count_drop();
return TC_ACT_SHOT; // 丢弃
}
}
return TC_ACT_OK; // 放行
}
char LICENSE[] SEC("license") = "GPL";这个程序的逻辑很清晰:
- 解析以太网头和 IP 头,做边界检查(BPF 验证器要求的)
- 用 IP 协议号作为 key,在
proto_statsmap 中递增计数 - 如果是 TCP 包,检查目标端口是否在
blocked_portsmap 中 - 命中则丢弃并计数,否则放行
用户态有两种方式和这个程序交互:一是用 tc
命令行加载程序、用 bpftool map update 写 map
条目;二是写一个 libbpf 用户态程序来做加载、map
读写和统计打印。下一节的实验用第一种方式,更容易上手。
七、eBPF 替代 iptables 的路线图
理解了 XDP、TC、BPF map 之后,我们可以来看一个更宏大的问题:eBPF 是如何一步步替代 iptables 的?
kube-proxy 的 iptables 问题
在标准的 Kubernetes 集群中,kube-proxy 负责 Service 的负载均衡。它的 iptables 模式有几个致命问题:
1. O(n) 规则匹配
每个 Service 的每个 endpoint 都对应几条 iptables 规则。一个有 5000 个 Service、每个 Service 平均 3 个 endpoint 的集群,会有约 45000 条 iptables 规则。每个包都要线性遍历这些规则。
规则数量 首包延迟
1,000 ~1 ms
5,000 ~5 ms
20,000 ~20 ms
2. 全量更新
iptables 不支持增量更新。每次有一个 endpoint 变更,kube-proxy 都要重写全部规则。在大规模集群中,一次规则更新可能需要几秒钟,期间内核锁住 iptables 子系统,新连接会被延迟。
3. conntrack 表膨胀
iptables 的 SNAT/DNAT 依赖 conntrack。每个连接在
conntrack 表中占一个条目(约 300
字节)。大规模微服务集群中,conntrack
表满溢(nf_conntrack: table full, dropping packet)是常见故障。
Cilium 的 kube-proxy replacement 架构
Cilium 用 eBPF 完全替代了 kube-proxy,核心思路是用 BPF map + TC/XDP 程序取代 iptables 规则:
- Service 负载均衡:agent 监听 K8s API 的
Service/Endpoint 变更,写入 BPF hash map。BPF 程序收包时做
O(1) hash lookup,命中后 DNAT 并用
bpf_redirect转发。不管有多少 Service 都一样快。 - Network Policy:每个 Pod 分配 security identity,策略编译为 map 条目,TC BPF 程序一次 map 查找判断是否允许。
- conntrack:自己维护 BPF LRU hash map
形式的 conntrack 表,不依赖内核
nf_conntrack模块。
替代路线图
| 阶段 | 替代的组件 | 关键技术 |
|---|---|---|
| 1 | kube-proxy iptables 规则 | TC BPF + LB map |
| 2 | kube-proxy IPVS 模式 | XDP LB + maglev hash |
| 3 | conntrack (nf_conntrack) | BPF LRU hash map |
| 4 | iptables Network Policy | BPF policy map + identity |
| 5 | iptables masquerade | TC egress BPF SNAT |
| 6 | kube-proxy 整体 | 完全移除 kube-proxy |
| 7 | iptables 所有用途 | 内核编译时关闭 netfilter |
Cilium 从 1.6 版本开始提供 kube-proxy replacement 功能,到 1.12 已经完全成熟。生产环境的经验表明,在大规模集群中(5000+ 节点),eBPF 数据面的性能比 iptables 好 1-2 个数量级。
还需要 iptables 吗
即使用了 Cilium,有些场景仍然需要 iptables:
- 宿主机防火墙规则(非 Kubernetes 管理的流量)
- 某些 CNI 插件的兼容层
- 遗留系统的 NAT 规则
但趋势很明确:iptables 在 Kubernetes 网络中的角色正在被 eBPF 全面取代。内核社区已经在讨论让 nf_conntrack 成为可选模块,并在纯 eBPF 数据面的节点上完全关闭 netfilter。
八、实验:veth 上的 TC BPF 包计数和选择性丢弃
这个实验把前面的知识串起来。我们创建一个 network namespace 模拟容器环境,用 veth pair 连接,然后在宿主机侧的 veth 上挂载 TC BPF 程序。
实验拓扑
Host netns "Pod" netns (ns-pod)
+-----------+ +-----------+
| veth-host | <--- veth pair ---> | veth-pod |
| 10.0.0.1 | | 10.0.0.2 |
+-----------+ +-----------+
|
TC ingress BPF
(包计数 + 端口过滤)
第一步:搭建环境
# 创建 network namespace
ip netns add ns-pod
# 创建 veth pair
ip link add veth-host type veth peer name veth-pod
# 把一端移入 namespace
ip link set veth-pod netns ns-pod
# 配置 IP 地址
ip addr add 10.0.0.1/24 dev veth-host
ip link set veth-host up
ip netns exec ns-pod ip addr add 10.0.0.2/24 dev veth-pod
ip netns exec ns-pod ip link set veth-pod up
ip netns exec ns-pod ip link set lo up
# 验证连通性
ping -c 2 10.0.0.2第二步:准备精简版 BPF 程序
如果你的环境没有完整的 libbpf 开发环境,可以用
tc 命令直接加载:
# 编译
clang -O2 -g -target bpf \
-D__TARGET_ARCH_x86 \
-c tc_filter.c -o tc_filter.o
# 为 veth-host 创建 clsact qdisc
tc qdisc add dev veth-host clsact
# 挂载 BPF 程序到 ingress
tc filter add dev veth-host ingress bpf da obj tc_filter.o sec tc_ingress
# 验证挂载成功
tc filter show dev veth-host ingress第三步:写入丢弃规则
用 bpftool 向 blocked_ports map
写入要阻止的端口:
# 找到 map ID
bpftool map list | grep blocked_ports
# 假设 map ID 是 42,写入端口 8080(网络序 = 0x901f)
bpftool map update id 42 key 0x90 0x1f hex value 0x01 hex
# 写入端口 3306(网络序 = 0xea0c)
bpftool map update id 42 key 0xea 0x0c hex value 0x01 hex也可以用一个简单的 Python 脚本来操作 map,避免手动计算网络序:
#!/usr/bin/env python3
import struct, subprocess, sys
def block_port(map_id, port):
key = ' '.join(f'0x{b:02x}' for b in struct.pack('!H', port))
cmd = f"bpftool map update id {map_id} key {key} hex value 0x01 hex"
print(f"Blocking port {port}: {cmd}")
subprocess.run(cmd, shell=True, check=True)
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <map_id> <port1> [port2] ...")
sys.exit(1)
for p in sys.argv[2:]:
block_port(int(sys.argv[1]), int(p))第四步:生成流量并观察
# 在 Pod namespace 中启动 TCP 服务器
ip netns exec ns-pod nc -lk 80 &
ip netns exec ns-pod nc -lk 8080 &
# 测试正常端口(应该成功)
echo "hello" | nc -w2 10.0.0.2 80
# 测试被阻止的端口(应该超时)
echo "hello" | nc -w2 10.0.0.2 8080
# 发送 ICMP(不受影响)
ping -c 3 10.0.0.2
# 发送 UDP
echo "test" | nc -u -w1 10.0.0.2 5353第五步:查看统计数据
# 查看协议统计 map
bpftool map dump id <proto_stats_map_id>
# 查看丢弃计数 map
bpftool map dump id <drop_counter_map_id>
# 持续监控(每 2 秒刷新)
watch -n 2 "bpftool map dump id <proto_stats_map_id> | head -20"第六步:清理
# 卸载 BPF 程序
tc qdisc del dev veth-host clsact
# 删除 veth pair(删除一端,另一端自动消失)
ip link del veth-host
# 删除 network namespace
ip netns del ns-pod实验中的关键观察
- 丢弃发生在 TC ingress,比 netfilter 早。被丢弃的包不会触发 conntrack、不会匹配 iptables 规则、不会进入路由判定。
- per-CPU 计数器没有锁竞争。即使在多核机器上高速收包,计数更新也不会成为瓶颈。
- map 更新是原子的。你可以在 BPF 程序运行的同时,从用户态添加或删除 blocked_ports 条目,不需要重新加载 BPF 程序。
- BPF
验证器确保安全。我们的程序中每次指针解引用前都做了边界检查(
(void *)(eth + 1) > data_end),这不是可选的好习惯——验证器会拒绝没有边界检查的程序。
九、进阶话题与常见陷阱
尾调用(tail call)
一个 BPF 程序最多 100 万条指令(从 Linux 5.2 开始,之前是
4096 条)。复杂的数据面逻辑可能超出这个限制。尾调用通过
BPF_MAP_TYPE_PROG_ARRAY 让一个 BPF
程序跳转到另一个,共享同一个栈帧:
struct {
__uint(type, BPF_MAP_TYPE_PROG_ARRAY);
__type(key, __u32);
__type(value, __u32);
__uint(max_entries, 8);
} jmp_table SEC(".maps");
SEC("tc_ingress")
int entry_prog(struct __sk_buff *skb)
{
// 跳转到子程序(索引 0),失败则继续执行
bpf_tail_call(skb, &jmp_table, 0);
return TC_ACT_OK;
}Cilium 大量使用尾调用来组织数据面逻辑:入口程序做 L2/L3 解析,然后根据包类型 tail call 到不同的处理程序(IPv4、IPv6、ARP 等)。
BTF 与 CO-RE
BTF(BPF Type Format)让 BPF
程序可以在不同内核版本之间移植(CO-RE,Compile Once Run
Everywhere)。内核在运行时根据 BTF
信息自动调整结构体字段的偏移量,即使内核版本变了、结构体布局变了,程序也能正确运行。生产环境的
TC BPF 程序强烈建议使用 BTF + vmlinux.h
来编写。
常见陷阱
1. 忘记边界检查
// 错误:验证器会拒绝
struct iphdr *iph = (void *)(eth + 1);
if (iph->protocol == IPPROTO_TCP) // 没检查边界就访问
...
// 正确
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end)
return TC_ACT_OK;
if (iph->protocol == IPPROTO_TCP)
...2. 变长 IP 头部
// 错误:假设 IP 头固定 20 字节
struct tcphdr *tcph = (void *)(iph + 1);
// 正确:用 ihl 字段计算实际长度
struct tcphdr *tcph = (void *)iph + (iph->ihl * 4);
if ((void *)(tcph + 1) > data_end)
return TC_ACT_OK;3.
字节序——网络协议头的字段都是网络序(大端),比较时用
bpf_htons(8080) 而不是裸数字
8080。
4. map 查找后不检查
NULL——bpf_map_lookup_elem 可能返回
NULL,验证器要求每次都检查。
5. 栈大小限制——BPF 程序的栈只有 512 字节。大结构体用 per-CPU array map 作为临时缓冲区,而不是栈上分配。
十、总结与延伸阅读
这篇文章覆盖了 eBPF 在网络栈中的五大挂载点、XDP 与 TC BPF
的差异、BPF map 的网络用法、bpf_redirect
三兄弟、TC BPF 程序的编写和加载,以及 eBPF 替代 iptables
的路线图。
几个关键要点:
- XDP 干脏活快活,TC 干细活精活。选对 hook 点比优化代码重要。
- BPF map 是 eBPF 数据面的灵魂。conntrack map、LB backend map、policy map 的设计决定了整个系统的性能和可维护性。
bpf_redirect三兄弟各有所长:基础版通用,peer 版优化 veth,neigh 版解决 L3 转发。- eBPF 替代 iptables 不是理论,而是 Cilium 在大规模生产环境中已经验证的事实。
下一篇 Kubernetes 网络模型,我们从 eBPF 数据面上升到 Kubernetes 网络模型的抽象层,看看 Pod IP、Service、CNI 的规范是怎么定义的。
延伸阅读
- XDP 详解——XDP 的三种模式、五种动作码、实战防火墙
- eBPF 与 Cilium 数据面——Cilium 如何用 eBPF 构建完整的 Kubernetes 网络
- eBPF 性能分析:bpftrace 实战——用 bpftrace 做内核级性能分析
- Netfilter 与 iptables——理解 eBPF 要替代的那个东西
- Cilium 官方文档:BPF and XDP Reference Guide
- 内核文档:Documentation/bpf/
- Brendan Gregg:BPF Performance Tools(O’Reilly)