K8S 中的 eBPF
BPF (Berkeley Packet Filter) 最早是用在 tcpdump
裡面的,比如 tcpdump tcp and dst port 80
這樣的過濾規則會單獨複製 tcp 協議並且目的埠是 80 的包到使用者態。整個實現是基於核心中的一個虛擬機器來實現的,通過翻譯 BPF 規則到位元組碼執行到核心中的虛擬機器當中。最早的論文是 ofollow,noindex">這篇 ,這篇論文我大概翻了一下,主要講的是原本的基於棧的過濾太重了,而 BPF 是一套能充分利用 CPU 暫存器,動態註冊 filter 的虛擬機器實現,相對於基於記憶體的實現更高效,不過那個時候的記憶體比較小才幾十兆。bpf 會從鏈路層複製 pakcet 並根據 filter 的規則選擇拋棄或者複製,位元組碼是這樣的,具體語法就不介紹了,一般也不會去直接寫這些位元組碼,然後通過核心中實現的一個虛擬機器翻譯這些位元組碼,註冊過濾規則,這樣不修改核心的虛擬機器也能實現很多功能。
socket(SOCK_RAW) bind(iface) setsockopt(SO_ATTACH_FILTER)
下面是一個低層級的 demo,首先 ethernet header 的十二個位元組記錄了 ip 的協議,ip 的第9個位元組記錄 tcp 的協議,如果協議編號不匹配都跳到最後 reject,然後在到 tcp 的第二個位元組是 port 看看是不是 80,都滿足的話就 accept。
#include<stdio.h> #include<string.h> #include<sys/types.h> #include<sys/socket.h> #include<net/if.h> #include<net/ethernet.h> #include<netinet/in.h> #include<netinet/ip.h> #include<arpa/inet.h> #include<netpacket/packet.h> #include<linux/filter.h> #defineOP_LDH (BPF_LD | BPF_H | BPF_ABS) #defineOP_LDB (BPF_LD | BPF_B | BPF_ABS) #defineOP_JEQ (BPF_JMP | BPF_JEQ | BPF_K) #defineOP_RET (BPF_RET | BPF_K) // Filter TCP segments to port 80 static struct sock_filter bpfcode[8] = { { OP_LDH, 0, 0, 12},// ldh [12] { OP_JEQ, 0, 5, ETH_P_IP},// jeq #0x800, L2, L8 { OP_LDB, 0, 0, 23},// ldb [23] # 14 bytes of ethernet header + 9 bytes in IP header until the protocol { OP_JEQ, 0, 3, IPPROTO_TCP },// jeq #0x6, L4, L8 { OP_LDH, 0, 0, 36},// ldh [36] # 14 bytes of ethernet header + 20 bytes of IP header (we assume no options) + 2 bytes of offset until the port { OP_JEQ, 0, 1, 80},// jeq #0x50, L6, L8 { OP_RET, 0, 0, -1,},// ret #0xffffffff # (accept) { OP_RET, 0, 0, 0},// ret #0x0 # (reject) }; int main(int argc, char **argv) { int sock; int n; char buf[2000]; struct sockaddr_ll addr; struct packet_mreq mreq; struct iphdr *ip; char saddr_str[INET_ADDRSTRLEN], daddr_str[INET_ADDRSTRLEN]; char *proto_str; char *name; struct sock_fprog bpf = { 8, bpfcode }; if (argc != 2) { printf("Usage: %s ifname\n", argv[0]); return 1; } name = argv[1]; sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); if (sock < 0) { perror("socket"); return 1; } memset(&addr, 0, sizeof(addr)); addr.sll_ifindex = if_nametoindex(name); addr.sll_family = AF_PACKET; addr.sll_protocol = htons(ETH_P_ALL); if (bind(sock, (struct sockaddr *) &addr, sizeof(addr))) { perror("bind"); return 1; } if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf))) { perror("setsockopt ATTACH_FILTER"); return 1; } memset(&mreq, 0, sizeof(mreq)); mreq.mr_type = PACKET_MR_PROMISC; mreq.mr_ifindex = if_nametoindex(name); if (setsockopt(sock, SOL_PACKET, PACKET_ADD_MEMBERSHIP, (char *)&mreq, sizeof(mreq))) { perror("setsockopt MR_PROMISC"); return 1; } for (;;) { n = recv(sock, buf, sizeof(buf), 0); if (n < 1) { perror("recv"); return 0; } ip = (struct iphdr *)(buf + sizeof(struct ether_header)); inet_ntop(AF_INET, &ip->saddr, saddr_str, sizeof(saddr_str)); inet_ntop(AF_INET, &ip->daddr, daddr_str, sizeof(daddr_str)); switch (ip->protocol) { #definePTOSTR(_p,_str) \ case _p: proto_str = _str; break PTOSTR(IPPROTO_ICMP, "icmp"); PTOSTR(IPPROTO_TCP, "tcp"); PTOSTR(IPPROTO_UDP, "udp"); default: proto_str = ""; break; } printf("IPv%d proto=%d(%s) src=%s dst=%s\n", ip->version, ip->protocol, proto_str, saddr_str, daddr_str); } return 0; }
執行 curl "http://www.baidu.com"
,結果如下:
sudo ./filter ens3 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244 IPv4 proto=6(tcp) src=172.31.3.210 dst=220.181.112.244
這些低級別的操作都封裝在了 libpcap 裡面,一般不太會自己這麼寫。
eBPF
eBPF 是 extended BPF 具有更強大的功能。老的 BPF 現在叫 cBPF (classic BPF)。
首先是位元組碼的指令集更加豐富了,並且現在有了 64 位的暫存器(相較於上古時期的 32 位的CPU),有了 JIT mapping 技術和 LLVM 的後端。JIT 指的的是 Just In Time,實時編譯。
一般的 ePBF 的工作流是編寫一個 C 的子集(比如沒有迴圈),通過 LLVM 編譯到位元組碼,然成生成 ELF 檔案,然後 JIT 編譯進核心。
eBPF 一個最重要的功能是可以做到動態跟蹤(dynamic tracing),可以不修改程式直接監控一個正在執行的程序。
在 eBPF 之前
在 ebpf 之前,為了實現同樣的功能,要在執行的指令中嵌入 hook,並且支援跳到 inspect 函式,然後再恢復執行,這個流程和 debugger 非常相似,這是用 kprobe 來實現,kprobe 是 2007 年引入核心的。比如下面的例子,把 Instruction 3 改成跳轉指令,然後再執行 Instruction 3,然後再跳轉回去。
使用 kprobe 需要通過編譯 kernel module 註冊到核心當中,非常麻煩,等於是直接動核心的程式碼很容易引起核心 panic,而且每個核心版本都不一樣,函式符號和位移是有區別的,對每個版本的核心都要編譯一個對應的版本的 module。為了解決這個問題引入了一些靜態的穩定的 trace point,不會因為版本而改變的地方可以插入 kprobe,但這樣就限制了 kprobe 可以探測的範圍。
有了 eBPF
有了 eBPF,就可以將使用者態的程式插入到核心中,不用編寫核心模組了,但是問題並沒有改善,核心版本帶來的問題還是沒有解決。
eBPF 的 kprobe 一種方式時候 mapping,對映 kprobe 的資料到使用者態程式,比如發包數,然後使用者態程式定期檢查這個對映進行統計。
另一種方式是 event (perf_events),如果 kprobe 向用戶態程式傳送事件來進行統計,這樣不同輪詢,直接非同步計算就可以。
低級別的 API,這個只有 Linux 有
bpf()系統呼叫 BPF_PROG_LOAD載入 BPF位元組碼 BPF_PROG_TYPE_SOCKET_FILTER BPF_PROG_TYPE_KPROBE BPF_MAP_*map 對映到 BPF當中
perf_event_open() + ioctl(PERF_EVENT_IOC/">IOC_SET_BPF)
高級別的介面是 bcc(BPFcompiler collection),轉換 c 到 LLVM-epbf 後端,並且前端是 python 的。可以實現動態載入 eBPF 位元組碼到核心中。
weave scope 就是用 bcc 實現的 HTTP stats 的統計。
在 這裡 可以看到程式的主體,這裡 hook 了核心函式 skb_copy_datagram_iter
,這個函式有一個 tracepoint trace_skb_copy_datagram_iovec
。在核心程式碼裡面對應的是下面這段。
/** *skb_copy_datagram_iter - Copy a datagram to an iovec iterator. *@skb: buffer to copy *@offset: offset in the buffer to start copying from *@to: iovec iterator to copy to *@len: amount of data to copy from buffer to iovec */ int skb_copy_datagram_iter(const struct sk_buff *skb, int offset, struct iov_iter *to, int len) { int start = skb_headlen(skb); int i, copy = start - offset, start_off = offset, n; struct sk_buff *frag_iter; trace_skb_copy_datagram_iovec(skb, len);
這裡對這個 hook 註冊了程式,具體的程式碼就不展示了,主要是根據協議統計這個 HTTP 的大小,方法等資訊。
/* skb_copy_datagram_iter() (Kernels >= 3.19) is in charge of copying socket * buffers from kernel to userspace. * * skb_copy_datagram_iter() has an associated tracepoint * (trace_skb_copy_datagram_iovec), which would be more stable than a kprobe but * it lacks the offset argument. */ int kprobe__skb_copy_datagram_iter(struct pt_regs *ctx, const struct sk_buff *skb, int offset, void *unused_iovec, int len) {
比如判斷 method 是不是 DELETE 的是實現就比較蠢,是因為 eBPF 不支援迴圈,只能這麼實現才能把 c 程式碼翻譯成位元組碼。
case 'D': if ((data[1] != 'E') || (data[2] != 'L') || (data[3] != 'E') || (data[4] != 'T') || (data[5] != 'E') || (data[6] != ' ')) { return 0; } break;
除了 bcc 之外,waeve 使用了 gobpf ,一個 bpf 的 go binding,並且通過建立 tcp 連線來猜測核心的資料結構,以達到核心版本無關,這個專案 tcptracer-bpf 還在開發中。
eBPF 的其他應用
還有一個比較大頭的基於 eBPF 的是 cilium ,一套比較完整的網路解決方案,用 eBPF 實現了 NAT,L3/L4 負載均衡,連線記錄等等功能。比如訪問控制,一般的 iptables 都是 drop 或者 rst,要過整個協議棧,但是 eBPF 可以在 connect 的時候就攔截然後返回 EACCESS,這樣就不用過協議棧了。cilium 一個優化就是通過 Express_Data_Path.pdf" target="_blank" rel="nofollow,noindex">XDP ,利用類似 DPDK 的加速方案,hook 到驅動層中,讓 eBPF 可以直接使用 DMA 的緩衝,優化負載均衡。
BPF/XDP allows for a 10x improvement in load balancing over IPVS for L3/L4 traffic.
現在 k8s 最新的 lb 方案是基於 ipvs 的,我在kube-proxy 分析 裡面有提到過,已經比原來的 iptables 提高很多了,現在有了 eBPF 加 XDP 的硬體加速可以實現更高的提升,facebook 的 katran L4 負載均衡器的實現也是類似的。
cilium 在我看來基本上是 k8s 網路的一個大方向吧,只不過包括 eBPF 和 XDP 對硬體和核心版本都要比較新,是一個要持續關注的更新。
效能調優
在 Velocity 2017: Performance Analysis Superpowers with Linux eBPF 裡, Brendan Gregg (Netflix 的效能調優專家) 提到,效能調優也是 eBPF 的一個大頭。用於網路監控其實只是 hook 在了協議棧的函式上,如果 hook 在別的地方可以有更多的統計維度。比如 bcc 官方的例子就是統計 IO Size 的大小的分佈,更多關於基於 eBPF 的效能調優可以參考他的 blog ,他給出了更詳細的關於 eBPF 的解釋,裡面有一些列 Linux 效能調優的內容。
# ./bitehist.py Tracing... Hit Ctrl-C to end. ^C kbytes: countdistribution 0 -> 1: 3|| 2 -> 3: 0|| 4 -> 7: 211|**********| 8 -> 15: 0|| 16 -> 31: 0|| 32 -> 63: 0|| 64 -> 127: 1|| 128 -> 255: 800|**************************************|
安全
在安全方面有 seccomp,可以實現限制 Linux 的系統呼叫,而 seccompe-bpf 則是通過 bpf 支援更強大的過濾和匹配功能,k8s pod 裡面的 SecurityContext 就有 seccomp 實現的部分。