bpftrace 在 Curve 使用總結

語言: CN / TW / HK

概述

Curve 是雲原生計算基金會 (CNCF) Sandbox 專案,是網易主導自研和開源的高效能、易運維、雲原生的分散式儲存系統,由塊儲存 CurveBS 和檔案系統 CurveFS 兩部分組成。

在版本開發過程中,避免不了出現各種各樣的問題。同時,問題出現的地方往往可能會缺乏日誌和監控等關鍵資訊。對於這些問題,通常的做法是新增必要的日誌、監控,然後重新進行復現。這樣的流程,延緩了故障定位修復的進度。為了規避這一問題,Curve 引入了一些故障定位的可觀測性工具和方法。下面對 bpftrace 進行簡要的介紹,以及使用中的實踐總結。

bpftrace 介紹

bpftrace 是基於 Linux 核心 eBPF 的高階跟蹤語言,利用 BCC 與核心 BPF 系統進行互動,同時也可以利用核心現有的跟蹤功能:kernel/user-level dynamic tracing、tracepoints等。語法類似於 awk 和 C,提供了豐富的功能,可以協助分析程式行為、效能瓶頸以及故障定位等。

bpftrace 基礎用法

下面以一個簡單的例子,介紹 bpftrace 的基礎用法:

#!/usr/bin/env bpftrace
kprobe:vfs_read {  @start[tid] = nsecs;}
kretprobe:vfs_read / @start[tid] / {  @ns = hist(nsecs - @start[tid]);  delete(@start[tid]);}
interval:s:3 {  print(@ns);  clear(@ns);}
END {  clear(@ns);  clear(@start);}

