內核觀測性方法

作者簡介:

Linuxery,內核愛好者,閱碼場資深用戶。

第一章 Linux 跟蹤技術

1.1 前言

本文目的是給大家建立一個對 Linux 跟蹤系統和 BPF 的整體認知.

1.2 跟蹤系統

系統和軟件的可觀測性是系統和應用性能分析和故障排查的基礎.最小化生產環境中性能觀測帶來的額外負擔是一件很有挑戰性的工作, 但其回報是豐厚的.在 Linux 系統上, 有一些很有用的跟蹤工具, 如 strace 和 ltrace 分別可用來跟蹤哪些系統調用和哪些動態庫被調用, 這些工具能提供一些有用的信息但是有限, 同時使用這些工具也會給性能帶來不小的額外影響, 這使得它們不是非常適合用於生產環境中的調試和觀測.

BPF 提供了一種全新的跟蹤技術方案, 它與其它的 Linux 跟蹤技術最大的不同之處在於 1) 它是可編程的: BPF 程序被鏈接到內核作爲內核的一部分運行; 2) 它同時具備高效率和生產環境安全性的特點, 我們可以在生產環境中直接運行 BPF 程序而無須增加新的內核組件.

在開始正式介紹 BPF 之間我們首先來看一下 Linux 跟蹤技術的的整體圖景.概括地來說, Linux 上的跟蹤系統由三層構成: 前端, 跟蹤框架和事件源

1.2.1 概覽

圖 1.1. 跟蹤系統框架

事件源是跟蹤數據的來源, 它們是內核提供的事件跟蹤最底層接口, 由跟蹤框架使用, Linux 提供了豐富的事件源.跟蹤框架運行於內核, 根據前端提供的參數註冊要跟蹤的事件, 負責事件發生時的數據採集和計數, 如果跟蹤框架支持可編程的跟蹤器, 那麼它還是直接在內核態對數據進行聚合、彙總、過濾、統計等處理.前端運行在用戶態, 它讀取跟蹤框架收集的數據進行處理和展示.用戶根據前端展示的結果來進行性能分析和故障排查.

1.2.2 術語

可觀測性 (observability) 是指通過全面觀測來理解一個系統, 可以實現這一目標的工具就可以歸類爲可觀測性工具.這其中包括跟蹤工具、採樣工具和基於固定計數器的工具.但不包括基準測量(benchmark)工具, 基準測量工具在系統上模擬業務負載, 會更改系統的狀態.BPF 工具就屬於可觀測性工具, 它們使用 BPF 技術進行可編程型跟蹤分析.

跟蹤 (tracing) 是基於事件的記錄—- 這也是 BPF 所使用的監測方式.例如 Linux 下的 strace(1)就是一個跟蹤工具, 它可以記錄和打印系統調用事件的信息.有許多監測工具並不跟蹤事件, 而是使用固定的計數器統計監測事件的頻次, 然後打印出摘要信息: Linux top(1)便是這樣的例子.跟蹤工具的一個顯著標志是, 它具備記錄原始事件和事件元數據的能力.但這類數據的數量不少, 因此可能需要經過後續處理生成摘要信息.BPF 技術催生了可編程的跟蹤工具的實現, 這些工具可以在事件發生時, 通過運行一段小程序(一個或幾個函數)來進行定製化的實時統計摘要生成或其他動作.

採樣 (sampling) 工具通過獲取全部觀測量的子集來描繪目標的大致圖像; 這也被稱作生成性能剖析樣本或 profiling.有一個 BPF 工具就叫 profile(8), 它基於計時器來對運行中的代碼定時採樣.採樣工具的一個優點是, 其性能開銷比跟蹤工具小, 因爲值對大量事件的一部分進行測量.採樣的缺點是, 它只提供了一個大致的畫像, 或遺漏事件.

探針 (probe) 軟件或者硬件中的探測點, 它產生一個事件, 該事件將導致內核中的一段程序被運行.

靜態跟蹤 (static tracing) 跟蹤的事件由硬編碼的探針產生, 這類探針的位置在編譯時就已經寫死比如 tracepoint.因爲這些探針是固定的, 所以它們的 API 比較穩定, 可以對它們編寫文檔, 但同時這也意味着這類事件源是不靈活的.

動態跟蹤 (dynamic tracing) 跟蹤的事件可以在運行時動態的創建和撤銷, 這使得我們可以跟蹤軟件中的任何事件比如任意函數的調用和返回, 具有極大的靈活性, 但是軟件接口是發展變化的, 這類事件接口是不穩定, 因此和也很難爲其編寫文檔.

事件 (event) 直接描述什麼是事件可能是一件很困難的事情, 因爲事件可能由硬件、內核和用戶程序觸發, 事件產生時的硬件和軟件環境也很複雜.不過好在用戶從來都不需要去直接處理事件, 對事件的直接處理是由內核自動完成的.因此我們可以抖機靈的把事件定義爲: 內核在特定的條件下執行的一段特定的程序, 這個程序會收集該條件發生時系統硬件和軟件環境的一些信息(“事件上下文”), 並將這些信息保存在內核緩衝區或者更新計數器.所以從用戶的角度看, 發生一個事件等效於內核產生了一個 “事件上下文” 或者更新了計數器, 如果跟蹤框架支持可編程的事件跟蹤(如 BPF,systemtap)那麼事件發生時內核還會執行由用戶注入到內核中關聯到該事件的程序.

1.2.3 Linux 跟蹤技術的發展時間線

圖 1.2. Linux 跟蹤技術發展時間線

1.3 Linux 跟蹤技術棧

圖 1.3. Linux 跟蹤技術棧

1.3.1   事件源

kernel tracepoint

tracepoint 是內核預先定義的的靜態跟蹤事件源.tracepoint 可以用來對內核進行靜態插樁.內核開發着在內核函數中的特定邏輯位置出, 有意放置了這些插樁點, 對於內核開發者來說, tracepoint 有一定的維護成本, 而且它的使用範圍比 kprobe 要窄得多.使用 tracepoint 的主要優勢是它的 API 穩定; 基於 tracepoint 的工具, 在內核版本升級後一般仍然可以正常工作.同時它帶來的額外負擔也較輕.在能滿足探測需要時應優先考慮使用 tracepoint.Linux tracepoint 事件名的格式是 “子系統: 事件名”.

