Try   HackMD

Building a TCP Server with eBPF

正式期末專題筆記,公開於 Linux 核心專題: 以 eBPF 打造 TCP 伺服器

參考 2023 年開發紀錄 之筆記

eBPF (Extended Berkeley Packet Filter)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

使用者在 User space 中撰寫 BPF 程式(C 語言),並透過特殊編譯工具(常見的 BPF 編譯工具包括 LLVM/Clang 和一些專用的 BPF 編譯器 (如 BCC 和 bpftrace))將其轉換為 BPF Bytecode,並注入到 Kernel。

此 Bytecode 是一種經過優化的程式碼,以利在 Kernel 中的效率執行;另外,其有特定格式,以讓 Verifier 能夠有效進行靜態分析。

此時,會需要一 Verifier 防止其注入有害的東西(如無法使用的 Kernel API、存取範圍之限制、非法記憶體存取、無限迴圈等)。驗證成功後,即可在 BPF 虛擬機(一種說法,BPF 就類似 Kernel 的虛擬機器,協助其過濾封包任務)上執行。

之後,依據不同 BPF 之 Program type,如果觸發到不同的 Hook point(允許 BPF 程式監控和操作不同的核心事件),如 kprobes、uprobes、tracepoints 和 perf_events 等,對應的 BPF 程式就會立即執行(如過濾、分析或資料蒐集等任務)。並將獲得的資訊記錄到 Map

最後,User 端可透過 System code 來存取 Map,以共享資料。

另外,程式則可以透過 perf_output 等機制,「非同步地」從 Map 中讀取資料,確保資料處理效率的最大化,且不影響 Kernel 中的其它程式。

tcpdump

封包過濾工具 tcpdump 之底層運作邏輯,即基於 BPF 來完成封包過濾。

流程如下:

  1. tcpdump 藉由 libpcap 轉譯「封包過濾條件」給位於核心內的 BPF;
  2. BPF 依照「條件」來過濾封包;
  3. BPF 將「過濾後的封包」從 Kernel space 複製到 User space;
  4. 「過濾後的封包」被 libpcap 接收;
  5. libpcap 再將「過濾後的封包」傳給 tcpdump

IP 相關過濾

透過以下指令捕捉 IP 相關的封包,並觀察 ip 過濾條件編譯的情形

$ 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
      (6 bytes)
      Source MAC Address
      (6 bytes)
      Ether Type
      (2 bytes)
      Payload
  • (001): 檢查暫存器的值是否為 0x0800,即 IP 的 Ether Type,如果是就跳到 (002),否則跳到 (003)
  • (002), (003): BPF 根據回傳值,決定是否要過濾該封包。
    • 非 0:要擷取的封包長度;
    • 0 :不要該封包。

BPF maps

不同型態之資料的儲存空間,以提供 User space 及 Kernel space 之資料共享。

其可透過 BPF syscall 來存取。

int map_fd;
union bpf_attr attr = {
    .map_type = BPF_MAP_TYPE_ARRAY;
    .key_size = sizeof(__u32);
    .value_size = sizeof(__u32);
    .max_entries = 256;
};

map_fd = bpf(BPF_MAP_CREATE, &attr, sizeof(attr));

eBPF System Call

int bpf(int command, union bpf_attr *attr, u32 size);

command:使用 bpf() system call 來執行 command 指定的操作。
attr:該操作採用 attr 中提供的參數。
sizeattr 的大小。

command 舉例如下,詳細可參見 eBPF Syscall

  • BPF_MAP_CREATE
  • BPF_MAP_LOOKUP_ELEM
  • BPF_MAP_UPDATE_ELEM
  • BPF_PROG_LOAD

可使用 Bpftool 工具,來達成與呼叫 System Call 相同的效果

Socket Lookup

當接收到一個封包時,可以透過 socket lookup 判斷這個封包該由哪個 socket 接收

觀察核心的 socket lookup 程式碼 linux/include/net/inet_hashtables.hlinux/net/ipv4
/inet_hashtables.c

  1. 先尋找 established 狀態的 socket;
  2. 如果上一步驟沒找到合適的 socket,再尋找 listening 狀態的 socket;
  3. 如果上一步驟沒找到合適的 socket,使用 INADDR_ANY 尋找 listening 狀態的 socket;
  4. 若還是沒找到,回傳 NULL。

其中下列程式碼的第 33 至 39 行,可以發現 eBPF 會在這裡 socket lookup,也就是在上述第二步驟前執行,也可以從 BPF sk_lookup program 得知,在上述步驟二尋找 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.

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; }

Socket Redirect

圖片

當傳送端與目的端「在同一台電腦」時,可省略 Network stack 的處理,直接在兩個 Socket 間傳輸即可。

使用 eBPF 建構 echo server

圖片

可在 Kernel space 達到接收/傳送封包的功能;不需修改、更新核心程式碼;User space 的 server 也就不用接收/傳送封包了。

如此,可節省兩次 Memory copies 的時間及空間,達到 Zero-copy 之成效。

圖片

如上圖左半,即 client 與 server 在同一台電腦時,傳遞封包的情況;如上圖右半,若其在不同電腦,則可在另一端之 socket 收到封包後,將封包 redirect 到「該封包的傳送端」,以在「不將資料複製到 User 端」的情形下,回傳封包給對方。

實作成果:YSRossi/ebpf-tcp-server

程式及 API 理解

部分參照 bpf-helpers(7)

bpf_sockops.h

bpf_sockops.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, __u32);
    __uint(max_entries, 65535);
    __uint(map_flags, 0);
} sockmap_ops SEC(".maps");

想要在兩個 socket 之間直接傳輸,需要一個 map 來紀錄 socket。
宣告 struct sockmap_key 以建立連線雙方的 ip 與 port 作為 map (sockmap_ops) 的 key。

bpf_sockops.c

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; ret = bpf_sock_hash_update(skops, &sockmap_ops, &skm_key, BPF_NOEXIST); if (ret) { bpf_printk("Update map failed. %d\n", -ret); return; } } 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; }
struct bpf_sock_ops

觀察 linux/bpf.hstruct bpf_sock_ops 的註解可知,可由 struct bpf_sock_ops 獲取 socket 資訊。

User bpf_sock_ops struct to access socket values and specify request ops and their replies.

update_sockmap_ops

利用 struct bpf_sock_ops 中獲取所需的 socket 資訊作為 key,再於第 12 行使用 bpf_sock_hash_update 將該 socket 存入 map 供後續操作使用。

