Linux中國

怎麼去轉換任何系統調用為一個事件:對 eBPF 內核探針的介紹

長文預警:在最新的 Linux 內核(>=4.4)中使用 eBPF,你可以將任何內核函數調用轉換為一個帶有任意數據的用戶空間事件。這通過 bcc 來做很容易。這個探針是用 C 語言寫的,而數據是由 Python 來處理的。

如果你對 eBPF 或者 Linux 跟蹤不熟悉,那你應該好好閱讀一下整篇文章。本文嘗試逐步去解決我在使用 bcc/eBPF 時遇到的困難,以為您節省我在搜索和挖掘上花費的時間。

在 Linux 的世界中關於推送與輪詢的一個看法

當我開始在容器上工作的時候,我想知道我們怎麼基於一個真實的系統狀態去動態更新一個負載均衡器的配置。一個通用的策略是這樣做的,無論什麼時候只要一個容器啟動,容器編排器觸發一個負載均衡配置更新動作,負載均衡器去輪詢每個容器,直到它的健康狀態檢查結束。它也許只是簡單進行 「SYN」 測試。

雖然這種配置方式可以有效工作,但是它的缺點是你的負載均衡器為了讓一些系統變得可用需要等待,而不是 … 讓負載去均衡。

可以做的更好嗎?

當你希望在一個系統中讓一個程序對一些變化做出反應,這裡有兩種可能的策略。程序可以去 輪詢 系統去檢測變化,或者,如果系統支持,系統可以 推送 事件並且讓程序對它作出反應。你希望去使用推送還是輪詢取決於上下文環境。一個好的經驗法則是,基於處理時間的考慮,如果事件發生的頻率較低時使用推送,而當事件發生的較快或者讓系統變得不可用時切換為輪詢。例如,一般情況下,網路驅動程序將等待來自網卡的事件,但是,像 dpdk 這樣的框架對事件將主動輪詢網卡,以達到高吞吐低延遲的目的。

理想狀態下,我們將有一些內核介面告訴我們:

  • 「容器管理器,你好,我剛才為容器 servestaticfiles 的 Nginx-ware 創建了一個套接字,或許你應該去更新你的狀態?
  • 「好的,操作系統,感謝你告訴我這個事件」

雖然 Linux 有大量的介面去處理事件,對於文件事件高達 3 個,但是沒有專門的介面去得到套接字事件提示。你可以得到路由表事件、鄰接表事件、連接跟蹤事件、介面變化事件。唯獨沒有套接字事件。或者,也許它深深地隱藏在一個 Netlink 介面中。

理想情況下,我們需要一個做這件事的通用方法,怎麼辦呢?

內核跟蹤和 eBPF,一些它們的歷史

直到最近,內核跟蹤的唯一方式是對內核上打補丁或者藉助於 SystemTap。SytemTap 是一個 Linux 系統跟蹤器。簡單地說,它提供了一個 DSL,編譯進內核模塊,然後被內核載入運行。除了一些因安全原因禁用動態模塊載入的生產系統之外,包括在那個時候我開發的那一個。另外的方式是為內核打一個補丁程序以觸發一些事件,可能是基於 netlink。但是這很不方便。深入內核所帶來的缺點包括 「有趣的」 新 「特性」 ,並增加了維護負擔。

從 Linux 3.15 開始給我們帶來了希望,它支持將任何可跟蹤內核函數可安全轉換為用戶空間事件。在一般的計算機科學中,「安全」 是指 「某些虛擬機」。在此也不例外。自從 Linux 2.1.75 在 1997 年正式發行以來,Linux 已經有這個多好年了。但是,它被稱為伯克利包過濾器,或簡稱 BPF。正如它的名字所表達的那樣,它最初是為 BSD 防火牆開發的。它僅有兩個寄存器,並且它僅允許向前跳轉,這意味著你不能使用它寫一個循環(好吧,如果你知道最大迭代次數並且去手工實現它,你也可以實現循環)。這一點保證了程序總會終止,而不會使系統處於掛起的狀態。還不知道它有什麼用?你用過 iptables 的話,其作用就是 CloudFlare 的 DDos 防護的基礎