tracepoint 出於不啓用狀態時, 性能開銷要儘可能小, 這是爲了避免對不使用的東西 “交性能稅”, 爲此 Linux 使用了一項叫做“靜態跳轉補丁(static jump patching)” 的技
術.其工作原理如下:

  1. 在內核編譯階段會在 tracepoint 所在的位置插入不做任何具體工作的指令, 在 x86 架構下就是有 5 個字節的 nop 指令, 這個長度的選擇是爲了確保之後可以將它替換爲一個 5 字節的 jmp 指令.

  2. 在函數尾部插入一個 tracepoint 處理函數, 也叫做蹦牀函數, 這個函數會遍歷一個存儲 tracepoint 回調函數的數組.這會導致函數編譯結果稍稍變大.(之所以稱之爲蹦牀函數, 是因爲在執行過程中函數會跳入, 然後再跳出這個處理函數), 這很可能會對指令緩存會有一些小影響.

  3. 在執行過程中, 當某個跟蹤器啓動 tracepoint 時(該 tracepoint 可能已經被其他跟蹤器啓用):

        a. 在 tracepoint 回調函數數組中插入一條新的 tracepoint 回調函數, 以 RCU 的形式進行同步更新

        b. 如果 tracepoint 之前出於禁用狀態, nop 指令的地址會被重寫爲跳轉到蹦牀函數的指令.

  1. 當跟蹤器禁用某個 tracepoint 時:

        a. 在 tracepoint 回調函數數組中刪除該跟蹤器的回調函數, 以 RCU 的形式進行

同步更新

        b. 如果最後一個回調函數也被刪除了, 那麼將 jmp 再重寫爲 nop 指令.

這樣可以最小化出于禁用狀態的 tracepoint 的性能開銷, 幾乎可以忽略不計.

tracepoint 有以下兩個接口:

kprobes

kprobes 提供了針對內核的動態插樁支持.kprobes 可以對任何內核函數進行插樁, 它還可以對函數內部的指令進行插樁.它可以實時在生產環境系統中啓用, 不需要重啓系統, 也不需要以特殊方式重啓內核.這是一項令人驚歎的能力, 這意味着我們可以對 Linux 中數以萬計的內核函數任意插樁.根據需要生成指標.

kprobes 技術還有另外一個接口, 即 kretprobes(其實還有一個 jprobes 接口, 但已經廢棄不再維護), 用來對 Linux 內核函數返回時進行插樁以獲取返回值.當用 kprobes 和 kretprobes 同時對同一個內核函數進行插樁時, 可以使用時間戳來記錄函數執行的時長, 這在性能分析中是一個重要的指標.

使用 kprobes 對內核進行動態插樁的過程如下:

A. 對於一個 kprobe 插樁來說:

  1. 把要插樁的目標地址中的字節內容複製並保存(爲的是給單步斷點指令騰出

位置).

  1. 以單步中斷指令覆蓋目標地址: 在 x86_64 上是 int3 指令.(如果 kprobes 開
    了優化, 則使用 jmp 指令).

  2. 當指令流執行到斷點時, 斷點處理函數會檢查這個斷點是否是由 kprobes 註冊的, 如果是, 就會執行 kprobes 處理函數.

  3. kprobes 處理函數執行完後原始的指令會接着執行, 指令流繼續.

  4. 當不再需要(deactivate)kprobes 時, 原始的字節內容會被複制回目標地址上,

這樣這些指令就回到了它們的原始狀態.

B. 如果這個 kprobe 是一個 Ftrace 已經做過插樁的地址(一般位於函數的入口處),
那麼可以基於 Ftrace 進行 kprobe 優化, 過程如下:

  1. 將一個 Ftrace kprobe 處理函數註冊爲對應函數的 Ftrace 處理器

  2. 當在函數起始處執行內建入口函數時 (在 x86 架構 gcc 4.6 下是 fentry),
    該函數會調用 Ftrace,Ftrace 接下來會調用 kprobe 處理函數.

  3. 當 kprobe 不在會被調用時, 從 Ftrace 中移除 Ftrace-kprobe 處理函數.

C. 如果是一個 kretprobe:

  1. 對函數入口進行 kprobe 插樁.

  2. 當函數入口被 kprobe 命中是, 將函數返回地址保存並替換爲一個 “蹦牀”(tram-
    poline)函數地址.

  3. 當函數最終返回時, CPU 將控制權交給蹦牀函數處理.

  4. 在 kretprobe 處理完成之後在返回到之前保存的地址.

  5. 當不再需要 kretprobe 時, 函數入口的 krobe 和 kretprobe 處理函數就被移除了.

根據當前系統和體系結構的一些其它的因素, kprobe 的處理過程可能需要禁止搶佔和中斷.另外在線修改內核函數體是風險極大的操作, 但是 kprobe 從設計上就已經保證了自身的安全性.在設計中包括了一個不允許 kprobe 動態插樁的函數黑名單, 其中 kprobe 自身就在名單之列, 可防止出現遞歸陷阱的情形.kprobe 同時利用的是安全的斷電插入技術, 比如使用 x86 內置的 int3 指令.當使用 jmp 指令時, 也會先調用 stop_machine() 函數, 來保證在修改代碼的時候其它 CPU 核不會執行指令.在實踐中, 最大的風險是, 在需要對一個執行頻率非常高的函數插樁時, 每次對函數調用小的開銷都會疊加, 這會對系統的性能產生一定的影響.

kprobe 在某些 ARM 64 位系統上不能正常工作, 出於安全性的考慮, 這些平臺上的內核代碼區不允許被修改.

有以下三種接口可以訪問 kprobe:

uprobe

uprobe 提供了用戶態程序的動態插樁, 與 kprobe 相似, 只是在用戶態程序使用.up- robe 可以在用戶態程序的以下位置插樁: 函數入口, 特定偏移處, 以及函數返回處.uprobe 也是基於文件的, 當一個可執行文件中的一個函數被跟蹤時, 所有使用到這個文件的進程都會被插樁, 包括奈雪兒尚未啓動的進程.這樣就可以在全系統範圍內跟蹤系統庫調用.