參照 bpf-helpers(7)bpf_sock_hash_update 的描述:
long bpf_sock_hash_update(struct bpf_sock_ops *skops, struct bpf_map *map, void *key, u64 flags)
Add an entry to, or update a sockhash map referencing sockets. The skops is used as a new value for the entry associated to key. flags is one of: BPF_NOEXIST(The entry for key must not exist in the map.), BPF_EXIST, BPF_ANY.

bpf_sockmap

首先觀察第 14 行,此程式只支援 IPv4(AF_INET)協議的 socket。

第 27 行判斷若 opBPF_SOCK_OPS_ACTIVE_ESTABLISHED_CBBPF_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.

bpf_redir.c

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; }
bpf_redir

先用一些條件句判斷該封包是否需 redirect。
然後判斷 client 與 server 是否在同一台電腦上,並分別執行對應的 redirect 操作(redirect 之路徑不同)。

struct __sk_buff

為「即將被 socket 接收的封包」,sk_skb 類型的程式碼可以存取 struct __sk_buff 的成員,選擇 sockmap key 的所需資訊,紀錄到 skm_key 中。

於第 3、6、9 行判斷送過來的封包是否可以直接通過(return SK_PASS),邏輯如下:

  • 若該 socket 非 IPv4 協議,不作 redirect。
  • 若不是「特定伺服器」埠上的封包,也直接使封包通過,不作 redirect。
  • 若為 FIN 封包,不作 redirect。

    skb->len 表示封包 payload 的大小,FIN 封包的 skb->len 為 0,因此當 skb->len 不為 0 時,才將該封包 redirect。

bpf_sk_redirect_hash
  • 同一台電腦:
    判斷若 skb->remote_ip4 == skb->local_ip4 表 client 與 server 在同一台電腦上,故其 bpf_sk_redirect_hash 邏輯是將封包 redirect 到 BPF_F_INGRESS(ingress path),即 client 的 socket。
  • 不同台電腦:
    若在不同台電腦,則 server socket 收到封包後, redirect 到自己的 egress interface,也就是傳送出去的方向。

bpf_sk_redirect_hash:This helper is used in programs implementing policies at the skb socket level. If the sk_buff skb is allowed to pass (i.e. if the verdict eBPF program returns SK_PASS), redirect it to the socket referenced by map (of type BPF_MAP_TYPE_SOCKHASH) using hash key. Both ingress and egress interfaces can be used for redirection. The BPF_F_INGRESS value in flags is used to make the distinction (ingress path is selected if the flag is present, egress otherwise). This is the only flag supported for now.

因此可以在 kernel space 中達到相當於在 user space 呼叫 recv 與 send 的功能,減少 user space 與 kernel space 之間 memory copy 的成本-在 map 中以 &skm_key 搜尋目標 socket,並將 msg 傳到指定之目標 &sockmap_ops

透過 ebpf-summit-2020 學習 eBPF 程式之載入與固定

Load echo_dispatch BPF program

vm $ cd /vagrant
vm $ sudo ./bpftool prog load ./echo_dispatch.bpf.o /sys/fs/bpf/echo_dispatch_prog
vm $ sudo ./bpftool prog show pinned /sys/fs/bpf/echo_dispatch_prog
49: sk_lookup  name echo_dispatch  tag da043673afd29081  gpl
        loaded_at 2020-10-28T16:13:42+0000  uid 0
        xlated 272B  jited 164B  memlock 4096B  map_ids 3,4
        btf_id 4

這裡使用 bpftool,將編譯好的 BPF 程式(echo_dispatch.bpf.o)載入到 /sys/fs/bpf,即 Linux 核心用來管理 BPF object 的檔案系統。

Pin BPF maps used by echo_dispatch

vm $ mkdir ~/bpffs
vm $ sudo mount -t bpf none ~/bpffs
vm $ sudo chown vagrant.vagrant ~/bpffs

建立一個資料夾來作為 BPF 檔案系統的掛載點,然後通過 mount 命令將 BPF 檔案系統掛載到該資料夾中。如此,即可在該資料夾中操作 BPF objects。

vm $ sudo ./bpftool map show name echo_ports
3: hash  name echo_ports  flags 0x0
        key 2B  value 1B  max_entries 1024  memlock 86016B
vm $ sudo ./bpftool map pin name echo_ports ~/bpffs/echo_ports

使用 bpftool map show name 可查看已建立的 BPF map 的詳細資料。

bpftool map pin name 的意思,就是將 BPF maps 或 BPF programs 固定到特定的路徑(剛創建的 BPF 檔案系統 bpffs/echo_ports),就能在 User space 永久存取這些 objects。

觀察 ebpf-tcp-server eBPF 程式之附加

附加到 hook。
# bpftool prog attach <program> <attach type> <target map>
# bpftool cgroup attach <cgroup> <attach type> <program> [flags]

Attach bpf_sockops to cgroup

$ sudo bpftool cgroup attach /sys/fs/cgroup/ sock_ops pinned /sys/fs/bpf/bpf_sockops

先前已將 bpf_sockops.o 載入到 /sys/fs/bpf/bpf_sockops
並將 sockmap_ops 之 map 固定到 bpffs/sockmap_ops

此段命令,即將 /sys/fs/bpf/bpf_sockops 程式,附加到 /sys/fs/cgroup 中的 sock_ops hook。

  • cgroup:是 Linux 中用來限制、記錄和隔離 process set 資源使用情況的一種機制。每個 cgroup 都可以包含多個 processes。
  • /sys/fs/cgroup/:是 Linux 系統中,cgroup 的掛載點。

Attach bpf_redir to sockmap_ops

$ sudo bpftool prog attach pinned /sys/fs/bpf/bpf_redir stream_verdict pinned bpffs/sockmap_ops

先前已將 bpf_sockops.o 載入到 /sys/fs/bpf/bpf_sockops

此段命令,即將 /sys/fs/bpf/bpf_redir 程式中的 stream_verdict 附加型態,附加到 bpffs/sockmap_ops 之 map 。

  • stream_verdict:特定的附加類型,表示該程式會處理 stream 事件。

重現 ebpf-tcp-server

目標:確保 ebpf-tcp-server 可在 Linux v6.8 以上的環境運作。

實現環境

$ gcc --version
gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0
Copyright (C) 2023 Free Software Foundation, Inc.

$ uname -a
Linux ss 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC
Sat Apr 20 00:40:06 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux

$ lscpu
Architecture:            x86_64
CPU op-mode(s):          32-bit, 64-bit
Address sizes:           45 bits physical, 48 bits virtual
Byte Order:              Little Endian
CPU(s):                  4
On-line CPU(s) list:     0-3
Vendor ID:               GenuineIntel
Model name:              Intel(R) Core(TM) i7-13700
CPU family:              6
Model:                   183
Thread(s) per core:      1
Core(s) per socket:      1
Socket(s):               4
Stepping:                10
BogoMIPS:                4224.00
Caches (sum of all):     
  L1d:                   192 KiB (4 instances)
  L1i:                   128 KiB (4 instances)
  L2:                    8 MiB (4 instances)
  L3:                    120 MiB (4 instances)
NUMA:                    
  NUMA node(s):          1
  NUMA node0 CPU(s):     0-3

編譯問題與解決方案

Commit 61d248d

問題一

In file included from bpf_sockops.c:1:
./bpf_sockops.h:4:10: fatal error: 'bpf/bpf_endian.h' file not found
    4 | #include <bpf/bpf_endian.h>
      |          ^~~~~~~~~~~~~~~~~~
1 error generated.

sudo apt-get install libbpf-dev 安裝 BPF 開發相關工具即可。

問題二

In file included from bpf_sockops.c:1:
In file included from ./bpf_sockops.h:5:
In file included from /usr/include/bpf/bpf_helpers.h:11:
/usr/include/bpf/bpf_helper_defs.h:78:83: error: unknown type name '__u64'
   78 | static long (*bpf_map_update_elem)(void *map, const void *key, const void *value, __u64 flags) = (void *) 2;
      |                                                                                   ^
/usr/include/bpf/bpf_helper_defs.h:102:42: error: unknown type name '__u32'
  102 | static long (*bpf_probe_read)(void *dst, __u32 size, const void *unsafe_ptr) = (void *) 4;
      /*...*/

此問題是程式未提供 __u32__u64 等資料型態給 bpf_helpers.h 使用。
因此,可在引用 bpf/bpf_helpers.h 前,先引用 linux/types.h 即可解決。

+ #include <linux/types.h> #include <bpf/bpf_helpers.h>

問題三

In file included from bpf_sockops.c:1:
In file included from ./bpf_sockops.h:4:
/usr/include/linux/types.h:5:10: fatal error: 'asm/types.h' file not found
    5 | #include <asm/types.h>
      |          ^~~~~~~~~~~~~
1 error generated.

透過命令 sudo apt-get install -y gcc-multilib 安裝相關檔案。

載入及附加

透過 scripts 中的 ebpf_load.sh 檔,以 bpftool 進行 ebpf 相關程式之載入及附加到核心即可。

成功後,可使用 sudo bpftool prog list 檢查該 2 程式之相關掛載資訊:

85: sock_ops  name bpf_sockmap  tag c40f4006341c743d  gpl
	loaded_at 2024-06-14T13:54:24+0800  uid 0
	xlated 336B  jited 193B  memlock 4096B  map_ids 14,16
	btf_id 120
94: sk_skb  name bpf_redir  tag f1c44dbbde813660  gpl
	loaded_at 2024-06-14T13:54:24+0800  uid 0
	xlated 504B  jited 287B  memlock 4096B  map_ids 14,20
	btf_id 130

另能透過執行 scripts/delete.sh 將其卸除。

測試問題與解決方案

問題一

開啟 ebpf-tcp-server 後,以 bench 程式重複執行測試時,儘管有成功連上 server;卻會在一定的測試次數(約 2 次)後失敗而不會回應 correct。而一段時間後,又可正常運作 2 次。

經測試後,發現程式碼會卡在如下之 recv 函式部分:

gettimeofday(&start, NULL); int send_len = send(sock_fd, msg_dum, strlen(msg_dum), 0); int recv_len = recv(sock_fd, dummy, MAX_MSG_LEN, 0); gettimeofday(&end, NULL);

為此,設計一 recv 函式之超時機制,並在特定函式執行時,輸出階段任務說明,以便追蹤測試現況:

Commit 8bc1cdc

struct timeval timeout;
timeout.tv_sec = 3; // 3秒超時
timeout.tv_usec = 0;
if (setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) {
    perror("setsockopt");
    exit(-1);
}

/*...*/

if (recv_len == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        printf("recv timeout occurred\n");
    } else {
        perror("recv");
    }
    exit(-1);
} else if (recv_len == 0) {
    printf("Connection closed by peer\n");
    exit(0);
} else {
    dummy[recv_len] = '\0';
}

使用 tcpdump 觀察封包傳遞情況,recv block 之情形如下:

10:41:38.614672 lo    In  IP localhost.34904 > localhost.12345: Flags [S], seq 1391280987, win 33280, options [mss 65495,sackOK,TS val 1045187079 ecr 0,nop,wscale 7], length 0
10:41:38.614682 lo    In  IP localhost.12345 > localhost.34904: Flags [S.], seq 2073540954, ack 1391280988, win 33280, options [mss 65495,sackOK,TS val 1045187080 ecr 1045187079,nop,wscale 7], length 0
10:41:38.614701 lo    In  IP localhost.34904 > localhost.12345: Flags [.], ack 1, win 260, options [nop,nop,TS val 1045187080 ecr 1045187080], length 0
10:41:38.614736 lo    In  IP localhost.34904 > localhost.12345: Flags [P.], seq 1:50, ack 1, win 260, options [nop,nop,TS val 1045187080 ecr 1045187080], length 49
10:41:38.614739 lo    In  IP localhost.12345 > localhost.34904: Flags [.], ack 50, win 260, options [nop,nop,TS val 1045187080 ecr 1045187080], length 0
10:41:41.662923 lo    In  IP localhost.34904 > localhost.12345: Flags [F.], seq 50, ack 1, win 260, options [nop,nop,TS val 1045190128 ecr 1045187080], length 0
10:41:41.703783 lo    In  IP localhost.12345 > localhost.34904: Flags [.], ack 51, win 260, options [nop,nop,TS val 1045190169 ecr 1045190128], length 0

Server 回應成功並輸出 correct 之封包傳遞情形如下:

