# 以 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 修改的範圍可涵蓋整個系統,不論是對應用程式內的多條連線,還是系統中的多個程式,都能夠帶來效益,省去了大量修改程式的工作。