uprobe 的工作方式和 kprobe 相似: 將一個快速斷點指令插入目標指令處, 該指令將控制權轉交給 uprobe 處理函數.當不再需要 uprobe 時, 目標指令被恢復成原來的樣子.對於 uretprobe, 也是在函數入口處使用 uprobe 進行插樁, 而在函數返回之前則使用一個蹦牀函數對返回地址進行劫持, 和 kretprobe 類似.gdb 就使用了這種方式來調試應用程序.

uprobe 有以下兩個可以使用的接口:

在內核中同時包含了 register_uprobe_event() 函數, 和 register_kprobe() 函數類似, 但是並沒有以 API 地形式顯露.

USDT

用戶態預定義靜態跟蹤 (user-level statically defined tracing, USDT) 提供了一個用戶空間版本地 tracepoint 機制.USDT 的與衆不同之處在於, 它依賴於外部的系統跟蹤器來喚起.如果沒有外部跟蹤器, 應用中的 USDT 點不會做任何事情, 也不會開啓.給應用程序添加 USDT 探針有兩種可選方式: 通過 systemtap-sdt-dev 包提供的頭文件和工具或者使用自定義的頭文件.這些探針定義了可以被放置在代碼中各個邏輯位置上的宏, 以此生成 USDT 的探針.

當編譯應用程序時, 在 USDT 探針的地址放置了一個 nop 指令.當 USDT 探針被激活時, 這個地址會由內核使用 uprobe 動態地將器修改位一個斷點指令.當該斷點被觸發時, 內核會執行相應的 BPF 程序.BPF 跟蹤器前端 BCC 和 bpftrace 均支持 USDT, 如果不使用這些前端, 則不能直接使用 USDT, 需要前端自己實現 USDT 支持.

動態 USDT

上一節介紹的 USDT 探針技術是需要添加到源代碼並編譯到最終的二進制文件中的, 在插樁點留下 nop 指令, 在 ELF notes 段中保存元數據.然而有一些編程語言, 比如 Java, 是在運行是的時候解釋或者編譯的.動態 USDT 可以用來給 Java 代碼添加插樁點.

JVM 已經在內置的 C++ 代碼中包含了許多 USDT 探針—- 比如對 GC 事件、類加載, 以及其它高級行爲.這些 USDT 探針會對 JVM 的函數進行插樁.但是 USDT 探針不能被添加到動態進行編譯的 Java 代碼中.USDT 需要一個提前編譯好的、帶一個包含了探針描述的 notes 段的 ELF 文件, 着對於以 JIT(just-in-time)方式編譯的 Java 代碼來說是不存在的.

動態 USDT 以如下方式解決該問題:

隱藏底層的共享庫調用.

Matheus Marchini 已經爲 Node.js 和 Python 實現了一個叫做 libstapsdt 的庫(一個新的 libusdt 庫正在開發中), 一提供這些語言中定義和呼叫 USDT 探針的方法.對其他語言的支持可以通過封裝這個庫實現.libstapsdt 會在運行時自動創建包含 USDT 探針和 ELF notes 區域的共享庫, 而且它會將這些區域映射到運行着的程序的地址空間.

BPF raw tracepoint

BPF raw tracepoint 是一種新的 tracepoint, 相比於內核的 tracepoint,BPF raw tra- cepoint 接口向 tracepoint 線路原始參數, 這樣可以避免需要創建穩定的 tracepoint 參數從而導致的開銷, 因爲這些參數可能壓根不被使用.

PMC

性能監控計數器(Performance monitoring counter, PMC)還有其它的一些名字, 比如性能觀測計數器(Performance instrumentation counter, PIC), 性能監控單元事件(Per- formance monitoring unit event,PMU event).這些名字指的都是同一個東西: 處理器上的硬件可編程計數器.

PMC 數量衆多, Intel 從中選擇了 7 個作爲 “架構集合”, 這些 PMC 會對一些核心功能提供全局預覽.

圖 1.4. Intel 架構上的 PMC

PMC 是性能分析領域的至關重要的資源.只有通過 PMC 才能測量 CPU 指令執行的效率、CPU 緩存的命中率、內存 / 數據互聯和設備總線的利用率, 以及阻塞的指令週期等.儘管有數百個可用的 PMC 可用, 但在任一時刻, 在 CPU 中只允許固定數量(可能只有 6 個)的寄存器進行讀取.在實現中需要選擇通過這 6 個寄存器來讀取哪些 PMC, 過着可以以循環採樣的方式覆蓋多個 PMC 集合(Linux perf(1) 工具可以自動支持這種循環採樣).

PMC 可以工作在下面兩種模式中.

比如每秒讀取一次.這種模式的開銷幾乎爲零.

由於存在中斷延遲或者亂序執行, 溢出採樣可能不能正確地記錄觸發事件發生時的指令指針.對於 CPU 週期性能分析來說, 這類 “打滑” 可能不是什麼問題, 但是對於測量另外一些事件, 比如緩存未命中率, 這些採樣的指令指針就必須是精確的.

Intel 開發了一種解決方案, 叫做精確事件採樣(precise event-based sampling, PEBS).PEBS 使用硬件緩衝區來記錄 PMC 事件發生時正確的指令指針.Linux 的 perf events 機制支持 PEBS.

perf_events

perf_events 是 perf(1) 命令所以來的採樣和跟蹤機制.BPF 跟蹤工具可以調用 perf_events 來使用它的特性.BCC 和 bpftrace 先是使用 perf_events 作爲它們的喚醒緩衝區, 然後
又增加了對 PMC 的支持, 現在又通過 perf_event_open() 來對所有的 perf_events 事件進行觀測.同時 perf(1) 也開發了一個使用 BPF 的接口, 這讓 perf(1) 成爲了一個 BPF 前端也是唯一內置在 linux 中的 BPF 前端.perf(1) 的 BPF 功能還在開發中, 目前在使用上還有一些不方便的地方.

1.3.2 跟蹤框架

Ftrace

圖 1.5. Ftrace 跟蹤框架