11:04:15.601552 lo    In  IP localhost.42356 > localhost.12345: Flags [S], seq 4015816221, win 33280, options [mss 65495,sackOK,TS val 1046544066 ecr 0,nop,wscale 7], length 0
11:04:15.601563 lo    In  IP localhost.12345 > localhost.42356: Flags [S.], seq 2350462786, ack 4015816222, win 33280, options [mss 65495,sackOK,TS val 1046544066 ecr 1046544066,nop,wscale 7], length 0
11:04:15.601581 lo    In  IP localhost.42356 > localhost.12345: Flags [.], ack 1, win 260, options [nop,nop,TS val 1046544066 ecr 1046544066], length 0
11:04:15.601623 lo    In  IP localhost.42356 > localhost.12345: Flags [P.], seq 1:50, ack 1, win 260, options [nop,nop,TS val 1046544066 ecr 1046544066], length 49
11:04:15.601632 lo    In  IP localhost.12345 > localhost.42356: Flags [.], ack 50, win 260, options [nop,nop,TS val 1046544066 ecr 1046544066], length 0
11:04:15.601693 lo    In  IP localhost.42356 > localhost.12345: Flags [F.], seq 50, ack 1, win 260, options [nop,nop,TS val 1046544067 ecr 1046544066], length 0
11:04:15.642171 lo    In  IP localhost.12345 > localhost.42356: Flags [.], ack 51, win 260, options [nop,nop,TS val 1046544107 ecr 1046544067], length 0

可以觀察到成功/失敗的封包傳遞情況相同-正常的三向交握、Client 傳長度為 49 bytes 的 PUSH 包給 Server,之後發送 FIN 包請求斷開連結;最後 Server 再回傳 ACK 包確認其 FIN 包之接收。

推測:應是 eBPF 流程方面的問題,TODO: 使用 eBPF debugger 尋找其問題所在。

Ftrace

閱讀【Demystifying the Linux CPU Scheduler】第六章,製作 筆記 以學習 Ftrace 基本知識。

這裡嘗試以 Ftrace 來找出「重複對 ebpf-echo-server 執行 bench 時,bench 會卡在 recv」的問題點。

首先確認 Ftrace 是否可供我的系統使用:

$ cat /boot/config-`uname -r` | grep CONFIG_HAVE_FUNCTION_TRACER

CONFIG_HAVE_FUNCTION_TRACER=y

觀察 /sys/kernel/debug/tracing/ 內的檔案,並搭配 Ftrace 中的 The File System 章節來查看對應檔案所負責的功能。

$ sudo ls /sys/kernel/debug/tracing
available_events		  options		  stack_trace_filter
available_filter_functions	  osnoise		  synthetic_events
available_filter_functions_addrs  per_cpu		  timestamp_mode
available_tracers		  printk_formats	  touched_functions
buffer_percent			  README		  trace
buffer_size_kb			  rv			  trace_clock
buffer_subbuf_size_kb		  saved_cmdlines	  trace_marker
buffer_total_size_kb		  saved_cmdlines_size	  trace_marker_raw
current_tracer			  saved_tgids		  trace_options
dynamic_events			  set_event		  trace_pipe
dyn_ftrace_total_info		  set_event_notrace_pid   trace_stat
enabled_functions		  set_event_pid		  tracing_cpumask
error_log			  set_ftrace_filter	  tracing_max_latency
events				  set_ftrace_notrace	  tracing_on
free_buffer			  set_ftrace_notrace_pid  tracing_thresh
function_profile_enabled	  set_ftrace_pid	  uprobe_events
hwlat_detector			  set_graph_function	  uprobe_profile
instances			  set_graph_notrace	  user_events_data
kprobe_events			  snapshot		  user_events_status
kprobe_profile			  stack_max_size
max_graph_depth			  stack_trace

觀察 Ftrace 預設追蹤之 eBPF 相關函式:

$ sudo cat /sys/kernel/debug/tracing/available_filter_functions | grep ebpf
unpriv_ebpf_notify
__tun_set_ebpf
tun_set_ebpf

設定 shell script 如下以追蹤 tun_set_ebpf,以便快速測試 eBPF 運作:

#!/bin/bash
TRACE_DIR=/sys/kernel/debug/tracing

# clear
echo 0 > $TRACE_DIR/tracing_on
echo > $TRACE_DIR/set_graph_function
echo > $TRACE_DIR/set_ftrace_filter
echo nop > $TRACE_DIR/current_tracer

# setting
echo function_graph > $TRACE_DIR/current_tracer
echo 3 > $TRACE_DIR/max_graph_depth
echo tun_set_ebpf > $TRACE_DIR/set_graph_function

# execute
echo 1 > $TRACE_DIR/tracing_on
./bench
echo 0 > $TRACE_DIR/tracing_on

開啟 ebpf-echo-server,以 root 模式(sudo -i)執行上述 script 即測試程式 bench,並透過 cat /sys/kernel/debug/tracing/trace 觀察 recv 無 blocked,伺服器與 bench 成功交流之輸出如下:

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
   3)               |  /* bpf_trace_printk: Update map success.
 */
   3)               |  /* bpf_trace_printk: Update map success.
 */
   3)               |  /* bpf_trace_printk: bpf_sk_redirect_hash() success, redirected
 */

可見 map 的成功建立,即封包的 redirect 成功。

繼續測試,觀察「recv blocked」,測試程式失敗之輸出如下:

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
   1)               |  /* bpf_trace_printk: Update map success.
 */
   1)               |  /* bpf_trace_printk: Update map success.
 */
   2)               |  finish_task_switch.isra.0() {
   2)   0.150 us    |    _raw_spin_unlock();
   2)   1.910 us    |  }
   0)               |  finish_task_switch.isra.0() {
   0)   0.200 us    |    _raw_spin_unlock();
   0)   1.387 us    |  }
 ------------------------------------------
   1)   bench-4119   =>  systemd-758  
 ------------------------------------------

   1)               |  finish_task_switch.isra.0() {
   1)   0.339 us    |    _raw_spin_unlock();
   1)   2.845 us    |  }
 ------------------------------------------
   1)  systemd-758   =>   bench-4119  
 ------------------------------------------

   1)               |  /* bpf_trace_printk: bpf_sk_redirect_hash() failed 0, error 
 */

