【譯】基於 eBPF 的 Linux 可觀測性

最近發佈的 Linux 內核帶了一個針對內核的能力強大的 Linux 監控框架。它起源於歷史上人們所說的的 BPF

BPF 是什麼?

BPF (Berkeley Packet Filter) 是一個非常高效的網絡包過濾機制,它的目標是避免不必要的用戶空間申請。它直接在內核空間處理網絡數據包。 BPF 支持的最常見的應用就是 tcpdump 工具中使用的過濾器表達式。在 tcpdump 中,表達式被編譯轉換爲 BPF 的字節碼。內核加載這些字節碼並且用在原始網絡包流中,以此來高效的把符合過濾條件的數據包發送到用戶空間。

eBPF 又是什麼?

eBPF 是對 Linux 觀測系統 BPF 的擴展和加強版本。可以把它看作是 BPF 的同類。有了 eBPF 就可以自定義沙盒中的字節碼,這個沙盒是 eBPF 在內核中提供的,可以在內核中安全的執行幾乎所有內核符號表拋出的函數,而不用擔心搞壞內核。實際上,eBPF 也是加強了在和用戶空間交互的安全性。在內核中的檢測器會拒絕加載引用了無效指針的字節碼或者是以達到最大棧大小限制。循環也是不允許的(除非在編譯時就知道是有常數上線的循環),字節碼只能夠調用一小部分指定的 eBPF 幫助函數。eBPF 程序保證能及時終止,避免耗盡系統資源,而這種情況出現在內核模塊執行中,內核模塊會造成內核的不穩定和可怕的內核奔潰。相反的,你可能會發現和內核模塊提供的自由度來比,eBPF 有太多限制了,但是綜合考慮下來還是更傾向於 eBPF,而不是面向模塊的代碼,主要是基於授權後的 eBPF 不會對內核造成損害。然而這還不是它唯一的優勢。

爲什麼用 eBPF 來做 Linux 監控?

作爲 Linux 內核核心的一部分,eBPF 不依賴於任何第三方模塊或者擴展依賴。它包含了穩定的 ABI(應用程序二進制接口),可以讓在老內核上編譯的程序在新內核上運行。由 eBPF 帶來的性能開銷通常可以忽略不計,這讓它非常適合做應用監控和跟蹤很重的系統執行。窗口用戶沒有 eBPF,但是他們可以使用窗口事件跟蹤

eBPF 是非常靈活而且可以跟蹤幾乎所有的主要內核子系統:涵蓋了 CPU 調度,內存管理,網絡,系統調用,塊設備請求等等。而且仍然在擴展中。

可以在終端裏運行下面的命令看到所有能用 eBPF 跟蹤的內核符號列表:

可以跟蹤的符號

The above command will produce a huge output. If we were only interested in instrumenting syscall interface, a bit of grep magic will help filter out unwanted symbol names:

$ cat /proc/kallsyms | grep -w -E “sys.*”
ffffffffb502dd20 T sys_arch_prctl
ffffffffb502e660 T sys_rt_sigreturn
ffffffffb5031100 T sys_ioperm
ffffffffb50313b0 T sys_iopl
ffffffffb50329b0 T sys_modify_ldt
ffffffffb5033850 T sys_mmap
ffffffffb503d6e0 T sys_set_thread_area
ffffffffb503d7a0 T sys_get_thread_area
ffffffffb5080670 T sys_set_tid_address
ffffffffb5080b70 T sys_fork
ffffffffb5080ba0 T sys_vfork
ffffffffb5080bd0 T sys_clone

不同類型的鉤子點負責對不同內核模塊觸發的事件作出響應。內核程序運行在指定的內存地址上,網絡數據包的流入或者用戶空間代碼的調用執行都是可以通過 eBPF 程序跟蹤的,通過給 kprobesXDP 下發 eBPF 可以跟蹤進入的網絡包,給 uprobes 下發 eBPF 可以跟蹤用戶空間程序調用。

在 Sematext(是一家公司,本文就是這家公司博客上的一篇文章),他們對 eBPF 非常癡迷,想盡辦法挖掘 eBPF 的能力,用於服務監控和容器可視化。他們也在招這方面的人才。如果有興趣可以試試。

下面深入介紹一下 eBPF 程序如何構建並加載到內核中的。

Linux eBPF 程序剖析