Ftrace 是內核 hacker 的最佳搭檔, 它是內核內置的, 支持內核 tracepoint,kprobe,uprobe 事件.提供事件跟蹤, 可選的過濾器和參數, 事件計數和定時, 在內核空間中的數據彙總.它通過 / sys 文件系統訪問, 是專門針對 root 用戶的.有一個專門的 Ftrace 前端 trace-cmd.不足之處是 Ftrace 不是可編程的, 用戶只能從內核緩衝區讀取事件原始數據然後在用戶態進行後續處理.
perf_event

圖 1.6. perf-event 跟蹤框架

perf_event 是 Linux 用戶使用的主要跟蹤框架, 其源代碼位於內核源代碼中, 其跟蹤器前端 perf(1)Linux 用戶最常用的性能分析工具.Ftrace 能做的事情 perf_ 幾乎都能做到.同時 perf_event 具有更強的安全檢查, 這也使得 perf_event 不容易被 hack.它可以用於採樣, CPU 性能計數器, 用戶態回棧.它還支持多用戶的併發使用.

perf_event 支持的事件源

圖 1.7. perf_event 支持的事件源

SystemTap

SystemTap 是最強大的跟蹤器.它幾乎無所不能: 採樣(剖析),tracepoints,kprobes,uprobes,USDT, 可編程等.它通過把程序編譯成模塊加載進內核來支持可編程的事件跟蹤, 這是一種極其
安全的可編程事件跟蹤實現方案.以使用 SystemTap 跟蹤一個 kprobe 事件爲例, 其基本步驟如下:

圖 1.8. systemtap 原理

1.3.3 跟蹤前端

跟蹤器前端往往不與跟蹤框架一一對應, 一個跟蹤器前端可能會使用多個跟蹤框架的接口, 常用的跟蹤其前端有: perf、trace-cmd、perf-tools 等

第二章 BPF

2.1 基本概念

BPF 是 Berkeley Packet Filter 的縮寫, 這項技術誕生與 1992 年, 其作用是提升網絡包過濾工具的性能.2013 年, Alexei Starrovoitov 向 Linux 社區提交了重新實現 BPF 的內核補丁, 經過他和 Daniel Borkmann 的共同完善, 相關工作在 2014 年正式併入 Linux 內核主線, 此舉將 BPF 變成了一個更通用的執行引擎.拓展後的 BPF 通常縮寫爲 eBPF, 但官方的縮寫仍是 BPF, 事實上內核只有一個執行引擎, 即 BPF(拓展後的 BPF), 它同時支持 “經典” 的 BPF 程序也支持拓展後的 BPF, 若無特殊說明, 我們所說的 BPF 就是指內核中的 BPF 執行引擎.

BPF 目標文件本質上就是 ELF 文件, 據我所知目前只有使用 LLVM 指定 target 爲 BPF 可以編譯出 BPF 目標文件.它與通常的目標文件的差別在於編譯後的目標文件是 BPF 虛擬機的字節碼程序, 同時它還包含了以 BTF 格式保存的調試信息以及重定位信息等.

BTF(BPF Type Format) 是編譯成 BPF 目標文件的 BPF 程序中用記錄 BPF 程序的調試、鏈接、重定位以及 BPF 映射表信息的元數據格式.其記錄的信息的作用類似於 ELF 文件中的 DWARF 段、notes 段和重定位相關的段.通常一個編譯成 BPF 目標文件的 BPF 程序中會有多個以. btf 爲前綴命名的段用於記錄以上信息.有關 BTF 的詳細內容請參考 BTF 文檔.

BPF 程序通常是指用戶編寫的要被注入內核的跟蹤器程序.內核態程序直接在內核態對內核收集的事件信息進行彙總統計等處理之後保存在 BPF 映射表, BPF 用戶態程序直接讀取 BPF 映射表中已經處理好的數據就可以直接或者稍加處理之後進行展示.有時 BPF 程序也指 BPF 內核態程序和用戶態程序, 把二者視爲一個整體.用戶態程序負責將內核態程序加載鏈接到內核並附着(“attach”)到指定的事件上, 讀取內核態程序處理好保存在 BPF 映射表中的數據並進行展示之類的上層操作以及退出前的清理操作.BPF 內核態程序通常由多個函數定義和 BPF 映射表定義構成.每一個函數屬於一個特定的 BPF 程序類型, BPF 程序類型是 BPF 函數和該函數所關聯的事件類型之間的接口.也就是說函數的程序類型和它所關聯的事件類型之間有一種對應關係, 不過這種對應關係並不非常嚴格, 一種程序類型可以附着(“attach”)到多種事件之上, 一種事件也可以被多種不同 BPF 程序類型的函數附着.同時 BPF 程序類型也限制了函數能夠使用的 BPF 虛擬機字節碼指令集, 這有利於將函數功能限制在其類型所定義的功能範圍之內, 提高安全性.關於 BPF 程序類型的詳細介紹請參考 Linux 手冊.

BPF 映射表 (BPF Map)BPF 映射表是 BPF 程序使用的內核緩衝區, 用於保存事件數據, 類似於 MySQL 的表.BPF 映射表的數據類型有很多種, 實現對用戶都是是透明的.描述 BPF 映射表的信息記錄在 BTF 格式的元數據中.BPF 程序只能使用 BPF 執行引擎提供的內核接口(這些內核接口是 BPF 系統調用的一部分)來創建、訪問、修改和刪除 BPF 映射表.關於 BPF 映射表的詳細介紹請參考 Linux 手冊

BPF 系統調用是 BPF 執行引擎提供的一套內核接口, 用於 BPF 用戶態程序與 BPF 執行引擎之間的交互以及創建、訪問、修改和刪除 BPF 映射表.關於 BPF 系統調用的詳細介紹請參考 Linux 手冊和 Linux 內核文檔.

BPF 幫助函數(BPF helpers)也是一套內核接口, 大部分 BPF 幫助函數作用和 BPF 系統調用相同, BPF 幫助函數和 BPF 系統調用有很多同名且功能也完全相同的接口.區別在於參數不同, 而且 BPF 幫助函數僅由 BPF 內核態程序調用.有關 BPF 幫助函數的詳細介紹請參考 Linux 手冊

2.2 是什麼, 以及爲什麼

如圖 1.2 所示: BPF 是一種最新的 Linux 跟蹤技術, 可用於網絡、可觀測性和安全三個領域, 我們將主要關心其在可觀測性領域的運用.