另外,改為測試 sock_recvmsg 之運作,成功的情況輸出如下:

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
   0)               |  /* bpf_trace_printk: Update map success.
 */
   0)               |  /* bpf_trace_printk: Update map success.
 */
   0)               |  /* bpf_trace_printk: bpf_sk_redirect_hash() success, redirected
 */
   0)               |  sock_recvmsg() {
   0)               |    security_socket_recvmsg() {
   0)   0.236 us    |      apparmor_socket_recvmsg();
   0)   0.585 us    |    }
   0)               |    inet_recvmsg() {
   0) + 25.348 us   |      tcp_bpf_recvmsg_parser();
   0) + 25.617 us   |    }
   0) + 26.736 us   |  }
   1)               |  sock_recvmsg() {
   1)               |    security_socket_recvmsg() {
   1)   0.205 us    |      apparmor_socket_recvmsg();
   1)   0.553 us    |    }
   1)               |    unix_stream_recvmsg() {
   1)   0.509 us    |      unix_stream_read_generic();
   1)   0.722 us    |    }
   1)   2.336 us    |  }
   
   /* ... */
   
 ------------------------------------------
   0)   bench-4092   =>  gnome-t-3235 
 ------------------------------------------

   0)               |  sock_recvmsg() {
   0)               |    security_socket_recvmsg() {
   0)   0.206 us    |      apparmor_socket_recvmsg();
   0)   0.528 us    |    }
   0)               |    unix_stream_recvmsg() {
   0)   6.843 us    |      unix_stream_read_generic();
   0)   7.053 us    |    }
   0)   8.561 us    |  }

產生問題之輸出如下:

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
   0)               |  /* bpf_trace_printk: Update map success.
 */
   0)               |  /* bpf_trace_printk: Update map success.
 */
   0)               |  sock_recvmsg() {
   0)               |    security_socket_recvmsg() {
   0)   0.212 us    |      apparmor_socket_recvmsg();
   0)   0.501 us    |    }
   0)               |    inet_recvmsg() {
   0) $ 3057307 us  |      tcp_bpf_recvmsg_parser();
   2)               |  sock_recvmsg() {
   2)               |    security_socket_recvmsg() {
   2)   0.198 us    |      apparmor_socket_recvmsg();
   2)   0.474 us    |    }
   2)               |    unix_stream_recvmsg() {
   2)   0.462 us    |      unix_stream_read_generic();
   2)   0.666 us    |    }
   2)   1.949 us    |  }
   
    /* ... */
    
   2)               |  sock_recvmsg() {
   2)               |    security_socket_recvmsg() {
   2)   0.314 us    |      apparmor_socket_recvmsg();
   2)   0.786 us    |    }
   2)               |    unix_stream_recvmsg() {
   2)   0.838 us    |      unix_stream_read_generic();
   2)   1.121 us    |    }
   2)   3.378 us    |  }
   0) $ 3057308 us  |    }
   0) $ 3057309 us  |  }
   0)               |  /* bpf_trace_printk: bpf_sk_redirect_hash() failed 0, error 
 */

可以看到 CPU 0 在處理 tcp_bpf_recvmsg_parser() 時就卡住了,最後導致 redirect 失敗,而時長為我設置的 3 秒超時機制。為了近一步找出問題,改以追蹤 tcp_bpf_recvmsg_parser 之失敗情形:

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
   1)               |  /* bpf_trace_printk: Update map success.
 */
   1)               |  /* bpf_trace_printk: Update map success.
 */
   1)               |  tcp_bpf_recvmsg_parser() {
   1)   0.129 us    |    __rcu_read_lock();
   1)   0.085 us    |    __rcu_read_unlock();
   1)               |    lock_sock_nested() {
   1)   0.085 us    |      __cond_resched();
   1)   0.090 us    |      _raw_spin_lock_bh();
   1)   0.126 us    |      _raw_spin_unlock_bh();
   1)   0.618 us    |    }
   1)               |    sk_msg_recvmsg() {
   1)   0.087 us    |      _raw_spin_lock_bh();
   1)   0.112 us    |      _raw_spin_unlock_bh();
   1)   0.443 us    |    }
   1)               |    tcp_msg_wait_data() {
   1)   0.176 us    |      add_wait_queue();
   1)   0.249 us    |      release_sock();
   1) $ 3032137 us  |      wait_woken();
   1)   0.832 us    |      lock_sock_nested();
   1)   1.019 us    |      remove_wait_queue();
   1) $ 3032142 us  |    }
   1)               |    tcp_rcv_space_adjust() {
   1)   0.276 us    |      tcp_mstamp_refresh();
   1)   0.618 us    |    }
   1)               |    release_sock() {
   1)   0.136 us    |      _raw_spin_lock_bh();
   1)   0.132 us    |      tcp_release_cb();
   1)   0.200 us    |      _raw_spin_unlock_bh();
   1)   1.164 us    |    }
   1) $ 3032146 us  |  }
   1)               |  /* bpf_trace_printk: bpf_sk_redirect_hash() failed 0, error 
 */

發現測試過程會卡在 wait_woken(),接著測試 wait_woken

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
   3)               |  /* bpf_trace_printk: Update map success.
 */
   3)               |  /* bpf_trace_printk: Update map success.
 */
   3)   0.416 us    |  wait_woken();
   3)               |  wait_woken() {
   3)   0.118 us    |    kthread_should_stop_or_park();
   3)               |    schedule_timeout() {
   3)   0.136 us    |      lock_timer_base();
   3)   0.109 us    |      detach_if_pending();
   3)   0.088 us    |      get_nohz_timer_target();
   3)   0.090 us    |      calc_wheel_index();
   3)   0.150 us    |      enqueue_timer();
   3)   0.081 us    |      _raw_spin_unlock_irqrestore();
   3) $ 3060935 us  |      schedule();
   3)   0.612 us    |      __timer_delete_sync();
   3) $ 3060939 us  |    }
   3) $ 3060939 us  |  }
   3)               |  /* bpf_trace_printk: bpf_sk_redirect_hash() failed 0, error 
 */

可知,問題來源在 schedule 函式的運行。

比較 echo 伺服器的效能表現

目標:比較 userspace echo server, kecho, 和 「以 eBPF 為基礎的 echo 伺服器」的效能表現。不只需測試本機 (localhost);也要在同一個網域找其他機器來測試。另需解讀實驗表現。

kecho 運作原理解析

kecho 是一可將伺服器模組掛載至 Kernel space 運行的專案。

kecho server 在實作上透過使用 Concurrency Managed Workqueue (CMWQ) 將任務有效率的分配給 threads 執行,可更有效率地運用 CPU 的資源;它也提供 Unbounded thread 讓 process 可以切換到 idle 的 CPU 上,進一步增加對系統資源的使用率。

