# Linux 核心專題: 以 eBPF 建構 TCP 伺服器 > 執行人: YSRossi > [專題解說錄影](https://youtu.be/TNd8F2bNkAQ) ## 任務簡述 依據 [ktcp](https://hackmd.io/@sysprog/linux2023-ktcp) 的指示,我們可在 [sysprog21/khttpd](https://github.com/sysprog21/khttpd) 的程式碼基礎之上,打造出高效且穩定的網頁伺服器,不過 Linux 核心模組建構和維護的成本極高,本任務嘗試以 [eBPF](https://hackmd.io/@sysprog/linux-ebpf) 來建構 TCP 伺服器。需要確保在 Linux v5.15+ 運作。 相關資訊: * [ebpf-proxy-test](https://github.com/bhhbazinga/ebpf-proxy-test): Compare a proxy-server using ebpf sockmap redirect with a normal proxy server using poll. * [ebpf-summit-2020](https://github.com/jsitnicki/ebpf-summit-2020): Steering connections to sockets with BPF socket lookup hook * [每秒 1 百萬個封包傳輸](https://colobu.com/2023/04/02/support-1m-pps-with-zero-cpu-usage/) 需要自行安裝 [libbpf](https://github.com/libbpf/libbpf) ## TODO: 學習 eBPF 及 Linux 核心封包管理 研讀 [BPF Documentation](https://www.kernel.org/doc/html/latest/bpf/),留意 Linux 核心封包的處理機制,特別是 sockmap,對照閱讀 [How to use eBPF for accelerating Cloud Native applications](https://hackmd.io/@Shawn5141/2022q1-final-project)。 > 對照 [Some Optimizations of Linux Network Stack](https://nan01ab.github.io/2019/10/Linux-NetStack.html) ## TODO: 利用 eBPF 建構簡易的 echo server 修改 [ebpf-summit-2020](https://github.com/jsitnicki/ebpf-summit-2020) 並實作簡易的 echo 伺服器,並比較 [kecho](https://github.com/sysprog21/kecho) 及使用 epoll 系統的應用程式。 ## TODO: 改進 eBPF TCP 伺服器的效率 ## Extended Berkeley Packet Filter (eBPF) Extended Berkeley Packet Filter (eBPF) 是從 Berkeley Packet Filter (BPF) 發展而來。BPF 最初是爲了過濾封包而創造的,但隨着功能的擴充變成 eBPF 後,功能已不限於過濾封包。 一般來說,想要在核心增加新功能,需要寫 kernel module 或修改核心程式碼來達成。而 BPF 可以不透過上述方法就能辦到,BPF 允許在 user mode 撰寫 BPF 程式碼後,編譯成 BPF bytecode 位於核心執行。BPF 提出了一種在核心內完成過濾封包的方法。BPF 可以說是在核心內的虛擬機器,根據虛擬機器判斷該封包是否符合條件,符合條件的話將其從 kernel space 複製到 user space,藉此在核心過濾封包。 封包過濾工具 `tcpdump` 底下是基於 BPF 來完成封包過濾,`tcpdump` 藉由 libpcap 轉譯封包過濾條件給位於核心內的 BPF , BPF 依照條件過濾出來的封包,從 kernel space 複製到 user space 被 libpcap 接收後,再傳給 `tcpdump`。 透過以下指令捕捉 IP 相關的封包,並觀察 `ip` 過濾條件編譯的情形 ```shell $ tcpdump -d ip (000) ldh [12] (001) jeq #0x800 jt 2 jf 3 (002) ret #262144 (003) ret #0 ``` * `(000)`: 從封包位移量 `12` 的地方載入一個 half word (2 bytes),對應到下圖的 Ether Type 欄位。 * Ethernet header (14 bytes) |-------------------------- Offset 12 bytes -------------------------->| | Desetination MAC Address<br/>(6 bytes) | Source MAC Address<br/>(6 bytes) | Ether Type<br/>(2 bytes) | Payload | |:--------------------------------------:|:--------------------------------:|:------------------------:|:-------:| * `(001)`: 檢查暫存器的值是否為 `0x0800`,即 IP 的 Ether Type,如果是就跳到 `(002)`,否則跳到 `(003)`。 * `(002), (003)`: BPF 根據回傳值決定是否要過濾該封包,非 0 值代表要擷取的封包長度,0 的話表示不要該封包。 ### [BPF maps](https://www.kernel.org/doc/html/latest/bpf/maps.html) 主要通用的資料結構是 eBPF map (key/value pair),提供不同的儲存類型,可以在核心中或 kernel space 和 user space 之間傳遞資料。 ```c int bpf(int command, union bpf_attr *attr, u32 size) ``` 使用 `bpf()` system call 來執行 `command` 指定的操作。該操作採用 `attr` 中提供的參數。 `size` 參數是 `attr` 的大小。 從 user space 呼叫 `bpf()`,藉由指定 `bpf()` 中的 `command` 來建立 maps 或對元素做操作。 > BPF maps are accessed from user space via the bpf syscall, which provides commands to create maps, lookup elements, update elements and delete elements. [BPF Documentation: eBPF Syscall](https://docs.kernel.org/userspace-api/ebpf/syscall.html) `bpf()` 的 `command` 可以有以下選擇(部分列出) * `BPF_MAP_CREATE` 建立具有所需類型和屬性的 map。 * 成功回傳新的 file descriptor (a nonnegative integer)。 * 失敗回傳 -1。 * 呼叫 `close(fd)` 可以刪除 map。 * `BPF_MAP_LOOKUP_ELEM` 使用 attr->map_fd, attr->key, attr->value 在給定的 map 中 lookup key。 * 成功回傳 0,將找到的元素存到 attr->value。 * 失敗回傳 -1。 * `BPF_MAP_UPDATE_ELEM` 使用 attr->map_fd, attr->key, attr->value 在給定的 map 中建立或更新 key/value pair。 * 成功回傳 0。 * 失敗回傳 -1。 * `BPF_MAP_DELETE_ELEM` 使用 attr->map_fd, attr->key 在給定的 map 中尋找 key 和刪除元素。 * 成功回傳 0。 * 失敗回傳 -1。 * `BPF_OBJ_PIN` 將 bpf_fd 所指的 eBPF program 或 map 固定在提供的路徑名。 * 成功回傳 0。 * 失敗回傳 -1。 * `BPF_OBJ_GET` 打開指定路徑的 eBPF object 的 file descriptor。 * 成功回傳新的 file descriptor (a nonnegative integer)。 * 失敗回傳 -1。 * `BPF_LINK_CREATE` 在指定的 attach_type hook 將 eBPF program 附加到 target_fd,並回傳用於管理 link 的 file descriptor。 * 成功回傳新的 file descriptor (a nonnegative integer)。 * 失敗回傳 -1。 ### Socket lookup 當接收到一個封包時,可以透過 socket lookup 判斷這個封包該由哪個 socket 接收。 觀察核心的 socket lookup 程式碼 [linux/include/net/inet_hashtables.h](https://github.com/torvalds/linux/blob/v5.10/include/net/inet_hashtables.h#L342-L361) 與 [linux/net/ipv4 /inet_hashtables.c](https://github.com/torvalds/linux/blob/e338142b39cf40155054f95daa28d210d2ee1b2d/net/ipv4/inet_hashtables.c#L4) 1. 先尋找 established 狀態的 socket 2. 如果上一步驟沒找到合適的 socket,再尋找 listening 狀態的 socket 3. 如果上一步驟沒找到合適的 socket,使用 `INADDR_ANY` 尋找 listening 狀態的 socket 4. 若還是沒找到,回傳 NULL 其中下列程式碼的第 33 至 39 行,可以發現 eBPF 會在這裡 socket lookup,也就是在上述第二步驟前執行,也可以從 [BPF sk_lookup program](https://docs.kernel.org/bpf/prog_sk_lookup.html) 得知,在上述步驟二尋找 listening 狀態的 socket 時,會執行 BPF sk_lookup 程式。 > The attached BPF sk_lookup programs run whenever the transport layer needs to find a listening (TCP) or an unconnected (UDP) socket for an incoming packet. ```c= static inline struct sock *__inet_lookup(struct net *net, struct inet_hashinfo *hashinfo, struct sk_buff *skb, int doff, const __be32 saddr, const __be16 sport, const __be32 daddr, const __be16 dport, const int dif, const int sdif, bool *refcounted) { u16 hnum = ntohs(dport); struct sock *sk; sk = __inet_lookup_established(net, hashinfo, saddr, sport, daddr, hnum, dif, sdif); *refcounted = true; if (sk) return sk; *refcounted = false; return __inet_lookup_listener(net, hashinfo, skb, doff, saddr, sport, daddr, hnum, dif, sdif); } struct sock *__inet_lookup_listener(struct net *net, struct inet_hashinfo *hashinfo, struct sk_buff *skb, int doff, const __be32 saddr, __be16 sport, const __be32 daddr, const unsigned short hnum, const int dif, const int sdif) { struct inet_listen_hashbucket *ilb2; struct sock *result = NULL; unsigned int hash2; /* Lookup redirect from BPF */ if (static_branch_unlikely(&bpf_sk_lookup_enabled)) { result = inet_lookup_run_bpf(net, hashinfo, skb, doff, saddr, sport, daddr, hnum, dif); if (result) goto done; } hash2 = ipv4_portaddr_hash(net, daddr, hnum); ilb2 = inet_lhash2_bucket(hashinfo, hash2); result = inet_lhash2_lookup(net, ilb2, skb, doff, saddr, sport, daddr, hnum, dif, sdif); if (result) goto done; /* Lookup lhash2 with INADDR_ANY */ hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum); ilb2 = inet_lhash2_bucket(hashinfo, hash2); result = inet_lhash2_lookup(net, ilb2, skb, doff, saddr, sport, htonl(INADDR_ANY), hnum, dif, sdif); done: if (IS_ERR(result)) return NULL; return result; } ``` ### [ebpf-summit-2020](https://github.com/jsitnicki/ebpf-summit-2020) 程式碼導讀 由於 [ebpf-summit-2020](https://github.com/jsitnicki/ebpf-summit-2020) 版本過舊無法執行,[YSRossi/ebpf-socket-lookup](https://github.com/YSRossi/ebpf-socket-lookup) 為修改後可執行的版本。 #### [echo_dispatch.bpf.c](https://github.com/jsitnicki/ebpf-summit-2020/blob/master/echo_dispatch.bpf.c) 宣告 BPF map 儲存所需資訊 ```c /* Declare hash table for storing opening ports. Key is the port number. */ struct { __uint(type, BPF_MAP_TYPE_HASH); __type(key, __u16); __type(value, __u8); __uint(max_entries, 1024); } echo_ports SEC(".maps"); /* Declare hash table for storing listening socket */ struct { __uint(type, BPF_MAP_TYPE_SOCKMAP); __type(key, __u32); __type(value, __u64); __uint(max_entries, 1); } echo_socket SEC(".maps"); ``` `echo_dispatch()` 將封包分派到 socket - 檢查該封包的 destination port 是否開啟 - 取得 echo server socket - 將封包分派給 echo server socket `echo_dispatch()` 回傳有兩種 根據 [BPF sk_lookup program](https://docs.kernel.org/bpf/prog_sk_lookup.html) 所述 : - SK_DROP:丟棄封包 - SK_PASS:繼續常規尋找 socket 的步驟 > The attached BPF programs must return with either `SK_PASS` or `SK_DROP` verdict code. As for other BPF program types that are network filters, `SK_PASS` signifies that the socket lookup should continue on to regular hashtable-based lookup, while `SK_DROP` causes the transport layer to drop the packet. ```c /* Dispatcher program for the echo service */ SEC("sk_lookup") int echo_dispatch(struct bpf_sk_lookup *ctx) { const __u32 zero = 0; struct bpf_sock *sk; __u16 port; __u8 *open; long err; /* Is echo service enabled on packets destination port? */ port = ctx->local_port; /* packet destination port */ open = bpf_map_lookup_elem(&echo_ports, &port); /* Perform a lookup in (echo_ports)map for an entry associated to port(key). */ if (!open) /* Port not found, let packet go */ return SK_PASS; /* Get echo server socket */ sk = bpf_map_lookup_elem(&echo_socket, &zero); if (!sk) /* Socket not found, drop the packet */ return SK_DROP; /* Dispatch the packet to echo server socket */ err = bpf_sk_assign(ctx, sk, 0); bpf_sk_release(sk); return err ? SK_DROP : SK_PASS; } ``` #### [sk_lookup_attach.c](https://github.com/jsitnicki/ebpf-summit-2020/blob/master/sk_lookup_attach.c) - Attach 根據不同的 eBPF program type,程式會被 attach 到對應的 hook。 - Link ([libbpf: introduce concept of bpf_link](https://lore.kernel.org/bpf/20190701235903.660141-3-andriin@fb.com/t/#m5b54b8adb95203d874feaf8285f837728be29164)) eBPF program 可以選擇 attach 到 eBPF link,而不是直接 attach 到常規的 hook。Link 會附加到 hook 上,並提供界面來管理。Link 可以讓載入的程式退出時保持 eBPF probes 執行。 - Pin Pin 是一種保存對 eBPF object(program, maps, link)reference 的方法。BPF 預設的虛擬檔案路徑是 /sys/fs/bpf,`bpf(BPF_OBJ_PIN, ...)` 可以將 object pin 到 BPF 虛擬檔案系統,成功執行此 system call 時,能在 /sys/fs/bpf 下看到新建立的路徑,之後就能從 user space 透過 `bpf(BPF_OBJ_GET, ...)` 打開此路徑從虛擬檔案系統獲得該 object 的 file descriptor 做後續操作。因此只要 object 被 pin,就算終止建立該 object 的程式或關閉該 file descriptor,object 也不會因此消失,可稱為 [Persistent BPF objects](https://lwn.net/Articles/664688/) (內文為較早期的名稱,概念可參考)。 此程式碼會建立 eBPF link 將 sk_lookup 程式 attach 到 network namespace - 使用 `bpf(BPF_OBJ_GET, ...)` 從 `prog_path` 獲取 sk_lookup 程式的 file descriptor - 獲取目前行程的 network namespace 的 file descriptor - 使用 `bpf(BPF_LINK_CREATE, ...)` 建立 link,將程式 attach 到 network namespace,attach 類型為 `sk_lookup` - 將 link pin 到 `link_path` ```c int main(int argc, char **argv) { const char *prog_path; const char *link_path; union bpf_attr attr; int prog_fd, netns_fd, link_fd, err; if (argc != 3) { fprintf(stderr, "Usage: %s <prog path> <link path>\n", argv[0]); exit(EXIT_SUCCESS); } prog_path = argv[1]; link_path = argv[2]; /* 1. Open the pinned BPF program */ memset(&attr, 0, sizeof(attr)); attr.pathname = (uint64_t) prog_path; attr.file_flags = BPF_F_RDONLY; prog_fd = bpf(BPF_OBJ_GET, &attr, sizeof(attr)); if (prog_fd == -1) error(EXIT_FAILURE, errno, "bpf(OBJ_GET)"); /* 2. Get an FD for this process network namespace (netns) */ netns_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); if (netns_fd == -1) error(EXIT_FAILURE, errno, "open"); /* 3. Attach BPF sk_lookup program to the (netns) with a BPF link */ memset(&attr, 0, sizeof(attr)); attr.link_create.prog_fd = prog_fd; attr.link_create.target_fd = netns_fd; attr.link_create.attach_type = BPF_SK_LOOKUP; attr.link_create.flags = 0; link_fd = bpf(BPF_LINK_CREATE, &attr, sizeof(attr)); if (link_fd == -1) error(EXIT_FAILURE, errno, "bpf(LINK_CREATE)"); /* 4. Pin the BPF link (otherwise would be destroyed on FD close) */ memset(&attr, 0, sizeof(attr)); attr.pathname = (uint64_t) link_path; attr.bpf_fd = link_fd; attr.file_flags = 0; err = bpf(BPF_OBJ_PIN, &attr, sizeof(attr)); if (err) error(EXIT_FAILURE, errno, "bpf(OBJ_PIN)"); close(link_fd); close(netns_fd); close(prog_fd); exit(EXIT_SUCCESS); } ``` #### [sockmap_update.c](https://github.com/jsitnicki/ebpf-summit-2020/blob/master/sockmap_update.c) 主要功能為插入 socket 到先前已建立的 map - 從輸入的參數取得要插入的 socket 的 PID (`target_pid`) 與 file descriptor (`target_fd`),以及該 map 的路徑 (`map_path`) - 使用 `pidfd_open(target_pid, 0)` 獲取 `target_pid` 的 `pid_fd` - 使用 `pidfd_getfd(pid_fd, target_fd, 0)` 複製位於別的行程的 file descriptor (`target_fd`) 到目前的行程,獲得一個 local file descriptor `sock_fd`。 - 使用 `bpf(BPF_MAP_UPDATE_ELEM, ...)` 將 `sock_fd` 存入 map 中 ```c int main(int argc, char **argv) { pid_t target_pid; int pid_fd, target_fd, sock_fd, map_fd, err; uint32_t key; uint64_t value; const char *map_path; union bpf_attr attr; if (argc < 4) { fprintf(stderr, "Usage: %s <target pid> <target fd> <map path> [map key]\n", argv[0]); exit(EXIT_SUCCESS); } target_pid = atoi(argv[1]); target_fd = atoi(argv[2]); map_path = argv[3]; key = 0; if (argc == 5) key = atoi(argv[4]); /* Get duplicate FD for the socket */ pid_fd = pidfd_open(target_pid, 0); if (pid_fd == -1) error(EXIT_FAILURE, errno, "pidfd_open"); sock_fd = pidfd_getfd(pid_fd, target_fd, 0); if (sock_fd == -1) error(EXIT_FAILURE, errno, "pidfd_getfd"); /* Open BPF map for storing the socket */ memset(&attr, 0, sizeof(attr)); attr.pathname = (uint64_t) map_path; attr.bpf_fd = 0; attr.file_flags = 0; map_fd = bpf(BPF_OBJ_GET, &attr, sizeof(attr)); if (map_fd == -1) error(EXIT_FAILURE, errno, "bpf(OBJ_GET)"); /* Insert socket FD into the BPF map */ value = (uint64_t) sock_fd; memset(&attr, 0, sizeof(attr)); attr.map_fd = map_fd; attr.key = (uint64_t) &key; attr.value = (uint64_t) &value; attr.flags = BPF_ANY; err = bpf(BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr)); if (err) error(EXIT_FAILURE, errno, "bpf(MAP_UPDATE_ELEM)"); close(map_fd); close(sock_fd); close(pid_fd); } ``` ## 藉由 socket redirect 改進伺服器效率 一般封包的傳輸過程會如下方左圖所示,經過 socket layer 與 network stack 傳送給對方。當傳送的來源端與目的端都在同一台機器上時,整個傳輸流程都在同個網路空間,事實上可以省略底層 network stack 的處理,如下方右圖所示,透過 eBPF 的 socket redirect 直接在兩個 socket 間傳輸,藉此來提高效率。 ![](https://hackmd.io/_uploads/B1ttrFOw3.png) ### 實作程式碼 想要在兩個 socket 之間直接傳輸,需要一個 map 來紀錄 socket。 宣告 `struct sockmap_key` 以建立連線雙方的 ip 與 port 作為 map (`sockmap_ops`) 的 key, 其中 `struct sockmap_key` 的成員名稱根據 [bpf.h](https://github.com/torvalds/linux/blob/9561de3a55bed6bdd44a12820ba81ec416e705a7/include/uapi/linux/bpf.h#LL6463C22-L6463C22) 的命名方式以方便對應。 ```c struct sockmap_key { __u32 family; __u32 remote_ip4; __u32 local_ip4; __u16 remote_port; __u16 local_port; }; struct { __uint(type, BPF_MAP_TYPE_SOCKHASH); __type(key, struct sockmap_key); __type(value, int); __uint(max_entries, 65535); __uint(map_flags, 0); } sockmap_ops SEC(".maps"); ``` 設定當有 socket 操作 (TCP 三向交握、建立連線等等) 時會執行下方程式,透過此程式監聽 socket。 [struct bpf_sock_ops](https://github.com/torvalds/linux/blob/9561de3a55bed6bdd44a12820ba81ec416e705a7/include/uapi/linux/bpf.h#LL6463C22-L6463C22) 的註解提到可以從 `struct bpf_sock_ops` 中獲取 socket 資訊。 > User bpf_sock_ops struct to access socket values and specify request ops and their replies. 首先檢查 `remote_ip4` 與 `local_ip4` 是否相同,來判斷是否符合傳送的來源端與目的端都在同一台機器上的目標情境,如果相同則繼續執行。 [struct bpf_sock_ops](https://github.com/torvalds/linux/blob/9561de3a55bed6bdd44a12820ba81ec416e705a7/include/uapi/linux/bpf.h#LL6463C22-L6463C22) 的註解提到 `BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB` 與 `BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB` 的意義,若為這兩種情況,分別執行 `update_sockmap_ops()` 將雙方的 socket 紀錄至 map。 > BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB: The SYNACK that concludes the 3WHS. BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB: The ACK that concludes the 3WHS. `update_sockmap_ops()` 利用 `struct bpf_sock_ops` 中獲取所需的 socket 資訊作為 key,使用 `bpf_sock_hash_update` 將該 socket 存入 map 供後續操作使用。 ```c SEC("sockops") int bpf_sockmap(struct bpf_sock_ops *skops) { /* Only support IPv4 */ if (skops->family != AF_INET) return 0; if (skops->remote_ip4 != skops->local_ip4) return 0; switch (skops->op) { case BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB: case BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB: update_sockmap_ops(skops); break; default: break; } return 0; } static inline void update_sockmap_ops(struct bpf_sock_ops *skops) { struct sockmap_key skm_key = { .family = skops->family, .remote_ip4 = skops->remote_ip4, .local_ip4 = skops->local_ip4, .remote_port = bpf_ntohl(skops->remote_port), .local_port = skops->local_port, }; int ret = bpf_sock_hash_update(skops, &sockmap_ops, &skm_key, BPF_NOEXIST); if (ret) { bpf_printk("Update map failed. %d\n", -ret); return; } } ``` 獲取 socket 資訊並紀錄至 map 後,接下來要控制傳送目標,因此需要在 socket 呼叫 `sendmsg()` 時,執行下方程式,將封包直接傳送給目標 socket。 透過 [struct sk_msg_md](https://github.com/torvalds/linux/blob/9561de3a55bed6bdd44a12820ba81ec416e705a7/include/uapi/linux/bpf.h#LL6242C19-L6242C19) 獲取封包的 metadata 作為 key,若來源端與目的端都在同一台機器上,透過 key 尋找 map 中要 redirect 的目標 socket 並傳送給該 socket。若不在同一台機器上,封包依舊可以用原本的方式傳送。 ```c SEC("sk_msg") int bpf_redir(struct sk_msg_md *msg) { struct sockmap_key skm_key = { .family = msg->family, .remote_ip4 = msg->remote_ip4, .local_ip4 = msg->local_ip4, .remote_port = msg->local_port, .local_port = bpf_ntohl(msg->remote_port), }; if (msg->family != AF_INET) return SK_PASS; if (msg->remote_ip4 != msg->local_ip4) return SK_PASS; int ret = bpf_msg_redirect_hash(msg, &sockmap_ops, &skm_key, BPF_F_INGRESS); if (ret != SK_PASS) bpf_printk("redirect failed\n"); return SK_PASS; } ``` ### 實驗 使用 [kecho/user-echo-server.c](https://github.com/sysprog21/kecho/blob/master/user-echo-server.c) 作為 echo server,並使用 [kecho/bench.c](https://github.com/sysprog21/kecho/blob/master/bench.c) 建立 1000 個 thread 對 echo server 傳送訊息,比較有使用 socket redirect 與沒有使用 socket redirect 的 response time,結果顯示的確使用 socket redirect 的表現較好。 ![](https://hackmd.io/_uploads/HyXHMtOw3.png) ## 利用 eBPF 建構簡易的 echo server > [GitHub](https://github.com/YSRossi/ebpf-tcp-server) 在 user space 建構伺服器,呼叫 `recv` 時,會從 kernel buffer 複製資料到 user buffer;呼叫 `send` 時,會從 user buffer 複製資料到 kernel buffer。處理一個封包會有兩次的 memory copy,相較於在核心建構伺服器,顯然還有改進的空間。 接下來會利用 eBPF 建構簡易的 echo server,在 kernel space 中達到接收與傳送封包的功能,節省兩次的 memory copy (亦即 zero-copy),且完全不用撰寫核心模組,更不用修改核心程式碼。 ### 實作程式碼 先在 user space 建立一個 socket,按照基本流程呼叫 `bind`, `listen`, `accept` 等待 client 的連線,與一般伺服器不同的地方可以看到下方程式碼,完全不用呼叫 `recv` 與 `send` 搭配 eBPF 程式就可以建構簡易的 echo server。 ```c int bpf_echo_server() { struct sockaddr_in addr = { .sin_family = PF_INET, .sin_port = htons(SERVER_PORT), .sin_addr.s_addr = htonl(INADDR_ANY), }; socklen_t socklen = sizeof(addr); int sock_fd; if ((sock_fd = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("Fail to create socket."); return -1; } if (bind(sock_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) { perror("Fail to bind."); printf("bind: %s\n", strerror(errno)); close(sock_fd); return -1; } if (listen(sock_fd, 1024) < 0) perror("Fail to listen"); while (1) { struct sockaddr_in client_addr; int client = accept(sock_fd, (struct sockaddr *)&client_addr, &socklen); if (client < 0) { perror("accept"); exit(EXIT_FAILURE); } printf("Connection accepted from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); } close(sock_fd); return 0; } ``` 與先前介紹 socket redirect 的作法一樣,遇到`BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB` 與 `BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB` 這兩種情況,分別執行 `update_sockmap_ops()` 將雙方的 socket 紀錄至 map。 ```c SEC("sockops") int bpf_sockmap(struct bpf_sock_ops *skops) { /* Only support IPv4 */ if (skops->family != AF_INET) return 0; switch (skops->op) { case BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB: case BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB: update_sockmap_ops(skops); break; default: break; } return 0; } static inline void update_sockmap_ops(struct bpf_sock_ops *skops) { struct sockmap_key skm_key = { .family = skops->family, .remote_ip4 = skops->remote_ip4, .local_ip4 = skops->local_ip4, .remote_port = bpf_ntohl(skops->remote_port), .local_port = skops->local_port, }; int ret = bpf_sock_hash_update(skops, &sockmap_ops, &skm_key, BPF_NOEXIST); if (ret == SK_DROP) { bpf_printk("Update map failed. %d\n", -ret); return; } } ``` 下方 eBPF 程式碼的類型為 BPF_PROG_TYPE_sk_skb (簡稱 sk_skb),該類型載入核心後,可以針對 socket 的 ingress traffic 做處理,因此可以對即將進入 socket 的封包做處理。 - `struct __sk_buff` 為即將被 socket 接收的封包,sk_skb 類型的程式碼可以存取 `struct __sk_buff` 的成員,選擇 sockmap key 的所需資訊,紀錄到 `skm_key` 中。 - `if (skb->local_port != SERVER_PORT)` 過濾屬於 server 的封包,若屬於 server 的封包,繼續後續操作,否則依照正常流程傳遞封包。 - 使用 `bpf_sk_redirect_hash` 將該封包 redirect 到 server socket 的 egress interface,也就是傳送出去的方向,因此可以在 kernel space 中達到相當於在 user space 呼叫 `recv` 與 `send` 的功能,減少 user space 與 kernel space 之間 memory copy 的成本。 ```c SEC("sk_skb/stream_verdict") int bpf_redir(struct __sk_buff * skb) { struct sockmap_key skm_key = { .family = skb->family, .remote_ip4 = skb->remote_ip4, .local_ip4 = skb->local_ip4, .remote_port = bpf_ntohl(skb->remote_port), .local_port = skb->local_port, }; if (skb->family != AF_INET) return SK_PASS; if (skb->local_port != SERVER_PORT) { return SK_PASS; } int ret; ret = bpf_sk_redirect_hash(skb, &sockmap_ops, &skm_key, 0); if (ret != SK_PASS) bpf_printk("bpf_sk_redirect_hash() failed %d, error \n", -ret); return SK_PASS; } ``` #### 加入 redirect 條件 [commit 7d6cf1f](https://github.com/YSRossi/ebpf-tcp-server/commit/7d6cf1f780670a07bf0434448ed49e0ced0cfa9b) 經過測試觀察,接收 SYN 與 ACK 封包時,不會執行 sk_skb 類型的 eBPF 程式碼,但接收 FIN 封包則會執行。為了不將 FIN 封包也 redirect,使伺服器能夠順利接收,將 eBPF 程式碼 (`bpf_redir`) 更改成下方程式碼,`skb->len` 表示封包 payload 的大小,FIN 封包的 `skb->len` 為 0,因此當 `skb->len` 不為 0 時,才將該封包 redirect。 ```c SEC("sk_skb/stream_verdict") int bpf_redir(struct __sk_buff *skb) { if (skb->family != AF_INET) return SK_PASS; if (skb->local_port != SERVER_PORT) return SK_PASS; if (skb->len == 0) /* let the FIN packet go. */ return SK_PASS; struct sockmap_key skm_key = { .family = skb->family, .remote_ip4 = skb->remote_ip4, .local_ip4 = skb->local_ip4, .remote_port = bpf_ntohl(skb->remote_port), .local_port = skb->local_port, }; int ret; ret = bpf_sk_redirect_hash(skb, &sockmap_ops, &skm_key, 0); if (ret != SK_PASS) { bpf_printk("bpf_sk_redirect_hash() failed %d, error %d --> %d\n", -ret, bpf_ntohl(skb->remote_port), skb->local_port); } return ret; } ``` #### 針對 client 與 server 在同台電腦與不同台電腦兩種情況,做出不同的 redirect 操作 [commit: f7ca7fc](https://github.com/YSRossi/ebpf-tcp-server/commit/f7ca7fcdd8ed17738789218de5f430e219f23339) 先判斷 client 與 server 是否在同台電腦 * 若在同台電腦,則 server socket 收到封包直接 redirect 到 client 的 socket。(下方左圖) * 若在不同台電腦,則 server socket 收到封包後, redirect 到自己的 egress interface。(下方右圖) ![](https://hackmd.io/_uploads/Sk8oskVdh.png) ```c SEC("sk_skb/stream_verdict") int bpf_redir(struct __sk_buff * skb) { if (skb->family != AF_INET) return SK_PASS; if (skb->local_port != SERVER_PORT) return SK_PASS; if (skb->len == 0) return SK_PASS; int ret; if (skb->remote_ip4 == skb->local_ip4) { struct sockmap_key skm_key = { .family = skb->family, .remote_ip4 = skb->remote_ip4, .local_ip4 = skb->local_ip4, .remote_port = skb->local_port, .local_port = bpf_ntohl(skb->remote_port), }; ret = bpf_sk_redirect_hash(skb, &sockmap_ops, &skm_key, BPF_F_INGRESS); } else { struct sockmap_key skm_key = { .family = skb->family, .remote_ip4 = skb->remote_ip4, .local_ip4 = skb->local_ip4, .remote_port = bpf_ntohl(skb->remote_port), .local_port = skb->local_port, }; ret = bpf_sk_redirect_hash(skb, &sockmap_ops, &skm_key, 0); } if (ret != SK_PASS) bpf_printk("bpf_sk_redirect_hash() failed %d, error \n", -ret); return ret; } ```