實現一個基於XDP_eBPF的學習型網橋
- 2019 年 11 月 13 日
- 筆記
eBPF技術風靡當下,eBPF位元組碼正以星火燎原之勢被HOOK在Linux內核中越來越多的位置,在這些HOOK點上,我們可以像編寫普通應用程式一樣編寫內核的HOOK程式,與以往為了實現一個功能動輒patch一整套邏輯框架程式碼(比如Netfilter)相比,eBPF的工作方式非常靈活。
我們先來看一下目前eBPF的一些重要HOOK點:

將來這個is_XXX序列肯定會不斷增加,布滿整個內核(有點密集恐懼症癥狀了…)。
本文將描述如何用eBPF實現一個學習型網橋的快速轉發,並將其部署在XDP。
在開始之前,為了讓所有人都能看懂本文,我們先來回顧一些前置知識,如果暫時還不懂這些前置知識,沒關係,先把程式run起來是一個很好的起點,如果到時候你覺得沒意思,再放棄也不遲。
前置知識
- 什麼是BPF和eBPF
簡單來講,BPF是一套完整的 電腦體系結構 。和x86,ARM這些類似,BPF包含自己的指令集和運行時邏輯,同理,就像在x86平台編程,最終要落實到x86彙編指令一樣,BPF位元組碼也可以看成是彙編指令的序列。我們通過tcpdump的-d/-dd參數可見一斑:
[root@localhost ~]# tcpdump -i any tcp and host 1.1.1.1 -d(000) ldh [14](001) jeq #0x86dd jt 10 jf 2(002) jeq #0x800 jt 3 jf 10(003) ldb [25](004) jeq #0x6 jt 5 jf 10(005) ld [28](006) jeq #0x1010101 jt 9 jf 7(007) ld [32](008) jeq #0x1010101 jt 9 jf 10(009) ret #262144(010) ret #0[root@localhost ~]#
BPF的歷史非常古老,早在1992年就被構建出來了,其背後的思想是, 「與其把數據包複製到用戶空間執行用戶態程式過濾,不如把過濾程式灌進內核去。」
遺憾的是,BPF後來並沒有大行其道,只是被應用於非常有限的並不起眼的比如抓包層面。因此,由於它的語法並不複雜,人們直接手寫BPF彙編指令碼經簡單封裝即可生成最終的位元組碼。
當人們認識到BPF非常強壯的功能並準備將其大用時,指令系統以及作業系統內核均已經持續進化了好多年,這意味著簡單的BPF不能再滿足需要,它需要 「被複雜化」 。
於是就出現了eBPF,即extended BPF。總體而言,eBPF相比BPF有了以下改進:1. 更複雜的指令系統。2. 更多可調用的函數。3. … 詳情可參見下面的鏈接:https://lwn.net/Articles/740157/
就像彙編語言進化到C語言一樣,直接手寫eBPF位元組碼顯得即笨拙又低效,於是人們開始使用C語言直接編寫eBPF程式,然後用編譯器將其編譯成eBPF位元組碼。遺憾的是,目前eBPF體系結構還不被gcc支援,不過很快就會支援了。我們不得不使用 特定的編譯器 來編譯eBPF的C程式碼,比如clang。
- 什麼是XDP
XDP,即eXpress Data Path,它其實是位於網卡驅動程式里的一個快速處理數據包的HOOK點,為什麼快?基於以下兩點:
- 數據包處理位置非常底層,避開了很多內核skb處理開銷。
- 可以將很多處理邏輯Offload到網卡硬體。
顯而易見,在XDP這個HOOK點灌進來一點eBPF位元組碼,將是一件令人愉快的事情。
- 學習型網橋
Linux的Bridge模組就是一個學習型網橋,其實就是一個現代交換式乙太網交換機,它可以從埠學習到MAC地址,在內部生成MAC/埠映射表,以優化轉發效率。
本文我們將用eBPF實現的網橋就是一個學習型網橋,並且它的數據路徑和控制路徑相分離,用eBPF位元組碼實現的正是其數據路徑,它將被灌入XDP,而控制路徑則由一個用戶態程式實現。
- 如何編譯eBPF程式
理論的學習自在平時,當打開電腦的時候,最快的速度run起來一些東西令人愉悅。我們不想花大量的時間在環境的搭建上。對於eBPF程式,內核源碼樹的samples/bpf目錄將是一個非常好的起點。
以我自己的環境為例,我使用的是Ubuntu 19.10發行版,5.3.0-19-generic內核,安裝源碼後,編譯之,最後編譯samples/bpf即可:
root@zhaoya-VirtualBox:/usr/src/linux-source-5.3.0/linux-source-5.3.0/samples/bpf# makemake -C ../../ /usr/src/linux-source-5.3.0/linux-source-5.3.0/samples/bpf/ BPF_SAMPLES_PATH=/usr/src/linux-source-5.3.0/linux-source-5.3.0/samples/bpfmake[1]: Entering directory '/usr/src/linux-source-5.3.0/linux-source-5.3.0' CALL scripts/checksyscalls.sh CALL scripts/atomic/check-atomics.sh DESCEND objtool ...
samples/bpf目錄下的程式碼都是比較典型的範例,我們照貓畫虎就能實現我們想要的功能。
大體上,每一個範例均由兩個部分組成:
- XXX_kern.c文件:eBPF位元組碼本身。
- XXX_user.c文件:用戶態控制程式,控制eBPF位元組碼的注入,更新。
即然我們要實現一個網橋,那麼文件名我們可以確定為:
- xdpbridgekern.c
- xdpbridgeuser.c
同時我們修改Makefile文件,加入這兩個文件即可:
root@zhaoya-VirtualBox: samples/bpf# cat Makefile...hostprogs-y += xdp2hostprogs-y += xdp_bridgehostprogs-y += xdp_router_ipv4...xdp_bridge-objs := xdp_bridge_user.oxdp_router_ipv4-objs := xdp_router_ipv4_user.o...always += xdp2_kern.oalways += xdp_bridge_kern.oalways += xdp_router_ipv4_kern.o
網橋XDP快速轉發的實現
對上述前置知識有了充分的理解之後,程式碼就非常簡單了,我們剩下的工作就是填充xdpbridgekern.c和xdpbridgeuser.c兩個C文件,然後make它們。
我們先來看xdpbridgekern.c文件:
// xdp_bridge_kern.c#include <uapi/linux/bpf.h>#include <linux/if_ether.h>#include "bpf_helpers.h" // mac_port_map保存該交換機的MAC/埠映射struct bpf_map_def SEC("maps") mac_port_map = { .type = BPF_MAP_TYPE_HASH, .key_size = sizeof(long), .value_size = sizeof(int), .max_entries = 100,}; // 以下函數是網橋轉發路徑的eBPF主函數實現SEC("xdp_br")int xdp_bridge_prog(struct xdp_md *ctx){ void *data_end = (void *)(long)ctx->data_end; void *data = (void *)(long)ctx->data; long dst_mac = 0; int in_index = ctx->ingress_ifindex, *out_index; // data即數據包開始位置 struct ethhdr *eth = (struct ethhdr *)data; char info_fmt[] = "Destination Address: %lx Redirect to:[%d] From:[%d]n"; // 畸形包必須丟棄,否則無法通過內核的eBPF位元組碼合法性檢查 if (data + sizeof(struct ethhdr) > data_end) { return XDP_DROP; } // 獲取目標MAC地址 __builtin_memcpy(&dst_mac, eth->h_dest, 6); // 在MAC/埠映射表裡查找對應該MAC的埠 out_index = bpf_map_lookup_elem(&mac_port_map, &dst_mac); if (out_index == NULL) { // 如若找不到,則上傳到慢速路徑,必要時由控制路徑更新MAC/埠表項。 return XDP_PASS; } // 非Hairpin下生效 if (in_index == *out_index) { // Hairpin ? return XDP_DROP; } // 簡單列印些調試資訊 bpf_trace_printk(info_fmt, sizeof(info_fmt), dst_mac, *out_index, in_index); // 轉發到出埠 return bpf_redirect(*out_index, 0);} char _license[] SEC("license") = "GPL";
這裡有必要說一下內核對eBPF程式的合法性檢查,這個檢查一點都不多餘,它確保你的eBPF程式碼是安全的。這樣才不會造成內核數據結構被破壞掉,否則,如果任意eBPF程式都能注入內核,那結局顯然是細思極恐的。
現在繼續我們的用戶態C程式碼:
// xdp_bridge_user.c #include <stdio.h>#include <signal.h>#include <sys/socket.h>#include <net/if.h>#include <bpf/bpf.h>#include <linux/bpf.h>#include <linux/rtnetlink.h>#include "bpf_util.h" int flags = XDP_FLAGS_UPDATE_IF_NOEXIST;static int mac_port_map_fd;static int *ifindex_list; // 退出時卸載掉XDP的eBPF位元組碼static void int_exit(int sig){ int i = 0; for (i = 0; i < 2; i++) { bpf_set_link_xdp_fd(ifindex_list[i], -1, 0); } exit(0);} int main(int argc, char *argv[]){ int sock, i; char buf[1024]; char filename[64]; static struct sockaddr_nl g_addr; struct bpf_object *obj; struct bpf_prog_load_attr prog_load_attr = { // prog_type指明eBPF位元組碼注入的位置,我們網橋的例子中當然是XDP .prog_type = BPF_PROG_TYPE_XDP, }; int prog_fd; snprintf(filename, sizeof(filename), "xdp_bridge_kern.o"); prog_load_attr.file = filename; // 載入eBPF位元組碼 if (bpf_prog_load_xattr(&prog_load_attr, &obj, &prog_fd)) { return 1; } mac_port_map_fd = bpf_object__find_map_fd_by_name(obj, "mac_port_map"); ifindex_list = (int *)calloc(2, sizeof(int *)); // 我們的例子中僅僅支援兩個埠的網橋,事實上可以多個。 ifindex_list[0] = if_nametoindex(argv[1]); ifindex_list[1] = if_nametoindex(argv[2]); for (i = 0; i < 2/*total */; i++) { // 將eBPF位元組碼注入到感興趣網卡的XDP if (bpf_set_link_xdp_fd(ifindex_list[i], prog_fd, flags) < 0) { printf("link set xdp fd failedn"); return 1; } } signal(SIGINT, int_exit); bzero(&g_addr, sizeof(g_addr)); g_addr.nl_family = AF_NETLINK; g_addr.nl_groups = RTM_NEWNEIGH; if ((sock = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE)) < 0) { int_exit(0); return -1; } if (bind(sock, (struct sockaddr *) &g_addr, sizeof(g_addr)) < 0) { int_exit(0); return 1; } // 持續監聽socket,捕獲Linux網橋上傳的notify資訊,從而更新,刪除eBPF的map里特定的MAC/埠表項 while (1) { int len; struct nlmsghdr *nh; struct ndmsg *ifimsg ; int ifindex = 0; unsigned char *cmac; unsigned long lkey = 0; len = recv(sock, buf, sizeof(buf), 0); if (len <= 0) continue; for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len); nh = NLMSG_NEXT(nh, len)) { ifimsg = NLMSG_DATA(nh) ; if (ifimsg->ndm_family != AF_BRIDGE) { continue; } // 獲取notify資訊中的埠 ifindex = ifimsg->ndm_ifindex; for (i = 0; i < 2; i++) { if (ifindex == ifindex_list[i]) break; } if (i == 2) continue; // 獲取notify資訊中的MAC地址 cmac = (unsigned char *)ifimsg + sizeof(struct ndmsg) + 4; memcpy(&lkey, cmac, 6); if (nh->nlmsg_type == RTM_DELNEIGH) { bpf_map_delete_elem(mac_port_map_fd, (const void *)&lkey); printf("Delete XDP bpf map-[HW Address:Port] item Key:[%lx] Value:[%d]n", lkey, ifindex); } else if (nh->nlmsg_type == RTM_NEWNEIGH) { bpf_map_update_elem(mac_port_map_fd, (const void *)&lkey, (const void *)&ifindex, 0); printf("Update XDP bpf map-[HW Address:Port] item Key:[%lx] Value:[%d]n", lkey, ifindex); } } }}
用戶態程式同樣很容易理解。
數據面和控制面分離,這是網路設備的標準路數,幾十年前就這樣了,如今我們也能簡單實現一個了,很有趣不是嗎?
run起來
執行make之後,我們可以得到可執行文件xdpbridge以及eBPF位元組碼文件xdpbridge_kern.o,在當前目錄下直接執行即可:
root@zhaoya-VirtualBox:samples/bpf# ./xdp_bridge enp0s9 enp0s10
在另一個終端查看eBPF位元組碼里的map,即MAC/埠映射表:
root@zhaoya-VirtualBox:/home/zhaoya# bpftool p |tail -n 4166: xdp name xdp_bridge_prog tag 956a68e9ac54a0b3 gpl loaded_at 2019-11-08T01:14:46+0800 uid 0 xlated 576B jited 340B memlock 4096B map_ids 105 btf_id 114root@zhaoya-VirtualBox:/home/zhaoya# bpftool map dump id 105Found 0 elementsroot@zhaoya-VirtualBox:/home/zhaoya#
OK,一切順利。現在讓我們正式用它搭建一個網橋吧。
暫時X掉xdp_bridge程式的運行,讓我們一步一步來。
首先構建下面的拓撲:

中間的Linux Bridge主機(後面簡稱主機B)的enp0s9,enp0s10網卡將是我們注入eBPF位元組碼的位置。
現在讓我們在主機B上創建一個標準的Linux網橋:
brctl addbr br0;brctl addif br0 enp0s9;brctl addif br0 enp0s10;ifconfig br0 up;
在主機H1和主機H2的enp0s9上配置同網段的地址:
H1-enp0s9:40.40.40.201/24H2-enp0s9:40.40.40.100/24
互相ping確認是通的,並且主機B的enp0s9/enp0s10可以抓到雙向包,這說明主機B的Linux標準網橋工作是OK的。
接下來,停掉這一切,把br0也刪除掉。重新運行xdpbridge程式,確認OK後創建Linux標準網橋,從H1來ping H2,很暢通,同時我們會發現主機B的xdpbridge程式的輸出:
root@zhaoya-VirtualBox:/usr/src/linux-source-5.3.0/linux-source-5.3.0/samples/bpf# ./xdp_bridge enp0s9 enp0s10Update XDP bpf map-[HW Address:Port] item Key:[683dbb270008] Value:[4]Update XDP bpf map-[HW Address:Port] item Key:[683dbb270008] Value:[4]Update XDP bpf map-[HW Address:Port] item Key:[e7f09f270008] Value:[5]Update XDP bpf map-[HW Address:Port] item Key:[e7f09f270008] Value:[5] Update XDP bpf map-[HW Address:Port] item Key:[e6f09f270008] Value:[4]
很顯然,eBPF的map學習到了新的MAC地址,我們可以用bpftool確認:
root@zhaoya-VirtualBox:~# bpftool p |tail -n 4170: xdp name xdp_bridge_prog tag 956a68e9ac54a0b3 gpl loaded_at 2019-11-08T01:26:19+0800 uid 0 xlated 576B jited 340B memlock 4096B map_ids 107 btf_id 117root@zhaoya-VirtualBox:~# bpftool map dump id 107key: 08 00 27 9f f0 e7 00 00 value: 05 00 00 00key: 08 00 27 9f f0 e6 00 00 value: 04 00 00 00key: 08 00 27 bb 3d 68 00 00 value: 04 00 00 00Found 3 elements
此時,主機B的enp0s9和enp0s10就抓不到任何H1和H2之間單播包了。廣播包仍然會被上傳到慢速路徑被標準Linux網橋處理。
我們看trace日誌:
root@zhaoya-VirtualBox:~# cat /sys/kernel/debug/tracing/trace_pipe <idle>-0 [003] ..s. 44274.198178: 0: Destination Address: e6f09f270008 Redirect to:[4] From:[5] ...
雖然主機B的網卡上沒有抓到包,但如何確保數據包真的就是從XDP的eBPF位元組碼轉發走的而不是直接飛過去的呢?
很好的問題,這作為下一個練習不是更好嗎?嗯,你應該試試加一個統計功能,而這個並不複雜。
資源與引用
本文只是拋磚引玉,如果覺得不過癮,是時候就著啤酒或咖啡可樂讀一下下面的資源了:
https://arthurchiao.github.io/blog/cilium-bpf-xdp-reference-guide-zh/
https://docs.cilium.io/en/v1.6/bpf/
https://github.com/tklauser/filter2xdp
https://klyr.github.io/posts/ebpf/https://linux.cn/article-9507-1.html
https://jvns.ca/blog/2017/06/28/notes-on-bpf—ebpf/
… 對了,如果你在使用VirtualBox搭建橋接環境遇到問題的時候,請參考這篇:https://blog.csdn.net/dog250/article/details/102972031