另外,開發者不需要對 Kernel threads 進行管理,CWMQ 會透過管理 Thread pool 來進行,以避免因為 daemon 動態建立過多的 threads 所衍生的問題。

觀察 sysprog21/kecho/echo_server.c 可知,我們可透過 daemon 操作,達成對 Work queues 的維護。

若有任務長時間佔用系統資源時,CMWQ 更會建立新的 thread 並分配任務到其他的 CPU 來執行。

觀察 sysprog21/kecho/kecho_mod.c 中之 kecho_init_module 函式可知,CMWQ 主要透過 linux/workqueue.h 中的 alloc_workqueue 函式以建立對應之 Work queue。

static int kecho_init_module(void)
{
    int error = open_listen(&listen_sock);
    if (error < 0) {
        printk(KERN_ERR MODULE_NAME ": listen socket open error\n");
        return error;
    }

    param.listen_sock = listen_sock;

    kecho_wq = alloc_workqueue(MODULE_NAME, bench ? 0 : WQ_UNBOUND, 0);
    echo_server = kthread_run(echo_server_daemon, &param, MODULE_NAME);
    if (IS_ERR(echo_server)) {
        printk(KERN_ERR MODULE_NAME ": cannot start server daemon\n");
        close_listen(listen_sock);
    }

    return 0;
}

user-echo-server 運作原理解析

除了基本的建立 socket、bind、listen 和 accept,user-echo-server 最關鍵的運作是其透過 epoll 監測事件的發生。

觀察 kecho/user-echo-server.c 中,main 函式的某段程式碼,擷取如下:

    struct sockaddr_in client_addr;
    int epoll_events_count;
    if ((epoll_events_count = epoll_wait(epoll_fd, events, EPOLL_SIZE,
                                         EPOLL_RUN_TIMEOUT)) < 0)
        server_err("Fail to wait epoll", &list);
    /*...*/
    for (int i = 0; i < epoll_events_count; i++) {
        /* EPOLLIN event for listener (new client connection) */
        if (events[i].data.fd == listener) {
            int client;
            while (
                (client = accept(listener, (struct sockaddr *) &client_addr,
                                 &socklen)) > 0) {
                /*...*/
            }
            if (errno != EWOULDBLOCK)
                server_err("Fail to accept", &list);
        } else {
            /* EPOLLIN event for others (new incoming message from client)
             */
            if (handle_message_from_client(events[i].data.fd, &list) < 0)
                server_err("Handle message from client", &list);
        }
    }

進入 while 迴圈之後,利用 epoll_wait 暫停 Server 執行,等待 epoll 所監測的列表有事件產生(如使用者建立連線等)的時候才進行接下來的處理。

當 epoll 監測到有事件發生時,會進入到 for 迴圈的部分,首先會先檢查該事件的 file descriptor 是不是 listener,如果是 listener,就表示有新的連線要求,所以我們利用 accept 建立 client 的 file descriptor 並加入 epoll 的列表中追蹤其使用。

如果是以連線的 client 所觸發的事件,則呼叫 handle_message_from_client 函式來處理。

ebpf-tcp-server 運作原理解析

參照 透過 eBPF 觀察作業系統行為,eBPF 引入全新的 map 機制以便與核心溝通,運作原理如下:

圖片

於使用者層級的程式在核心中開闢出一塊空間,建立起一個特製資料庫,讓 eBPF 程式得以互動 (見 bpf_create_map 函式),而這個資料庫 key-value 儲存方式,無論是從使用者層級抑或核心內部,都可存取,當然,如此設計最大的優勢就是效率。

而 ebpf-tcp-server 的核心機制正是經由 map 作為 Userspace 與 Kernel space 之溝通橋梁,設計 socket redirect 相關程式操作,並透過對應的 eBPF 程式虛擬空間,在不影響核心程式碼也不用撰寫核心模組的情況下,在 Kernel space 中達到接收與傳送封包的功能,建構 echo server。

觀察 YSRossi/ebpf-tcp-server/bpf_redir.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;   
}

Socket redirect 之邏輯即「使用 bpf_sk_redirect_hash 將該封包 redirect 到 server socket 的 egress interface」,也就是傳送出去的方向,因此可以在 kernel space 中達到相當於在 user space 呼叫 recvsend 的功能。

另外需判斷 Server 與 Client 是否位於同一台電腦,這會影響 Redirect 的目標;還需判斷過濾封包的條件如 FIN 封包等。

測試程式

解析 kecho/bench.c 著重在測量「同時處理多個 Clients 連線」的效能表現。

其透過 create_worker 建立 MAX_THREAD 個數的 threads 同時與 Server 建立連線,並使用 struct timeval 記錄「傳送資料與收到資料所花費的時間」,並將結果儲存在 time_res 的陣列中。

以原專案之 bench 簡單測試 echo server 之效能

kecho

參照 sysprog21/kecho 將專案 clone 下來並編譯完成後,透過 sudo insmod kecho.ko 掛載核心模組,輸入 telnet localhost 12345 與運行在 localhost 上 12345 端口的伺服器建立 TCP 連接,並能輸入字串以測試回應。

而後運行 bench 程式,簡單評估 echo server 在特定數量的並行連接(這裡設置為 1000 個 threads;且 BENCH_COUNT 設置為 10)下的 Response time。

It starts by creating number of threads (which is specified via MAX_THREAD in bench.c) requested, once all threads are created, it starts the benchmarking by waking up all threads with pthread_cond_broadcast(), each thread then creates their own socket and sends message to the server, afterward, they wait for echo message sent by the server and then record time elapsed by sending and receiving the message.

觀察 bench.txt 之 kecho 測試結果如下:

0 142
1 232
2 282
3 316
/*...*/
625 6865
626 5791
627 9120
628 5345
629 8720
/*...*/
996 4415
997 4498
998 4093
999 3810

左方為 thread ID;右方為 Response time。

user-echo-server

測試 user-echo-server 前須先透過 sudo rmmod kecho 卸載 kecho 模組,否則會出現以下報錯:

$ ./user-echo-server 
Main listener (fd=3) was created.
Fail to bind: Address already in use

透過命令 netstat -tuln | grep 12345 可觀察如下:

$ netstat -tuln | grep 12345 
tcp        0      0 0.0.0.0:12345           0.0.0.0:*               LISTEN

其指示 12345 埠遭 TCP 連結佔用,原因為起初我們掛載 kecho.ko 時,預設使用的埠正為 12345;而 user-echo-server 預設使用的 SERVER_PORT 也為 12345,故產生該錯誤。