好的,因此,隨著 Linux 3.15,BPF 被擴展 成為了 eBPF。對於 「擴展的」 BPF。它從兩個 32 位寄存器升級到 10 個 64 位寄存器,並且增加了它們之間向後跳轉的特性。然後它 在 Linux 3.18 中被進一步擴展,並將被移出網路子系統中,並且增加了像映射(map)這樣的工具。為保證安全,它 引進了一個檢查器,它驗證所有的內存訪問和可能的代碼路徑。如果檢查器不能保證代碼會終止在固定的邊界內,它一開始就要拒絕程序的插入。

關於它的更多歷史,可以看 Oracle 的關於 eBPF 的一個很棒的演講

讓我們開始吧!

來自 inet_listen 的問候

因為寫一個彙編程序並不是件十分容易的任務,甚至對於很優秀的我們來說,我將使用 bcc。bcc 是一個基於 LLVM 的工具集,並且用 Python 抽象了底層機制。探針是用 C 寫的,並且返回的結果可以被 Python 利用,可以很容易地寫一些不算簡單的應用程序。

首先安裝 bcc。對於一些示例,你可能會需要使用一個最新的內核版本(>= 4.4)。如果你想親自去嘗試一下這些示例,我強烈推薦你安裝一台虛擬機, 而不是 一個 Docker 容器。你不能在一個容器中改變內核。作為一個非常新的很活躍的項目,其安裝教程高度依賴於平台/版本。你可以在 https://github.com/iovisor/bcc/blob/master/INSTALL.md 上找到最新的教程。

現在,我希望不管在什麼時候,只要有任何程序開始監聽 TCP 套接字,我將得到一個事件。當我在一個 AF_INET + SOCK_STREAM 套接字上調用一個 listen() 系統調用時,其底層的內核函數是 inet_listen。我將從鉤在一個「Hello World」 kprobe 的入口上開始。

from bcc import BPF

# Hello BPF Program
bpf_text = """ 
#include <net/inet_sock.h>
#include <bcc/proto.h>

// 1. Attach kprobe to "inet_listen"
int kprobe__inet_listen(struct pt_regs *ctx, struct socket *sock, int backlog)
{
    bpf_trace_printk("Hello World!\n");
    return 0;
};
"""

# 2. Build and Inject program
b = BPF(text=bpf_text)

# 3. Print debug output
while True:
    print b.trace_readline()

這個程序做了三件事件:

  1. 它通過命名慣例來附加到一個內核探針上。如果函數被調用,比如說 my_probe 函數,它會使用 b.attach_kprobe("inet_listen", "my_probe") 顯式附加。
  2. 它使用 LLVM 新的 BPF 後端來構建程序。使用(新的) bpf() 系統調用去注入結果位元組碼,並且按匹配的命名慣例自動附加探針。
  3. 從內核管道讀取原生輸出。

注意:eBPF 的後端 LLVM 還很新。如果你認為你遇到了一個 bug,你也許應該去升級。

注意到 bpf_trace_printk 調用了嗎?這是一個內核的 printk() 精簡版的調試函數。使用時,它產生跟蹤信息到一個專門的內核管道 /sys/kernel/debug/tracing/trace_pipe 。就像名字所暗示的那樣,這是一個管道。如果多個讀取者在讀取它,僅有一個將得到一個給定的行。對生產系統來說,這樣是不合適的。

幸運的是,Linux 3.19 引入了對消息傳遞的映射,以及 Linux 4.4 帶來了對任意 perf 事件的支持。在這篇文章的後面部分,我將演示基於 perf 事件的方式。

# From a first console
ubuntu@bcc:~/dev/listen-evts$ sudo /python tcv4listen.py 
              nc-4940  [000] d... 22666.991714: : Hello World!

# From a second console
ubuntu@bcc:~$ nc -l 0 4242
^C

搞定!

抓取 backlog

現在,讓我們輸出一些很容易訪問到的數據,比如說 「backlog」。backlog 是正在建立 TCP 連接的、即將被 accept() 的連接的數量。

只要稍微調整一下 bpf_trace_printk

bpf_trace_printk("Listening with with up to %d pending connections!\n", backlog);

如果你用這個 「革命性」 的改善重新運行這個示例,你將看到如下的內容:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py 
              nc-5020  [000] d... 25497.154070: : Listening with with up to 1 pending connections!

nc 是個單連接程序,因此,其 backlog 是 1。而 Nginx 或者 Redis 上的 backlog 將在這裡輸出 128 。但是,那是另外一件事。

簡單吧?現在讓我們獲取它的埠。