如圖 2.1 所示, 作爲可觀測性工具, BPF 支持上一章所述的全部事件源, 並且它是可編程的.

圖 2.1. BPF 支持的事件源

BPF 跟蹤可以在整個軟件範圍內提供能見度, 允許我們隨時根據需要開發新的工具和監測功能.在生產環境中可以立刻部署 BPF 跟蹤程序, 不需要重啓系統, 也不需要以特殊方式重啓應用軟件.圖 2.2 展示了一個通用的系統軟件棧的各部分及相應的 BPF 跟蹤工具.

圖 2.2. BPF 跟蹤工具提供的能見度

表 2.1 列出了傳統工具, 同時也列出了 BPF 工具是否支持對這些組件進行監測.傳統工具提供的信息可以作爲性能分析的起點, 後續則可以通過 BPF 跟蹤工具做更見深入的調查.

表 2.1. 傳統分析工具 VS BPF 工具

圖 2.3 是 perf_event 和 BPF 採樣流程的對比.可以看到相比 perf_event,BPF 大大精簡了流程, 避免了內核與用戶空間之間大量不必要的數據拷貝以及磁盤 IO.

圖 2.3 BPF vs perf_event 

2.3 BPF 虛擬機和運行時

簡單來說 BPF 提供了一種在各種內核事件和應用程序事件發生時運行一段小程序的機制.類似 JavaScript 允許網站在瀏覽器中發生某事件時運行一段小程序, BPF 則允許內核在系統和應用程序事件(如磁盤 IO 事件)發生時運行一段小程序(後面我們將看到這是如何實現的), 這就催生了新的編程技術, 該技術將內核變得可編程, 允許用戶(包括非專業內核開發人員)定製和控制他們的系統, 以解決現實問題.

BPF 是一項靈活爲高效的技術, 由指令集(有時也稱 BPF 字節碼)、存儲對象和輔助函數等及部分組成.由於它採用了虛擬指令集規範, 因此也可以將它視作一種虛擬機實現.

BPF 指令由 Linux 內核的 BPF 運行時模塊執行, 具體來說, 該運行時模塊提供兩種執行機制: 一個解釋器和一個將 BPF 指令動態轉換爲本地化指令的即時編譯器(JIT,just- in-time).注意只有當 BPF 程序通過解釋器執行時其實現纔是一種虛擬機.當 JIT 啓用之後 BPF 指令將通過 JIT 編譯後像任何其它本地內核代碼一樣, 直接在處理器上運.如果沒有啓用 JIT 則經由解釋器執行.相比於解釋器執行, JIT 執行的性能更能好, 一些發行版在 x86 架構上 JIT 是默認開啓的, 完全移除了內核中解釋器的實現.

這裏暫停一下繼續囉嗦幾句, 對編譯原理不是很瞭解的讀者可能不能快速地理解上一段闡述.無論是解釋器解釋執行還是編譯執行, 源代碼都需要被翻譯成機器代碼然後由 CPU 執行, 二者地區別在於: 1)從用戶地角度看, 解釋執行把用戶輸入和源代碼作爲解釋器的輸入, 解釋器解釋運行之後直接給出在用戶的輸入下, 源代碼的執行結果.請注意解釋器的輸入是源代碼本身和源代碼的輸入.而輸出則是源代碼的輸出.而編譯執行則至少分爲兩個階段, 第一個階段編譯的輸入是源代碼, 輸出是可執行文件, 可執行文件是可以保存在磁盤中被重複讀取執行的文件.只要源代碼沒有修改, 第一階段就只需要執行一次.第二階段纔是運行可執行程序.2)解釋器在解釋執行源代碼是也需要編譯源代碼得到機器代碼, 但是其編譯結果是作爲以一種中間數據保存在內存中, 這意味着每次解釋執行一個源代碼, 解釋器都要編譯一次源代碼, 因爲解釋器不輸出機器代碼到磁盤.回到上一段, JIT 會把加載進內核的 BPF 字節碼程序編譯成機器代碼並保存下來, 每一次事件觸發運行 BPF 程序時直接運行機器代碼即可, 而 BPF 解釋器保存的則是 BPF 字節碼程序, 每次事件觸發都需要先將字節碼編譯成機器代碼然後再運行, 因此 JIT 比解釋器性能更好.瞭解 Java 的讀者或許已經想到 BPF 運行時和 Java 運行時的原理完全相同, Java 同樣存在解釋執行和 JIT 編譯執行兩種執行方式.

在實際執行之前, BPF 指令必須先通過驗證器的安全性檢查, 以確保 BPF 程序自身不會崩潰或者損壞內核.Linux BPF 運行時的各模塊的建構如圖 2.4 所示, 它展示了 BPF 指令如果通過 BPF 驗證器驗證, 再有 BPF 虛擬機執行.BPF 虛擬機的實現既包括一個解釋器又包括一個 JIT 編譯器: JIT 編譯器負責生成處理器可直接執行的機器指令.驗證其會拒絕那些不安全的操作, 這包括對無界循環的檢查: BPF 必須在有限的時間內完成.同時 BPF 程序還有大小限制, 最初的 BPF 總指令數限制是 4096, 不過 Linux 5.2 內核極大地提升了這個值的上限, 因此在 Linux 5.2 以上的內核中這不再是一個需要關心的問題.

圖 2.4. BPF 運行時的內部結構

BPF 可以利用輔助函數獲取內核狀態, 利用 BPF 映射表進行存儲.BPF 程序在特定事件發生時執行, 包括 kprobes、uprobes、內核跟蹤點(tracepoint)、PMC 和 perf events 事件.

BPF 程序的主要運行過程如下:

  1. BPF 用戶態程序調用 BPF_PROG_LOAD 系統調用加載已經編譯成 BPF 字節碼的 BPF 內核態程序.根據 BPF 程序中的 BPF 映射表定義爲 BPF 映射表分配內存

  2. BPF 用戶態程序調用 BPF_PROG_ATTACH 系統調用將 BPF 內核態程序和事件關聯起來並向內核註冊事件

  3. 事件被觸發, 內核收集 “事件上下文” 傳遞給關聯到該事件的 BPF 內核態程序並執行該 BPF 程序.BPF 內核態程序處理內核收集的數據將處理結果保存在 BPF 映射表

  4. 用戶態程序調用 BPF 系統調用讀取映射表中的數據並做進一步的處理.

  5. 用戶提程序邏輯結束以後卸載 BPF 內核態程序、註銷事件並釋放 BPF 映射表內存.