這個指令碼用於統計 read 系統呼叫的延遲,並以直方圖的形式顯示結果。

  • 指令碼第3行的 kprobe:vfs_read 表示對核心中的 vfs_read 進行探測,在呼叫該函式時,會執行花括號中的 actions。這裡是記錄了一個時間戳,tid、 nsecs是 bpftrace 內建的變數,分別對應 thread id 和當前的納秒級別的時間戳。@start[tid定義了一個名為 start 的 map,key 是 thread id,value 為時間戳。

  • 第5行的 kretprobe:vfs_read 表示在 vfs_read 呼叫返回處進行探測,在函式返回時,執行相應的 actions。花括號前的 / @start[tid] / 是一個過濾器,表示只有在 @start[tid] 有記錄時,才執行後續的 actions。這裡定義了一個名為 @ns 的變數,記錄的是 hist(nsecs - @start[tid]) 的返回結果。hist 是內建函式,將延遲以對數直方圖的形式儲存。delete(@start[tid]) 用於刪除對應的記錄。

  • 第12行 interval:s:3 是一個定時器,表示每 3 秒執行一次。這裡是把 @ns 中記錄的延遲列印到控制檯,然後 clear(@ns) 清楚所有的記錄。

  • END 表示在最終結束時,比如 Ctrl+C 退出時執行。

將上面的指令碼儲存為 vfs_read_latency.bt,然後以 root 許可權執行 bpftrace vfs_read_latency.bt ,或給予可執行許可權後 ./vfs_read_latency.bt 可以看到如下的輸出結果:

Attaching 4 probes...@ns:[512, 1K)            297 |@@@@@                                               |[1K, 2K)            3007 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|[2K, 4K)             834 |@@@@@@@@@@@@@@                                      |[4K, 8K)              58 |@                                                   |[8K, 16K)             13 |                                                    |[16K, 32K)            12 |                                                    |[32K, 64K)          2511 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@         |[64K, 128K)          364 |@@@@@@                                              |[128K, 256K)         976 |@@@@@@@@@@@@@@@@                                    |[256K, 512K)           1 |                                                    |
@ns:[256, 512)            70 |                                                    |[512, 1K)            365 |@@@@@                                               |[1K, 2K)            3645 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|[2K, 4K)             371 |@@@@@                                               |[4K, 8K)              89 |@                                                   |[8K, 16K)            106 |@                                                   |[16K, 32K)            45 |                                                    |[32K, 64K)          2330 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@                   |[64K, 128K)          439 |@@@@@@                                              |[128K, 256K)         996 |@@@@@@@@@@@@@@                                      |[256K, 512K)           5 |                                                    |

@ns 後面就是每3秒的輸出結果,第一列是延遲的範圍,[512, 1K) 表示延遲在512ns到1ms之間。後面分別是在這個延遲範圍內的請求的個數,以及對應的直方圖顯示。

更詳細使用說明,請參考官方文件:bpftrace.adoc[1]、reference_guide[2]

動態追蹤 kprobes/uprobes 的工作方式

使用 kprobe 對核心進行動態探測的過程如下:

1. kprobe 註冊時,會儲存並替換被探測的指令(在 x86_64 上替換為int3)。

2. 當 CPU 執行到斷點時,斷點處理函式會將控制權轉交給 Kprobes,並執行與當前探測點相關的 pre_hander

3. Kprobes 會單步執行被探測的指令,然後執行與當前探測點相關的 post_handler

4. 繼續執行被探測點之後的指令

kretprobe 的過程比較類似,在註冊 kretprobe 時,會在函式入口處建立一個 kprobe。當函式被呼叫命中 kprobe 時,會儲存原有的函式返回地址,並替換為”蹦床“(trampoline)函式的地址。在函式呼叫返回時,控制權交給 trampoline,kretprobe 處理完成後,再次返回到之前被替換的地址。

下面講解 uprobe 探測前後的指令變化:

#include <stdio.h>
__attribute__((noinline)) void hello() {    printf("hello, world\n");}
int main() {    getchar();    hello();    return 0;}
#!/usr/bin/env bpftrace
uprobe:./a.out:hello {   printf("Hello\n");}

在探測前,hello 函式的彙編指令如下:

(gdb) disass helloDump of assembler code for function hello:   0x00005630e9dbd145 <+0>:     push   %rbp   0x00005630e9dbd146 <+1>:     mov    %rsp,%rbp   0x00005630e9dbd149 <+4>:     lea    0xeb4(%rip),%rdi        # 0x5630e9dbe004   0x00005630e9dbd150 <+11>:    callq  0x5630e9dbd030 <[email protected]>   0x00005630e9dbd155 <+16>:    nop   0x00005630e9dbd156 <+17>:    pop    %rbp   0x00005630e9dbd157 <+18>:    retqEnd of assembler dump.

探測後的指令如下:

(gdb) disass helloDump of assembler code for function hello:   0x00005630e9dbd145 <+0>:     int3   0x00005630e9dbd146 <+1>:     mov    %rsp,%rbp   0x00005630e9dbd149 <+4>:     lea    0xeb4(%rip),%rdi        # 0x5630e9dbe004   0x00005630e9dbd150 <+11>:    callq  0x5630e9dbd030 <[email protected]>   0x00005630e9dbd155 <+16>:    nop   0x00005630e9dbd156 <+17>:    pop    %rbp   0x00005630e9dbd157 <+18>:    retqEnd of assembler dump.

探測使用者態 C++ 程式

在探測 C 程式時,需要探測的函式名稱,就是程式碼中對應的函式名。對於一些簡單的 C++ 函式,這種方式也是可行的,即便存在函式過載(bpftrace  v0.10.0 以上版本支援)。例如:

#include <iostream>
void hello() {    std::cout << __PRETTY_FUNCTION__ << std::endl;}
void hello(int i) {    std::cout << __PRETTY_FUNCTION__ << std::endl;}
void hello(std::string name) {    std::cout << __PRETTY_FUNCTION__ << std::endl;}
int main() {     hello();    hello(1);    hello("curve");    return 0;}​​​​​
$ bpftrace -e 'uprobe:./a.out:hello { printf("hello\n"); }'Attaching 3 probes...

這裡的表示對 3 個函式添加了探測。但如果程式碼中存在自定義型別、名稱空間,或者需要探測成員函式時,上面的方式使用起來就會比較麻煩。此時,直接對函式重整後的名字進行探測會比較方便。

在 C++ 程式碼編譯的過程中,會對函式名連同其引數、名稱空間等進行一次名字重整(name mangling),使得不同的過載函式有不同的符號。

以上面的 demo 為例,使用 GCC 以 C 語言編譯時,函式名稱不會發生變化,

$ readelf -s a.out | grep hello    57: 0000000000001145    19 FUNC    GLOBAL DEFAULT   14 hello

當以 C++ 語言進行編譯時,hello 就被修改為 _Z5hellov

$ readelf -s a.out | grep hello    67: 0000000000001145    19 FUNC    GLOBAL DEFAULT   14 _Z5hellov

在進行過載後,也會有不同的符號:

void hello();                  -> _Z5hellovvoid hello(int a);             -> _Z5helloivoid hello(double a);          -> _Z5hellodvoid hello(std::string name);  -> _Z5helloNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

所以,我們需要找到重整後對應的符號,這裡會用到兩個工具 nm 和 c++filt,nm 是列出可執行檔案或動態連結庫中的符號,c++filt 可以恢復命名重整前的函式簽名。以下為例:

#include <iostream>
namespace curve {struct Demo {    void hello(std::string name) {        std::cout << "hello, " << name << std::endl;    }};}
int main() {    curve::Demo demo;    demo.hello("curve");        return 0;}

首先,使用 nm 列出可執行檔案中的所有符號,然後用 grep 過濾出對應的函式。其次,可以使用 c++filt 將符號轉換為函式對應的簽名。

$ nm a.out | grep Demo | grep hello  # 查詢重整後的符號00000000000012b4 W _ZN5curve4Demo5helloENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
$ c++filt 00000000000012b4 W _ZN5curve4Demo5helloENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE  # 確認重整前的函式簽名curve::Demo::hello(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)
$ bpftrace -e 'uprobe:./a.out:_ZN5curve4Demo5helloENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE { printf("hello\n"); }'Attaching 1 probe...

探測容器中執行的服務

Curve 檔案儲存目前使用 CurveAdm 進行容器化部署,所有的服務都執行在容器中,方便進行部署和運維操作。但是,也給使用 bpftrace 探測容器中執行的服務帶來了一些問題。

前面講到,在探測使用者態進行時,需要指定可執行檔案或者動態連結庫的物理路徑。由於容器中的環境與宿主機環境是相互隔離的,直接使用可執行檔案在容器內的路徑是不可行的。

一種方式是利用 docker inspect 命令檢視對應容器的 MergedDir 在宿主機上的路徑,然後把可執行檔案在容器內的路徑拼接到一起。例如,需要探測容器內 curve-fuse 程序中的 FuseOpWrite 函式,步驟如下:

$ docker inspect af19f9940 | grep "MergedDir"    "MergedDir": "/var/lib/docker/overlay2/32581ea24889ab2de49569e2c00c6d6f95cd9cc7016afcfbcfb7701e478681dd/merged",
$ bpftrace -e 'uprobe:/var/lib/docker/overlay2/32581ea24889ab2de49569e2c00c6d6f95cd9cc7016afcfbcfb7701e478681dd/merged/curvefs/client/sbin/curve-fuse:FuseOpWrite { printf("probe FuseOpWrite\n"); }'Attaching 1 probe...probe FuseOpWrite

另一種方式是,利用 docker inspect 檢視容器的 pid,/proc/${pid}/root 對應的就是上面的 MergedDir,

$ docker inspect af19f9940 | grep -m1 "Pid"    "Pid": 753858,    $ cd /proc/753858/root    $ df .Filesystem      1K-blocks       Used Available Use% Mounted onoverlay        1135634272 1040824488  37052960  97% /var/lib/docker/overlay2/32581ea24889ab2de49569e2c00c6d6f95cd9cc7016afcfbcfb7701e478681dd/merged    $ bpftrace -e 'uprobe:/proc/753858/root/curvefs/client/sbin/curve-fuse:FuseOpWrite { printf("probe FuseOpWrite\n"); }'Attaching 1 probe...probe FuseOpWrite

如果要在容器內直接執行 bpftrace,就不需要上述的步驟,但是需要在容器執行時,授予一些許可權。

具體可以參考How to: run BpfTrace from a small alpine image, with least privileges. [3]

總結

本文簡單介紹了 bpftrace 的基本用法,以及探測 C++ 程式和容器中執行程序的方法。通過 bpftrace 動態追蹤的能力,能夠協助我們分析執行中程序的行為,加快了問題定位的進度。在實際的問題定位過程中,往往需要結合使用多種工具,例如,先通過日誌、監控等縮小問題的範圍,然後利用 bpftrace 對關鍵函式進行動態追蹤,從而確定問題的根源。

參考:

[1] https://github.com/iovisor/bpftrace/blob/master/man/adoc/bpftrace.adoc

[2] https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md

[3] ‍https://itnext.io/how-to-run-bpftrace-from-a-small-alpine-image-and-with-least-privileges-379146fcfcf1?gi=e7f8d5499eb4

原創作者:吳漢卿,Curve Contributor