抓取埠和 IP

正在研究的 inet_listen 來源於內核,我們知道它需要從 socket 對象中取得 inet_sock。只需要從源頭拷貝,然後插入到跟蹤器的開始處:

// cast types. Intermediate cast not needed, kept for readability
struct sock *sk = sock->sk;
struct inet_sock *inet = inet_sk(sk);

埠現在可以按網路位元組順序(就是「從小到大、大端」的順序)從 inet->inet_sport 訪問到。很容易吧!因此,我們只需要把 bpf_trace_printk 替換為:

bpf_trace_printk("Listening on port %d!\n", inet->inet_sport);

然後運行:

ubuntu@bcc:~/dev/listen-evts$ sudo /python tcv4listen.py 
...
R1 invalid mem access &apos;inv&apos;
...
Exception: Failed to load BPF program kprobe__inet_listen

拋出的異常並沒有那麼簡單,Bcc 現在提升了 許多。直到寫這篇文章的時候,有幾個問題已經被處理了,但是並沒有全部處理完。這個錯誤意味著內核檢查器可以證實程序中的內存訪問是正確的。看這個顯式的類型轉換。我們需要一點幫助,以使訪問更加明確。我們將使用 bpf_probe_read 可信任的函數去讀取一個任意內存位置,同時確保所有必要的檢查都是用類似這樣方法完成的:

// Explicit initialization. The "=0" part is needed to "give life" to the variable on the stack
u16 lport = 0;

// Explicit arbitrary memory access. Read it:
//    Read into &apos;lport&apos;, &apos;sizeof(lport)&apos; bytes from &apos;inet->inet_sport&apos; memory location
bpf_probe_read(&lport, sizeof(lport), &(inet->inet_sport));

讀取 IPv4 邊界地址和它基本上是相同的,使用 inet->inet_rcv_saddr 。如果我把這些一起放上去,我們將得到 backlog、埠和邊界 IP:

from bcc import BPF  

# BPF Program  
bpf_text = """   
#include <net/sock.h>  
#include <net/inet_sock.h>  
#include <bcc/proto.h>  

// Send an event for each IPv4 listen with PID, bound address and port  
int kprobe__inet_listen(struct pt_regs *ctx, struct socket *sock, int backlog)  
{  
    // Cast types. Intermediate cast not needed, kept for readability  
    struct sock *sk = sock->sk;  
    struct inet_sock *inet = inet_sk(sk);  

    // Working values. You *need* to initialize them to give them "life" on the stack and use them afterward  
    u32 laddr = 0;  
    u16 lport = 0;  

    // Pull in details. As &apos;inet_sk&apos; is internally a type cast, we need to use &apos;bpf_probe_read&apos;  
    // read: load into &apos;laddr&apos; &apos;sizeof(laddr)&apos; bytes from address &apos;inet->inet_rcv_saddr&apos;  
    bpf_probe_read(&laddr, sizeof(laddr), &(inet->inet_rcv_saddr));  
    bpf_probe_read(&lport, sizeof(lport), &(inet->inet_sport));  

    // Push event
    bpf_trace_printk("Listening on %x %d with %d pending connections\n", ntohl(laddr), ntohs(lport), backlog);  
    return 0;
};  
"""  

# Build and Inject BPF  
b = BPF(text=bpf_text)  

# Print debug output  
while True:  
  print b.trace_readline()

測試運行輸出的內容像下面這樣:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py 
              nc-5024  [000] d... 25821.166286: : Listening on 7f000001 4242 with 1 pending connections

這證明你的監聽是在本地主機上的。因為沒有處理為友好的輸出,這裡的地址以 16 進位的方式顯示,但是這是沒錯的,並且它很酷。

注意:你可能想知道為什麼 ntohsntohl 可以從 BPF 中被調用,即便它們並不可信。這是因為它們是宏,並且是從 「.h」 文件中來的內聯函數,並且,在寫這篇文章的時候一個小的 bug 已經 修復了

全部達成了,還剩下一些:我們希望獲取相關的容器。在一個網路環境中,那意味著我們希望取得網路的命名空間。網路命名空間是一個容器的構建塊,它允許它們擁有獨立的網路。

抓取網路命名空間:被迫引入的 perf 事件

