Это очередная статья из цикла «BPF для самых маленьких» (0, 1, 2) и первая из серии практических статей про трассировку Linux современными средствами.
Из нее вы узнаете о программе и языке bpftrace
— самом простом способе погрузиться в мир BPF с практической точки зрения, даже если вы не знаете про BPF ровным счетом ничего. Утилита bpftrace
позволяет при помощи простого языка прямо из командной строки создавать программы-обработчики и подсоединять их к огромному количеству событий ядра и пространства пользователя. Посмотрите на КПДВ ниже… поздравляю, вы уже умеете трейсить системные вызовы при помощи bpftrace
!
В отличие от предыдущих статей серии, эта относительно короткая и ее основная часть написана в формате туториала, так что уже после пяти минут чтения вы сможете создавать обработчики и собирать статистику о любом событии в ядре Linux прямо из командной строки. В конце статьи рассказывается об альтернативах — ply
и BCC. Минуты во фразе «после пяти минут чтения» подразумеваются меркурианские. Появление уникальных навыков после пяти минут чтения не гарантируется.
Содержание
- Установка bpftrace
- Какие события мы можем трейсить?
- Bpftrace: tutorial
- BCC: утилиты и фреймворк
- Ply: bpftrace для бедных
Установка bpftrace
Короткая версия. Попробуйте выполнить команду sudo apt install bpftrace
(скорректированную под ваш дистрибутив). Если bpftrace
установился, то переходите к следующему разделу.
Длинная версия. Хотя bpftrace
и доступен в качестве пакета в популярных дистрибутивах, но не во всех, а кроме этого он может быть собран криво, например, без поддержки BTF. Поэтому давайте посмотрим на то, как добыть bpftrace
альтернативными средствами.
При каждом обновлении master-ветки репозитория bpftrace собирается и публикуется новый docker image с упакованным внутри bpftrace
. Таким образом, мы можем просто скачать и скопировать бинарник:
$ docker pull quay.io/iovisor/bpftrace:latest
$ cd /tmp
$ docker run -v $(pwd):/o quay.io/iovisor/bpftrace:latest /bin/bash -c "cp /usr/bin/bpftrace /o"
$ sudo ./bpftrace -V
bpftrace v0.11.4
Если bpftrace ругается на слишком старую версию glibc
, то вам нужен другой docker image со старой glibc
.
Проверим, что программа работает. Для этого запустим пример из КПДВ, который трейсит системный вызов execve(2)
и в реальном времени показывает какие программы запускаются в системе, и кем:
$ sudo ./bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%s -> %s\n", comm, str(uptr(args->filename))); }'
Attaching 1 probe...
bash -> /bin/echo
bash -> /usr/bin/ls
gnome-shell -> /bin/sh
sh -> /home/aspsk/bin/geeqie
sh -> /usr/local/sbin/geeqie
...
Наконец, когда мы убедились, что все работает, давайте положим бинарник в правильное место:
$ sudo mv /tmp/bpftrace /usr/local/bin
Если вам этого мало, то можете скачать исходники, запустить чуть больше проверок, собрать свое ядро с поддержкой BTF, а также свою версию bpftrace
, при помощи docker или локально. Инструкции по самостоятельной сборке bpftrace
см. в официальной документации.
Важная деталь. Если bpftrace
и/или ядро собрано без поддержки BTF, то для полноценной работы нужно установить kernel headers. Если вы не знаете как это сделать, то в документации bpftrace
есть универсальный дистрибутивонезависимый рецепт.
Мы не будем в этой статье подробно рассказывать про BTF, скажем только, что BTF убирает зависимость от kernel headers, позволяет писать более простые программы, а также расширяет набор событий, к которым мы можем подключиться. Если внешние обстоятельства требуют от вас изучения BTF прямо сейчас, то начните с этой статьи и продолжите этой и этой.
Какие события мы можем трейсить?
Вы сидите в лесу с фотоаппаратом на удобном раскладном стуле. Идет мелкий дождь, но он вам не страшен, ведь шалаш из еловых веток надежно укрывает вас как от дождя, так и от случайных взглядов. Рядом стоит термос с пуэром, йогурт и поднос с копчеными селедками. Но тут вы вдруг задумываетесь: «а зачем я сюда пришел и что я буду снимать?!» Когда вы просыпаетесь, на лице краснеет слепок клавиатуры, а в терминале написано
$ sudo apt install bpftrace
The following NEW packages will be installed:
bpftrace
Preparing to unpack .../bpftrace_0.11.0-1_amd64.deb ...
Unpacking bpftrace (0.11.0-1) ...
Setting up bpftrace (0.11.0-1) ...
Processing triggers for man-db (2.9.3-2) ...
$
Итак, bpftrace
у нас уже установлен. Какие события мы можем инструментировать? Давайте посмотрим на них по очереди, а заодно познакомимся с синтаксисом языка bpftrace
. Вот спойлер-оглавление данной секции:
- Специальные события:
BEGIN
,END
- События, основанные на kprobes:
kprobe
,kretprobe
,uprobe
,uretprobe
- События, основанные на BPF trampolines:
kfunc
,kretfunc
- События, основанные на tracepoints:
tracepoint
- Статическая отладка в пространстве пользователя:
usdt
- События, основанные на подсистеме perf:
software
,hardware
,profile
,interval
,watchpoint
В зависимости от настроения, вы можете прочитать весь этот раздел, можете один-два пункта на ваш выбор, а можете просто перейти к следующему разделу и возвращаться сюда за справкой.
Bpftrace: hello world
Язык bpftrace
создавался по аналогии с языком awk и поэтому в нем есть два специальных события, BEGIN
и END
, которые случаются в момент запуска и выхода из bpftrace
, соответственно. Вот первая простая программа:
# bpftrace -e 'BEGIN { printf("Hello world!\n"); }'
Attaching 1 probe...
Hello world!
^C
Программа сразу после старта напечатала "Hello world!"
. Заметьте, что нам пришлось нажимать Ctrl-C
, чтобы завершить работу bpftrace
— это его поведение по умолчанию. Мы можем завершить работу bpftrace
из любого события при помощи функции exit
. Продемонстрируем это, а заодно добавим и обработку END
:
# bpftrace -e '
BEGIN { printf("Hello world!\n"); exit(); }
END { printf("So long\n"); }
'
Attaching 2 probes...
Hello world!
So long
Kprobes — динамическая инструментация ядра
Ядро — это большая программа, функции этой программы, как водится, состоят из инструкций, а механизм ядра под названием kprobes (Kernel Probe — ядерный зонд) позволяет нам поставить точку останова практически на любой инструкции, а точнее, по началу конкретной функции или коду внутри нее. В контексте данной статьи нам, вообще говоря, не важно как именно создаются обработчики kprobes
, но вы можете узнать об этом из предыдущих статей этой серии, ссылки на которые есть в конце, а также из будущих статей, в которых мы разберем все технические подробности трассировки Linux с BPF.
В качестве примера давайте посмотрим на то, как часто и кем вызывается функция schedule
:
$ sudo bpftrace -e '
k:schedule { @[comm] = count(); }
i:s:2 { exit();}
END { print(@, 10); clear(@); }
'
Attaching 3 probes...
@[Timer]: 147
@[kworker/u65:0]: 147
@[kworker/7:1]: 153
@[kworker/13:1]: 158
@[IPC I/O Child]: 170
@[IPC I/O Parent]: 172
@[kworker/12:1]: 185
@[Web Content]: 229
@[Xorg]: 269
@[SCTP timer]: 1566
Мы также сказали программе выйти через две секунды и в конце напечатать только десять верхних элементов словаря @
.
Много ли функций можно потрейсить при помощи кей-проб? Это легко проверить:
$ sudo bpftrace -l 'k:*' | wc -l
61106
Это почти все функции загруженного в данный момент ядра. Исключения составляют функции, которые компилятор решил встроить в код и немногочисленные функции, которые запрещено трейсить при помощи kprobe, например, функции, которые реализуют сам механизм kprobes.
kretprobes
Для каждой kprobe
мы можем создать обработчик kretprobe
. Если kprobe
запускается в момент входа в функцию, то kretporobe
запускается в момент выхода из функции. При этом код возврата функции содержится в специальной встроенной переменной retval
.
Например, вот что на отрезке в две секунды возвращала функция vfs_write
на моей системе (в виде логарифмической гистограммы):
$ sudo bpftrace -e 'kr:vfs_write { @ = hist(retval); } i:s:2 { exit(); }'
Attaching 2 probes...
@:
[1] 606 |@@@@@@@@@@@@@@@@@@@@@@@@@ |
[2, 4) 0 | |
[4, 8) 0 | |
[8, 16) 1223 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16, 32) 0 | |
[32, 64) 25 |@ |
uprobes и uretprobes
Кроме инструментации функций ядра, мы можем инструментировать каждую программу (и библиотеку), работающую в пространстве пользователя. Реализуется это при помощи тех же kprobes
. Для этого в bpftrace
определены события uprobes
и uretprobes
— вызов и возврат из функции.
Вот как мы можем подглядывать за тем, что печатают пользователи баша (в квадратных скобках печатается UID пользователя):
$ sudo bpftrace -e 'uretprobe:/bin/bash:readline { printf("readline: [%d]: \"%s\"\n", uid, str(uptr(retval))); }'
Attaching 1 probe...
readline: [1000]: "echo "hello habr""
readline: [0]: "echo "hello from root""
^C
Динамическая инструментация ядра, версия 2
Для счастливых обладателей CONFIG_DEBUG_INFO_BTF=y
в конфиге ядра существует более дешевый, по сравнению с kprobes, способ динамической инструментации ядра, основанный на bpf trampolines. Так как BTF в дистрибутивных ядрах обычно выключен, я про эти события дальше не рассказываю. Если интересно, то смотрите сюда и/или задавайте вопросы в комментариях.
Tracepoints — статическая инструментация ядра
Если что-то можно сделать динамически, то, с большой вероятностью, это же можно сделать статически чуть быстрее и удобнее. Механизм ядра под названием tracepoints предоставляет набор статических точек останова ядра для наиболее важных функций. Вы можете посмотреть на свой набор так:
$ sudo bpftrace -l 't:*'
Их сильно меньше, чем kprobes
:
$ sudo bpftrace -l 't:*' | wc -l
1782
но самой важной особенностью tracepoints является то, что они предоставляют стабильный API: вы можете быть уверены, что tracepoint, на основе которой вы написали свой код для отладки или сбора информации, не пропадет или не поменяет семантику в самый неудобный момент. Еще одним удобным отличием является то, что bpftrace
может нам рассказать о том, какие аргументы передаются в конкретный tracepoint, например:
$ sudo bpftrace -lv tracepoint:thermal:thermal_temperature
tracepoint:thermal:thermal_temperature
__data_loc char[] thermal_zone;
int id;
int temp_prev;
int temp;
В случае kprobe
, если у вас не включен BTF, вам придется читать исходники ядра, причем той версии, которую вы используете. А с BTF вы можете смотреть и на строение kprobes
и kfuncs
.
usdt — статическая инструментация в пространстве пользователя
Статическая инструментация программ пользователя позволяет напихать в программу статических точек останова в момент компиляции. Давайте сразу посмотрим на пример (который я стянул почти без изменений отсюда):
#include <sys/sdt.h>
#include <sys/time.h>
#include <unistd.h>
int main(int argc, char **argv)
{
struct timeval tv;
for (;;) {
gettimeofday(&tv, NULL);
DTRACE_PROBE1(test, probe, tv.tv_sec);
sleep(1);
}
}
Мы добавили одну статическую точку останова под названием test:probe
, в которую передаем один аргумент tv.tv_sec
— текущее время в секундах. Чтобы скомпилировать эту программу, нужно поставить пакет systemtap-sdt-dev
(или аналогичный для вашего дистрибутива). Дальше мы можем посмотреть на то, что получилось:
$ cc /tmp/test.c -o /tmp/test
$ sudo bpftrace -l 'usdt:/tmp/test'
usdt:/tmp/test:test:probe
Запустим /tmp/test
в одном терминале, а в другом скажем
$ sudo bpftrace -e 'usdt:/tmp/test:test:probe { printf("московское время %u\n", arg0); }'
Attaching 1 probe...
московское время 1612903991
московское время 1612903992
московское время 1612903993
московское время 1612903994
московское время 1612903995
...
Здесь arg0
— это значение tv.tv_sec
, которое мы передаем в breakpoint.
События Perf
Программа bpftrace
поддерживает множество событий, предоставляемых подсистемой ядра Perf. Мы сейчас коротко посмотрим на следующие типы событий, поддерживаемые bpftrace
:
software
: статически-сгенерированные софтверные событияhardware
: железные PMCsinterval
: интервальное событиеprofile
: интервальное событие для профилирования
События типа `software`
В ядре определяется несколько статических событий perf, посмотреть их список можно так:
# bpftrace -l 's:*'
software:alignment-faults:
software:bpf-output:
software:context-switches:
software:cpu-clock:
software:cpu-migrations:
software:dummy:
software:emulation-faults:
software:major-faults:
software:minor-faults:
software:page-faults:
software:task-clock:
События такого типа могут происходить очень часто, поэтому на практике указывается количество сэмплов, например, команда
# bpftrace -e 'software:cpu-migrations:10 { @[comm] = count(); }'
Attaching 2 probes...
^C@[kworker/u65:1]: 1
@[bpftrace]: 1
@[SCTP timer]: 2
@[Xorg]: 2
Подсчитает каждое десятое событие миграции процесса с одного CPU а другой. Значение событий из списка выше объясняется в perf_event_open(2)
, например, cpu-migrations
, которую мы использовали выше можно найти в этой man-странице под именем PERF_COUNT_SW_CPU_MIGRATIONS
.
События типа `hardware`
Ядро дает нам доступ к некоторым hardware counters, а bpftrace
может вешать на них программы BPF. Точный список событий зависит от архитектуры и ядра, например:
bpftrace -l 'h*'
hardware:backend-stalls:
hardware:branch-instructions:
hardware:branch-misses:
hardware:bus-cycles:
hardware:cache-misses:
hardware:cache-references:
hardware:cpu-cycles:
hardware:frontend-stalls:
hardware:instructions:
hardware:ref-cycles:
Посмотрим на то как мой процессор предсказывает инструкции перехода (считаем каждое стотысячное событие, см. PERF_COUNT_HW_BRANCH_MISSES
):
bpftrace -e 'hardware:branch-misses:100000 { @[tid] = count(); }'
Attaching 3 probes...
@[1055]: 4
@[3078]: 4
@[1947]: 5
@[1066]: 6
@[2551]: 6
@[0]: 29
События типа `interval` и `profile`
События типов interval
и profile
позволяют пользователю запускать обработчики через заданные интервалы времени. Событие первого типа запустится один раз на одном CPU, а событие второго — на каждом из CPU.
Мы уже использовали интервал раньше, чтобы выйти из программы. Давайте посмотрим на этот пример еще раз, но чуть пропатчим его:
$ sudo bpftrace -e '
kr:vfs_write { @ = hist(retval); }
interval:s:2 { print(@); clear(@); }
'
Первой строчкой мы подключаемся к выходу из функции ядра vfs_write
и считаем в ней гистограмму всех вызовов, а на второй строчке используем interval
, который будет запускаться каждые две секунды, печатать и очищать словарь @
.
Аналогично можно использовать и profile
:
# bpftrace -e '
profile:hz:99 { @[kstack] = count(); }
i:s:10 { exit(); }
END { print(@,1); clear(@); }
'
Attaching 3 probes...
@[
cpuidle_enter_state+202
cpuidle_enter+46
call_cpuidle+35
do_idle+487
cpu_startup_entry+32
start_secondary+345
secondary_startup_64+182
]: 14455
Здесь мы запускаем profile
на каждом ядре 99 раз в секунду, через десять секунд выстрелит интервал и вызовет exit()
, а секция END
напечатает только верхний элемент словаря @
— самый часто встречающийся ядерный стек (по которому мы видим, что моя система, в основном, бездействует).
Bpftrace: tutorial
Базовые навыки
Начнем с простого, запустим bpftrace
без аргументов:
# bpftrace
Программа напечатает короткую справку о том, как ее использовать. В частности, посмотрите какие переменные окружения использует bpftrace
. Заметьте, что мы запускаем bpftrace
от рута. Для вывода справки это не нужно, но для любых других действий — да.
Посмотрим внимательнее на аргумент -l
. Он позволяет найти события по шаблону. (Если что-то дальше не ясно, то читайте про события в предыдущем разделе, который вы пропустили.) Вот как можно искать события среди всех возможных:
# bpftrace -l '*kill_all*'
kprobe:rfkill_alloc
kprobe:kill_all
kprobe:btrfs_kill_all_delayed_nodes
А здесь мы ищем события только среди tracepoints
:
# bpftrace -l 't:*kill*'
tracepoint:cfg80211:rdev_rfkill_poll
tracepoint:syscalls:sys_enter_kill
tracepoint:syscalls:sys_exit_kill
tracepoint:syscalls:sys_enter_tgkill
tracepoint:syscalls:sys_exit_tgkill
tracepoint:syscalls:sys_enter_tkill
tracepoint:syscalls:sys_exit_tkill
Подмножество tracepoint:syscalls
, на которое мы только что наткнулись, можно использовать для самостоятельных экспериментов по изучению bpftrace
. Для каждого системного вызова X
определены две точки останова:
tracepoint:syscalls:sys_enter_X
tracepoint:syscalls:sys_exit_X
Поиграемся с каким-нибудь системным вызовом, например, execve(2)
. Для того, чтобы посмотреть на то, как использовать какой-либо tracepoint
можно использовать дополнительный аргумент -v
, например:
# bpftrace -lv 't:s*:sys_*_execve'
tracepoint:syscalls:sys_enter_execve
int __syscall_nr;
const char * filename;
const char *const * argv;
const char *const * envp;
tracepoint:syscalls:sys_exit_execve
int __syscall_nr;
long ret;
(заметьте как ловко мы дважды использовали *
, чтобы не писать syscalls
полностью и чтобы не перечислять sys_enter_execve
и sys_exit_execve
по отдельности). Параметры, перечисленные в выводе -lv
доступны через встроенную переменную args
:
# bpftrace -e '
t:s*:sys_enter_execve { printf("ENTER: %s\n", str(uptr(args->filename))); }
t:s*:sys_exit_execve { printf("EXIT: %s: %d\n", comm, args->ret); }
'
Attaching 2 probes...
ENTER: /bin/ls
EXIT: ls: 0
ENTER: /bin/lss
EXIT: bash: -2
Этот короткий листинг позволяет разглядеть несколько интересных вещей.
В первом обработчике мы печатаем аргумент args->filename
. Так как передается он нам как указатель, нам нужно вычитать строку при помощи встроенной функции str
, но просто так ее использовать нельзя: указатель этот указывает в пространство пользователя, а значит мы должны об этом специально сказать при помощи функции uptr
. Сам bpftrace
постарается угадать принадлежность указателя, но он не гарантирует результат. Также, к сожалению, вызов bpftrace -lv
не расскажет нам о семантике указателя, для этого придется изучать исходники, в данном случае, мы посмотрим на определение системного вызова execve (обратите внимание на квалификатор типа __user
).
Во втором обработчике мы используем встроенную переменную comm
, которая возвращает текущее имя потока. Код возврата системного вызова доступен через переменную args->ret
. Как известно, этот системный вызов "не возвращается" в текущую программу, так как его работа заключается собственно в замене кода программы новым. Однако, в случае ошибки он-таки вернется, как мы и можем видеть в выводе выше: в первом случае я запустил /bin/ls
из баша и exec
запустился нормально и вернул 0 (внутри процесса ls
прямо перед запуском кода /bin/ls
), а во втором случае я запустил несуществующую программу /bin/lss
и exec
вернул -2
внутри процесса bash
.
Упражнение. Возьмите ваш любимый системный вызов и напишите несколько программ, аналогичных приведенной выше. Попробуйте напечатать все аргументы системного вызова и значение, которое он возвращает. Не забудьте использовать uptr
, если нужно.
Структура программ `bpftrace`
Язык bpftrace
создавался по аналогии с языком awk (см. также главу 6 в книжке The AWK Programming Language
) и имеет очень похожую структуру. Программы состоят из списка блоков вида
<probe> <filter> { <action> }
Например,
# bpftrace -e 'BEGIN { printf("Hello\n") } END { printf("World\n") }'
Здесь <probe>
— это BEGIN
и END
, а <action>
— это printf
. Поле
<filter>
является опциональным и используется для фильтрации событий,
например, программа
# bpftrace -e 'p:s:1 /cpu == 0/ { printf("Привет с CPU%d\n", cpu); }'
Attaching 1 probe...
Привет с CPU0
Привет с CPU0
^C
будет передавать привет, только если запускается на CPU 0.
Упражнение. Что выведет эта команда на вашей машине, если убрать фильтр /cpu == 0/
?
На практике <filter>
удобно использовать для синхронизации между двумя событиями. Например, вы хотите подсчитать время выполнения системного вызова write
на вашей системе. Для этого мы можем использовать пару трейспоинтов — sys_enter_write
и sys_exit_write
и считать время выполнения по тредам:
# cat /tmp/write-times.bt
t:syscalls:sys_enter_write {
@[tid] = nsecs
}
t:syscalls:sys_exit_write /@[tid]/ {
@write[comm] = sum((nsecs - @[tid]) / 1000);
delete(@[tid]);
}
END {
print(@write, 10);
clear(@write);
clear(@);
}
Эта программа уже довольно длинная, поэтому мы записали ее в отдельный файл. Запустить ее можно так:
# bpftrace /tmp/write-times.bt
В первом событии, sys_enter_write
, мы запоминаем время запуска системного вызова write
в наносекундах в словаре @
, ключом к которому служит tid
.
Во втором событии, sys_exit_write
, мы при помощи фильтра /@[tid]/
проверяем, запускался ли обработчик первого события для данного потока. Нам нужно это делать, ведь мы могли запустить программу в тот момент, когда какой-то поток был внутри системного вызова write
. Дальше мы записываем потраченное время (в микросекундах) в отдельный словарь @write
и удаляем элемент @[tid]
.
Наконец, после того как мы нажимаем ^C
, запускается секция END
, в которой мы печатаем десять самых прожорливых процессов и чистим словари @write
и @
, чтобы bpftrace
не выводил их содержимое.
Упражнение. Так что же именно может пойти не так, если убрать фильтр /@[tid]/
?
Храним состояние: переменные и мапы
Внутри программ bpftrace
вы можете использовать обычные для языка C конструкции, например, :?
, ++
, --
. Вы можете использовать блоки if {} else {}
. Можно составлять циклы при помощи while
и экзотического unroll
(который появился в то время, когда в архитектуре BPF циклы были запрещены). Содержание же во все эти конструкции добавляют переменные и структуры ядра, доступные из контекста.
Переменные бывают двух типов: локальные и глобальные. Локальные переменные начинаются со знака доллара $
и доступны в пределах заданного события, оба следующих варианта сработают:
# bpftrace -e 'BEGIN { $x = 1; printf("%d\n", ++$x); exit(); }'
# bpftrace -e 'BEGIN { if (1) { $x = 1; } printf("%d\n", ++$x); exit(); }'
а следующее — нет:
# bpftrace -e 'BEGIN { $x = 1; exit(); } END { printf("%d\n", $x); }'
Глобальные переменные, с которыми мы уже встречались выше, начинаются со знака @
и доступны между событиями. Вы можете использовать "безымянную" глобальную переменную @
, как мы делали выше для хранения начала вызова write
(@[tid]
). (Глобальные переменные в bpftrace
хранятся в мапах — специальных размеченных областях памяти. Они, между прочим, глобальные в более общем смысле: любая программа с рутовым доступом на системе может их читать и писать. Но для данной статьи это не так важно, смотрите предыдущие серии, если вам интересны подробности.)
И теперь, мы переходим к самому главному: зачем нам нужны переменные и что мы в них будем записывать? Каждое событие bpftrace
запускается с определенным контекстом. Для kprobes
нам доступны аргументы вызываемой функции, для tracepoints
— аргументы, передаваемые в tracepoint, а для событий Perf, как и для других программ, — глобальные переменные. Мы уже видели как мы можем работать с tracepoints
, в этой и следующих секциях мы посмотрим на kprobes
, а в секции Веселые Картинки мы посмотрим на события Perf.
Аргументы kprobes
доступны внутри программы как arg0
, arg1
, и т.д. Аргументы передаются без типа, так что вам придется к нужному типу их приводить вручную. Пример:
#include <linux/skbuff.h>
#include <linux/ip.h>
k:icmp_echo {
$skb = (struct sk_buff *) arg0;
$iphdr = (struct iphdr *) ($skb->head + $skb->network_header);
@pingstats[ntop($iphdr->saddr), ntop($iphdr->daddr)]++;
}
Эта программа строит статистику о том, кто пингует данный хост. Мы цепляемся к kprobe
на входе в функцию icmp_echo
, которая вызывается на приход ICMPv4 пакета типа echo request. Ее первый аргумент, arg0
в нашей программе, — это указатель на структуру типа sk_buff
, описывающую пакет. Из этой структуры мы достаем IP адреса и увеличиваем соответствующий счетчик в глобальной переменной @pingstats
. Все, теперь у нас есть полная статистика о том, кто и как часто пинговал наши IP адреса! Раньше для написания такой программы вам пришлось бы писать модуль ядра, регистрировать в нем обработчик kprobe, а также придумывать механизм взаимодействия с user space, чтобы хранить и читать статистику.
Посмотрим на нее еще раз. Вначале мы включили два хедера ядра, для этого нужно установить пакет с kernel headers. Эти хедеры нужны нам для определения структур sk_buff
и iphdr
, которые мы собираемся разыменовывать. (Если бы у нас был собран BTF, то нам не нужно было бы это делать — ни устанавливать пакет, ни включать хедеры.) В первой строчке программы мы приводим единственный аргумент функции icmp_echo
к указателю на sk_buff
и сохраняем его в локальной переменной $skb
. На второй строчке мы разыменовываем $skb
и находим место в памяти, где хранится сетевой заголовок, который мы, в свою очередь, приводим к указателю на iphdr
. На третьей строчке мы используем сетевой заголовок и встроенную функцию ntop
языка bpftrace
, которая преобразует бинарный IP адрес в строку.
Упражнение. Возьмите любую интересующую вас функцию ядра и попробуйте разыменовать ее аргументы. Не забывайте про uptr
и kptr
. Например: возьмите функцию vfs_write
ядра, ее первый аргумент — это указатель на структуру struct file
, определенную в заголовке <linux/fs.h>
. Попробуйте напечатать интересующие вас флаги файла до и после вызова vfs_write
. (Hint: как вы можете передать указатель на struct file
внутрь kretprobe
?)
Упражнение. Напишите программу bpftrace
, которая будет печатать имя и пароль пользователя, всякий раз, как он запускает sudo
.
Считаем и агрегируем события
В предыдущей программе про ping
мы сделали ошибку — не защитились от того, что программа может быть запущена на разных CPU. Для более точного подсчета мы можем использовать функцию count
. Следующий пример иллюстрирует проблему:
# bpftrace -e 'p:hz:5000 { @x++; @y = count(); } i:s:10 { exit(); }'
Attaching 2 probes...
@x: 760528
@y: 799002
В течение 10 секунд по 5000 раз в секунду на каждом из 16 ядер моей системы мы увеличиваем значения двух счетчиков @x
и @y
. Операция ++
выполняется безо всяких блокировок и поэтому значение счетчика не совсем точное. Операция count()
на самом деле выполняется тоже безо всяких блокировок, но использует CPU-локальные переменные: для каждого из CPU хранится свой счетчик, значения которых при печати суммируются.
Кроме подсчета событий в bpftrace
есть несколько полезных функций, которые могут быстро показать какие-то баги в работающей системе. Главный инструмент тут — это гистограммы. Посмотрим на простой пример.
# bpftrace -e 'kr:vfs_write { @ = hist(retval); } i:s:10 { exit() }'
Attaching 2 probes...
@:
[1] 14966407 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2, 4) 0 | |
[4, 8) 0 | |
[8, 16) 1670 | |
[16, 32) 0 | |
[32, 64) 123 | |
[64, 128) 0 | |
[128, 256) 0 | |
[256, 512) 0 | |
[512, 1K) 0 | |
[1K, 2K) 0 | |
[2K, 4K) 0 | |
[4K, 8K) 0 | |
[8K, 16K) 7531982 |@@@@@@@@@@@@@@@@@@@@@@@@@@ |
В течении десяти секунд мы строим гистограмму возвращаемых значений функции vfs_write
, и мы можем заметить, что кто-то уверенно пытается писать по одному байту! Давайте чуть усовершенствуем программу (то заняло у меня около 20 секунд):
# bpftrace -e '
kr:vfs_write /retval == 1/ { @[pid, comm] = hist(retval); }
i:s:10 { exit() }
END { print(@, 1); clear(@); }'
Attaching 3 probes...
@[133835, dd]:
[1] 14254511 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
Для начала, мы смотрим только на записи, в которых retval
равен единице. Для того, чтобы различить процессы мы добавляем ключи — идентификатор и имя процесса. Наконец, в END
мы печатаем только процесс, который сделал больше всего записей. Что же он делает? Думаю вы уже догадались:
# tr '\000' ' ' < /proc/133835/cmdline
dd if=/dev/zero of=/dev/null bs=1
Упражнение. Найдите ссылку на официальный туториал по bpftrace
в конце статьи и выполните все шаги, в которых встречаются гистограммы.
Веселые Картинки: flame graphs
События типа profile
помогают нам смотреть на систему. Так мы можем строить гистограммы событий и собирать сэмплы с работающей системы. Напомню, что событие типа profile
описывается в одной из следующих форм:
profile:hz:rate
profile:s:rate
profile:ms:rate
profile:us:rate
В первом случае мы задаем частоту события в HZ, а в следующих — частоту события в секундах, миллисекундах и микросекундах, соответственно.
Одной из интересных глобальных переменных, которые мы можем сэмплировать, является стек ядра, выглядит он так:
bpftrace -e 'profile:hz:99 { print(kstack); exit() }'
Attaching 1 probe...
cpuidle_enter_state+202
cpuidle_enter+46
call_cpuidle+35
do_idle+487
cpu_startup_entry+32
rest_init+174
arch_call_rest_init+14
start_kernel+1724
x86_64_start_reservations+36
x86_64_start_kernel+139
secondary_startup_64+182
здесь в момент сэмплирования мы попали на ядро-бездельника.
Для практического профилирования, однако, смотреть на миллионы стеков ядра не очень удобно. Поэтому БГ разработал механизм агрегации под названием flame graphs, превращающий текст в удобную кликабельную картинку.
Для начала, нам нужно запустить bpftrace
следующим образом:
# bpftrace -e 'profile:hz:333 { @[kstack] = count(); } i:s:10 { exit() }' > /tmp/raw
# wc -l /tmp/raw
3374 /tmp/raw
Здесь мы по 333 раза в секунду сэмплируем стек ядра и считаем сколько раз мы увидели разные стеки (мы используем kstack
как ключ в словаре @
, ведь kstack
— это просто строка).
Далее нам нужно склонировать репозиторий FlameGraph и запустить пару скриптов:
$ git clone https://github.com/brendangregg/FlameGraph.git
$ cd FlameGraph
$ ./stackcollapse-bpftrace.pl /tmp/raw > /tmp/ready
$ ./flamegraph.pl /tmp/ready > /tmp/kstack.svg
Первый скрипт приводит вывод bpftrace
к каноническому виду, а второй строит по нему картинку (кликните на нее, чтобы открылся gist с SVG):
Здесь моя система наполовину бездействует, а на половине CPU крутится все тот же dd
, копирующий /dev/zero
в /dev/null
по одному байту. Кликайте на картинку, чтобы посмотреть подробности.
Упражнение. Снимки стека можно делать не только при помощи bpftrace
. Загляните в в репозиторий FlameGraph
и сделайте снимок своей системы другим способом.
Пора закругляться
Если вы дочитали до этого момента и запустили хотя бы половину примеров, то вы уже можете считать себя профессионалом в нелегком деле отладки при помощи bpftrace
. Смотрите на ссылки в конце статьи для того, чтобы узнать куда двигаться дальше.
BCC: утилиты и фреймворк
Благодаря проекту BCC люди, роботы и не определившиеся могут использовать возможности BPF без необходимости утруждать себя программированием — проект содержит почти 100 готовых утилит. Эти утилиты — не случайные примеры, а рабочие инструменты, используемые повседневно в недрах Netflix, Facebook и других компаний добра. См. ссылки на книжки БГ в конце статьи, в которых подробно описано большинство утилит и подробно обсуждается почему и зачем они нужны.
Утилиты написаны на основе libbcc
и libbpf
и представляют из себя код на питоне, в который встроены куски псевдо-C кода, так что их легко редактировать и расширять даже на боевой машине. Также вы можете писать новые утилиты по аналогии с существующими, см. следующий подраздел.
Утилиты BCC должны быть более-менее опакечены в популярных дистрибутивах. Например, на убунте достаточно поставить пакет bpfcc-tools
. После этого мы можем сразу ими пользоваться. Например, команда
# funccount-bpfcc 'vfs_*' -d 5
Tracing 67 functions for "b'vfs_*'"... Hit Ctrl-C to end.
FUNC COUNT
b'vfs_statfs' 1
b'vfs_unlink' 1
b'vfs_lock_file' 2
b'vfs_fallocate' 31
b'vfs_statx_fd' 32
b'vfs_getattr' 80
b'vfs_getattr_nosec' 88
b'vfs_open' 108
b'vfs_statx' 174
b'vfs_writev' 2789
b'vfs_write' 6554
b'vfs_read' 7363
Detaching...
посчитает сколько раз были вызваны функции ядра с префиксом vfs_
на интервале в пять секунд. Чуть интереснее подсунуть программе параметр -p
, в котором передается PID процесса. Например, вот что делает мой mplayer
, пока я это пишу:
# funccount-bpfcc 'vfs_*' -d 5 -p 29380
Tracing 67 functions for "b'vfs_*'"... Hit Ctrl-C to end.
FUNC COUNT
b'vfs_write' 208
b'vfs_read' 629
Detaching...
Пишем новую утилиту BCC
Давайте напишем простую утилиту BCC. Эта утилита будет считать сколько раз в секунду были вызваны функции ядра mutex_lock
и mutex_unlock
. Ее полный код приведен далее, также вы можете прочитать его здесь.
#! /usr/bin/python3
from bcc import BPF
from ctypes import c_int
from time import sleep, strftime
from sys import argv
b = BPF(text="""
BPF_PERCPU_ARRAY(mutex_stats, u64, 2);
static inline void inc(int key)
{
u64 *x = mutex_stats.lookup(&key);
if (x)
*x += 1;
}
void do_lock(struct pt_regs *ctx) { inc(0); }
void do_unlock(struct pt_regs *ctx) { inc(1); }
""")
b.attach_kprobe(event="mutex_lock", fn_name="do_lock")
b.attach_kprobe(event="mutex_unlock", fn_name="do_unlock")
print("%-8s%10s%10s" % ("TIME", "LOCKED", "UNLOCKED"))
while 2 * 2 == 4:
try:
sleep(1)
except KeyboardInterrupt:
exit()
print("%-8s%10d%10d" % (
strftime("%H:%M:%S"),
b["mutex_stats"].sum(0).value,
b["mutex_stats"].sum(1).value))
b["mutex_stats"].clear()
Вначале мы подключаем нужные библиотеки. Понятно, что самая интересная часть тут — это импорт класса BPF
:
from bcc import BPF
Этот класс позволяет нам определить программы BPF, которые мы будем подключать к событиям. В качестве параметра класс BPF
принимает текст программы на псевдо-C. В нашем случае это
BPF_PERCPU_ARRAY(mutex_stats, u64, 2);
static inline void inc(int key)
{
u64 *x = mutex_stats.lookup(&key);
if (x)
*x += 1;
}
void do_lock(struct pt_regs *ctx) { inc(0); }
void do_unlock(struct pt_regs *ctx) { inc(1); }
Этот код написан на магическом C, вы не сможете скомпилировать его в таком виде, но при инициализации класса BPF
некоторые части будут заменены реальным кодом на C.
Так или иначе, вначале мы определяем массив mutex_stats
из двух элементов типа u64
, наших счетчиков. Заметьте, что мы использовали PERCPU массив, это означает, что для каждого логического CPU будет создан свой массив. Далее мы определяем функцию inc
, принимающую в качестве аргумента индекс в массиве mutex_stats
. Эта функция увеличивает значение соответствующего счетчика.
Наконец, тривиальные функции do_lock
и do_unlock
увеличивают каждая свой счетчик.
На этом с ядерной частью почти покончено — во время инициализации класс BPF
обратится к библиотеке libllvm
, чтобы скомпилировать код, и потом зальет его в ядро. Осталось только подключить программы к интересующим нас kprobes
:
b.attach_kprobe(event="mutex_lock", fn_name="do_lock")
b.attach_kprobe(event="mutex_unlock", fn_name="do_unlock")
Пользовательская часть кода занимается исключительно сбором информации:
print("%-8s%10s%10s" % ("TIME", "LOCKED", "UNLOCKED"))
while 2 * 2 == 4:
try:
sleep(1)
except KeyboardInterrupt:
exit()
print("%-8s%10d%10d" % (
strftime("%H:%M:%S"),
b["mutex_stats"].sum(0).value,
b["mutex_stats"].sum(1).value))
b["mutex_stats"].clear()
После печати заголовка бесконечный цикл раз в секунду печатает значение счетчиков и обнуляет массив mutex_stats
. Значение счетчиков мы получаем при помощи метода sum
массива mutex_stats
, который суммирует значения счетчиков для каждого CPU:
sum(index) {
result = 0
для каждого CPU {
result += cpu->mutex_stats[index]
}
return result
}
Вот и все. Программа должна работать примерно так:
$ sudo ./bcc-tool-example
TIME LOCKED UNLOCKED
18:06:33 11382 12993
18:06:34 11200 12783
18:06:35 18597 22553
18:06:36 20776 25516
18:06:37 59453 68201
18:06:38 49282 58064
18:06:39 25541 27428
18:06:40 22094 25280
18:06:41 5539 7250
18:06:42 5662 7351
18:06:43 5205 6905
18:06:44 6733 8438
Где-то в 18:06:35 я переключился из терминала на вкладку с youtube в Firefox, поставил youtube на паузу и затем в 18:06:40 переключился назад в терминал. Итого, программа показала, что при просмотре youtube вы заставляете ядро локать примерно сорок тысяч мьютексов в секунду.
Напоследок хочется сказать, что если вы предпочитаете писать на C, то смотрите в сторону libbpf
и CO-RE. Использование libbpf
напрямую позволяет избавиться от тяжелых зависимостей времени запуска, типа libllvm
, ускоряет время работы, а также экономит дисковое пространство. В частности, некоторые утилиты BCC уже переписаны на libbpf
+ CO-RE прямо внутри проекта BCC, см. libbpf-tools. За подробностями обращайтесь к статье BCC to libbpf conversion guide (или ждите следующую статью из этой серии).
Ply: bpftrace для бедных
Утилита ply
, написанная шведом Tobias Waldekranz в доисторическом 2015 году, является в определенном смысле прямым предком bpftrace
. Она поддерживает awk-подобный язык для создания и инструментации ядра программами BPF, например,
ply 'tracepoint:tcp/tcp_receive_reset {
printf("saddr:%v port:%v->%v\n", data->saddr, data->sport, data->dport);
}'
Отличительной особенностью ply
является минимизация зависимостей: ей нужна только libc
(любая). Это удобно, если вы хотите с минимальными усилиями поиграться в BPF на встроенных системах. Для того, чтобы отрезать все зависимости, в ply
встроен компилятор ply script language -> BPF
.
Однако, не умаляя достоинств ply
, стоит отметить, что разработка проекта к настоящему времени заглохла — ply
работает, поддерживается, но новые фичи не появляются. Вы все еще можете использовать ply
, например, для того, чтобы потестировать сборку ядра на встроенной системе или для тестирования прототипов, но я бы советовал сразу писать программы на C с использованием libbpf
— для эмбедщиков это не составит труда, см., например, статью Building BPF applications with libbpf-bootstrap.
Ссылки
Предыдущие серии:
- BPF для самых маленьких, часть 0: classic BPF
- BPF для самых маленьких, часть 1: extended BPF
- BPF для самых маленьких, часть 2: разнообразие типов программ BPF
Ссылки на ресурсы по bpftrace
, BCC и вообще отладке Linux:
- The bpftrace One-Liner Tutorial. Это туториал по
bpftrace
, в котором перечисляются основные возможности. Представляет из себя список из двенадцати однострочных, или около того, программ. - bpftrace Reference Guide. Все, что вы хотели знать про использование
bpftrace
, но боялись спросить. Если вам этого документа мало, то идите читать про внутренностиbpftrace
. - BCC Tutorial. Если вы освоились с
bpftrace
и хотите копнуть глубже (но еще не готовы к освоениюlibbpf
и настоящим приключениям), то смотрите на этот туториал по BCC, на BCC Reference Guide, а также на книжки, перечисленные ниже. - Brendan Gregg, «BPF Performance Tools». БГ распознал потенциал BPF в деле трассировки Linux сразу после его появления и в данной книжке описывает результаты работы последних пяти лет — сотню или больше отладочных утилит из проекта BCC. Книжка является отличным справочным дополнением по BPF к следующей.
- Brendan Gregg, «Systems Performance: Enterprise and the Cloud, 2nd Edition (2020)». Это второе издание знаменитой «Systems Performance». Главные изменения: добавлен материал по BPF, выкинут материал по Solaris, а сам БГ стал на пять лет опытнее. Если книжка «BPF Performance Tools» отвечает на вопрос «как?», то эта книжка отвечает на вопрос «почему?», а также рассказывает о других техниках (не BPF единым жив человек).