Tracing kann so einfach sein: Dank einer plattformunabhängigen VM im Linux-Kernel bekommen Tracing-Skripte Zugang zu den System-Interna.
Extended Berkeley Packet Filter, das hört sich nach renommierter Universität und zugleich nach Firewall an – beides korrekt. Nach langer Schwangerschaft erblickte der BPF im Jahr 1992 an der University of California das Licht der Welt [1]. Die virtuelle Maschine im Betriebssystemkern war die Antwort auf die Suche nach einem effizienten Netzwerk-Monitoring.
Aufgrund der Anforderung, effizient und hochgradig flexibel zu sein und auch auf Unvorhergesehenes reagieren zu können, wurde der BPF als Computer im Computer konzipiert. Er sollte auf einer einfach gestrickten CPU basieren sowie plattformunabhängig und über exakt definierte Schnittstellen mit dem Restsystem verbunden sein. Eine VM also, die sich relativ einfach mit Monitoring- beziehungsweise Filtering-Software füttern lässt. Die Programme werden – wie bei Java – per Just in Time Compiler (Jit) in Bytecode übersetzt und dem Betriebssystemkern übergeben.
Der damaligen Zeit entsprechend entwickelten die Macher eine 32-Bit-Maschine, die mittlerweile auf 64 Bit erweitert wurde. Diese eBPF-Variante fand im Linux-Kernel eine Heimat und ersetzt peu à peu die ursprüngliche Firewall-Implementierung. Doch das ist noch nicht alles, haben die Entwickler doch weitsichtig ein Werkzeug entwickelt, das sich auch für Unvorhergesehenes eignet: Warum sollte man die In-Kernel-VM auf den Zugang zum Netzwerkverkehr beschränken, warum ihr nicht auch andere Komponenten zugänglich machen? Zum Beispiel die Kprobes (Kernel-Probes) oder die sogenannten Tracepoints?
Dtrace 2.0
Tatsächlich entpuppt sich eBPF als überaus nützliches Werkzeug, auch und gerade wenn es um Tracing geht. Damit lässt sich im laufenden Betrieb einfach feststellen, ob jemand außergewöhnlich große Datenpakete per Netzwerk verschickt oder auf welche Dateien aktuell zugegriffen wird. Schnell entwickelte sich ein umfangreiches Ökosystem rund um eBPF, das sehr einfach selbst komplexe Tracing-Aufgaben löst und das es mit Dtrace aufnimmt, dem Platzhirsch in der Nicht-Linux-Welt. Tracing-Guru und Netflix-Mitarbeiter Brendan Gregg spricht stets von Dtrace 2.0, wenn er über eBPF doziert.
Um sich nicht mit Assembler-Code, CPU-Registern oder Adressierungsarten herumschlagen zu müssen, haben pfiffige Entwickler zwei Werkzeuge für Tracer entwickelt: Bpftrace und BCC. Bpftrace definiert eine simple Programmiersprache für Tracing-Aufgaben, deren Code in Bytecode für eBPF übersetzt wird.
Für die eigentliche Interaktion mit dem Kernel, also das Laden des Bytecodes, das Abholen von Ergebnissen und die Ansteuerung vorhandener Tracing-Möglichkeiten von Linux (Kprobes, Uprobes und Tracepoints) ist die Berkeley Compiler Collection BCC zuständig (Abbildung 1). Die von Alastair Robertson ersonnene und zu Bpftrace gehörende Programmiersprache macht Anleihen bei C und Awk, aber auch bei Dtrace und SystemTap. Für die meisten Tracing-Aufgaben genügt ein Bpftrace-Einzeiler, komplexere Auswertungen lassen sich skripten. Beispiele für nützliche Einzeiler finden sich zur Genüge im Netz [2], sodass der von Performance-Problemen geplagte Admin häufig abschreiben kann (Tabelle 1).