在用戶空間中,網路命名空間可以通過檢查 /proc/PID/ns/net 的目標來確定,它將看起來像 net:[4026531957] 這樣。方括弧中的數字是網路空間的 inode 編號。這就是說,我們可以通過 /proc 來取得,但是這並不是好的方式,我們或許可以臨時處理時用一下。我們可以從內核中直接抓取 inode 編號。幸運的是,那樣做很容易:

// Create an populate the variable
u32 netns = 0;

// Read the netns inode number, like /proc does
netns = sk->__sk_common.skc_net.net->ns.inum;

很容易!而且它做到了。

但是,如果你看到這裡,你可能猜到那裡有一些錯誤。它在:

bpf_trace_printk("Listening on %x %d with %d pending connections in container %d\n", ntohl(laddr), ntohs(lport), backlog, netns);

如果你嘗試去運行它,你將看到一些令人難解的錯誤信息:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py
error: in function kprobe__inet_listen i32 (%struct.pt_regs*, %struct.socket*, i32)
too many args to 0x1ba9108: i64 = Constant<6>

clang 想嘗試去告訴你的是 「嗨,哥們,bpf_trace_printk 只能帶四個參數,你剛才給它傳遞了 5 個」。在這裡我不打算繼續追究細節了,但是,那是 BPF 的一個限制。如果你想繼續去深入研究,這裡是一個很好的起點

去修復它的唯一方式是 … 停止調試並且準備投入使用。因此,讓我們開始吧(確保運行在內核版本為 4.4 的 Linux 系統上)。我將使用 perf 事件,它支持傳遞任意大小的結構體到用戶空間。另外,只有我們的讀者可以獲得它,因此,多個沒有關係的 eBPF 程序可以並發產生數據而不會出現問題。

去使用它吧,我們需要:

  1. 定義一個結構體
  2. 聲明事件
  3. 推送事件
  4. 在 Python 端重新聲明事件(這一步以後將不再需要)
  5. 處理和格式化事件

這看起來似乎很多,其它並不多,看下面示例:

// At the begining of the C program, declare our event
struct listen_evt_t {
    u64 laddr;
    u64 lport;
    u64 netns;
    u64 backlog;
};
BPF_PERF_OUTPUT(listen_evt);

// In kprobe__inet_listen, replace the printk with
struct listen_evt_t evt = {
    .laddr = ntohl(laddr),
    .lport = ntohs(lport),
    .netns = netns,
    .backlog = backlog,
};
listen_evt.perf_submit(ctx, &evt, sizeof(evt));

Python 端將需要一點更多的工作:

# We need ctypes to parse the event structure
import ctypes

# Declare data format
class ListenEvt(ctypes.Structure):
    _fields_ = [
        ("laddr",   ctypes.c_ulonglong),
        ("lport",   ctypes.c_ulonglong),
        ("netns",   ctypes.c_ulonglong),
        ("backlog", ctypes.c_ulonglong),
    ]

# Declare event printer
def print_event(cpu, data, size):
    event = ctypes.cast(data, ctypes.POINTER(ListenEvt)).contents
    print("Listening on %x %d with %d pending connections in container %d" % (
        event.laddr,
        event.lport,
        event.backlog,
        event.netns,
    ))

# Replace the event loop
b["listen_evt"].open_perf_buffer(print_event)
while True:
    b.kprobe_poll()

來試一下吧。在這個示例中,我有一個 redis 運行在一個 Docker 容器中,並且 nc 運行在主機上:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py
Listening on 0 6379 with 128 pending connections in container 4026532165
Listening on 0 6379 with 128 pending connections in container 4026532165
Listening on 7f000001 6588 with 1 pending connections in container 4026531957

結束語

現在,所有事情都可以在內核中使用 eBPF 將任何函數的調用設置為觸發事件,並且你看到了我在學習 eBPF 時所遇到的大多數的問題。如果你希望去看這個工具的完整版本,像 IPv6 支持這樣的一些技巧,看一看 https://github.com/iovisor/bcc/blob/master/tools/solisten.py。它現在是一個官方的工具,感謝 bcc 團隊的支持。

更進一步地去學習,你可能需要去關注 Brendan Gregg 的博客,尤其是 關於 eBPF 映射和統計的文章。他是這個項目的主要貢獻人之一。

via: https://blog.yadutaf.fr/2016/03/30/turn-any-syscall-into-event-introducing-ebpf-kernel-probes/

作者:Jean-Tiare Le Bigot 譯者:qhwdw 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國