在進一步分析 eBPF 程序的結構之前,有必要說一下 BCC(BPF 編譯器),這是一個工具集,用於編譯 eBPF 需要的字節碼,並且提供了 Python 和 Lua 的綁定支持,可以把代碼加載到內核並,和底層的 eBPF 設施交互執行。它還包含了許多有用的工具,讓你可以瞭解來用 eBPF 可以做那些事情。

在過去,BPF 程序是通過手工組合原始 BPF 指令集的方式生成的。幸運的是,clang (是 LLVM 前端的一部分)可以把 C 語言轉換成爲 eBPF 字節碼,這就省去了我們自己處理 BPF 指令的麻煩了。如今,它也是唯一能夠生成 eBPF 字節碼的編譯器,雖然也可以通過 Rust 生成 eBPF 字節碼

一旦成功編譯了 eBPF 程序,並且生成了目標文件,我們就可以準備注入到內核中了。爲了實現注入,內核引入了一個新的 bpf 系統調用。除了加載 eBPF 字節碼,這個看起來簡單的系統調用還做了很多其它的事情。它創建和操控內核中的 maps(後面會詳細介紹,一個非常重要的數據結構),map 是 eBPF 指令中非常高級的特性。你可以從 bpf 的幫助手冊上了解更多相關說明(man 2 bpf)。

當用戶空間進程要通過 bpf 系統調用下發 eBPF 字節碼的時候,內核會檢測這些字節碼,並且之後會把這些字節碼進行 JIT(轉換爲機器可執行代碼),也就是把這些字節碼指令轉換爲當前 CPU 的指令集。轉換後的代碼執行會非常快。如果由於任何原因導致 JIT 編譯器不可用,內核將會退回使用沒有上面提到高性能執行的一個解釋器來執行。

Linux eBPF 例子

現在來看一個 Linux eBPF 程序的例子。目標是捕獲對 setns 系統調用的調用者。進程可以通過調用這個系統調用來加入一個獨立的命名空間,而這個命名空間是子進程的描述符被創建之後才建立的(子進程可以控制這個命名空間,通過給 clone 系統調用參數的一個標示指定一個位掩碼讓這個命名空間脫離父進程)。這個系統調用常常用於給進程提供一個獨立的自動資源描述,比如 TCP 棧,掛載點,PID 號空間等等。

#include <linux/kconfig.h>
#include <linux/sched.h>
#include <linux/version.h>
#include <linux/bpf.h>
#ifndef SEC
#define SEC(NAME)                  
  __attribute__((section(NAME), used))
#endif
SEC("kprobe/sys_setns")
int kprobe__sys_setns(struct pt_regs *ctx) {
   return 0;
}
char _license[] SEC("license") = "GPL";
__u32 _version SEC("version") = 0xFFFFFFFE;

上面是一個非常簡單的 eBPF 程序。它包含了幾段程序,第一段是包含了各種內核頭文件,包含了多種數據類型的定義。後面是申明瞭 SEC 宏,這個是用於在目標文件中生成程序段,後面 ELF BPF 加載會解釋這個段。如果沒有發現許可證和版本段信息加載器會報錯,所以需要定義許可證和版本信息。

接下來看看 eBPF 程序中最有意思的部分 - setns 系統調用的實際掛載點。以 kprobe__ 爲前綴的函數和綁定相應的 SEC 宏,可以指示內置在內核中的虛擬機附加對 sys_setns 符號的回調指令,該符號會觸發 eBPF 程序並且每次發送系統調用時在函數體內執行代碼。每個 eBPF 程序都有一個上下文。在內核探測的例子中,這個上下文是處理器的寄存器(pt_regs structure)當前狀態,其中存有 libc 在從用戶空間轉換到內核空間時放置的函數參數。爲了編譯程序(llvm 和 clang 應該和配置好)可以使用下面的命令(要注意你要通過 LINUX_HEADERS 環境變量來指定內核頭文件的路徑),這裏 clang 會生成程序的 LLVM 中間碼,LLVM 再編譯生成最終的 eBPF 字節碼:

$ clang -D__KERNEL__ -D__ASM_SYSREG_H
         -Wunused
         -Wall
         -Wno-compare-distinct-pointer-types
         -Wno-pointer-sign
         -O2 -S -emit-llvm ns.c
         -I $LINUX_HEADERS/source/include
         -I $LINUX_HEADERS/source/include/generated/uapi
         -I $LINUX_HEADERS/source/arch/x86/include
         -I $LINUX_HEADERS/build/include
         -I $LINUX_HEADERS/build/arch/x86/include
         -I $LINUX_HEADERS/build/include/uapi
         -I $LINUX_HEADERS/build/include/generated/uapi
         -I $LINUX_HEADERS/build/arch/x86/include/generated
         -o - | llc -march=bpf -filetype=obj -o ns.o

可以使用 readelf 工具來查看目標文件中的 ELF 的段信息和符號表:

$ readelf -a -S ns.o
…
 2: 0000000000000000         0 NOTYPE  GLOBAL DEFAULT        4 _license
 3: 0000000000000000         0 NOTYPE  GLOBAL DEFAULT        5 _version
 4: 0000000000000000         0 NOTYPE  GLOBAL DEFAULT        3 kprobe__sys_setns

上面的輸出證實符號表是編譯到了目標文件中的。我們有了一個有效的目標文件,那現在就可以加載到內核中看會發生什麼了。

使用 Go 語言給內核下發 eBPF 程序

上面已經說到過 BCC 並且提到它如何通過給 eBPF 系統提供有效的接口來撬動內核。爲了編譯和運行 eBPF 程序,BCC 需要安裝 LLVM 和內核頭文件,但是有時候這種條件是不具備的沒有編譯環境和編譯機。在這樣的場景中,如果我們可以將生成的 ELF 對象放入二進制文件的數據段中,並最大限度地提高跨機器的可移植性,那將是比較理想。

除了爲 libbcc 提供綁定,gobpf 包能夠從與編譯的字節碼加載 eBPF 程序。如果我們把它和 packr 這樣的工具結合起來,packr 可以把 blob 嵌入到 Go 應用程序中,這樣我們就有了分發二進制文件所需的所有工具,並且沒有運行時依賴項。

我們稍微修改一下 eBPF 程序,它就可以在觸發 kprobe 的時候打印信息到內核追蹤管道中。爲了簡潔起見,不會包含打印宏函數和其它 eBPF 幫助函數一,但是你可以在這個頭文件中找到相關定義。

SEC("kprobe/sys_setns")
int kprobe__sys_setns(struct pt_regs *ctx) {
  int fd = (int)PT_REGS_PARM1(ctx);
  int pid = bpf_get_current_pid_tgid() >> 32;
  printt("process with pid %d joined ns through fd %d", pid, fd);
  return 0;
}

現在我們可以開始寫 Go 代碼,來處理 eBPF 字節碼加載。我們將在 gobpf 上實現一個小型抽象(KprobeTracer):

import (
  "bytes"
  "errors"
  "fmt"

  bpflib "github.com/iovisor/gobpf/elf"
)

type KprobeTracer struct {
  // bytecode is the byte stream with embedded eBPF program
  bytecode []byte

  // eBPF module associated with this tracer. The module is a  collection of maps, probes, etc.
  mod *bpflib.Module
}

func NewKprobeTracer(bytecode []byte) (*KprobeTracer, error) {
   mod := bpflib.NewModuleFromReader(bytes.NewReader(bytecode))
   if mod == nil {
      return nil, errors.New("ebpf is not supported")
   }
   return KprobeTracer{mod: mod, bytecode: bytecode}, nil
}

// EnableAllKprobes enables all kprobes/kretprobes in the module. The argument
// determines the maximum number of instances of the probed functions the can
// be handled simultaneously.
func (t *KprobeTracer) EnableAllKprobes(maxActive int) error {

  params := make(map[string]*bpflib.PerfMap)

  err := t.mod.Load(params)
  if err != nil {
     return fmt.Errorf("unable to load module: %v", err)
  }

  err = t.mod.EnableKprobes(maxActive)
  if err != nil {
     return fmt.Errorf("cannot initialize kprobes: %v", err)
  }
  return nil
}

準備開始做內核探針跟蹤:

package main

import (
  "log"
  "github.com/gobuffalo/packr"
)

func main() {
  box := packr.NewBox("/directory/to/your/object/files")
  bytecode, err := box.Find("ns.o")
  if err != nil {
      log.Fatal(err)
  }

  ktracer, err := NewKprobeTracer(bytecode)

  if err != nil {
     log.Fatal(err)
  }

  if err := ktracer.EnableAllKprobes(10); err != nil {
     log.Fatal(err)
  }
}

