掛鉤才是難的部分
第二篇給你留下了一台有力的小機器:eBPF 讓你把一段小而經過驗證的程式載入核心並安全地執行。但一段會執行的程式在它於正確的時刻執行之前都是無用的——某個檔案被開啟、某個封包抵達、或某個特定函式被呼叫的那一瞬間。那個「於正確的時刻」就是缺掉的另一半,而它確實是更難的那一半。本篇談的是 Linux 給你的四個附著點,讓你說出你的觀察要在哪裡、何時觸發:ftrace、kprobes、uprobes 與追蹤點。eBPF 是引擎;這些是你能把它栓上去的地方。
把這個家族沿兩條線去看會很有幫助。第一條線是核心對使用者:一個 kprobe 掛鉤核心內部的程式碼,而一個 uprobe 掛鉤一個普通使用者空間程式內部的程式碼,像是你自己的 a.out 或一份 nginx。第二條線是動態對靜態:kprobe 與 uprobe 是動態的——它們能附著到幾乎任何一個指令位址、不需要目標的任何配合,於執行期被找到並修補;而一個 追蹤點是靜態的,是核心作者刻意放進原始碼、並承諾保持穩定的一個具名掛鉤。這個二乘二格子的每一格都在安全性與觸及範圍之間權衡,而知道你正在用哪一個,會精確地告訴你該多麼信任你所看見的東西。
ftrace:早已住在你核心裡的追蹤器
四者之中,ftrace 是最溫和的起點,因為它根本不需要你提供任何程式——它是一個內建於核心的追蹤框架,完全透過檔案來駕馭。它住在一個特殊目錄之下,/sys/kernel/tracing,而你藉由把純文字寫進那些檔案、再把結果讀回來去驅動它。它的招牌絕活是函式追蹤器:靠一個編譯器功能,核心被建構成讓每一個函式都以一個微小的佔位起頭,追蹤器可以把它打開,於是整個核心都變成你能逐函式觀看、不必重建的東西。
心智模型是一個你從外面撥動的電燈開關。比如說,要追蹤每一次進入那個處理開檔的函式的呼叫,你把那個函式的名字寫進一個過濾檔、把「function」這個字寫進當前追蹤器檔、把追蹤打開,然後讀一個 trace 檔,就能看見帶著時間戳與 CPU 編號的即時呼叫串流。重點不在確切的檔名——它們在不同核心版本之間會變動——而在那個形狀:ftrace 是被組態的,不是被編程的。這使它成為回答「這個函式到底有沒有被呼叫、多頻繁、從哪裡呼叫」的最快方式,而那往往正是你真正僅有的問題。
# turn on ftrace's function tracer for one kernel function, # read a few lines, then turn it back off. (run as root) cd /sys/kernel/tracing echo do_sys_openat2 > set_ftrace_filter # watch just this function echo function > current_tracer # pick the function tracer echo 1 > tracing_on # flip the switch ON cat trace | head # see live calls: # TASK-PID CPU# TIMESTAMP FUNCTION # cat-3812 [002] 91.123456: do_sys_openat2 <-__x64_sys_openat # cat-3812 [002] 91.123512: do_sys_openat2 <-__x64_sys_openat echo 0 > tracing_on # flip it OFF (always clean up)
kprobes 與 uprobes:一個不會讓世界停下的中斷點
ftrace 給你看的是核心被建構來揭露的那些函式。一個 kprobe 走得更遠:它讓你附著到核心裡幾乎任何一個指令位址,即使深在一個函式內部、不論有沒有名字。如果你記得除錯那一段的除錯器,這個把戲會讓你感到熟悉。一個 kprobe 的運作像一個中斷點:核心把你所選位址上的原始指令存起來,並用一個陷阱指令覆寫它的第一個位元組。當 CPU 抵達那個位址時,它會錯入 kprobe 的機制、執行你的處理常式,然後悄悄地復原並執行原始指令再繼續下去。被你修補的那段程式碼從不知道自己被觀察了。
但要誠實地拿著這個類比,因為它與除錯器中斷點的差別正是整個重點。一個除錯器中斷點會把程式停下並等一個人類;那在生產環境裡是致命的,在那裡讓核心停下就是讓機器停下。一個 kprobe 從不停下任何東西——它的處理常式在幾個指令內跑完,執行就流過去了。這就是為什麼你能在一台服務著真實流量的線上伺服器上跑它。代價是真實的、但很小:每一次命中都是一個陷阱加一個處理常式,所以一個掛在每秒被呼叫數百萬次的函式上的 kprobe 是可量測的。你買的是看進一個執行中核心的透視眼,而你以每次呼叫幾奈秒的代價去付。
一個 uprobe 是同樣的點子、瞄準使用者空間。你不去修補一個核心函式,而是把它指向磁碟上某個普通程式或共享函式庫內部的一個位址——比如 libc 裡 malloc() 的進入點,或你自己二進位檔裡的一個函式。核心在那裡設下相當於一個中斷點的東西,從那之後,每一個執行到那段程式碼的行程都會絆到你的處理常式。這就是你如何去追蹤一個你無法或不願重新編譯的程式:一個掛在函式上的 uprobe 不需要程式作者的任何東西、不需要特殊的建構、不需要原始碼。但要留意那份不對稱——一個 uprobe 在每次命中時都牽涉一次切換進核心,所以它比 kprobe 重、比一個程式為自己內建的掛鉤重得多,而這正是下一節要填補的那道落差。
追蹤點與 USDT:作者為你留下的穩定掛鉤
動態探針強大得美妙、也悄悄地危險,而你該同時感受到這兩面。一個 kprobe 以名字與位移附著到一個函式——但那些名字是核心的內部細節,沒有任何穩定性的承諾。下一個核心版本那個函式可能被改名、被內聯掉、或被拆成兩個,而你的 kprobe 就悄悄地附著到錯的東西、或根本附不上去。一個 追蹤點是解藥。它是核心自己的作者放在原始碼裡某個有意義之處的一個掛鉤,給了它一個穩定的名字,並承諾讓它跨版本持續運作,連同一組它交給你的、有文件記載的欄位。透過 sched_switch 的追蹤點去追蹤它,多年來都會持續表示「發生了一次脈絡切換」;而一個掛在這個月碰巧實作它的那個函式上的 kprobe 則不會。
同樣的點子在使用者空間程式裡也存在,而在那裡它有個名字:USDT,代表 Userland Statically Defined Tracing(使用者層靜態定義追蹤)。一個 USDT 探針是程式設計師刻意寫進自己應用程式裡的一個追蹤點——一個具名的標記,像「一個請求開始了」或「一次查詢結束了」,放在語意上有意義之處,而不是函式邊界碰巧落下的任何地方。像 PostgreSQL 這樣的資料庫、像 JVM 與 Node.js 這樣的執行期,都內建了 USDT 探針出貨。當那個標記沒有觸發時,它編譯成單一個無動作指令,所以它幾乎不花任何代價;當真的有人在看時,一個追蹤器可以即時把它撥開。
把它組起來,以及它背後的傳承
實務上你很少親手去戳這些檔案。那四種掛鉤是管路;你實際打字使用的工具通常是 bpftrace,一種把 eBPF 程式附著到其中任何一者的單行語言。一行就說出要掛在哪裡、要做什麼:「在這個函式的每一個 kprobe 上,依某個鍵去計數」,或「在這個追蹤點上,記錄一個延遲」。同一個小腳本只要改掉那個指名掛鉤的前綴,就能瞄準一個追蹤點、一個 kprobe、一個 uprobe 或一個 USDT 探針——這正是把這四者當成一個家族來學值得的整個原因。掛鉤是動詞;bpftrace 與 eBPF 是把它們串成一個句子的文法。
這一切並非憑空冒出。整套做法——對一個活著的系統進行安全、可編程、達到生產等級的追蹤——是由 DTrace 開創的,它約在 2003 年於 Sun Microsystems 為 Solaris 而建。DTrace 引進了你一直在讀的這些如今已成標準的點子:修補一個執行中核心的動態探針、在每個探針上執行的一種安全小語言、應用程式作者所定義的靜態 USDT 標記,以及把這一切都設計得安全到足以留在生產環境裡開著的那份紀律。Linux 的 ftrace、kprobes、uprobes 與追蹤點,有一部分是作為 Linux 世界對 DTrace 的回應而長大的,而 eBPF 最終成了把它們綁在一起的那個安全的核心內引擎。為這個血脈記上一筆並不是瑣碎的掌故——它告訴你這些是成熟、深思熟慮的工具,而不是聰明的取巧。
退一步,這張地圖就清楚了。ftrace 是那台「組態而非編程」、早已在你核心裡的追蹤器;kprobes 與 uprobes 是動態的中斷點,幾乎能附著到任何地方——分別在核心裡與在使用者程式裡——代價是耦合到內部細節;追蹤點與 USDT 是穩定、經作者祝福、你該優先選用的掛鉤。它們落在「核心對使用者」與「動態對靜態」這兩條軸上,而選對那一格就是這項技能的大半。有了這些作為你的附著點、有了 eBPF 作為引擎,第四篇就從觀察一個執行中的系統,轉向持續地取樣它、以及拆解一個早已當掉的系統。