Abbildung 1: Als Schnittstelle zwischen Kernel und Userland dient ein MAPS genannter Assoziativspeicher.
|
Nr. |
Bedeutung |
Befehl |
|---|---|---|
|
1 |
Trigger-Punkte listen |
»bpftrace -l« |
|
2 |
schlafende Jobs melden |
»bpftrace -e ‘tracepoint:syscalls:sys_enter_clock_nanosleep { printf(“%s is sleeping.\n”, comm); }’« |
|
3 |
Page Faults zählen |
»bpftrace -e ‘software:faults:1 { @[comm] = count(); }’« |
|
4 |
Öffnen von Dateien (Job/Datei) protokollieren |
»bpftrace -e ‘tracepoint:syscalls:sys_enter_openat { printf(“%s %s\n”, comm, str(args->filename)); }’« |
|
5 |
Systemcalls für jeden Job zählen |
»bpftrace -e ‘tracepoint:raw_syscalls:sys_enter { @sysenter[comm] = count(); }’« |
|
6 |
Anzahl Bytes, die der Job mit der PID 1 einliest |
»bpftrace -e ‘kretprobe:vfs_read /pid == 1/ { @bytes = hist(retval); }’« |
|
7 |
Cache-Misses zählen |
»bpftrace -e ‘hardware:cache-misses:1000000 { @[comm, pid] = count(); }’« |
Aller Anfang ist leicht
Die Installation von Bpftrace erfordert kein Studium. Voraussetzung ist allerdings ein Kernel mit einer Versionsnummer von 4.9 oder höher. Das trifft sowohl auf das aktuelle Ubuntu 20.04 mit seinem 5.4er-Kernel zu als auch auf die älteren Ubuntu Varianten 18.04 oder 16.04. Zur Installation genügt das Kommando »apt install bpftrace«.
Bpftrace gibt es daneben auch als Snap-Paket (https://snapcraft.io/bpftrace). Da Snap jedoch nur sehr eingeschränkte Zugriffsrechte bietet, das Tracing aber den vollen Zugriff erfordert, muss man bei der Installation die Option »–devmode« angeben.
Ist die Installation erfolgreich abgeschlossen, geht es los. Das berühmte “Hello, world” sieht als Einzeiler wie in Listing 1 gezeigt aus. Abbrechen lässt sich das Programm per [Strg]+[C]. Freilich hat Bpftrace einige Tricks mehr auf Lager, als nur den Kernel zu einem schlichten Gruß an die Welt aufzufordern. Der Einzeiler Nummer 2 in Tabelle 1 meldet jede Applikation, die sich über den Systemcall »clock_nanosleep()« aufs Ohr legt.
Listing 1
Hello-World-Einzeiler
$ sudo bpftrace -e 'BEGIN { printf("Hello, world!\n"); }'
Attaching 1 probe...
Hello, world!
Grundsätzlich folgen Bpftrace-Programme einer dreiteiligen Struktur (Abbildung 2): Sie definieren erstens einen Triggerpunkt (Probe), zweitens optional einen Filter und drittens schließlich eine Aktion. Die Probe (deutsch: Sonde) definiert die Ereignisse, bei deren Auftreten die Aktion greift, sofern der Filter es zulässt.
eBPF baut keine neuen Probes in den Kernel ein, sondern setzt auf Vorhandenes und Erprobtes (Abbildung 3). Tabelle 2 führt die diversen Sonden auf. Generell unterscheidet man zwischen Probes für Kernel-Funktionen und -Ereignisse und solchen für Applikationen.

Abbildung 3: Bpftrace hat Zugriff auf beinahe sämtliche Kernel-Subsysteme. Quelle: github.com/iovisor
|
Trigger-Punkt |
Beschreibung |
|---|---|
|
»tracepoint« |
Funktionen, deren Interface über Kernel-Versionen hinweg stabil bleibt (etwa Systemcalls) |
|
»kprobe« |
Kernel-Funktionen, die sich von Version zu Version in ihren Prototypen unterscheiden können |
|
»kretprobe« |
Aussprungspunkte der Kprobe-Funktionen |
|
»usdt« |
Tracing von in Applikationen instrumentierten Funktionen |
|
»uprobe« |
Start einer Applikationsfunktion |
|
»uretprobe« |
Ende einer Applikationsfunktion |
|
»software« |
per Software generierte Kernel-Ereignisse (etwa Page-Faults) |
|
»hardware« |
Hardware-Ereignisse (etwa Cache-Misses) |
|
»watchpoint« |
Speicherzellenüberwachung |
|
»profile« |
zeitgesteuerte Ereignisse auf allen Prozessorkernen |
|
»interval« |
zeitgesteuertes Ereignis auf einem Prozessorkern |
|
»BEGIN« |
Probe, die zum Start des Bpftrace-Programms aktiviert wird |
|
»END« |
Probe, die zum Ende des Bpftrace-Programms aktiviert wird |
Bei den Kernel-Ereignissen grenzen sich die Tracepoints von Kprobes und Kretprobes ab. Erstere instrumentieren Kernel-Funktionen mit stabilen und statischen Interfaces. Trace-Skripte, die diese Trigger-Punkte verwenden, funktionieren weitgehend unabhängig von unterschiedlichen Kernel-Versionen. Bei dem von Ubuntu verwendeten Kernel 5.4 sind das rund 2600.
Kprobes und Kretprobes instrumentieren rund 64 000 weitere Funktionen (Tabelle 1, Einzeiler 1). Hier darf sich der Entwickler aber nicht auf festgezurrte Prototypen verlassen, da Chefentwickler Torvalds ab und zu Funktionsnamen oder Parameter ändert. Kprobe selbst nistet sich zu Anfang der Funktion ein, per Kretprobe lassen sich die Rückgabewerte von Funktionen abfangen.
Für das Userland sind die Sonden Usdt, Uprobe und Uretprobe vorgesehen. Auch hier unterscheidet man zwischen statischen und dynamischen Funktionen. Darüber hinaus gibt es noch Probes, die bei Software-Ereignissen getriggert werden, wie beispielsweise Page-Faults (Tabelle 1, Einzeiler 3) und Hardware-basierte Probes. So lässt sich beispielsweise eine Bpftrace-Aktion bei einem Cache-Miss triggern (Tabelle 1, Einzeiler 7).
Zeitgesteuerte Ereignisse, die beispielsweise periodisch auftreten, sind per Interval- und Profile-Probe ebenfalls implementiert. Zu guter Letzt kann auch der Start eines Bpftrace-Programms beziehungsweise dessen Ende als Triggerpunkt dienen. Davon haben wir bereits im Hello-World-Beispiel Gebrauch gemacht.
Die zweite Komponente eines Bpftrace-Programms ist der optionale Filter, eine boolesche Bedingung. Damit lassen sich beispielsweise Daten gezielt für einen per PID gegebenen Job erfassen (Tabelle 1, Einzeiler 6). Bei erfüllter Filterbedingung wird die Aktion ausgeführt, das eigentliche Programm. Fehlt die Filterbedingung, greift die Aktion grundsätzlich.
Auch definiert und initialisiert bpftrace eine Reihe von Variablen, sodass der Programmierer direkt auf Task-Namen, Zeitstempel oder Funktionsparameter zugreifen kann. Komplexe Datentypen sind dabei ebenfalls kein unüberwindliches Problem, solange das Werkzeug mit den zugehörigen Deklarationen beispielsweise über die Header-Dateien des Linux-Kernels gefüttert wird. Eine Liste der ausgewählten, vordefinierten Variablen finden Sie in Tabelle 3.
|
Name |
Bedeutung |
|---|---|
|
»pid« |
Prozess-ID (Kernel: »tgid«) |
|
»tid« |
Thread-ID (Kernel: »pid«) |
|
»cgroup« |
Cgroup-ID der aktiven Task |
|
»uid« |
User-ID |
|
»gid« |
Group-ID |
|
»nsecs« |
Zeitstempel in Nanosekunden |
|
»cpu« |
Prozessor-ID |
|
»comm« |
Name des Tasks |
|
»curtask« |
aktueller Task-Kontrollblock als »u64« |
|
»kstack« |
Kernel Stack Trace |
|
»ustack« |
User-Level Stack Trace |
|
»arg0…argN« |
Funktionsparameter |
|
»args« |
Tracepoint-Parameter |
|
»retval« |
Return-Wert einer Funktion |
|
»func« |
Funktionsname |
|
»name« |
vollständiger Name der Probe |
|
»rand« |
Zufallszahl |
|
»$1…$N« |
Positionsparameter |
Neben den vordefinierten Variablen spielen die selbst definierten Variablen eine wichtige Rolle. Über sie erfährt der neugierig wartende Admin von den Tracing-Ergebnissen. Technisch verbergen sich dahinter die zu eBPF gehörenden Maps, eine Art gemeinsamer Speicher, in den der Kernel schreibt und aus dem die Userland-Applikation (per BCC) liest.
Aus Sicht des Bpftrace-Programmierers handelt es sich bei den Maps um einen Assoziativspeicher, also einen Speicher, den man nicht über (hexadezimale) Adressen anspricht, sondern über Schlüsselworte (Keys). Dabei werden sowohl skalare Variablen als auch Felder unterstützt. Gerade Letztere eignen sich hervorragend, um damit Histogramme oder Statistiken zu erstellen.
Will man beispielsweise zählen, wie oft ein jeder Thread den Systemcall »vfs_read« aufruft, definiert man ein Array »@vfsread«, das mit der jeweiligen Thread-ID (TID) indiziert wird. Noch besser: Der Admin kann auch den Task-Namen als Index verwenden, was später zu einer besser lesbaren Ausgabe führt. Selbstdefinierte Variablen erkennt der Anwender am Klammeraffen (»@«) vor dem Variablennamen. Felder identifiziert er wie gewohnt über eckige Klammern. Die Variablen sind grundsätzlich global. Um unterwegs einmal einen Wert zwischenzuspeichern, kann man auch eine »$«-Variable definieren.
Theoretisch stehen für die Programmierung der Aktionen viele der bekannten Elemente einer Programmiersprache zur Verfügung, zum Beispiel Variablen, Bedingungen und Schleifen. Praktisch braucht der Admin davon in den meisten Fällen jedoch nur sehr wenig, denn Bpftrace ist mit seinen High-Level-Funktionen bereits auf das Tracing ausgerichtet (siehe Tabelle 4).
|
Funktion |
Beschreibung |
|
|---|---|---|
|
»printf(“…”)« |
gibt formatierten String aus |
|
|
»str(char *s [, int length])« |
gibt Inhalt des Zeigers als String zurück |
|
|
»sym(void *p)« |
gibt Symbol der gegebenen Kernel-Adresse zurück |
|
|
»kaddr(char *name)« |
liefert Adresse einer als Name gegebenen Kernel-Funktion |
|
|
»ntop([int af,]int|char[4:16] addr)« |
konvertiert IP-Adresse zu String |
|
|
»reg(char *name)« |
gibt Inhalt eines CPU-Registers zurück |
|
|
»time(“…”)« |
gibt Zeit formatiert aus |
|
|
»kill(x)« |
sendet Job ein Signal |
|
|
»system(“…”)« |
führt Shell-Kommando aus |
|
|
»cat(char *filename)« |
gibt Inhalt einer Datei aus |
|
|
»@ = count()« |
zählt Ereignisse |
|
|
»@ = sum(x)« |
summiert Wert auf |
|
|
»@ = avg(x)« |
|
liefert Durchschnittswert |
|
»@ = hist(x)« |
Histogramm (Power-of-2) |
|
|
»@ = lhist(x, min, max, step)« |
liefert lineares Histogramm für »x« |
|
|
»@ = min(x)« |
speichert kleinsten Wert ab |
|
|
»@ = max(x)« |
speichert größten Wert ab |
|
|
»@ = stats(x)« |
legt Anzahl, Durchschnitt und Gesamtsumme für den Wert ab |
|
|
»delete(@x[key])« |
löscht Map-Element |
|
|
»clear(@x)« |
löscht alle Schlüssel aus der Map |
Bpftrace kann Ereignisse zählen, aufsummieren oder als Histogramme aufzeichnen. Per »printf()« lassen sich Kernel-interne Informationen publizieren, zum Beispiel Werte von Parametern. Zusätzlich ruft eBPF auch Shell-Kommandos auf. Das geht ganz allgemein über die »system()«-Funktion oder spezifisch mit »cat()« oder »kill()«.
Ein- und Mehrzeiler lassen sich aber ebenso leicht skripten. Dazu schreibt der Admin die Kommandos in eine Textdatei, die er Bpftrace beim Aufruf übergibt. Noch einfacher wird es, wenn er in die erste Zeile als sogenanntes Shebang den Pfad und Namen von Bpftrace einträgt, die Datei per »chmod 755« ausführbar macht und sie dann in ein Verzeichnis verschiebt, in dem die Shell nach Kommandos sucht (beispielsweise »/usr/local/bin/«).
Listing 2 zeigt ein von Tracing-Guru Brendan Gregg erstelltes Skript, das Latenzzeiten von Ein- und Ausgabeoperationen auf SSD oder Festplatte misst. Das Skript installiert nicht weniger als vier Probes. Mit der Sonde »BEGIN« erinnert es den Anwender daran, dass die Messung der Latenzzeiten bis zu einem Abbruch per [Strg]+[C] läuft. »END« räumt nach dem Abbruch den globalen eBPF-Speicher wieder auf. Dazwischen findet die eigentliche Messung statt.
Listing 2
Block-I/O-Latency
#!/usr/bin/env bpftrace
/*
* biolatency.bt Block I/O latency as a histogram.
* For Linux, uses bpftrace, eBPF.
*
* This is a bpftrace version of the bcc tool of the same name.
*
* Copyright 2018 Netflix, Inc.
* Licensed under the Apache License, Version 2.0 (the "License")
*
* 13-Sep-2018 Brendan Gregg Created this.
*/
BEGIN
{
printf("Tracing block device I/O... Hit Ctrl-C to end.\n");
}
kprobe:blk_account_io_start
{
@start[arg0] = nsecs;
}
kprobe:blk_account_io_done
/@start[arg0]/
{
@usecs = hist((nsecs - @start[arg0]) / 1000);
delete(@start[arg0]);
}
END
{
clear(@start);
}
Per Kprobe auf die Funktion »blk_account_io_start« wird die Startzeit für einen Ein-/Ausgabeauftrag abgespeichert, und zwar separat für jeden einzelnen Request. Der wird, wie ein Blick in den Kernel-Quellcode zeigt, als erster Parameter (»arg0«) der Funktion »blk_account_io_start« übergeben.
Die dritte Probe wird als Kprobe auf das Ende einer IO-Operation (Funktion »blk_account_io_done«) angesetzt. Dabei kommt ein Filter zum Einsatz: Die nachfolgende Aktion greift nur, wenn für den aktuellen Request auch eine Startzeit abgelegt wurde (»@start[arg0]«). Die Differenz zwischen aktueller Zeit (»nsecs«) und abgespeicherter Zeit ergibt die Latenzzeit. Um eine Ausgabe in Mikrosekunden zu erhalten, dividiert der Code das in Nanosekunden vorliegende Ergebnis durch 1000 und füttert es schließlich in das Histogramm.
Die Histogramm-Daten selbst verbergen sich hinter der Variablen »@usecs«. Der bewusst gewählte Name dieser Variablen sorgt dafür, dass sich die Ergebnisse der beim Abbruch erfolgenden Ausgabe einfach interpretieren lassen. Der Filter in der dritten Probe ist übrigens notwendig, um beim Start des Skripts fehlerhafte Werte durch bereits gestartete IO-Operationen auszuschließen. Ein Beispiel für die Ausgabe des Skripts zeigt Abbildung 4.
Admins an die Tasten!
Mit diesen Grundkenntnissen ausgestattet, wird es Zeit für eigene erste Gehversuche. Neben “Hello, world” finden Sie in Tabelle 1 weitere Einzeiler zum Austesten. Noch systematischer weisen kurzweilige Beispiele in den Umgang und die Möglichkeiten von Bpftrace ein, die Sie im Bpftrace-One-Liner-Tutorial [2] und in Brendan Greggs Einführung in Bpftrace für Linux [3] finden. Darüber hinaus listet die Manpage zu Bpftrace unter anderem sämtliche Variablen und Funktionen auf.
Beifang
Bevor man sich jetzt ans Werk macht, komplexere Abfragen für Bpftrace zu programmieren, sollte man noch einen Blick auf Tabelle 5 werfen. Brendan Gregg hat Bpftrace rund 35 Skripte beigepackt, die verschiedene Tracing-Aufgaben perfekt übernehmen. Sie finden sich im Verzeichnis »/usr/sbin/«. Ein Blick in die Dateien lohnt sich doppelt: Im Kopf der Skripte sind Funktion und Aufruf detailliert beschrieben, und der Programmcode kann als Referenz und Hilfestellung für die Eigenentwicklungen dienen.
|
bashreadline.bt |
|
biolatency.bt |
|
biosnoop.bt |
|
biostacks.bt |
|
bitesize.bt |
|
capable.bt |
|
cpuwalk.bt |
|
dcsnoop.bt |
|
execsnoop.bt |
|
gethostlatency.bt |
|
killsnoop.bt |
|
loads.bt |
|
mdflush.bt |
|
naptime.bt |
|
oomkill.bt |
|
opensnoop.bt |
|
pidpersec.bt |
|
runqlat.bt |
|
runqlen.bt |
|
setuids.bt |
|
statsnoop.bt |
|
swapin.bt |
|
syncsnoop.bt |
|
syscount.bt |
|
tcpaccept.bt |
|
tcpconnect.bt |
|
tcpdrop.bt |
|
tcplife.bt |
|
tcpretrans.bt |
|
tcpsynbl.bt |
|
threadsnoop.bt |
|
vfscount.bt |
|
vfsstat.bt |
|
writeback.bt |
|
xfsdist.bt |
Von der einfachen Handhabung sollte man sich aber nicht täuschen lassen. Eine wirksame Diagnose von Performance-Problemen erfordert nicht nur gute Werkzeuge, sondern vor allem erhebliche System- und Kernel-Kenntnisse: Der Admin und der Kernel-Entwickler sind gefragt. Funktionsnamen oder auch Parameter kann man im Linux-Quellcode studieren, um sie dann zu instrumentieren. Auch Applikationsentwickler profitieren von Bpftrace, das ihre Software quasi gläsern macht.
Security inside
Und der Hacker? Reibt er sich ob der plattformunabhängigen Code Injection bereits die Hände? Doch mögliche Systemübernahme-Fantasien werden schnell ausgebremst. Ähnlich wie die Sandbox von Java schränkt auch der Linux-Kernel aus sicherheitstechnischen Gründen den Entwickler stark ein. Beim Versuch, andere als die freigegebenen Schnittstellen zu nutzen, tut er sich glücklicherweise sehr schwer.
Für Produktivsysteme
Es gibt noch mehr gute Nachrichten: Bpftrace lässt sich mit eBPF problemlos auf Produktivsystemen einsetzen. Der generierte Bytecode läuft im Sandkasten, sodass der Rechner zumindest durch diese Aktionen nicht abstürzt oder hängenbleibt. Der Performance-Verlust hält sich ebenfalls in Grenzen und hängt weniger von eBPF ab als vielmehr von der Implementierung der Probes (Tracepoints, Kprobes).
Bpftrace dient als Werkzeug für den Arbeitsalltag. Wer ausgefeilteste Performance-Probleme tracen möchte, der greift auf BCC zurück und kann mit Python im Gepäck noch komplexere Szenarien analysieren [4]. Die auf eBPF basierende VM im Linux-Kernel macht es möglich. Aber das ist eine andere Geschichte, und bezüglich der Technologie eine andere Kern-Technik. (jlu)
Die Autoren
Eva-Katharina Kunst ist seit den Anfängen von Linux Fan von Open Source. Jürgen Quade, Professor an der Hochschule Niederrhein, führt auch für Unternehmen Schulungen zu den Themen Treiberprogrammierung und Embedded Linux durch.
Infos
-
“The BSD Packet Filter” (Dezember 1992): http://www.tcpdump.org/papers/bpf-usenix93.pdf
-
“The Bpftrace One-Liner Tutorial”: https://github.com/iovisor/bpftrace/blob/master/docs/tutorial_one_liners.md
-
“An introduction to bpftrace for Linux” (August 2019): https://opensource.com/article/19/8/introduction-bpftrace
-
BPF Compiler Collection: »https://github.com/iovisor/bcc«








