# 以 eBPF 建構 TCP 伺服器 [前開發紀錄](https://hackmd.io/@sysprog/ryBw0adH2) ## 問題清單 - [x] 編譯 bpf/bpf_helpers.h 報錯 __u32, __u64 unknown type name - [x] 編譯 syscall.h 報錯 undeclared identifier '__NR_pidfd_getfd' - [ ] (只是警告)bpftool 加載 bpf_sockops.o 出錯,`bpf_create_map_xattr` 得到錯誤碼 ENOTSUPP (524) - [x] bpftool 附加 bpf_sockops 到 cgroup 失敗 - [x] 連續實驗時,重複加載和卸載可能因 sockmap_ops 沒成功卸載而失敗 - [x] msg_verdict 程式中 `bpf_msg_redirect_hash()` 失敗 ## 利用 eBPF 建構簡易的 echo server 實驗的說明參考[前開發紀錄](https://hackmd.io/@sysprog/ryBw0adH2#%E5%88%A9%E7%94%A8-eBPF-%E5%BB%BA%E6%A7%8B%E7%B0%A1%E6%98%93%E7%9A%84-echo-server)。簡單說明專案開發 eBPF 的方式:使用的工具有 libbpf 函式庫和 `bpftool` 命令。利用 libbpf 撰寫 sock_ops 、 stream_verdict 程式,在透過 `bpftool` 將程式加載和附加到系統核心。 操作步驟參考 [GitHub](https://github.com/YSRossi/ebpf-tcp-server/blob/main/README.md) , `bpftool` 命令的部份可以改用 script/ 底下的腳本進行操作。在完成 eBPF 程式加載和附加後,可以執行專案中的 *ebpf-echo-server* 和 *bench* 進行測試,測試成功時, *bench* 會在終端輸出 “correct” 並結束執行。 ### 編譯 bpf/bpf_helpers.h 報錯 __u32, __u64 unknown type name - 現象:`make` 編譯時報錯,以下擷取一部份錯誤訊息 ```shell In file included from bpf_sockops.c:1: In file included from ./bpf_sockops.h:7: In file included from /usr/include/bpf/bpf_helpers.h:11: /usr/include/bpf/bpf_helper_defs.h:78:90: error: unknown type name '__u64' static long (* const bpf_map_update_elem)(void *map, const void *key, const void *value, __u64 flags) = (void *) 2; ^ ``` - 問題:在使用 libbpf 的 helper 時,需要提供 u32 的定義 - 解決方式:在引用 *bpf/bpf_helpers.h* 前先引用 *linux/types.h* ### 編譯 syscall.h 報錯 undeclared identifier '__NR_pidfd_getfd' - 現象:`make` 編譯時報錯,以下擷取一部份錯誤訊息 ```shell ./syscall.h:23:20: error: use of undeclared identifier '__NR_pidfd_getfd'; did you mean 'pidfd_getfd'? return syscall(__NR_pidfd_getfd, pidfd, targetfd, flags); ^~~~~~~~~~~~~~~~ pidfd_getfd ``` - 問題:系統沒有正確更新,使用 5.14 的 linux-header 中沒有定義此巨集。 透過 `uname -r` 查看版本顯示為 5.15.0-102-generic,但在 /usr/include/linux/version.h 查詢 `LINUX_VERSION_*` 的巨集來確認版本時,卻為 5.14 的版本 - 解決方式:嘗試過更新 linux-header 仍是相同狀況,我選擇直接升級 ubuntu 20.04 到 22.04 的版本。升級後查詢 linux/version.h 確認為 5.15 的版本,即可在標頭檔找到 `__NR_pidfd_getfd` 定義 ### bpftool 加載 bpf_sockops.o 出錯,`bpf_create_map_xattr` 得到錯誤碼 ENOTSUPP (524) - 現象: `bpftool` 加載程式時出現警告,第一次 `attr` 參數有傳入 BTF id 的 `bpf(BPF_MAP_CREATE)` 呼叫回傳 ENOTSUPP 的錯誤碼, `bpftool` 會再呼叫一次且不傳入 BTF ,便能成功加載。 ```shell johnny@johnny-X550JX:~/ncku-proj/ebpf-tcp-server$ sudo bpftool prog load bpf_sockops.o /sys/fs/bpf/bpf_sockops libbpf: Error in bpf_create_map_xattr(sockmap_ops):ERROR: strerror_r(-524)=22(-524). Retrying without BTF. ``` 使用 `strace` 觀察發生錯誤的系統呼叫傳入的引數是否正確 ```shell bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_SOCKHASH, key_size=16, value_size=4, max_entries=65535, map_flags=0, inner_map_fd=0, map_name="sockmap_ops", map_ifindex=0, btf_fd=3, btf_key_type_id=6, btf_value_type_id=7, btf_vmlinux_value_type_id=0, map_extra=0}, 128) = -1 ENOTSUPP (Unknown error 524) libbpf: Error in bpf_create_map_xattr(sockmap_ops):ERROR: strerror_r(-524)=22(-524). Retrying without BTF. bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_SOCKHASH, key_size=16, value_size=4, max_entries=65535, map_flags=0, inner_map_fd=0, map_name="sockmap_ops", map_ifindex=0, btf_fd=0, btf_key_type_id=0, btf_value_type_id=0, btf_vmlinux_value_type_id=0, map_extra=0}, 128) = 4 ``` 為了確認 BTF id 無誤,使用 `bpftool btf dump` 查詢,在類別前面的方括號中會顯示其 id ,發現前面傳入的引數正確。 ```shell johnny@johnny-X550JX:~/ncku-proj/ebpf-tcp-server$ bpftool btf dump file bpf_sockops.o ... [6] STRUCT 'sockmap_key' size=16 vlen=5 'family' type_id=7 bits_offset=0 'remote_ip4' type_id=7 bits_offset=32 'local_ip4' type_id=7 bits_offset=64 'remote_port' type_id=9 bits_offset=96 'local_port' type_id=9 bits_offset=112 [7] TYPEDEF '__u32' type_id=8 ``` 使用 libbpf-boostrap 取代 `bpftool load` 的時候,函式庫底層呼叫 `bpf(BPF_MAP_CREATE)` 也採取不用 BTF 的方式, btf_fd、btf id 皆是 0。 如何在 `bpf(BPF_MAP_CREATE)` 帶入 BTF 資訊不報錯誤,還要再找相關說明文件,或研究實作程式碼。 - 問題:??? - 解決方式:??? ### bpftool 附加 bpf_sockops 到 cgroup 失敗 - 現象: ```shell johnny@johnny-X550JX:~/ncku-proj/ebpf-tcp-server$ sudo strace -e bpf bpftool cgroup attach /sys/fs/cgroup/ sock_ops pinned /sys/fs/bpf/bpf_sockops --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=6582, si_uid=0, si_status=0, si_utime=0, si_stime=0} --- --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=6583, si_uid=0, si_status=0, si_utime=0, si_stime=0} --- bpf(BPF_OBJ_GET, {pathname="/sys/fs/bpf/bpf_sockops", bpf_fd=0, file_flags=0}, 128) = 4 bpf(BPF_PROG_ATTACH, {target_fd=3, attach_bpf_fd=4, attach_type=BPF_CGROUP_SOCK_OPS, attach_flags=0}, 128) = -1 EBADF (Bad file descriptor) ``` - 問題:??? - 解決方式:ubuntu 升級後就不再報錯 ### 連續實驗時,重複加載和卸載可能因 sockmap_ops 沒成功卸載而失敗 - 現象:連續實驗時,map 實例 sockmap_ops 並不會在操作命令後立即從核心卸載,因此之後加載時可能會因為重複的實例衝突導致失敗,警告錯誤 `libbpf: failed to pin map: File exists` 。 在未成功卸載的情形,透過 bpftool 觀察仍留在系統的物件有:map sockmap_ops 和 bpf_redir 程式。 - 問題:參考資料 [Lifecycle of eBPF Programs](https://eunomia.dev/tutorials/28-detach/) 可以知道資源管理的手段是 refcount ,接下來討論影響 map 和 bpf_redir 程式 refcount 修改的時機和生命週期。 map 是 bpf_redir 的全域變數, bpf_redir 在加載和卸載時會修改 map refcount ,確保 map 的生命週期比程式還長。 bpf_redir 則是被 socket 持有,當 socket 被加入 map 時, socket 會持有 map 上附加的 parser 和 verdict 程式,也就是 bpf_redir ,並且增加程式的 refcount 。 - 解決方式: map 中的 socket 會持有 bpf_redir 程式,可以透過將 socket 從 map 移除來釋放程式,可以使用 `bpftool map delete` 操作。 ## 藉由 socket redirect 改進伺服器效率 > 參考資料:[sockops 網路加速轉發](https://eunomia.dev/zh/tutorials/29-sockops/#_2) ### 簡介 需要兩個 eBPF 程式,類別分別是 sockops 和 sk_msg 。 - sockops :在多處觸發,透過 `struct bpf_sock_ops` 參數中 `ops` 的欄位判斷觸發類型。大致可分為兩種: - “進行配置修改”,如 `BPF_SOCK_OPS_TIMEOUT_INIT` - “ TCP 狀態改變時,觸發 callback ”,如 `BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB` 或 `BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB` - sk_msg :在封包傳送的 socket 層觸發,根據回傳值進行對應操作,轉傳到指定 socket 或維持正常操作。 sockops 的程式,會在客戶和伺服器連線建立時,`BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB` 和 `BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB` 的情況,將 socket 存入 map 。然後封包傳送觸發 sk_msg 時,會查詢 map 有無對應 socket ,找到就進行轉傳。 ### 實驗過程 程式部分, *bpf_sockops.c* 與 ebpf-tcp-server 相同, *bpf_redir.c* 改成以下程式 ```clike 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; } ``` 附加程式的命令中,類別要從 stream_verdict 改為 msg_verdict 。 ```shell sudo bpftool prog attach pinned /sys/fs/bpf/bpf_redir msg_verdict pinned bpffs/sockmap_ops ``` eBPF 封包轉傳的程式可以透過 `tcpdump` 測試,轉傳成功時,封包不會進入核心處理網路協定的流程,因此不會被捕獲。測試過程包含建立連線、傳送封包和關閉連線,成功的情形只會捕捉到建立連線的三次握手(3WHS)和關閉連線的四次揮手(4WWH)的流量。 使用不同的程式作為伺服器和客戶測試,操作的指令如下 ```shell python3 -m http.server curl http://0.0.0.0:8000/ ``` ```shell iperf3 -s -p 5001 # server iperf3 -c 127.0.0.1 -t 10 -l 64k -p 5001 # client ``` ```shell sudo socat TCP4-LISTEN:1000,fork exec:cat # server nc localhost 1000 # client ``` [kehco](https://github.com/sysprog21/kecho/tree/master) 專案也能作為伺服器和客戶 ```shell user-echo-server bench ``` 測試成功時, `tcpdump` 輸出如下 ```shell johnny@johnny-X550JX:~/ncku-proj/bpf-developer-tutorial/src/29-sockops$ sudo tcpdump -i lo port 8000 tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes ... # 3WHS 16:02:40.321717 IP localhost.53658 > localhost.8000: Flags [S], seq 1294391696, win 65495, options [mss 65495,sackOK,TS val 1295927894 ecr 0,nop,wscale 7], length 0 16:02:40.321740 IP localhost.8000 > localhost.53658: Flags [S.], seq 599799243, ack 1294391697, win 65483, options [mss 65495,sackOK,TS val 1295927895 ecr 1295927894,nop,wscale 7], length 0 16:02:40.321754 IP localhost.53658 > localhost.8000: Flags [.], ack 1, win 512, options [nop,nop,TS val 1295927895 ecr 1295927895], length 0 # 4WWH 16:02:41.758004 IP localhost.53658 > localhost.8000: Flags [F.], seq 1, ack 1, win 512, options [nop,nop,TS val 1295929331 ecr 1295927895], length 0 16:02:41.758521 IP localhost.8000 > localhost.53658: Flags [F.], seq 1, ack 2, win 512, options [nop,nop,TS val 1295929331 ecr 1295929331], length 0 16:02:41.758548 IP localhost.53658 > localhost.8000: Flags [.], ack 2, win 512, options [nop,nop,TS val 1295929331 ecr 1295929331], length 0 ``` 不同伺服器和客戶測試結果 | server | client | result | | -------- | -------- | -------- | | python3 | curl | pass | | socat | netcat | pass | | iperf3 | iperf3 | fail | | kecho | kecho | fail | 在 iperf3/iperf3 和 kecho/kecho 的失敗案例, `sudo cat /sys/kernel/debug/tracing/trace_pipe` 檢查除錯訊息,會看到 “redirect failed” ,代表 `bpf_msg_redirect_hash()` 失敗。 ### msg_verdict 程式中 `bpf_msg_redirect_hash()` 失敗 - 現象: iperf3/iperf3 和 kecho/kecho 測試失敗,*trace_pipe* 除錯訊息顯示 “redirect failed” ,代表 `bpf_msg_redirect_hash` 呼叫失敗。 透過 `netstat -tln` 檢查連線狀態時,發現 `iperf3` 命令建立的伺服器預設採取 IPv6 ,修改命令 `perf3 -s -p 5001 -4` 後則順利通過測試。 #### 利用 eBPF 分析 `bpf_msg_redirect_hash()` `bpf_msg_redirect_hash()` 核心程式碼位於 [net/core/sock_map.c](https://elixir.bootlin.com/linux/latest/source/net/core/sock_map.c#L1269) 。根據呼叫函式提供的引數,第 6 行的條件不可能成立,因此回傳 SK_DROP 的情況,只有當 map 找不到元素或是檢查 TCP 連線狀態沒有建立成功。 ```clike= BPF_CALL_4(bpf_msg_redirect_hash, struct sk_msg *, msg, struct bpf_map *, map, void *, key, u64, flags) { struct sock *sk; if (unlikely(flags & ~(BPF_F_INGRESS))) return SK_DROP; sk = __sock_hash_lookup_elem(map, key); if (unlikely(!sk || !sock_map_redirect_allowed(sk))) return SK_DROP; msg->flags = flags; msg->sk_redir = sk; return SK_PASS; } ``` eBPF 程式中, fexit 類型的程式可以取得核心中任何函式的回傳值,透過檢查 `__sock_hash_lookup_elem()` 回傳值可以判斷 map 查詢的結果。開發方式可以參考 [libbpf-boostrap](https://nakryiko.com/posts/libbpf-bootstrap/) ,核心態程式如下 ```clike #include "vmlinux.h" #include <bpf/bpf_helpers.h> #include <bpf/bpf_tracing.h> char LICENSE[] SEC("license") = "Dual BSD/GPL"; SEC("fexit/__sock_hash_lookup_elem") int BPF_PROG(my_probe, struct bpf_map *map, void *key, struct sock *retval) { int pid = bpf_get_current_pid_tgid() >> 32; char cmd_buf[30]; bpf_get_current_comm(cmd_buf, sizeof cmd_buf); if (!retval) bpf_printk("%s-%d lookup fail", cmd_buf, pid); return 0; } ``` 在回傳空指標,元素不存在 map 的時候,輸出 "lookup fail" 到 trace_pipe ,測試結果輸出如下 ```shell bench-81766 [001] d...2 19180.747526: bpf_trace_printk: bench-79948 lookup fail bench-81766 [001] d...1 19180.747527: bpf_trace_printk: redirect failed bench-82316 [003] d...2 19180.825598: bpf_trace_printk: bench-79948 lookup fail bench-82316 [003] d...1 19180.825609: bpf_trace_printk: redirect failed bench-82277 [001] d...2 19181.835012: bpf_trace_printk: bench-79948 lookup fail bench-82277 [001] d...1 19181.835019: bpf_trace_printk: redirect failed ... ``` 觀察所有輸出的內容,會發現錯誤發生總次數,與轉傳失敗總次數相同,但是和 *bench* 設定的連線數目不同。表示失敗原因確實為 socket 不在 map ,而且封包轉傳並沒有全部失敗。另外, fexit 的程式透過 `bpf_get_current_comm()` 取得發送封包的程式名稱,全部都是 *bench* 。 整理已知的失敗情境: 1. map 找不到對應 socket 導致失敗 2. 只有客戶向伺服器傳送時失敗 3. 連線數量大時才會發生部分失敗 推測原因可能為:系統在高負載時,客戶 `send()` 發生的順序先於伺服器 established 。因此客戶 verdict 查詢 map 無法找到對應的伺服器 socket 。 <img src="https://hackmd.io/_uploads/HynTaJJpR.png" width=450 style="display: block; margin: auto;"> #### 觀察 sock_ops 和 verdict 操作 map 的順序 :::warning 一般來說,若是能透過上鎖來持有共享變數的使用權,就可以在 bpf_msg_redirect_hash() 持有時使用 bpf_map_lookup_elem() 確認元素是否存在。可惜 eBPF 目前不支援鎖,而且 eBPF verifier 不允許 bpf_map_lookup_elem() 查詢 SOCKHASH 類型的 map ::: 為了證明轉傳失敗時, verdict 查詢 map 先於 sock_ops 寫入 map ,分別紀錄 “verdict 查詢 map 後”和 “sock_ops 寫入 map 前”的時間,在“操作 map” 和“記錄時間”兩個操作間隔足夠短的情況下,便能藉由兩個紀錄的時間點,判斷 map 操作的順序。但是此方法不夠精準,可能低估實際發生的次數。 程式部分,新增兩個與用戶共享的 map ,用來記錄 verdict 和 sock_ops 的時間。核心態把連線資訊作為 key ,時間作為 value ,將記錄存入 map ,且 verdict 只在轉傳失敗寫入。另外需要[以 libbpf 開發用戶態程式](#以-libbpf-開發-socket-redirect-的用戶態程式),在 *bench* 結束後,用戶態逐一取出 verdict map 的紀錄,並在 sock_ops map 找到對應的紀錄,比較時間先後並進行統計。 實驗結果發現總計 1269 筆失敗中,有 784 筆 verdict 的查詢先於 sock_ops 寫入。 - 問題: - iperf3/iperf3:伺服器預設採用 IPv6 , eBPF 封包轉傳程式不支援。 - kecho/kecho:伺服器處理效率低於客戶連線流量,導致 sock_ops 執行晚於 verdict 。 - 解決方式: - iperf3/iperf3:命令增加 `-4` 選項,啟用 IPv4 伺服器。 - kecho/kecho:X ### 以 libbpf 開發 socket redirect 的用戶態程式 > [GitHub](https://github.com/soyWXY/socket_redirect) eBPF 用戶態常見的開發工具有 BCC、libbpf、GO 和 Rust ,此處採用 libbpf 並以 [libbpf-boostrap](https://nakryiko.com/posts/libbpf-bootstrap/) 的方式進行開發。 首先,核心態程式經過編譯生成目標檔,對其使用 `bpftool gen skeleton` 生成 skeleton 標頭檔, skeleton 會定義一個類別表示核心態程式,將 bpf_sockops.c 和 bpf_redir.c 合併,最終生成的類別成員包含 sock_ops 和 verdict 兩個程式和共用的 sockmap_ops 。skeleton 也對 libbpf API 進行封裝,提供 open 、 load 、 attach 和 destroy 的介面,用戶態程式呼叫 open 和 load ,便能自動建立 map ,把程式加載到系統。 attach 只對 tracing 類別的程式提供自動附加,因此需要自行呼叫 libbpf ,將 sock_ops 附加到指定 cgroup ,將 verdict 到 map 。 ```clike // sock_ops attach to cgroup int cgrp = open(cgroup_path, O_RDONLY | __O_CLOEXEC); if (cgrp < 0) { puts("fail to open cgroup"); err = 1; goto clean_skel; } struct bpf_program *prog_sockops = skel->progs.bpf_sockmap; skel->links.bpf_sockmap = bpf_program__attach_cgroup(prog_sockops, cgrp); if (!skel->links.bpf_sockmap) { puts("fail to attach to cgroup"); err = 1; goto close_cgrp; } // msg_verdict attach to map sockmap_ops struct bpf_program *prog_redir = skel->progs.bpf_redir; err = bpf_prog_attach( bpf_program__fd(prog_redir), bpf_map__fd(skel->maps.sockmap_ops), bpf_program__expected_attach_type(prog_redir), 0); if (err) { puts("fail to attach to map"); err = 1; goto close_cgrp; } ``` 接著便是在 while 迴圈阻塞,直到收到信號在呼叫 destroy 。 開發完後同樣使用 `tcpdump` 進行測試,不同伺服器和客戶的組合都順利通過。 | server | client | result | | ------- | ------ | ------ | | python3 | curl | pass | | socat | netcat | pass | | iperf3 | iperf3 | pass | ### 測量 socket redirect 對效能的影響 使用 `iperf3` 進行測量,紀錄 sender/receiver 的頻寬,比較性能 ratio 。 $$\text{ratio} = \frac{direct}{no\; redirect}$$ | run 1 | sender | receiver | | ----------- | ------------- | ------------- | | no redirect | 28.1 Gbit/sec | 27.9 Gbit/sec | | redirect | 43.8 Gbit/sec | 43.8 Gbit/sec | | ratio | x1.56 | x1.57 | | run 2 | sender | receiver | | ----------- | ------------- | ------------- | | no redirect | 28.0 Gbit/sec | 27.3 Gbit/sec | | redirect | 43.3 Gbit/sec | 43.3 Gbit/sec | | ratio | x1.55 | x1.59 | 可以看出在兩次的實驗中,效能至少提昇為 1.55 倍。透過轉傳封包簡化核心處理流程,確實能有更好的效能表現。 ## 比較 unix domain socket (UDS) 和 eBPF socket redirect 程式 兩者皆是本機上 socket 的通訊,系統核心內提供個別的實作,來簡化封包處理流程。此段落將介紹兩者在核心內的實作細節,討論兩者的異同。 ### 共通實作 作業系統要提供不同協定的網路通訊,需要在核心模組初始化註冊協定。之後系統呼叫 `socket()` 的時候,便可依不同的協定呼叫對應的 create 實作函式。 以 UDS 為例,可看到 `unix_family_ops` 被註冊,此變數的成員 `create` 為函式指標指向 `unix_create()`: ```clike static const struct net_proto_family unix_family_ops = { .family = PF_UNIX, .create = unix_create, .owner = THIS_MODULE, }; static int __init af_unix_init(void) { ... sock_register(&unix_family_ops); } ``` `SYSCALL_DEFINE3(socket)` 是 `socket()` 在系統核心中對應的函式,函式內部會使用 `__sock_create()` 的子程序 (subroutine) ```clike int __sock_create(struct net *net, int family, int type, int protocol, struct socket **res, int kern) { ... // 取得協定的實例 unix_family_ops pf = rcu_dereference(net_families[family]); ... // 呼叫 unix_family_ops.create 也就是 unix_create() err = pf->create(net, sock, protocol, kern); } ``` socket 在系統核心中以 `struct socket` 表示, create 函式會對其初始化,該結構的成員 `const struct proto_ops *ops` 用來儲存其他系統呼叫,如: `connect()` 、 `send()`...。以 `SYSCALL_DEFINE3(connect)` 來說,在呼叫的子程序 `__sys_connect_file()` 中,使用 `sock->ops->connect` 處理連線的流程。 ```clike int __sys_connect_file(struct file *file, struct sockaddr_storage *address, int addrlen, int file_flags) { ... err = READ_ONCE(sock->ops)->connect(sock, (struct sockaddr *)address, addrlen, sock->file->f_flags | file_flags); } ``` 各種 socket 操作的系統呼叫,在 [/net/socket.c](https://elixir.bootlin.com/linux/v6.9/source/net/socket.c) 的前段流程相似,後續的流程則根據協定使用不同實作函式。下面會介紹 UDS 和 eBPF 程式各自使用的實作函式和函式流程。 ### UDS 核心實作介紹 > 參考 [本机网络 IO 之 Unix Domain Socket 性能分析](https://zhuanlan.zhihu.com/p/448373622) UDS socket 的建立實際是呼叫 `unix_create()` 。 SOCK_STREAM 的情況下 `struct socket::ops` 將其指向 `unix_stream_op` connect 則是透過 `unix_stream_connect()` ```clike static int unix_stream_connect(struct socket *sock, struct sockaddr *uaddr, int addr_len, int flags) { // 為伺服器申請一個 struct sock newsk = unix_create1(net, NULL, 0, sock->type); ... // 申請一個 owner 為 newsk 的 skb skb = sock_wmalloc(newsk, 1, 0, GFP_KERNEL); ... // 查詢伺服器的 struct sock other = unix_find_other(net, sunaddr, addr_len, sk->sk_type); ... // 建立兩個 struct sock 的關係 unix_peer(newsk) = sk; newsk->sk_state = TCP_ESTABLISHED; newsk->sk_type = sk->sk_type; ... sk->sk_state = TCP_ESTABLISHED; unix_peer(sk) = newsk; // 把 skb 放入伺服器的接收佇列 __skb_queue_tail(&other->sk_receive_queue, skb); } ``` 連線只需將 `struct socket` 下的 `struct sock` 互相指向對方,並且封包可以直接放入對方 `struct sock::sk_receiving_queue` 的佇列中 send 的實作 `unix_stream_sendmsg()` 同理,將應用程式的資料複製到核心,並直接將封包放入對方的接收佇列中 ```clike static int unix_stream_sendmsg(struct socket *sock, struct msghdr *msg, size_t len) { // 申請一塊 skb skb = sock_alloc_send_pskb(sk, len - data_len, data_len, msg->msg_flags & MSG_DONTWAIT, &err, get_order(UNIX_SKB_FRAGS_SZ)); ... // 將使用者的資料從 msghdr 複製到 skb err = skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, len); ... // 將 skb 加入另一端的接收佇列 skb_queue_tail(&other->sk_receive_queue, skb); ... // 呼叫另一端的回調函式 other->sk_data_ready(other); } ``` `unix_stream_recvmsg()` 則在子程序 `unix_stream_read_generic()` 中從己方的接收佇列讀取封包,複製一次到應用程式提供的緩存區 ```clike static int unix_stream_read_generic(struct unix_stream_read_state *state, bool freezable) { // 取得接收佇列第一個 skb 的指標 last = skb = skb_peek(&sk->sk_receive_queue); ... // recv_actor 指向 unix_stream_read_actor // 將封包從 skb 複製到 msghdr chunk = state->recv_actor(skb, skip, chunk, state); ... // 釋放複製完的 skb consume_skb(skb); } ``` ### eBPF socket redirect 核心實作介紹 socket redirect 首先以 tcp 的方式建立 socket , create 函式與 tcp 同樣為 `inet_create()` ```clike static int inet_create(struct net *net, struct socket *sock, int protocol, int kern) { // 根據協定,查詢對應的實作函式 list_for_each_entry_rcu(answer, &inetsw[sock->type], list) { ... } // 初始化 struct socket::ops sock->ops = answer->ops; answer_prot = answer->prot; ... // 初始化 struct sock sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern); ... } ``` list `inetsw` 的內容透過 `inetsw_array` 初始化,參考該陣列找到 `answer->ops` 、 `answer->prot` 實際值分別是 `&inet_stream_ops` 和 `&tcp_prot` , `&tcp_prot` 將存入 `struct sock::sk_prot` 。 UDS 主要流程在 `struct socket::ops` 完成,tcp 與之不同, `struct socket::ops` 會間接呼叫 `struct sock::sk_prot` 。參考 `inet_sendmsg()` 便一目了然。 ```clike 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); } ``` connect 和 accept 的部分與普通 tcp 流程無異,須經底層協定和網路裝置驅動等層層關卡,特別之處在連線建立後會觸發 sock_ops 程式,程式中會呼叫 `bpf_sock_hash_update()` 將 socket 加入 map ,核心中經過以下流程 ```clike BPF_CALL_4(bpf_sock_hash_update) sock_hash_update_common() sock_map_link() sock_map_init_proto() psock_set_prog() ``` `sock_map_init_proto()` 函式內呼叫的 `struct sock::sk_prot::psock_update_sk_prot` 實際上是 `tcp_bpf_update_proto()` , ```clike int tcp_bpf_update_proto(struct sock *sk, struct sk_psock *psock, bool restore) { ... // 將 sk->sk_prot 指派為 tcp_bpf_prots[TCP_BPF_IPV4][TCP_BPF_TX] sock_replace_proto(sk, &tcp_bpf_prots[family][config]); return 0; } ``` `tcp_bpf_prots[TCP_BPF_IPV4][TCP_BPF_TX]` 的 `destroy` 、 `close` 、 `recvmsg` 、 `sock_is_readable` 和 `sendmsg` 採用 eBPF 特別的實作函式,其餘函式則和 `tcp_prot` 保持一致。透過修改 sk_prot 就能改變系統呼叫 `send()` 、 `recv()` 在核心的處理流程。 `psock_set_prog()` 將 map 上附加的程式存入 `struct sk_psock` ,並建立其與 `struct socket` 的關聯,把指標記錄在 `struct sock::sk_user_data` 經過上面修改之後, `sendmsg` 將呼叫 `tcp_bpf_sendmsg()` ,複製封包到 sk_msg 後,進入後續處理 函式內將呼叫 verdict 程式,依據回傳值決定封包傳輸方向 ```clike static int tcp_bpf_sendmsg(struct sock *sk, struct msghdr *msg, size_t size) { // 申請一個 sk_msg err = sk_msg_alloc(sk, msg_tx, msg_tx->sg.size + copy, msg_tx->sg.end - 1); ... // msghdr 複製封包到 sk_msg err = sk_msg_memcopy_from_iter(sk, &msg->msg_iter, msg_tx, copy); ... // 實際發送的函式,會先進行 verdict err = tcp_bpf_send_verdict(sk, psock, msg_tx, &copied, flags); ... } ``` `tcp_bpf_send_verdict()` 會呼叫 verdict 程式,再依據回傳值決定封包傳輸方向。轉傳成功時走以下路徑 ```clike static int tcp_bpf_send_verdict(struct sock *sk, struct sk_psock *psock, struct sk_msg *msg, int *copied, int flags) { // 呼叫 verdict 程式 psock->eval = sk_psock_msg_verdict(sk, psock, msg); // 根據 verdict 回傳決定處理方式 switch (psock->eval) { case __SK_REDIRECT: ... ret = tcp_bpf_sendmsg_redir(sk_redir, redir_ingress, msg, tosend, flags); ... } } ``` `sk_psock_msg_verdict()` 執行 verdict 程式 ```clike int sk_psock_msg_verdict(struct sock *sk, struct sk_psock *psock, struct sk_msg *msg) { // 指標 msg_parser 指向 verdict 程式 prog = READ_ONCE(psock->progs.msg_parser); ... // 執行 verdict 程式 ret = bpf_prog_run_pin_on_cpu(prog, msg); ... } ``` 轉傳成功時, `tcp_bpf_sendmsg_redir()` 會區分 ingress 和 egress 進入不同程序 ```clike 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, flags) : tcp_bpf_push_locked(sk, msg, bytes, flags, false); sk_psock_put(sk, psock); return ret; } ``` ingress 的情況,呼叫 `bpf_tcp_ingress()` 將封包存入目標 socket 的 `struct sk_psock::ingress_msg` 佇列 ```clike static int bpf_tcp_ingress(struct sock *sk, struct sk_psock *psock, struct sk_msg *msg, u32 apply_bytes, int flags) { ... // 將 sk_msg 加入另一端的接收佇列 sk_psock_queue_msg(psock, tmp); // 呼叫另一端的回調函式 sk_psock_data_ready(sk, psock); } ``` 封包接收部分, `tcp_bpf_recvmsg()` 判斷該從 `ingress_msg` 佇列接收時,將 sk_msg 複製到使用者提供的緩存區。 ```clike static int tcp_bpf_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int flags, int *addr_len) { ... // 將 sk_msg 複製到 msghdr copied = sk_msg_recvmsg(sk, psock, msg, len, flags); ... } ``` ### UDS 和 eBPF 程式的異同 兩者使用的動機大多是為了改善本機的 socket 通訊性能。因此,將它們與通過 loopback 進行通訊的方式進行比較時,UDS 和 eBPF 有一些相似之處。兩者在收發封包時都能夠避免處理網路協定、設備驅動以及路由的部分,因此其工作量顯著低於通過 loopback 進行通訊的情況。在資料複製方面,這三者的操作相似,即從用戶空間複製到核心空間,以及從核心空間複製到用戶空間,總共複製兩次。 接著比較 UDS 和 eBPF 核心實作的差異。連線建立的階段, eBPF 採用 tcp 的方式,意味著需經過底層所有的流程,反觀 UDS 只要將物件透過指標互相指向對方就可完成,性能上 UDS 可能更具優勢。封包傳送的部分,兩者的模式都是直接將封包加入目標 socket 的接收佇列,在資料複製的次數也相同,但在封包的表示上, eBPF 選用 `struct sk_msg` 作為核心的數據類別,比 UDS 使用的類別 `struct sk_buff` 精簡許多,在大量封包傳送的情況,或許能有更好的表現。 在開發和執行應用程式方面,UDS 和 eBPF 分別採用靜態和動態的修改方式。UDS 需要對程式碼修改,但應用程式只要針對業務需求,不用像 eBPF 需要額外的程式碼,變更系統的行為。另外,執行應用程式時,UDS 不需要特別權限或者較新的系統,且不需要擔心 eBPF 加載是否成功的問題。而 eBPF 動態加載的優勢在於,應用程式不需要進行修改或重啟,即可在系統運行時進行更改,從而保持系統的可用性。此外,eBPF 修改的範圍可涵蓋整個系統,不論是對應用程式內的多條連線,還是系統中的多個程式,都能夠帶來效益,省去了大量修改程式的工作。