圖 2.5. BPF 程序執行流程

2.4 CO-RE

BPF 程序的可移植性是指在某個內核版本中可以成功加載、驗證、編譯、執行的 BPF 程序也能夠在不加修改的前提下在其它的內核版本中成功地加載、驗證、編譯和執行.BPF 程序運行於內核態可以直接訪問內核的內存和內核數據結構使得 BPF 成爲一個強大而靈活的工具, 但同時它也導致了 BPF 與生俱來的可移植問題.因爲不同版本的內核, 內核數據結構的定義是發展變化的, 成員順序變化、重命名、從一個結構體移動到另一個結構體等均會導致內存訪問出錯, 這種錯誤是致命的.舉一個例子假設我們在 BPF 內核態程序中訪問了 struct task_struct 的成員 pid, 目標平臺的內核版本在 pid 成員前面插入了一個新的成員, 當我們編譯 BPF 內核態程序後, 對 pid 的成員訪問將被翻譯成 task_struct 結構體的起始地址加上成員偏移量.而目標平臺在 pid 之前插入了新的成員因此目標平臺的 pid 成員偏移量發生了變化, 很顯然如果次程序在目標平臺運行將導致內存訪問出錯.

解決 BPF 程序的可移植性問題的一種解決方案是依賴 BCC(BPF Compiler Collec- tion).在 BCC 中, BPF 內核態源代碼被當作一個字符串, 這個字符串被 BPF 用戶態程序當作普通的數據保存.當 BPF 用戶態程序被編譯之後在目標平臺上被 BCC 運行時, BCC 調用目標平臺的 Clang/LLVM 來編譯 BPF 內核態程序源代碼, 因爲 BPF 內核態程序源代碼是在目標平臺上編譯成字節碼的, 因此 BPF 內核態程序可以正確地訪問內核數據結構.這個方案的本質是延遲 BPF 內核態程序的編譯, 直到內核相關的信息全部已知之後纔開始編譯.不過這種解決方案不夠好, Clang/LLVM 是非常龐大地庫, 而且也需要大量地系統資源, 同時還需要目標平臺安裝了內核頭文件, 而且使用這種方式開發的 BPF 程序的調試和開發迭代過程也是十分的繁瑣.尤其是在嵌入式這種資源有限的系統, 幾乎不會有軟件是在目標平臺上直接編譯的.

一種優美的解決方式是 CO-RE(Code Once,Run Everywhere).BPF CO-RE 依賴於以下組件之間的協作:

程序進行重定位以適配目標主機的內核版本

簡單來說 CO-RE 是使用 BTF 記錄源代碼的重定位信息和內核數據結構的相關信息, 由 libbpf 在加載時進行重定位來解決 BPF 程序的可移植問題的.仍然以前面 task_struct 結構體的 pid 成員訪問爲例, 在 CO-RE 的解決方案中, BPF 字節碼中對 pid 的訪問被記錄爲一個重定位點.當 libbpf 加載 BPF 字節碼程序時, 會根據目標平臺的內核數據結構信息進行重定位.

更多關於 CO-RE 的知識可以參考博客

2.5 bpftool

BPF 目標文件時 BPF 字節碼程序, BPF 字節碼是 BPF 虛擬機的 “機器指令”.類似於 GCC 編譯工具鏈中的 objdump,addr2line 和 readelf 等處理真是硬件平臺的 ELF 文件的工具, BPF 目標文件這種 ELF 文件也有功能與 objdump,addr2line 和 readelf 相同的工具, 這個工具就是 bpftool, 它位於 Linux 源代碼的 tools/bpf/bpftool 中.它可以用來查看可操作 BPF 對象, 即 BPF 程序和 BPF 映射表.bpftool 是一個很強大的工具, 也是唯一可以用查看和操作 BPF 程序的工具, 關於其用法讀者可參考內核文檔, 工具自身的幫助文檔以及網絡上其它的學習資料.

第三章 開發 BPF 跟蹤工具

前面我們已經概述了 BPF 相關的基本概念, 工作原理以及它能做什麼.現在我們來看看具體怎麼開發 BPF 程序.開發 BPF 程序的方案有很多種, 可使用的編程語言原則上也沒有限制, 只要有相應的編譯器能將源代碼編譯成 BPF 目標文件(BPF 虛擬機上的字節碼指令可執行 ELF 文件)就可以了, 其它的與一般的軟件開發並沒有什麼不同.如果你對 BPF 字節碼和虛擬機足夠了解, 甚至可以直接使用 BPF 字節碼來開發 BPF 程序, 這無異於使用機器代碼來開發軟件.

BPF 的開發者提供了 BCC 和 bpftrace 兩個 BPF 開發前端, 它們都提供了把高級語言編譯成 BPF 字節碼並自動加載的功能.BCC 支持 C/C++、python、lua 等高級語言, bpftrace 則自己提供了一種腳本語言.不過這兩個前端都不適合用於開發嵌入式 BPF 程序, BCC 的運行時依賴過重, bpftrace 只適合開發非常簡單的, 短小精悍的 BPF 程序.另外 GoLang 也有不少可用的 BPF 開發庫, 不過它們大多都依賴於 BCC.這裏就不過多贅述

本文主要介紹一下基於 libbpf 的 BPF 程序開發.libbpf 是一個 C 語言庫, 它封裝 BPF 系統調用, 並提供了大量的使用的函數, 把 BPF 虛擬機比作運行 BPF 程序的操作系統的話, libbpf 就好比是 libc.

libbpf 依賴於 zlib 和 libelf, 在正式開發之前需要先安裝好這三個庫.下面以我自己寫的用 fp 回棧獲取特定系統調用發生時的調用鏈的 BPF 程序爲例, 講述 BPF 程序的代碼結構和開發流程.

編碼

內核態程序