卸載 kecho 模組後,可透過 lsmod | grep kecho 檢查是否卸載成功。

成功建立 user-echo-server 後,一樣運行 bench 程式簡單測試伺服器之 Response time:

0 3726
1 3752
2 3769
3 3808
/*...*/
625 375889
626 375915
627 375827
628 375998
629 375808
/*...*/
996 1769091
997 1769057
998 1769112
999 1769143

kecho v.s. user-echo-server

圖片

很明顯能發現,在伺服器 Response time 方面,「執行於使用者層級」的 TCP echo server 比起「執行在 Linux 核心模式」的 TCP echo server 慢了許多。

鑒於 bench 所記錄的時間是從「Client 送出資料到收到 Server 端資料回傳」的時間,而且因為 user-echo-server 並不是一個並行的 server,所以在 epoll 同時收到多個 Client 的事件時,會依據 file descriptor 位於 epoll_ev 陣列的位置依序執行,導致處理速度緩慢。

針對 user-echo-server 之效能瓶頸總結:

  1. 非並行設計:使用者層級之 echo server 不是並行處理的伺服器。在同時收到多個客戶端事件時,使用 epoll 機制會依據 file descriptor 在 epoll_ev 陣列中的位置依序處理每個事件。代表每個事件的處理是「串行 (Serial)」的,會導致總處理時間變長。
  2. Response time delay:根據 bench 所記錄的時間,Response time 是從「客戶端送出資料到收到伺服器端回傳資料」的整段時間。因此 user-level echo server 的串行處理方式,在負載較高時,每個客戶端的請求都會受到之前請求的處理時間影響,造成 Response time 顯著增加。

相比之下,核心模式下的 TCP echo server(kecho)則有以下優勢:

  1. 並行能力:Kecho 通過使用 CMWQ(Consolidated Work Queue)來提高並行處理能力。這允許伺服器同時處理多個客戶端的請求,避免了串行處理所帶來的效能瓶頸。
  2. Locality 優勢:在核心模式下執行的伺服器可以更佳地利用 CPU 的 Locality 優勢,減少 Context-switch 和資料在 Userspace 和 Kernel space 之間的複製,從而提升處理效率。
  3. 多執行緒:因應 CMWQ 技術,Kecho 可以更有效地利用「可用的多個執行緒」來處理不同的客戶端請求,進一步提升整體效能。

如上敘述,kecho 在整體效能評測上優於使用 epoll 而非並行處理的 user-echo-server。

本機端測試 echo server 之平均效能分析

測試程式

鑒於使用 原 bench.c 測試 ebpf-tcp-server 仍會有 問題,在還未解決該問題之前,為了取得效能測試資料,我將 bench.c 中,資料寫進 bench.txt 的邏輯改成「每個 thread 執行結束並斷聯後,就直接將 Response time 資料寫入檔案中」,而非所有 thread 完成後才一併寫入檔案。此舉可在測試時,避免有 thread 掛掉或未能收到伺服器之回應(?,原因待釐清)而導致先前無任何測試資料保留輸出的情形。

commit 8c40d3b
commit c0fed36

ebpf-tcp-server

在進行實驗之前需輸入命令 ulimit -n 65535 以增加檔案描述子的上限,否則伺服器端會報錯以下訊息:accept: Too many open files,其表示伺服器在執行 accept 函式時已達檔案描述子的上限。

參照 Linux 下应用程序最大打开文件数的理解和修改 文章,解釋「為何 Linux 核心要限制檔案描述子的數量」:

檔案描述子是連結每個程序和它所訪問的檔案(包括檔案和 socket)的指標。每個檔案描述子佔用系統的資源,包含記錄檔案名稱、位置、存取權限等信息的 file entry。而這些 file entry 儲存在「open files table」中,是系統的一部分。因此,若無限制地開啟大量檔案會導致系統資源枯竭,影響整體性能和穩定性。

另外,其分為兩種限制:

  • 系統層級限制:Linux 系統配置了 open files table 的上限,以防止系統被過多開啟的檔案拖垮。系統層級的檔案描述子限制對所有使用者有效,可以通過修改系統配置檔案(如 /etc/sysctl.conf)來調整。
  • 使用者層級限制:限制每個使用者能夠開啟的檔案數量,避免單個使用者消耗過多資源,影響其他使用者。可以使用 ulimit -n 命令來查看和修改這些限制。

在高並行環境下(如多執行緒的網路連接),程序可能會因為打開太多檔案或 socket 而沒有正確關閉,導致資源洩漏-這會消耗系統的檔案描述子資源,最終導致 Too many open files 之錯誤。

因此核心需限制檔案描述子數量,以及時發現這些問題。

運行新 bench 測試 ebpf-tcp-server 之 Response time 效能結果部分擷取如下:

Thread 130540644599488 30 // Thread id: 0
Thread 130543454783168 42
Thread 130544209757888 67
Thread 130543433811648 35
Thread 130544188786368 28
Thread 130544146843328 45
Thread 130543287011008 59
Thread 130543192639168 65
Thread 130542427178688 28
Thread 130544031499968 31
Thread 130534038570688 47
Thread 130542353778368 13
Thread 130544492873408 39
Thread 130541011601088 24
Thread 130540571199168 61
Thread 130543245067968 106 // Thread id: 15
/*...*/
Thread 130542479607488 43710 // Thread id: 331
Thread 130540445370048 43680
Thread 130540738971328 18606
Thread 130541661718208 18476
Thread 130540340512448 75
Thread 130539721852608 69
Thread 130537425471168 18565
Thread 130536198637248 43370
Thread 130537498871488 43
Thread 130537320613568 88
Thread 130536712439488 18505
Thread 130538987849408 22
Thread 130537603729088 42822 // Thread id: 343
/*...*/
Thread 130541514917568 13570 // Thread id: 796
Thread 130541787547328 8594
Thread 130540518770368 54819
Thread 130543958099648 2616
Thread 130543580612288 27378 // Thread id: 800

資料左方為 pthread_self();右方為 Response time。

同在 MAX_THREAD 設為 1000 且 BENCH_COUNT 設置為 1 的情況下,經反覆實驗後,該測試程式大多都在 700 至 800 多 threads 時會 blocked,因此只能收集約 800 多筆 Response time 資料。

圖片

Gnuplot 學習