使用 sudo cat /sys/kernel/debug/tracing/trace_pipe 來讀取發到管道中的調試信息。測試 eBPF 程序最簡單的方式就是通過把它追加到正在運行的 Docker 容器上:

$ docker exec -it nginx /bin/bash

這個場景的背後是容器運行時會重新關聯 bash 進程到 nginx 容器的命名空間。通過 PT_REGS_PARM1 宏獲取的第一個參數是命名空間的第一個文件描述符,這個命名的描述是在 /proc/<pid>/ns 這個符號鏈接目錄中。所以我們可以監控到是否有進程加入這個命名空間。這個功能或許不是很有用,但是這是一個如何跟蹤系統調用執行和獲取其參數的示例。

使用 eBPF Maps

把結果寫到跟蹤管道對調試來說好的,但是在生產環境中,我們肯定需要一個更高級的機制來在用戶空間和內核空間共享狀態數據。這就需要 eBPF maps 來解決這個問題了。這是一種爲數據聚合而設計的高效的內核健 / 值存儲結構,他可以從用戶空間異步訪問。eBPF maps 有很多中類型,但是對上面這個特定的例子我們將會使用 BPF_MAP_TYPE_PERF_EVENT_ARRAY map。它可以存儲自定義的數據結構並且通過 perf 事件環緩衝區發送和廣播到用戶空間進程。

Go-bpf 可以讓創建 perf map 並且將時間流來提供 Go 管道。我們可以增加下面的代碼來發送 C 結構體到我們的程序。

rxChan := make(chan []byte)
lostChan := make(chan uint64)
pmap, err := bpflib.InitPerfMap(
  t.mod,
  mapName,
  rxChan,
  lostChan,
)

if err != nil {
  return quit, err
}

if _, found := t.maps[mapName]; !found {
  t.maps[mapName] = pmap
}

go func() {
  for {
     select {

     case pe := <-rxChan:
        nsJoin := (*C.struct_ns_evt_t)(unsafe.Pointer(&(*pe)[0]))
        log.Info(nsJoin)

     case l := <-lostChan:
        if lost != nil {
           lost(l)
        }
     }
  }
}()

pmap.PollStart()

我們初始化了接受者和丟棄事件管道,並且把它們傳給 InitPerfMap 函數,同時把我們要消費事件的模塊引用和 perf map 的名稱都傳過去。每次有新的事件發送到接受者管道,我們可以通過原始指針轉換出上面 eBPF 程序中定義的 C 的結構體(ns_evt_t)。我們也需要申明一個 perf map 並且通過 bpf_perf_event_output 幫助函數發送數據結構:

struct bpf_map_def SEC("maps/ns") ns_map = {
  .type = BPF_MAP_TYPE_HASH,
  .key_size = sizeof(u32),
  .value_size = sizeof(struct ns_evt_t),
  .max_entries = 1024,
  .pinning = 0,
  .namespace = "",
};

struct ns_evt_t evt = {};

/* Initialize structure fields.*/
u32 cpu = bpf_get_smp_processor_id();
bpf_perf_event_output(ctx, &ns_map,
                     cpu,
                     &evt, sizeof(evt));

結論

eBPF 還在持續的發展並且在持續獲得更廣泛的應用。隨着內核的發展,每個內核版本中都有新的 eBPF 特性和改進。低開銷和本地化編程特性讓它對各種使用場景都非常有吸引力。比如,Suricata 入侵檢測系統用它來實現在 Linux 網絡棧的早期階段進行高級套接字負載平衡策略和包過濾。 Cilium 重度依賴 eBPF 來容器提供複雜的安全策略。Sematext Agent 使用 eBPF 來精確定位需要關注的事件,比如 kill 信號廣播或者 Docker 和 Kubernetes 監控中的 OOM 通知,以及通用的服務監控。它也通過使用 eBPF 來捕獲 TCP/UDP 流量統計,爲網絡監控提供了一種高效的網絡跟蹤。eBPF 的目標似乎是通過 Linux 內核監控成爲一個事實上的 Linux 監控標準。

原文:Linux Observability with eBPF

作者:Nedim Šabić

原文發表時間:2019 年 3 月 4 號

譯者:helight

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源http://www.helight.info/blog/2020/linux-kernel-observability-ebpf/