#include <linux/version .h>     // linux 內核版本頭文件
#include 11 linux / vmlinux.h11 // linux內核數據結構頭文件
//用到的libbpf頭文件
#include "bpf_helpers.h" // BPF 幫助函數
#include "bpf_tracing.h"
#include nbpf_core_read•h "
#include "common.h”
struct
{
    __uint(type, BPF_MAP_TYPE_STACK_TRACE);
    __uint(key_size, sizeof(u32));
    __uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
    __uint(max_entries, 1000);
} kstack_map SEC(".maps");
struct
{
    __uint(type, BPF_MAP_TYPE_STACK_TRACE);
    __uint(key_size, sizeof(u32));
    __uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
    __uint(max_entries, 1000);
} ustack_map SEC(".maps");

#define KERN_STACKID_FLAGS (0 I BPF_F_FAST_STACK_CMP)
#define USER_STACKID_FLAGS (0 I BPF_F_FAST_STACK_CMP I BPF_F_USER_STACK)

SEC("kprobe/do_sys_open")
int bpg_open(struct pt_regs *ctx)
{
    const char target_comm[] = "static_demo";
    char curr_comm[MAX_COMM_LEN] = "\0";
    long ret = bpf_get_current_comm(curr_comm, sizeof(curr_comm));
    if (ret == 0)
    {
        bool is_target = false;
        for (size_t j = 0; j <= sizeof(target_conim); ++j)
        {
            if (j == sizeof(target_comm))
            {
                char fmt[] = "current comm is %s";
                bpf_trace_printk(fmt, sizeof(fmt), curr_comm);
                is_target = true;
                break;
            }
            if (target_comm[j] != curr_comm[j])
            {
                break;
            }
        }
        if (is_target)
        {
            long kstack_id = bpf_get_stackid(ctx, &kstack_map, KERN_STACKID_FLAGS);
            if (kstack_id < 0)
            {
                char fmt[] = "get kern stack id failed with kstack_id = %ld";

                bpf_trace_printk(fmt, sizeof(fmt), kstack_id);
                return 0;
            }
            else
            {
                char fmt[] = "get kern stack id success with kstack_id = Xld";
                bpf_trace_printk(fmt, sizeof(fmt), kstack_id);
            }
            long ustack_id = bpf_get_stackid(ctx, &ustack_map, USER_STACKID_FLAGS);
            if (ustack_id < 0)
            {
                char fmt[] = "get user stack id failed with ustack_id = %ld";
                bpf_trace_printk(fmt, sizeof(fmt), ustack_id);
                return 0;
            }
            else
            {
                char fmt[] = "get user stack id success with ustack_id = %ld";
                bpf_trace_printk(fmt, sizeof(fmt), ustack_id);
            }
            char filename[64];
            bpf_core_read(filename, sizeof(filename)(void *)PT_REGS_PARM2(ctx));
            char msg[] = "file %s is opened";
            bpf_trace_printk(msg, sizeof(msg), filename);
        }
    }
    return 0;
}

char _license[] SECClicense ") = " GPL ";
u32 version SEC("version") = LINUX VERSION CODE;

內核版本頭文件和 Linux 數據結構頭文件總是必須的.其中 vmlinux.h 需要手動從目標平臺的內核生成.也可不使用 vmlinux.h, 直接手動添加內核頭文件(同樣必須是目標平臺的內核頭文件), 不過這樣的話需要開發者自己管理內核頭文件依賴, 個人覺得這種方式容易出錯, 推薦使用 vmlinux.h.之所以內核態程序需要依賴 Linux 內核頭文件, 是因爲內核態程序往往需要訪問內核的數據結構.接下來的四個頭文件都是 libbpf 的頭文件, 是代碼中用到的接口所依賴的頭文件, 這四個頭文件是最常用的, 大部分情況下都需要.

kstack_map 和 ustack_map 是兩個 BPF 映射表的定義, 每個 BPF 映射表的定義方式都是一樣的: 需要指定映射表類型, 鍵的大小, 值的大小, 鍵值對的個數.

第 26 行則定義了一個 BPF 程序, 其關聯的是一個 kprobe 探針, 探針位於 do_sys_open 內核函數的入口.該 BPF 程序首先獲取了當前進程的運行命令(bpf_get_current_comm()), 將當前進程的運行命令與我們感興趣的進程命令進行比較, 如果一致說明當前運行的是我們感興趣的進程, 於是我們首先獲取其內核堆棧並保存於 kstack_map(bpf_get_stackid() 使用 FP 回棧獲取堆棧), 然後獲取其用戶態堆棧並保存於 ustack_map.其中 bpf_trace_printk() 用於打印內核調試信息, 因爲 BPF 程序運行於內核態, 這可能是我們唯一可用的調試方式了.注意一個源文件可以定義多個 BPF 程序.

最後兩行是指定內核證書和版本, LINUX_VERION_CODE 是一個定義在 vmlinux.h 中的宏.這兩行在任何 BPF 程序中都是一樣的.

用戶態程序

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <linux/perf_event.h>
#include <linux/bpf.h>
#include <signal.h>
#include <errno.h>
#include <sys/resource.h>
#include "bpf.h"
#include "libbpf.h"
#include "perf-sys.h"
#include "uapi/linux/trace_helpers.h"
#include "common.h"
// declare variables
static int err = 0;
static struct bpf_object *obj = NULL;
static const int NR_BPF_PROGS = 1;
static const char *bpf_prog_names[NR_BPF_PROGS] = {
    "bpg_open",
};
static struct bpf_program *bpf_progs[NR_BPF_PROGS] = {};

static struct bpf_link *bpf_prog_links[NR_BPF_PROGS] = {};

static const int NR_MAPS = 2;
static const char *map_names[NR_MAPS] = {
    "kstack_map",
    "ustack_map"};
static int map_fds[NR_MAPS] = {};

void print_ksym(__u64 addr)
{
    struct ksym *sym;
    if (!addr)
        return;
    sym = ksym_search(addr);
    if (!sym)
    {
        printf("ksym not found. Is kallsyms loaded?\n");
        return;
    }
    printf("ip = %11u sym = %s;\n", addr, sym->name);
}

