这是一个实战系列文章,它是eBPF学习计划里面的应用场景之网络部分,终极目标是源码级别学习云原生网络方案Cilium(声明:下文提到的BPF字样是泛指,包括cBPF和eBPF)。 Cilium方案中大量使用了XDP、TC等网络相关的BPF hook,以实现高性能的网络RX和TX。 第一篇文章提到了XDP只能处理入站流量(正在接收的数据包)。为了处理出站流量(传输数据包出去),我们可以使用Traffic Control,简称TC,它是离网卡最近的可以控制全部流向的控制层。今天来实战TC BPF。
文章涉及的实验环境和代码可以到这个git repo获取:
https://github.com/nevermosby/linux-bpf-learning
TC全称「Traffic Control」,直译过来是「流量控制」,在这个领域,你可能更熟悉的是Linux iptables或者netfilter,它们都能做packet mangling,而TC更专注于packet scheduler,所谓的网络包调度器,调度网络包的延迟、丢失、传输顺序和速度控制。
使用并配置TC,为用户带来了对于网络包的可预测性,减少对于网络资源的争夺,实现对不同优先等级的网络服务分配网络资源(如带宽),达到互不干扰的目的,因此服务质量(QoS)一词经常被用作TC的代名词。
配置复杂性成为使用TC最显著的缺点,如果配置TC得当,可以使网络资源分配更加公平。但一旦它以不恰当的方式配置使用,可能会导致资源的进一步争夺。因此相比学习如何正确配置TC,很多IT企业可能会倾向购买更高的带宽资源,
TC有4大组件:
给大家上个图,了解下他们之间的关系:
TC的调度结构远不止这么简单,大家感兴趣可以到这里看看更为详细的介绍。
那么TC是怎么和BPF联系在一起的呢?
从内核4.1版本起,引入了一个特殊的qdisc,叫做clsact,它为TC提供了一个可以加载BPF程序的入口,使TC和XDP一样,成为一个可以加载BPF程序的网络钩子。
这两个钩子都可以用于相同的应用场景,如DDoS缓解、隧道、处理链路层信息等。但是,由于XDP在任何套接字缓冲区(SKB)分配之前运行,所以它可以达到比TC上的程序更高的吞吐量值。然而,后者可以从通过 struct __sk_buff
提供的额外的解析数据中受益,并且可以执行 BPF 程序,对入站流量和出站流量都可以执行 BPF 程序,是 TX 链路上的能被操控的最一层。
TC接受单个输入参数,类型为struct __sk_buff
。这个结构是一种UAPI(user space API of the kernel),允许访问内核中socket buffer内部数据结构中的某些字段。它具有与 struct xdp_md
相同意义两个指针,data
和data_end
,同时还有更多信息可以获取,这是因为在TC层面上,内核已经解析了数据包以提取与协议相关的元数据,因此传递给BPF程序的上下文信息更加丰富。结构 __sk_buff
的整个声明如下所说,可以在 include/uapi/linux/bpf.h 文件中看到,下面是结构体的定义,比XDP的要多出很多信息,这就是为什么说TC层的吞吐量要比XDP小了,因为实例化一堆信息需要很大的cost。
* user accessible mirror of in-kernel sk_buff.
* new fields can only be added to the end of this structure
*/
struct __sk_buff {
__u32 len;
__u32 pkt_type;
__u32 mark;
__u32 queue_mapping;
__u32 protocol;
__u32 vlan_present;
__u32 vlan_tci;
__u32 vlan_proto;
__u32 priority;
__u32 ingress_ifindex;
__u32 ifindex;
__u32 tc_index;
__u32 cb[5];
__u32 hash;
__u32 tc_classid;
__u32 data;
__u32 data_end;
__u32 napi_id;
/* Accessed by BPF_PROG_TYPE_sk_skb types from here to ... */
__u32 family;
__u32 remote_ip4; /* Stored in network byte order */
__u32 local_ip4; /* Stored in network byte order */
__u32 remote_ip6[4]; /* Stored in network byte order */
__u32 local_ip6[4]; /* Stored in network byte order */
__u32 remote_port; /* Stored in network byte order */
__u32 local_port; /* stored in host byte order */
/* ... here. */
__u32 data_meta;
__bpf_md_ptr(struct bpf_flow_keys *, flow_keys);
__u64 tstamp;
__u32 wire_len;
__u32 gso_segs;
__bpf_md_ptr(struct bpf_sock *, sk);
};
和XDP一样,TC的输出代表了数据包如何被处置的一种动作。它的定义在include/uapi/linux/pkt_cls.h找到。最新的内核版本里定义了9种动作,其本质是int类型的值,以下是5种常用动作:
上文提到了,XDP是RX链路的第一层,TC是TX链路上的第一层,那么我们就设计一个同时使用这两个hook的程序,让他们一起发光发热,这次我们把流量粒度控制得更细点,实现把双向TCP流量都drop掉。
为了更贴近系列文章的初心——了解并学习容器网络Cilium的工作原理,我们这次拿容器实例作为流控目标。在实验环境上通过docker run
运行一个Nginx服务:
docker run -d -p 80:80 --name=nginx-xdp nginx:alpine
这样主机层就会多出一个veth网络设备,与容器里的eth0形成veth pair,流量都是通过这对veth pair。因此我们可以将XDP程序attach到主机层的veth网络设备上,以此控制容器流量:
> ip a | grep veth
6: veth09e1d2e@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
# 加载XDP BPF程序
> ip link set dev veth09e1d2e xdp obj tc-xdp-drop-tcp.o sec xdp
由于我们需要测试双向流量,即也测试从容器内部访问外部网站,因此需要在容器内部执行curl命令,本人整理一篇如何从容器内部curl外部服务的文章,大家请移步查阅,这里不再赘述。
这次我们先看实战结果,再来分析代码。
从Demo视频中看到,实现丢弃TCP流量的XDP程序attach到veth设备上后,就无法从外部访问基于HTTP协议的Nginx服务了(HTTP基于TCP)。
2. 在Nginx容器内部curl外部网站
可以看到视频在容器内访问百度网站是可达的,所以XDP程序不会影响从目标网卡出去的数据包。
从上面的例子可以看到XDP BPF程序只会对传给目标网卡的数据包进行丢弃,不会影响从目标网卡出去的数据包,也就是只影响ingress流量。
如同XDP BPF程序可以通过ip命令进行加载,只要你安装了iproute2,也可以通过tc命令加载TC BPF程序。上文提到的了TC控制的单元是qdisc,用来加载BPF程序是个特殊的qdisc 叫clsact,示例命令如下所示:
# 为目标网卡创建clsact
tc qdisc add dev [network-device] clsact
# 加载bpf程序
tc filter add dev [network-device] <direction> bpf da obj [object-name] sec [section-name]
# 查看
tc filter show dev [network-device] <direction>
简单说明下:
direct-action | da
instructs eBPF classifier to not invoke external TC actions, instead use the TC actions return codes (TC_ACT_OK, TC_ACT_SHOT etc.) for classifiers.
是不是看得一头雾水,这里先按下不表。
结合我们刚刚运行的Nginx容器实例,下面是实现TC BPF控制Egress的真真实命令:
# 最开始的状态
> tc qdisc show dev veth09e1d2e
qdisc noqueue 0: root refcnt 2
# 创建clsact
> tc qdisc add dev veth09e1d2e clsact
# 再次查看,观察有什么不同
> tc qdisc show dev veth09e1d2e
qdisc noqueue 0: root refcnt 2
qdisc clsact ffff: parent ffff:fff1
# 加载TC BPF程序到容器的veth网卡上
> tc filter add dev veth09e1d2e egress bpf da obj tc-xdp-drop-tcp.o sec tc
# 再次查看,观察有什么不同
> tc qdisc show dev veth09e1d2e
qdisc noqueue 0: root refcnt 2
qdisc clsact ffff: parent ffff:fff1
> tc filter show dev veth09e1d2e egress
filter protocol all pref 49152 bpf chain 0
filter protocol all pref 49152 bpf chain 0 handle 0x1 tc-xdp-drop-tcp.o:[tc] direct-action not_in_hw id 24 tag 9c60324798bac8be jited
TC BPF程序加载好了,配上刚才控制Ingress的XDP程序,我们来看看效果:
「Talk is cheap, show me the code」,来上代码。
// tc-xdp-drop-tcp.c
#include <stdbool.h>
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
#include <linux/pkt_cls.h>
#include "bpf_endian.h"
#include "bpf_helpers.h"
/*
check whether the packet is of TCP protocol
*/
static bool is_TCP(void *data_begin, void *data_end){
struct ethhdr *eth = data_begin;
// Check packet's size
// the pointer arithmetic is based on the size of data type, current_address plus int(1) means:
// new_address= current_address + size_of(data type)
if ((void *)(eth + 1) > data_end) //
return false;
// Check if Ethernet frame has IP packet
if (eth->h_proto == bpf_htons(ETH_P_IP))
{
struct iphdr *iph = (struct iphdr *)(eth + 1); // or (struct iphdr *)( ((void*)eth) + ETH_HLEN );
if ((void *)(iph + 1) > data_end)
return false;
// Check if IP packet contains a TCP segment
if (iph->protocol == IPPROTO_TCP)
return true;
}
return false;
}
SEC("xdp")
int xdp_drop_tcp(struct xdp_md *ctx)
{
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
if (is_TCP(data, data_end))
return XDP_DROP;
return XDP_PASS;
}
SEC("tc")
int tc_drop_tcp(struct __sk_buff *skb)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
if (is_TCP(data, data_end))
return TC_ACT_SHOT;
return TC_ACT_OK;
}
char _license[] SEC("license") = "GPL";
上面的代码里已经添加了相关注释帮助大家理解,我再针对几个关键点作下说明:
is_TCP
功能函数,判断是否是TCP网络包,供两个Section调用。这样做的好处是只要维护一份代码文件,根据Section名称,分别给XDP hook和TC hook加载。bpf_endian.h
」和「bpf_helpers
」是本地引用的头文件,其实是从内核代码的这个位置复制过来的,这个是参照了sample/bpf里面示例代码的做法。它们都是工具类函数集合,在写复杂逻辑时非常有用。is_TCP
这个函数里,有如下这一段:if ((void *)(eth + 1) > data_end)
return false;
这个代码段第一次看肯定觉得有点无厘头。。。其实它有两层含义:
eth+1
是个非常有趣的表达式,它的本质是指针运算,指针变量+1
就是指针向右移动n个字节,这个n为该指针变量指向的对象类型的字节长度,这里就是struct ethhdr
的字节长度,为14个字节,可以在这个内核头文件里找到相关定义:struct ethhdr {
// ETH_ALEN 为6个字节
unsigned char h_dest[ETH_ALEN]; /* destination eth addr */
unsigned char h_source[ETH_ALEN]; /* source ether addr */
// __be16 为16 bit,也就是2个字节
__be16 h_proto; /* packet type ID field */
}
// 所以整个struct就是14个字节长度。
如果不使用指针运算,还是作显式的长度判断,如下所示:
u64 h_offset;
struct ethhdr *eth = data;
// 显式声明并赋值ethhdr长度
h_offset = sizeof(*eth);
// 根据左右变量类型,运算符号加号重载成相关运算机制
if (data + h_offset > data_end)
return false;
另外,注意观察(eth + 1)
前面加了一个显示类型转换,如果不做这个操作,编译时会有如下warning。代码里其他类似这样的显示类型转换都是出于规避编译warning的考虑。
warning: comparison of distinct pointer types
('struct ethhdr *' and 'void *') [-Wcompare-distinct-pointer-types]
if (eth + 1 > data_end)
~~~~~~~ ^ ~~~~~~~~
1 warning generated.
data
变量获取到IP头,如下代码为获取IP头:struct iphdr *iph = (struct iphdr *)(eth + 1);
因此需要判断这个右移结果是否有效,如果无效,就直接return出去了,防止内存越界。类似的右移判断逻辑在BPF程序里出现频次会很高,大家自己写BPF的时候,一定要做好边界判断逻辑。
跟XDP程序一样,可以使用clang进行编译,不同之处是由于引用了本地头文件,所以需要加上-I
参数,指定头文件所在目录:
clang -I ./headers/ -O2 -target bpf -c tc-xdp-drop-tcp.c -o tc-xdp-drop-tcp.o
将编译成功后输出的tc-xdp-drop-tcp.o
文件,通过tc命令行加载到指定网卡设备上去。下面是使用verbose模式后的加载结果,可以看到BPF验证器通过检查tc-xdp-drop-tcp.o
文件包含的BPF instructions,保障了加载到内核的安全性。
> tc filter add dev veth09e1d2e egress bpf da obj tc-xdp-drop-tcp.o sec tc verbose
Prog section 'tc' loaded (5)!
- Type: 3
- Instructions: 19 (0 over limit)
- License: GPL
Verifier analysis:
0: (61) r2 = *(u32 *)(r1 +80)
1: (61) r1 = *(u32 *)(r1 +76)
2: (bf) r3 = r1
3: (07) r3 += 14
4: (2d) if r3 > r2 goto pc+12
R1=pkt(id=0,off=0,r=14,imm=0) R2=pkt_end(id=0,off=0,imm=0) R3=pkt(id=0,off=14,r=14,imm=0) R10=fp0
5: (bf) r3 = r1
6: (07) r3 += 34
7: (2d) if r3 > r2 goto pc+9
R1=pkt(id=0,off=0,r=34,imm=0) R2=pkt_end(id=0,off=0,imm=0) R3=pkt(id=0,off=34,r=34,imm=0) R10=fp0
8: (71) r2 = *(u8 *)(r1 +13)
9: (67) r2 <<= 8
10: (71) r3 = *(u8 *)(r1 +12)
11: (4f) r2 |= r3
12: (57) r2 &= 65535
13: (55) if r2 != 0x8 goto pc+3
R1=pkt(id=0,off=0,r=34,imm=0) R2=inv8 R3=inv(id=0,umax_value=255,var_off=(0x0; 0xff)) R10=fp0
14: (b7) r0 = 2
15: (71) r1 = *(u8 *)(r1 +23)
16: (15) if r1 == 0x6 goto pc+1
R0=inv2 R1=inv(id=0,umax_value=255,var_off=(0x0; 0xff)) R2=inv8 R3=inv(id=0,umax_value=255,var_off=(0x0; 0xff)) R10=fp0
17: (b7) r0 = 0
18: (95) exit
from 16 to 18: R0=inv2 R1=inv6 R2=inv8 R3=inv(id=0,umax_value=255,var_off=(0x0; 0xff)) R10=fp0
18: (95) exit
from 13 to 17: safe
from 7 to 17: safe
from 4 to 17: safe
processed 23 insns, stack depth 0
刚刚用到了一个参数da,它的全称是「direct action」。其实它是TC支持BPF后的「亲密合作」的产物。
对于tc filter来说,一般在命中过滤条件后需要指定下一步操作动作,如:
# 一个没有使用bpf的tc filter
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
match ip src 1.2.3.4 action drop
注意到这个tc filter后面跟了一个action drop,意思是命中过滤条件后将网络包丢弃,而这个操作动作如果我们使用BPF程序,其实就是已经定义在程序里了。为了避免重复指定,内核引入了da模式,告诉TC请repect BPF程序提供的返回值,无需再手动指定action了,节省了调用action模块的开销,这也是目前TC with BPF的推荐做法。这篇文章对此作了详细介绍。
当我开开心心准备结束本次实验时,突然发现当我停止了上面实验中的XDP ingress hook,只保留TC egress hook时,使用命令curl localhost
也是无法访问Nginx容器服务的?(读者也可以自行试试)这是为什么呢?
需要解开这个问题,我们就需要调试BPF程序了。