由於我的 .txt 檔案中之輸出格式沒有行號,先透過 awk '{print NR-1, $3}' (FILENAME).txt > data_with_lineno.txt 將行號及對應之 Response time 資料輸出。

撰寫 gnuplot 程式(gp_scpt.gp)如下:

reset                                                                           
set xlabel 'Thread ID'
set ylabel 'Response time (ms)'
set title 'Performance in Response time'
set term png enhanced font 'Verdana, 10'
set xrange [0:800]
set yrange [0:150000]
set output 'time.png'
set format x "%10.0f"
set xtics rotate by 45 right

plot 'data_with_lineno.txt' using 1:2 with points title 'ebpf'

執行 gnuplot gp_scpt.gp 命令以繪製分布圖。

kecho

運行新 bench 測試 kecho 之 Response time 效能結果部分擷取如下:

Thread 127336760477376 251 // Thread id: 0
Thread 127336403961536 281
Thread 127336466876096 285
Thread 127336508819136 356
/*...*/
Thread 127330762622656 66047 // Thread id: 625
Thread 127334506038976 78380
Thread 127326924834496 71647
Thread 127331307882176 78
Thread 127331182053056 45654
/*...*/
Thread 127335481214656 65343 // Thread id: 797
Thread 127331790227136 60372
Thread 127331905570496 21684
Thread 127331779741376 21178 // Thread id: 800

圖片

user-echo-server

運行新 bench 測試 user-echo-server 之 Response time 效能結果部分擷取如下:

Thread 123808719570624 426 // Thread id: 0
Thread 123817150121664 648
Thread 123814077793984 61
Thread 123817307408064 126 
/*...*/
Thread 123806895048384 47293 // Thread id: 625
Thread 123808300140224 58431
Thread 123817192064704 74
Thread 123809873004224 22026
Thread 123807733909184 121
/*...*/
Thread 123809883489984 119189 // Thread id: 797
Thread 123816604862144 70824
Thread 123808730056384 51914
Thread 123817045264064 117844 // Thread id: 800

圖片

數據圖表

整合 3 種伺服器透過 bench 測試後,產出之 Response time 資料之分布圖表如下:

圖片

轉換成箱型圖以更簡潔地展示實驗結果:

圖片

效能分析

擷取前 800 筆 Response time 資料並進行「算數平均」,以利比較 3 種不同設計之伺服器效能。

這裡我先透過 sed -i 's/[[:space:]]/,/g' (FILENAME).txt 命令將文件內的空白字元轉換成 , ,再將其轉換成 Excel 試算表之格式以利加總及相關計算。

Server Average Response Time (in microseconds)
kecho 22589.4425
user-echo-server 41863.085
ebpf-tcp-server 21360.23875

可以發現在本地端以測試程式分別對此 3 種伺服器進行壓力測試,結果為 ebpf-tcp-server 略優於 kecho server 再優於 user-echo-server。

我們可回顧下圖之 eBPF socket redirect 流程:

圖片

當傳送端與目的端「在同一台電腦(localhost)」時,可省略 Network stack 的處理,直接在兩個 Socket 間傳輸;因此 ebpf-tcp-server 之效能,預期上是會優於「封包會通過 Network stack 處理」之 kecho 及 user-echo-server。

ebpf-tcp-server 雖然少了並行處理的能力,但其利用 eBPF 掛載到核心中,以直接在 Kernel space 動態處理 socket 與 socket 間封包的 redirect,在此測試中可證明其與「有並行處理能力且運作於 Kernel space 的 kecho」有接近甚至超越的性能。

若追求性能,透過 eBPF 打造 TCP server 及直接將 echo server 載入至核心是很好的選擇;但若想易於開發和靈活性,我認為在使用者層級運行的 echo server 仍是可行的選擇。

思考:若加強 kecho 的並行處理能力,或急遽提高 Threads 的數量,ebpf-echo-server 是否還有效能上的勝算?

同網域之其他機器測試 echo server 之平均效能分析

實驗環境

於同一臺電腦上,建立 2 臺虛擬機器分別作為 TCP Server 端及執行 bench 之 Client 端,其中 TCP Server 端之電腦設備與 本機端測試之實驗環境 相同。

更改使 Client 端之封包傳送對象為另一臺電腦之 IP(而非 127.0.0.1)。

數據圖表

整合 3 種伺服器透過 bench 測試後,產出之 Response time 資料之分布圖表如下:

圖片

轉換成箱型圖以更簡潔地展示實驗結果:

圖片

效能分析

特別的是,在此實驗環境下的 ebpf-tcp-server 可以負擔 900 多 threads 之互動後才 blocked(原因待調查)。

因此,效能分析擷取前 900 筆 Response time 資料並進行「算數平均」以提供更準確之評測結果,來比較 3 種不同設計之伺服器效能。

Server Average Response Time (in microseconds)
kecho 48725.63111
user-echo-server 67953.77111
ebpf-tcp-server 36346.39111

對比起在本機端測試伺服器效能,當傳送/接收端在不同機器上,可能因網路延遲或更多的 Context-switch 之因素,導致三種伺服器之平均 Response time 皆增加。

在不同機器測試 echo server 之 Response time 能更明顯地看出 3 種 echo server 的效能落差。

尤其是 ebpf-tcp-server 在效能上極具優勢,原因可回頭思考基於 eBPF echo server 之原理-若其在不同電腦,則可在另一端之 socket 收到封包後,將封包 redirect 到「該封包的傳送端(Egress interface)」,以在「不將資料複製到 User 端」的情形下,回傳封包給對方。可節省兩次 Memory copies 的時間及空間,達到 Zero-copy 之成效。

回顧下圖:
圖片

左半圖是執行於「使用者層級」之 user-echo-server 之封包傳遞示意圖;右半圖則為透過 socket redirect 技術所建構之 ebpf-tcp-server。可證實 user-echo-server 及 ebpf-tcp-server 之效能差距之巨大的原因。

另外,執行於「核心層級」之 kecho 由於無「基於 eBPF 之 socket redirect」的輔助,即使伺服器不需要跨到 Userspace 去運行,但相比 ebpf-tcp-server,其仍須呼叫 send/recv 等 System call 以便與 Client 溝通,硬生生多了許多系統呼叫的開銷。而此缺點也在如上之「效能評測表格」中反映出來。

TODO:

  • 使用 eBPF Debugger 或 Ftrace 尋找 recv 函式 blocked 的問題所在。
  • 以更多種效能指標(如 Throughput 等)評測伺服器效能。