土法炼钢兴趣小组的算法知识备份

eBPF 网络编程:从 XDP 到 TC,可编程数据面入门

目录

你大概已经听过 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 网络程序的前提。

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_OPSBPF_PROG_TYPE_SK_MSG

操作对象bpf_sock_opssk_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, &params, 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() 使用效果更好(批量转发,减少锁竞争)。

工作流程

  1. BPF 程序调用 bpf_redirect(target_ifindex, 0)
  2. 返回 TC_ACT_REDIRECT(TC)或 XDP_REDIRECT(XDP)
  3. 内核把包送到目标设备的 ingress 路径
  4. 目标设备上的 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_peerbpf_redirect15-25%,因为少了一次 __netif_receive_skb 的完整处理。

限制

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 在不同场景下组合使用这三个函数:


五、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_OKTC_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 上,做两件事:

  1. 按协议类型(TCP/UDP/ICMP)统计包数量
  2. 选择性丢弃匹配特定目标端口的 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";

这个程序的逻辑很清晰:

  1. 解析以太网头和 IP 头,做边界检查(BPF 验证器要求的)
  2. 用 IP 协议号作为 key,在 proto_stats map 中递增计数
  3. 如果是 TCP 包,检查目标端口是否在 blocked_ports map 中
  4. 命中则丢弃并计数,否则放行

用户态有两种方式和这个程序交互:一是用 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 规则:

替代路线图

阶段 替代的组件 关键技术
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:

但趋势很明确: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

第三步:写入丢弃规则

bpftoolblocked_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

实验中的关键观察

  1. 丢弃发生在 TC ingress,比 netfilter 早。被丢弃的包不会触发 conntrack、不会匹配 iptables 规则、不会进入路由判定。
  2. per-CPU 计数器没有锁竞争。即使在多核机器上高速收包,计数更新也不会成为瓶颈。
  3. map 更新是原子的。你可以在 BPF 程序运行的同时,从用户态添加或删除 blocked_ports 条目,不需要重新加载 BPF 程序。
  4. 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 的路线图。

几个关键要点:

  1. XDP 干脏活快活,TC 干细活精活。选对 hook 点比优化代码重要。
  2. BPF map 是 eBPF 数据面的灵魂。conntrack map、LB backend map、policy map 的设计决定了整个系统的性能和可维护性。
  3. bpf_redirect 三兄弟各有所长:基础版通用,peer 版优化 veth,neigh 版解决 L3 转发。
  4. eBPF 替代 iptables 不是理论,而是 Cilium 在大规模生产环境中已经验证的事实。

下一篇 Kubernetes 网络模型,我们从 eBPF 数据面上升到 Kubernetes 网络模型的抽象层,看看 Pod IP、Service、CNI 的规范是怎么定义的。

延伸阅读


By .