# 淺嚐 Linux SOCKMAP SOCKMAP 自 Linux 4.14 引入,提供 BPF 介面讓使用者空間行程快速地將資料於 socket 之間轉傳。一個範例是讓兩個本機 TCP/IP socket 互相傳送資料時不必經過 TCP/IP 層的複雜函式呼叫,可藉由 SOCKMAP 讓核心將資料直接放至目標 socket 的接收佇列;又如將一個 socket 接收到的訊息在不經修改的情況下透過另一個 socket 傳出,也可藉由 SOCKMAP 讓核心直接透過目標 socket 送出資料而不必傳回使用者空間,進而減少使用者空間與核心空間的切換次數與資料複製。 以下是 Jakub Sitnicki 在 KubeCon 2024 針對 SOCKMAP 的介紹影片,本篇筆記會引用其投影片輔助說明。 {%youtube m34itvY_VWM%} 本篇筆記搭配 Linux 6.17 核心程式碼,從一個 socket 的建立說明 socket 中的 callbacks,並說明 SOCKMAP 如何透過覆蓋 callbacks 的方式修改 socket 行為進而實現資料轉傳,最後透過實驗展示 SOCKMAP 實際帶來的延遲改善。 ## Socket ### Socket 是檔案系統中的檔案 寫過 socket 程式都知道,`socket()` 會回傳一個檔案描述器(file descriptor),之後程式便會透過此檔案描述器讀寫 socket,此檔案描述器的來源是 socket 子系統的檔案系統 sockfs。 `net/socket.c` 中的 `init_sock` 函式初始化 socket 子系統,其中會註冊一個名為 sockfs 的檔案系統。sockfs 配置 inode 時會直接配置一個 `struct socket_alloc`,用於連接 VFS 介面與 socket 子系統,其成員包含 `socket` 與 `vfs_inode`: ```c struct socket_alloc { struct socket socket; // socket 子系統內部使用 struct inode vfs_inode; // VFS 介面(socket 子系統外部)使用 }; ``` 此結構體中的 `vfs_inode` 只用於在 VFS(Virtual File System)中代表這個 socket 檔案,當 `vfs_inode` 傳回 socket 子系統內部時,便會透過 `container_of` 巨集從 inode 取得其對應的 socket,可見 Linux 核心中一個子系統如何使用 VFS 介面抽象化內部實作細節。 ### 使用 socket() 建立 Socket 當我們在使用者空間呼叫 `socket()` 時,會透過 libc 呼叫 `socket` 系統呼叫,一般系統呼叫的定義大致上如下所示: ```c SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol) { return __sys_socket(family, type, protocol); } int __sys_socket(int family, int type, int protocol) { struct socket *sock; int flags; sock = __sys_socket_create(family, type, update_socket_protocol(family, type, protocol)); if (IS_ERR(sock)) return PTR_ERR(sock); flags = type & ~SOCK_TYPE_MASK; if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK)) flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK; return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK)); } ``` 在 `__sys_socket` 中,主要任務就是呼叫 `__sys_socket_create` 取得一個 socket 後回傳檔案描述器至使用者空間。參數中的 `update_socket_protocol` 是一個 BPF 掛載點,它預設回傳傳入的 `protocol`,我們可以忽略它。 ```c static struct socket *__sys_socket_create(int family, int type, int protocol) { struct socket *sock; int retval; // some checks... if ((type & ~SOCK_TYPE_MASK) & ~(SOCK_CLOEXEC | SOCK_NONBLOCK)) return ERR_PTR(-EINVAL); type &= SOCK_TYPE_MASK; retval = sock_create(family, type, protocol, &sock); if (retval < 0) return ERR_PTR(retval); return sock; } int sock_create(int family, int type, int protocol, struct socket **res) { return __sock_create(current->nsproxy->net_ns, family, type, protocol, res, 0); } ``` `__sys_socket_create` 將參數往下傳至 `sock_create`,後者再加上一些參數往下傳到建立與初始化 socket 的函式 `__sock_create`: ```c int __sock_create(struct net *net, int family, int type, int protocol, struct socket **res, int kern) { int err; struct socket *sock; const struct net_proto_family *pf; ... // (1) 配置乾淨的 socket sock = sock_alloc(); if (!sock) { net_warn_ratelimited("socket: no more sockets\n"); return -ENFILE; /* Not exactly a match, but its the closest posix thing */ } sock->type = type; ... // (2) 避免 net_families[family] 被移除 rcu_read_lock(); pf = rcu_dereference(net_families[family]); err = -EAFNOSUPPORT; if (!pf) goto out_release; /* * We will call the ->create function, that possibly is in a loadable * module, so we have to bump that loadable module refcnt first. */ // 為 socket 取得該模組的 reference,避免被移除 if (!try_module_get(pf->owner)) goto out_release; /* Now protected by module ref count */ rcu_read_unlock(); // 使用 family(IPv4)的 create 函式初始化 socket err = pf->create(net, sock, protocol, kern); if (err < 0) { // .. goto out_module_put; } *res = sock; return 0; } ``` 我們先理解 `family`、`type` 與 `protocol` 這三個參數,可以想像成目錄層級的概念,在我們這個例子中,我們的 socket 是在 IPv4 上的 TCP socket,使用這三個參數可表達為 `family=PF_INET` 中 `type=SOCK_STREAM` 中 `protocol=IPROTO_TCP` 的通訊協定,稍後我們會看到這些通訊協定在哪裡被定義以及被取用。 `__sock_create` 中先呼叫 `sock_alloc` 從 sockfs 中配置一個全新乾淨的 socket,然後把 `type` 設定好。接著有趣的事情發生了,因為有些 family 是以 LKM(Loadable Kernel Module)的形式存在於核心中,隨時有可能被移除,所以核心中有 [RCU](/2dsLRhWERDaHtFIlTmG2nQ) 以及 [Reference Counting](/t3RGd3TFSNOjDnRPTcfiFQ) 機制來確保安全性。 `__sock_create` 先透過 `rcu_dereference` 左手抓住 `pf`(模組的一部份),再透過 `try_module_get(pf->owner)` 右手抓住整個模組的 reference(模組的核心)。這時就可以呼叫 `rcu_read_unlock` 放開左手了,因為右手已經確保這個模組不會被移除;右手則是等到 socket 回收時才會放開,進而避免模組在 socket 的生命週期中移除。 最後我們再使用剛剛抓來的 `pf->create` 搭配指定的 `protocol` 初始化我們的 socket,這個 `pf = net_families[PF_INET]` 是在 IPv4 模組初始化時填入的。`net_families` 這個陣列會儲存各種 family,因此它與它的設定函數 `sock_register` 都存在於 `net/socket.c` 中。 ```c // net/socket.c static const struct net_proto_family __rcu *net_families[NPROTO] __read_mostly; int sock_register(const struct net_proto_family *ops) { int err; if (ops->family >= NPROTO) { pr_crit("protocol %d >= NPROTO(%d)\n", ops->family, NPROTO); return -ENOBUFS; } spin_lock(&net_family_lock); if (rcu_dereference_protected(net_families[ops->family], lockdep_is_held(&net_family_lock))) err = -EEXIST; else { rcu_assign_pointer(net_families[ops->family], ops); err = 0; } spin_unlock(&net_family_lock); pr_info("NET: Registered %s protocol family\n", pf_family_names[ops->family]); return err; } ``` 而 IPv4 只是其中一種 family,它在初始化時會將自己註冊到 socket 子系統中的 `net_families`,程式碼中的註解很生動地描述了這件事: ```c static const struct net_proto_family inet_family_ops = { .family = PF_INET, .create = inet_create, .owner = THIS_MODULE, }; static int __init inet_init(void) { ... /* * Tell SOCKET that we are alive... */ (void)sock_register(&inet_family_ops); ... } ``` 回到剛才的 `pf->create(net, sock, protocol, kern)`,因為我們使用的是 IPv4 family,所以實際上呼叫的是 `inet_create(net, sock, protocol, kern)`,我們看看這個 IPv4 模組中的函式如何將我們的 socket 初始化為 TCP socket。 ```c static int inet_create(struct net *net, struct socket *sock, int protocol, int kern) { // ... list_for_each_entry_rcu(answer, &inetsw[sock->type], list) { err = 0; /* Check the non-wild match. */ if (protocol == answer->protocol) { if (protocol != IPPROTO_IP) break; } else { /* Check for the two wild cases. */ if (IPPROTO_IP == protocol) { protocol = answer->protocol; break; } if (IPPROTO_IP == answer->protocol) break; } err = -EPROTONOSUPPORT; } sock->ops = answer->ops; answer_prot = answer->prot; sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern); //... sock_init_data(sock, sk); // ... } ``` 到了 IPv4 中,接下來就是要找 `protocol` 對應的結構了,`inet_create` 根據我們剛才設定好的 `sock->type`,從鏈結串列 `inetsw[sock->type]` 中找出我們要的 `protocol` 對應的 `struct proto *answer`,`answer` 中存放了一個 TCP socket 該有的行為,包含 `sendmsg`、`recvmsg` 等,把這些都放進 socket 後,初始化一個負責管理接收傳送的結構 `struct sock sk` 就完成了。 由於核心中存在 `socket`、`sock` 和 `sk`,我會以非等寬字 socket 代指 `struct socket` 結構,sock 代指 `struct sock` 結構,符合其結構名稱。 以下是定義在 IPv4 模組中的通訊協定,在模組初始化時會將這些通訊協定註冊到 `inetsw` 讓 socket 使用。其中包含我們熟知的 TCP、UDP 與 ICMP(ping 使用的通訊協定)。 ```c static struct inet_protosw inetsw_array[] = { { .type = SOCK_STREAM, .protocol = IPPROTO_TCP, .prot = &tcp_prot, .ops = &inet_stream_ops, .flags = INET_PROTOSW_PERMANENT | INET_PROTOSW_ICSK, }, { .type = SOCK_DGRAM, .protocol = IPPROTO_UDP, .prot = &udp_prot, .ops = &inet_dgram_ops, .flags = INET_PROTOSW_PERMANENT, }, { .type = SOCK_DGRAM, .protocol = IPPROTO_ICMP, .prot = &ping_prot, .ops = &inet_sockraw_ops, .flags = INET_PROTOSW_REUSE, }, { .type = SOCK_RAW, .protocol = IPPROTO_IP, /* wild card */ .prot = &raw_prot, .ops = &inet_sockraw_ops, .flags = INET_PROTOSW_REUSE, } }; ``` ### Socket 的抽象化 Linux 核心中使用大量的抽象化讓各個核心部件盡可能地模組化,socket 子系統也不例外。 首先如我們於先前提過的,socket 提供 VFS 介面對使用者空間抽象化,使用者如同操作檔案一般操作一個 socket,完全不知道 socket 的內部實作與結構。 此外,剛才提到的 sock 也是對 socket 這一層的抽象化,socket 負責將資料於使用者空間與 sock 間搬移,sock 則負責協定層的真實網路連線,管理連線的所有狀態與資料。 在我們的例子中,sock 使用一個特定的通訊協定(TCP)將來自 socket 層的資料裝入封包後傳送至網路卡,或是將來自網路卡的封包根據通訊協定取出其中的資料傳送至 socket。Socket 完全不知道 sock 使用什麼通訊協定、如何傳送資料,但是 socket 知道它可以把資料交給 sock 傳送,並且從 sock 取得傳入的資料。 Socket 就像是使用者空間與真正的網路連線 sock 的橋樑,負責兩者間的訊息轉換。 ``` user space programs(應用層) | | (file descriptor) | struct socket (VFS) | | | struct sock (傳輸層) ``` ## skmsg、skb 與 psock 在深入 SOCKMAP 程式碼前,我們再認識幾個重要的資料結構,以幫助了解 SOCKMAP 的運作方式。 ### struct sk_msg 與 struct sk_buff `struct sk_msg`(下稱 skmsg)是資料尚未被切割成封包並加上標頭的型態,資料在 egress 方向被轉傳時的型態會是 skmsg,此時尚未被 sock 加上標頭。 而 `struct sk_buff`(下稱 skb)則是資料被切割成封包並加上標頭的型態,資料在 ingress 方向被轉傳時的型態會是 skb,此時尚未被 sock 解開封包。 ### struct sk_psock `struct sk_psock`(下稱 psock)這個結構體的角色與功能。psock 是 sock 的外掛插件,會儲存 sock 原有的 proto 並換成 BPF proto,之後資料都會先經過 BPF 程式處理。除此之外,轉傳到 sock 的 skmsg 與 skb 都會由 psock 代收至 `psock->ingress_msg` 與 `psock->ingress_skb`。 - `psock->ingress_msg` 只存放 ingress skmsg,會在 `tcp_bpf_recvmsg` 中被消耗,如果一個 egress skmsg 被轉傳到此 socket 送出,那麼就會直接呼叫此 sock 將 skmsg 傳出,而不會停留在佇列中。 - `psock->ingress_skb` 存放轉傳至 socket 的 skbuf,無論該 skbuf 的最終轉傳方向是 ingress 還是 egress 都會被放在這個佇列中,會被 `sk_psock_backlog` 處理後放入 `psock->ingress_msg` 或是直接透過 sock 傳出。 `psock->work` 這個成員是一個 delayed work,當被排程時會執行 `sk_psock_backlog` 處理 `psock->ingress_skb` 中的所有 skb。 延伸閱讀: - [Linux 核心設計: Concurrency Managed Workqueue](/iI4s2v5tRWON05CI1ihFDQ) - [Workqueue — The Linux Kernel documentation](https://docs.kernel.org/core-api/workqueue.html) ## 將 socket 加入 SOCKMAP 在使用者空間中,程式可以呼叫 libbpf 提供的輔助函式 [bpf_map_update_elem](https://docs.kernel.org/bpf/map_sockmap.html#id2) 將一個 socket 加入至 SOCKMAP 中。參數中的 `fd` 是 map 的檔案描述器,`value` 才是 socket 的檔案描述器。 ```c int bpf_map_update_elem(int fd, const void *key, const void *value, __u64 flags) ``` 呼叫此函式時 libbpf 會代為呼叫 `bpf` 系統呼叫,該系統呼叫處理函式中有一個巨大的 switch dispatcher,最終呼叫 `map_update_elem`。因為傳入核心的 map 可能有多種,不一定都是 SOCKMAP,所以 `map_update_elem` 透過 `bpf_map_update_value` 函式呼叫該 map 的 callback `sock_map_update_elem_sys`。 `sock_map_update_elem_sys` 準備好參數並判斷 map 類型是 SOCKMAP 或 SOCKHASH,若為 SOCKMAP 則呼叫 `sock_map_update_common`。後者除了將 socket 加入 SOCKMAP 中,也進一步呼叫 `sock_map_link` 將 SOCKMAP 提供的 BPF 程式與 `struct proto` 掛載至 socket。 或是直接讓 BPF 程式在核心空間中呼叫 `bpf_sock_map_update` 也可以呼叫 `sock_map_update_common`。 ```c static int sock_map_link(struct bpf_map *map, struct sock *sk) { struct sk_psock_progs *progs = sock_map_progs(map); struct bpf_prog *stream_verdict = NULL; struct bpf_prog *stream_parser = NULL; struct bpf_prog *skb_verdict = NULL; struct bpf_prog *msg_parser = NULL; struct sk_psock *psock; int ret; stream_verdict = READ_ONCE(progs->stream_verdict); stream_parser = READ_ONCE(progs->stream_parser); msg_parser = READ_ONCE(progs->msg_parser); skb_verdict = READ_ONCE(progs->skb_verdict); psock = sock_map_psock_get_checked(sk); // ... if (psock) { // ... } else { psock = sk_psock_init(sk, map->numa_node); // ... } if (msg_parser) psock_set_prog(&psock->progs.msg_parser, msg_parser); if (stream_parser) psock_set_prog(&psock->progs.stream_parser, stream_parser); if (stream_verdict) psock_set_prog(&psock->progs.stream_verdict, stream_verdict); if (skb_verdict) psock_set_prog(&psock->progs.skb_verdict, skb_verdict); ret = sock_map_init_proto(sk, psock); // ... return 0; } ``` `sock_map_link` 把 SOCKMAP 中的 BPF 程式都掛到 socket 上,與此同時也初始化 psock 並儲存 sock 原有的行為,接著再呼叫 `sock_map_init_proto` 覆蓋 sock 的行為。 ```c static int sock_map_init_proto(struct sock *sk, struct sk_psock *psock) { if (!sk->sk_prot->psock_update_sk_prot) return -EINVAL; psock->psock_update_sk_prot = sk->sk_prot->psock_update_sk_prot; return sk->sk_prot->psock_update_sk_prot(sk, psock, false); } ``` 因為這是一個 TCP socket,所以 `sk->sk_prot` 是 `tcp_prot`,而後者的成員 `psock_update_sk_prot` 定義如下: ```c struct proto tcp_prot = { .name = "TCP", .owner = THIS_MODULE, .close = tcp_close, // ... #ifdef CONFIG_BPF_SYSCALL .psock_update_sk_prot = tcp_bpf_update_proto, #endif // ... }; ``` 所以說在 BPF 開啟的情況下,任何 TCP socket 都已經做好隨時被加入 SOCKMAP 的準備了,只需要呼叫 `tcp_bpf_update_proto` 便可改變這個 socket 的行為。 ```c int tcp_bpf_update_proto(struct sock *sk, struct sk_psock *psock, bool restore) { int family = sk->sk_family == AF_INET6 ? TCP_BPF_IPV6 : TCP_BPF_IPV4; int config = psock->progs.msg_parser ? TCP_BPF_TX : TCP_BPF_BASE; if (psock->progs.stream_verdict || psock->progs.skb_verdict) { config = (config == TCP_BPF_TX) ? TCP_BPF_TXRX : TCP_BPF_RX; } if (restore) { // restore ... return 0; } // ... sock_replace_proto(sk, &tcp_bpf_prots[family][config]); return 0; } ``` 這裡呼叫 `sock_replace_proto` 以 `tcp_bpf_prots[family][config]` 覆寫 socket 的行為,`tcp_bpf_prots` 的初始化如下: ```c static struct proto tcp_bpf_prots[TCP_BPF_NUM_PROTS][TCP_BPF_NUM_CFGS]; static void tcp_bpf_rebuild_protos(struct proto prot[TCP_BPF_NUM_CFGS], struct proto *base) { prot[TCP_BPF_BASE] = *base; prot[TCP_BPF_BASE].destroy = sock_map_destroy; prot[TCP_BPF_BASE].close = sock_map_close; prot[TCP_BPF_BASE].recvmsg = tcp_bpf_recvmsg; prot[TCP_BPF_BASE].sock_is_readable = sk_msg_is_readable; prot[TCP_BPF_TX] = prot[TCP_BPF_BASE]; prot[TCP_BPF_TX].sendmsg = tcp_bpf_sendmsg; prot[TCP_BPF_RX] = prot[TCP_BPF_BASE]; prot[TCP_BPF_RX].recvmsg = tcp_bpf_recvmsg_parser; prot[TCP_BPF_TXRX] = prot[TCP_BPF_TX]; prot[TCP_BPF_TXRX].recvmsg = tcp_bpf_recvmsg_parser; } static int __init tcp_bpf_v4_build_proto(void) { tcp_bpf_rebuild_protos(tcp_bpf_prots[TCP_BPF_IPV4], &tcp_prot); return 0; } late_initcall(tcp_bpf_v4_build_proto); ``` 總結來說,當我們將一個 socket 加入 SOCKMAP,不僅會將該 socket 放入 SOCKMAP 的其中一個 entry,還會改變其傳送資料與接收資料的行為,如 `sendmsg` 被替換成 `tcp_bpf_sendmsg` 而 `recvmsg` 被替換成 `tcp_bpf_recvmsg` 以在過程中執行 BPF 程式。 ## Redirection ![KubeCon 2024 - eBPF Day - SOCKMAP 68](https://hackmd.io/_uploads/Bk4Ale8xZl.jpg) 上圖取自 KubeCon 2024 Jakub Sitnicki 的投影片,SOCKMAP 支援以上幾種轉傳,本篇筆記最後的實驗是 socket 發送資料後經由 SOCKMAP 轉傳到本機的另一個 socket,因此是上圖中的 send to local,除此之外我們也會看 ingress to egress 的運作方式。 值得注意的是在這張圖中從主機發出的資料都是以 skmsg 的形式轉傳,而來自網路卡的資料則是以 skb 的形式轉傳,這樣的設計是為了在轉傳前盡可能減少資料的封包處理,如 send to local 就可以跳過加上封包的過程。 另外,BPF verdict 程式在沒有錯誤的情況下只會回傳 `SK_PASS`,是否需要 redirect 則是根據 `skb->_sk_redir` 高位判斷,其高位是轉傳目標的 sock 指標,低位則包含 ingress bit `BPF_F_INGRESS` 決定轉傳方向。`sk_psock_map_verd` 中再將 `SK_PASS` 細分為不轉傳的 `__SK_PASS` 與轉傳的 `__SK_REDIRECT`,錯誤則對應到 `__SK_DROP`。 ### Redirect: Send to Local ![KubeCon 2024 - eBPF Day - SOCKMAP 49](https://hackmd.io/_uploads/SJavfg8xbg.jpg) SOCKMAP 的功能之一是將傳進 socket 的資料轉傳到另一個 socket,為了理解這個過程,我們觀察資料從 `send` 系統呼叫開始直到被 SOCKMAP 接手之間經過了哪些函式。其實我們也可以透過 `write` 系統呼叫將資料寫入 socket,但是會經過更多的抽象封裝如 VFS 檔案 I/O 等,但最終核心仍然會發現這是一個 socket 而與 `send` 呼叫相同的函式 `__sock_sendmsg`,為了專注於 SOCKMAP 我們只看 `send` 這條路徑。 C 程式呼叫 `send()` 後,C 函式庫會幫忙執行 `send` 系統呼叫,此系統呼叫實作可以在 `net/socket.c` 中找到: ```c SYSCALL_DEFINE4(send, int, fd, void __user *, buff, size_t, len, unsigned int, flags) { return __sys_sendto(fd, buff, len, flags, NULL, 0); } int __sys_sendto(int fd, void __user *buff, size_t len, unsigned int flags, struct sockaddr __user *addr, int addr_len) { struct socket *sock; struct sockaddr_storage address; int err; struct msghdr msg; // 將資料包裝為 iov_iter err = import_ubuf(ITER_SOURCE, buff, len, &msg.msg_iter); if (unlikely(err)) return err; // int fd -> struct fd f CLASS(fd, f)(fd); if (fd_empty(f)) return -EBADF; // struct fd -> struct file -> struct socket sock = sock_from_file(fd_file(f)); if (unlikely(!sock)) return -ENOTSOCK; // init msg ... return __sock_sendmsg(sock, &msg); } ``` `__sys_sendto` 取出檔案描述器對應的 socket 並將資料包裝為 `iov_iter` 後,呼叫 `__sock_sendmsg` 發送資料至 socket。關於 `iov_iter` 可閱讀 LWN 上的文章 [*The iov_iter interface*](https://lwn.net/Articles/625077/)。 ```c static int __sock_sendmsg(struct socket *sock, struct msghdr *msg) { int err = security_socket_sendmsg(sock, msg, msg_data_left(msg)); return err ?: sock_sendmsg_nosec(sock, msg); } ``` 程式碼中的 `?:` 為 gcc 擴展語法 [Elvis operator](https://en.wikipedia.org/wiki/Elvis_operator),相當於 `err ? err : sock_sendmsg_nosec(sock, msg)`。許多現代程式語言都有這個語法。根據維基百科,gcc 的擴展語法是此語法的最早實作,被視為是此語法的創始語言。 接著 `__sock_sendmsg` 呼叫 `security_socket_sendmsg` 檢查當前行程是否有足夠的權限能夠發送該資料至該 socket,確認行程有充足的權限後,便開始傳送資料至 socket: ```c static inline int sock_sendmsg_nosec(struct socket *sock, struct msghdr *msg) { int ret = INDIRECT_CALL_INET(READ_ONCE(sock->ops)->sendmsg, inet6_sendmsg, inet_sendmsg, sock, msg, msg_data_left(msg)); BUG_ON(ret == -EIOCBQUEUED); if (trace_sock_send_length_enabled()) call_trace_sock_send_length(sock->sk, ret, 0); return ret; } ``` 第一行程式碼可展開為 `READ_ONCE(sock->ops)->sendmsg(sock, msg, msg_data_left(msg))`,由於我們使用 TCP socket,所以 `sock->ops` 會被設定為 `&inet_stream_ops`,其 `sendmsg` 成員對應至 `inet_sendmsg`。 ```c int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t size) { struct sock *sk = sock->sk; if (unlikely(inet_send_prepare(sk))) return -EAGAIN; return INDIRECT_CALL_2(sk->sk_prot->sendmsg, tcp_sendmsg, udp_sendmsg, sk, msg, size); } ``` 接著 `inet_sendmsg` 將傳送資料的工作進一步傳給協定(TCP)特定實作 `sk->sk_prot->sendmsg`,至此 socket 的這一層的工作就完成了,剩下的都交給 sock 處理。 還記得將 socket 加入 SOCKMAP 後 `sk->sk_prot->sendmsg` 會被改變嗎?預設的函式為 `tcp_sendmsg` 直接開始 TCP 傳送: ```c int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size) { int ret; lock_sock(sk); ret = tcp_sendmsg_locked(sk, msg, size); release_sock(sk); return ret; } ``` 而將 socket 加入 SOCKMAP 後,被呼叫的函式則變為 `tcp_bpf_sendmsg`: ```c static int tcp_bpf_sendmsg(struct sock *sk, struct msghdr *msg, size_t size) { struct sk_msg tmp, *msg_tx = NULL; int copied = 0, err = 0, ret = 0; struct sk_psock *psock; long timeo; int flags; // ... psock = sk_psock_get(sk); if (unlikely(!psock)) return tcp_sendmsg(sk, msg, size); lock_sock(sk); while (msg_data_left(msg)) { // ... copy = msg_data_left(msg); // ... err = sk_msg_alloc(sk, msg_tx, msg_tx->sg.size + copy, msg_tx->sg.end - 1); // ... ret = sk_msg_memcopy_from_iter(sk, &msg->msg_iter, msg_tx, copy); // ... copied += ret; // ... err = tcp_bpf_send_verdict(sk, psock, msg_tx, &copied, flags); if (unlikely(err < 0)) goto out_err; continue; // ... } out_err: if (err < 0) err = sk_stream_error(sk, msg->msg_flags, err); release_sock(sk); sk_psock_put(sk, psock); return copied > 0 ? copied : err; } ``` 先取得 sock 對應的 psock,如果 psock 是無效指標則呼叫預設實作 `tcp_sendmsg`,不過第 12 行的 `unlikely` 指出這種狀況不太可能會發生。迴圈中將資料從 `msg->msg_iter` 複製到 skmsg,接著呼叫 `tcp_bpf_send_verdict` 看這個 sk_msg 是要直接傳出還是需要轉傳。 ```c static int tcp_bpf_send_verdict(struct sock *sk, struct sk_psock *psock, struct sk_msg *msg, int *copied, int flags) { bool cork = false, enospc = sk_msg_full(msg), redir_ingress; struct sock *sk_redir; u32 tosend, origsize, sent, delta = 0; u32 eval; int ret; more_data: if (psock->eval == __SK_NONE) { /* Track delta in msg size to add/subtract it on SK_DROP from * returned to user copied size. This ensures user doesn't * get a positive return code with msg_cut_data and SK_DROP * verdict. */ delta = msg->sg.size; psock->eval = sk_psock_msg_verdict(sk, psock, msg); delta -= msg->sg.size; } // ... tosend = msg->sg.size; if (psock->apply_bytes && psock->apply_bytes < tosend) tosend = psock->apply_bytes; eval = __SK_NONE; switch (psock->eval) { case __SK_PASS: ret = tcp_bpf_push(sk, msg, tosend, flags, true); if (unlikely(ret)) { *copied -= sk_msg_free(sk, msg); break; } sk_msg_apply_bytes(psock, tosend); break; case __SK_REDIRECT: // ... (later) case __SK_DROP: default: sk_msg_free(sk, msg); sk_msg_apply_bytes(psock, tosend); *copied -= (tosend + delta); return -EACCES; } // ... return ret; } ``` 一開始 `psock->eval == __SK_NONE` 時會先跑一次 BPF 程式看看這個 sk_msg 要如何處理,如果是 `__SK_PASS` 則照原有的規劃傳送,如果是 `__SK_REDIRECT` 則開始轉傳程序。 ```c case __SK_REDIRECT: redir_ingress = psock->redir_ingress; sk_redir = psock->sk_redir; // ... release_sock(sk); // ... ret = tcp_bpf_sendmsg_redir(sk_redir, redir_ingress, msg, tosend, flags); // ... lock_sock(sk); // ... break; ``` 先前執行的 BPF 程式已經設定好轉傳的目標 `sk_redir` 與對於目標的方向 `redir_ingress`,將這些參數傳入 `tcp_bpf_sendmsg_redir` 就可以轉傳 skmsg 了。由於轉傳的過程可能會耗費一些時間,且和原本的 sock 無關,因此轉傳過程中會暫時釋放鎖。 ```c int tcp_bpf_sendmsg_redir(struct sock *sk, bool ingress, struct sk_msg *msg, u32 bytes, int flags) { struct sk_psock *psock = sk_psock_get(sk); int ret; if (unlikely(!psock)) return -EPIPE; ret = ingress ? bpf_tcp_ingress(sk, psock, msg, bytes) : tcp_bpf_push_locked(sk, msg, bytes, flags, false); sk_psock_put(sk, psock); return ret; } ``` 轉傳時根據對於目標的方向 `ingress` 決定傳送到目標的 `psock->ingress_msg` 或是使用目標 sock 發送 skmsg,此過程沒有 workqueue 的介入。 ### Redirect: Ingress to Egress ![KubeCon 2024 - eBPF Day - SOCKMAP 62](https://hackmd.io/_uploads/SJlJQeUlbe.jpg) 將 socket 加進 SOCKMAP 時,若我們有在 SOCKMAP 中掛載 verdict BPF 程式,便會呼叫 `sk_psock_start_verdict` 將 sock 的 `sk_data_ready` 換成 `sk_psock_verdict_data_ready`,而 `sk_data_ready` 是當有資料傳進來時用於通知 sock 的 callback。 ```c static void sk_psock_verdict_data_ready(struct sock *sk) { struct socket *sock = sk->sk_socket; const struct proto_ops *ops; int copied; trace_sk_data_ready(sk); if (unlikely(!sock)) return; ops = READ_ONCE(sock->ops); if (!ops || !ops->read_skb) return; copied = ops->read_skb(sk, sk_psock_verdict_recv); if (copied >= 0) { struct sk_psock *psock; rcu_read_lock(); psock = sk_psock(sk); if (psock) sk_psock_data_ready(sk, psock); rcu_read_unlock(); } } ``` 在此函式被執行前,skb 已經被放在 sock 中了,所以此函式呼叫 `ops->read_skb` 也就是 `tcp_read_skb` 處理 sock 中的 skb。 ```c int tcp_read_skb(struct sock *sk, skb_read_actor_t recv_actor) { struct sk_buff *skb; int copied = 0; // ... while ((skb = skb_peek(&sk->sk_receive_queue)) != NULL) { int used; // ... used = recv_actor(sk, skb); // ... copied += used; // ... } return copied; } ``` `tcp_read_skb` 將 sock 中的每個 skb 從 receive queue 取出並交給 `recv_actor` 也就是 `sk_psock_verdict_recv` 處理。 ```c static int sk_psock_verdict_recv(struct sock *sk, struct sk_buff *skb) { struct sk_psock *psock; struct bpf_prog *prog; int ret = __SK_DROP; int len = skb->len; rcu_read_lock(); psock = sk_psock(sk); // ... prog = READ_ONCE(psock->progs.stream_verdict); if (!prog) prog = READ_ONCE(psock->progs.skb_verdict); if (likely(prog)) { skb_dst_drop(skb); skb_bpf_redirect_clear(skb); ret = bpf_prog_run_pin_on_cpu(prog, skb); ret = sk_psock_map_verd(ret, skb_bpf_redirect_fetch(skb)); } ret = sk_psock_verdict_apply(psock, skb, ret); if (ret < 0) len = ret; out: rcu_read_unlock(); return len; } ``` `sk_psock_verdict_recv` 拿到一個 skb 後就拿去問 BPF verdict 程式,然後呼叫 `sk_psock_verdict_apply` 執行該結果。 ```c static int sk_psock_verdict_apply(struct sk_psock *psock, struct sk_buff *skb, int verdict) { struct sock *sk_other; int err = 0; u32 len, off; switch (verdict) { case __SK_PASS: err = -EIO; sk_other = psock->sk; // ... /* If the queue is empty then we can submit directly * into the msg queue. If it's not empty we have to * queue work otherwise we may get OOO data. Otherwise, * if sk_psock_skb_ingress errors will be handled by * retrying later from workqueue. */ if (skb_queue_empty(&psock->ingress_skb)) { len = skb->len; off = 0; if (skb_bpf_strparser(skb)) { struct strp_msg *stm = strp_msg(skb); off = stm->offset; len = stm->full_len; } err = sk_psock_skb_ingress_self(psock, skb, off, len, false); } if (err < 0) { spin_lock_bh(&psock->ingress_lock); if (sk_psock_test_state(psock, SK_PSOCK_TX_ENABLED)) { skb_queue_tail(&psock->ingress_skb, skb); schedule_delayed_work(&psock->work, 0); err = 0; } spin_unlock_bh(&psock->ingress_lock); if (err < 0) goto out_free; } break; case __SK_REDIRECT: tcp_eat_skb(psock->sk, skb); err = sk_psock_skb_redirect(psock, skb); break; case __SK_DROP: default: out_free: skb_bpf_redirect_clear(skb); tcp_eat_skb(psock->sk, skb); sock_drop(psock->sk, skb); } return err; } ``` 如果這個封包不轉傳(`__SK_PASS`),那麼就會送到 sock 的 ingress queue,但若這個 queue 不是空的就會傳到 `psock->ingress_skb` 並排程 `psock->work`,目前我還是不懂為什麼要這麼做。如果遇到要轉傳的情況(`__SOCK_REDIRECT`),就會呼叫 `sk_psock_skb_redirect` 將 skb 放到另一個 socket 的 `psock->ingress_skb`,同時排程 `psock->work`。 ```c static int sk_psock_skb_redirect(struct sk_psock *from, struct sk_buff *skb) { struct sk_psock *psock_other; struct sock *sk_other; sk_other = skb_bpf_redirect_fetch(skb); // ... psock_other = sk_psock(sk_other); // ... spin_lock_bh(&psock_other->ingress_lock); // ... skb_queue_tail(&psock_other->ingress_skb, skb); schedule_delayed_work(&psock_other->work, 0); spin_unlock_bh(&psock_other->ingress_lock); return 0; } ``` ### sk_psock_backlog 處理傳入 skb 無論是否被轉傳,以 ingress 方向進入 socket 的 skb 都會存放於 `psock->ingress_skb`,這個佇列會被執行 `sk_psock_backlog` 的 `psock->work` 處理。 ```c struct sk_psock *sk_psock_init(struct sock *sk, int node) { struct sk_psock *psock; // ... INIT_DELAYED_WORK(&psock->work, sk_psock_backlog); // ... return psock; } ``` 將 socket 加入 SOCKMAP 時會一併初始化 psock 與 `psock->work`。如先前所述,一個來自網路卡的 skb 無論是進入原先的目標 socket 還是被轉傳到其他 socket,都會被放在目標的 `psock->ingress_skb` 並排程 `psock->work` 執行 `sk_psock_backlog`。 ```c static void sk_psock_backlog(struct work_struct *work) { struct delayed_work *dwork = to_delayed_work(work); struct sk_psock *psock = container_of(dwork, struct sk_psock, work); struct sk_psock_work_state *state = &psock->work_state; struct sk_buff *skb = NULL; u32 len = 0, off = 0; bool ingress; int ret; // ... /* Increment the psock refcnt to synchronize with close(fd) path in * sock_map_close(), ensuring we wait for backlog thread completion * before sk_socket freed. If refcnt increment fails, it indicates * sock_map_close() completed with sk_socket potentially already freed. */ if (!sk_psock_get(psock->sk)) return; mutex_lock(&psock->work_mutex); while ((skb = skb_peek(&psock->ingress_skb))) { // ... ingress = skb_bpf_ingress(skb); skb_bpf_redirect_clear(skb); do { ret = -EIO; if (!sock_flag(psock->sk, SOCK_DEAD)) ret = sk_psock_handle_skb(psock, skb, off, len, ingress); // ... off += ret; len -= ret; } while (len); /* The entire skb sent, clear state */ sk_psock_skb_state(psock, state, 0, 0); skb = skb_dequeue(&psock->ingress_skb); kfree_skb(skb); } end: mutex_unlock(&psock->work_mutex); sk_psock_put(psock->sk, psock); } ``` `sk_psock_backlog` 會拿出所有 `psock->ingress_skb` 中的 skb 傳入 `sk_psock_handle_skb` 處理。 ```c static int sk_psock_handle_skb(struct sk_psock *psock, struct sk_buff *skb, u32 off, u32 len, bool ingress) { if (!ingress) { if (!sock_writeable(psock->sk)) return -EAGAIN; return skb_send_sock(psock->sk, skb, off, len); } return sk_psock_skb_ingress(psock, skb, off, len); } ``` `sk_psock_handle_skb` 再根據方向決定處理方式,若為 ingress 方向,則呼叫 `sk_psock_skb_ingress` 將 skb 處理後得到的 skmsg 放入 `psock->ingress_msg`;否則直接呼叫 `skb_send_sock` 將 skb 傳出。 ## 實驗:透過 SOCKMAP 降低延遲 本節我們使用 [sockperf](https://github.com/Mellanox/sockperf) 工具測量網路延遲,展示 SOCKMAP 轉傳降低延遲的效果。sockperf 的運作方式為先執行伺服端並指定埠號,再執行客戶端設定測試參數後連線到伺服器,接著等待一段時間後就會產生測試結果,其中包含連線延遲。 本實驗設定參考投影片中的 send to local,執行 sockperf 伺服端與客戶端於本機,測試 SOCKMAP 能夠降低多少延遲。 ![KubeCon 2024 - eBPF Day - SOCKMAP 49](https://hackmd.io/_uploads/SJavfg8xbg.jpg) 我們的 SOCKMAP 程式分為兩部分:運行在核心空間中的 BPF 程式,以及運行在使用者空間、將 BPF 程式載入核心空間的程式。 為了簡化說明不會列出所有程式碼,詳細的程式碼與執行方式請參考 [NatsuCamellia/sockmap-example](https://github.com/NatsuCamellia/sockmap-example)。 ### BPF 程式 我們先建立一個 SOCKMAP: ```c struct { __uint(type, BPF_MAP_TYPE_SOCKMAP); __uint(max_entries, 2); __type(key, __u32); __type(value, __u64); } sock_map SEC(".maps"); ``` 這段程式碼的建立了一個名為 `sock_map` 的 SOCKMAP(`BPF_MAP_TYPE_SOCKMAP`),其中有兩個欄位分別給伺服端與客戶端,鍵的型別為 32 位無號整數,而值的型別則為 64 位無號整數。 我們的 BPF 程式有兩個任務要達成: 1. 將 sockperf 兩端的 socket 放入 SOCKMAP 2. sockperf 兩端的 socket 傳送資料時使用 SOCKMAP 轉傳 第一個任務是將 socket 放入 SOCKMAP,因為 socket 並不是由我們自己的程式產生,所以將 socket fd 加入 SOCKMAP 這種做法不可行。我們將 BPF 掛載到 `sockops` 區段以在 socket 建立連線時將其加入 SOCKMAP。以下程式碼判斷如果該 socket 是來自 sockperf 的任何一端且操作是建立連線,則將其加入 SOCKMAP。 ```c SEC("sockops") int bpf_sockmap_handler(struct bpf_sock_ops *skops) { __u32 local_port = skops->local_port; __u32 remote_port = bpf_ntohl(skops->remote_port); __u32 key; // IPv4 only if (skops->family != 2) return BPF_OK; bool is_server = (local_port == server_port); bool is_client = (remote_port == server_port); // We only care about the server and the client if (!is_server && !is_client) return BPF_OK; switch (skops->op) { // If the connection is established case BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB: // server-side case BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB: // client-side // Put server's socket in sock_map[0] and client's in sock_map[1] key = is_server ? 0 : 1; bpf_sock_map_update(skops, &sock_map, &key, BPF_ANY); break; } return BPF_OK; } ``` 以下是轉傳 skmsg 的 SOCKMAP 程式。需要注意的是會執行這個 BPF 程式的只有被加入 SOCKMAP 的 socket,也就是 sockperf 兩端。對於每個 `msg`,只需要呼叫 `bpf_msg_redirect_map` 從 SOCKMAP 中選出目標的 socket 並設定 `msg` 的轉傳資訊即可。在核心程式碼中執行 BPF 程式以判斷資料是否需要轉傳,指的就是這個程式。 ```c SEC("sk_msg") int bpf_redir_handler(struct sk_msg_md *msg) { __u32 key; if (msg->local_port == server_port) { // I'm server (Key 0), redirect to client (Key 1) key = 1; } else { key = 0; } return bpf_msg_redirect_map(msg, &sock_map, key, BPF_F_INGRESS); } ``` ### 將 BPF 程式載入核心空間 ```c // Auto-generated by bpftool #include "sockmap_accel.bpf.skel.h" #define CGROUP_PATH "/sys/fs/cgroup" // (1) Open and load BPF skeleton struct sockmap_accel_bpf *skel = sockmap_accel_bpf__open(); sockmap_accel_bpf__load(skel); // (2) Attach SOCK_OPS to Cgroup int cgroup_fd = open(CGROUP_PATH, O_RDONLY); skel->links.bpf_sockmap_handler = bpf_program__attach_cgroup( skel->progs.bpf_sockmap_handler, cgroup_fd ); // (3) Attach SK_MSG to SOCKMAP skel->links.bpf_redir_handler = bpf_program__attach_sockmap( skel->progs.bpf_redir_handler, bpf_map__fd(skel->maps.sock_map) ); ``` BPF 程式編譯完會產生 `.skel.h` 標頭檔,載入程式引用此標頭檔並呼叫其提供的函式即可讀取 BPF 並將其載入核心空間。 ### 實驗結果 - OS:Ubuntu 25.10(Linux 6.17.0) - CPU:Intel(R) Core(TM) i5-5200U CPU @ 2.20GHz ``` ================================================== RESULTS ================================================== Baseline: 18.021 usec Accelerated: 9.449 usec Reduction: 8.572 usec ================================================== ``` 測試結果如上,平均延遲時間將低了接近一半,可見 SOCKMAP 在 send to local 應用場景的效果顯著。 ## Takeaway 一個 socket 建立時會根據協定設定不同的 socket callback,而將 socket 加入 SOCKMAP 時又會將其設定為 SOCKMAP 為該協定設計的 callback。透過覆寫 callback,SOCKMAP 能夠在 socket 處理資料的過程中執行 BPF 程式決定資料的轉傳,且藉由在 sock 中加入 psock 插件,得以將 SOCKMAP 邏輯執行於 psock 而不干擾 sock。 SOCKMAP 應用場景多元,本篇筆記展示了 SOCKMAP 能夠有效降低 send to local 的延遲。