long get_base_addr()
{
    size_t start, offset;
    char buf[256];
    FILE *f;
    f = fopen("/proc/self/maps",
              "r");
    if (!f)
        return -errno;
    while (fscanf(f, "%zx-%\*x %s %zx %\*[^\n]\n"&start, buf, &offset) == 3)
    {
        if (strcmp(buf, "r-xp") == 0)
        {
            fclose(f);
            return start - offset;
        }
    }
    fclose(f);
    return -1;
}

int setupprobes(const char *filename)
{
    // set up libbpf deubug level
    libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
    // set RLIMIT MEMLOCK
    struct rlimit r = {RLIM_INFINITY, RLIM INFINITY};
    setrlimit(RLIMIT_MEMLOCK, & r);

    // open kern object file
    obj = bpf_object__open_file(filename, NULL);
    err = 0;
    err = libbpf_get_error(obj);
    if (err)
    {
        fprintf(stderr, "opening BPF object file %s fialed: %s\n", filename, strerror(err));
        return -1;
    }
    // find bpf progs
    for (int j = 0; j < NR_BPF_PROGS; ++j)
    {
        bpf_progs[j] = bpf_object__find_program_by_name(obj, bpf_prog_names[j]);
        err = 0;
        err = libbpf_get_error(bpf_progs[j]);
        if (err)
        {
            fprintf(stderr, "finding BPF prog %s failed: %s\n", bpf_prog_names[j],strerror(err));
            return -1;
        }

        // load bpf programs
        if (bpf_object load(obj))
        {
            fprintf(stderr, "loading BPF object file %s failed: %s\n", filename,
                    strerror(errno));
            return -1;
        }

        // attach bpf progs
        for (int j = 0; j < NR_BPF_PROGS; ++j)
        {
            bpf_prog_links[j] = bpf_program__attach(bpf_progs[j]);
            err = 0;
            err = libbpf_get_error(bpf_prog_links[j]);
            if (err)
            {
                fprintf(stderr, "attaching BPF program %s failed: %s", bpf_prog_names[j], strerror(err));
                return -1;
            }

            // find map fds
            for (intj = 0; j < NR_MAPS; ++j)
            {
                map_fds[j] = bpf_object__find_map_fd_by_name(obj,map_names[j]);
                if (map_fds[j] < 0)
                {
                    printf("find map fd by name %s failed\n", map_names[j]);
                    return -1;
                }
                return 0;
            }

            void cleanup_probes()
            {
                for (int j = 0; j < NR_BPF_PROGS; ++j)
                {
                    bpf_link__destroy(bpf_prog_links[j]);
                }
                bpf_object__close(obj);
            }

            int main(int argc, char *argv[])
            {
                char filename[256] = {};
                snprintf(filename, sizeof(filename)"%s_kern.o", argv[0]);
                if (setup_probes(filename))
                {
                    printf("ER0OR: setup_probes failed\n");
                    cleanup_probes();
                    return 0;
                }
                printf("setup_probes() done, press any key to continue\n");
                char str[32] = "\0";
                scanf("%s",str);
                printf("%s received, continue processing data\n", str);
                // process data
                load_kallsyms();
                __u32 key = 0;
                __u32 next_key = 0;
                __u64 ips[MAX_STACK_DEPTH] = {};
                printf("---------kernel stacks---------\n");
                while (bpf_map_get_next_key(map_fds[0]&key, &next_key) == 0)
                {
                    if (bpf_map_lookup_elem(map_fds[0]& next_key,ips) == 0)
                    {
                        printf("kern stacks with kstack_id = %u:\n", next_key);
                        for (int j = 0; j < MAX_STACK_DEPTH; ++j)
                        {
                            print_ksym(ips[j]);
                        }
                        key = next_key;
                    }
                }
                key = 0;
                next_key = 0;
                printf("---------user stacks---------\n");
                while (bpf_map_get_next_key(map_fds[1]&key, &next_key) == 0)
                {
                    if (bpf_map_lookup_elem(map_fds[1]& next_key,ips) == 0)
                    {
                        printf("user stacks with ustack_id = %u:\n", next_key);
                        for (int j = 0; j < MAX_STACK DEPTH; ++j)
                        {
                            if (ips[j] == 0)
                            {
                                break;
                            }
                            printf("ip = %l1x\n",ips[j]);
                        }
                    }
                    key = next_key;
                }
                cleanup_probes();
                return 0;
            }

用戶態程序的流程分爲三部分: 準備階段(在 setup_probes() 函數), 業務處理(在 main() 函數), 清理 BPF 程序和映射表(在 cleanup_probes() 函數).準備階段的邏輯分爲: 設置 libbpf 調試級別(可選), 設置 RLIMIT_MEMLOCK(大部分情況下都需要做此設置, 否則 BPF 內核態程序太小限制太小將導致內核態程序加載失敗), 打開 BPF 目標文件, 獲取 BPF 程序對象句柄, 加載 BPF 程序, 附着(attach)BPF 程序, 獲取 BPF 映射表對象句柄.這些步驟在任何用戶態程序中都是固定不變的.

用戶態程序的業務邏輯都在 main() 函數中, 它首先調用了 setup_probes() 函數設置好 BPF 程序和映射表, 然後分別讀取 BPF 映射表 kstack_map 和 ustack_map, 在內核態程序中, 這兩個映射表被寫入了目標進程調用鏈的 ip 指針.讀取內核和用戶態調用鏈的 ip 值以後將它們打印了出來.

編譯和運行

內核態程序必須使用 Clang/LLVM 編譯並指定 “-target bpf” 選項, 這樣我們將得到一個 BPF 目標文件, 用戶態程序則像通常的程序一樣交叉編譯即可.在上述例子中, 我將用戶態程序命名爲 fs_user.c, 用戶態程序打開 BPF 目標文件是假設了目標文件的名字是 fs_kern.o, 運行時只需將編譯好用戶態可執行文件和內核態 BPF 目標文件放在同一目錄下, 然後執行用戶態程序, 用戶態的準備階段就會讀取 BPF 目標文件跟 BPF 目標文件的內容加載並附着 BPF 程序併爲 BPF 映射表分配內存, 這些都成功以後每次事件觸發, 相關聯的 BPF 程序就會被自動執行.

關於 libbpf API 的詳細信息可以參考 libbpf API 文檔

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0Oingrfnm2ZBBl_Xz2Zogw