Наблюдаемость и производительность ОС: /proc, strace, perf, eBPF, ftrace
Есть характерный разговор, который повторяется в каждой команде примерно раз в квартал. «Сервис тормозит». — «Где?» — «Не знаю, CPU 40%, память в норме, диск не загружен». И дальше начинается угадывание: кто-то предлагает добавить реплик, кто-то — поднять таймауты, кто-то — переписать на Go. Через две недели выясняется, что виноват один fsync() в логгере, который вызывается на каждую строчку.
Наблюдаемость — это дисциплина, которая заменяет угадывание измерением. И в отличие от прикладного мониторинга (метрики, трейсы, логи, которые вы сами написали), наблюдаемость на уровне ОС отвечает на вопросы, которые вы не догадались задать заранее. Никто не инструментирует код счётчиком «сколько раз меня вытеснил планировщик» или «сколько наносекунд я ждал спин-лок в ядре». Но ядро уже знает ответ — надо только уметь спросить.
Эта статья — про то, как спрашивать. Мы пойдём снизу вверх по стоимости: сначала бесплатные счётчики, потом выборка, потом трассировка. Порядок не случаен: каждый следующий уровень даёт больше правды и стоит дороже, и профессионализм состоит ровно в том, чтобы не начинать с самого дорогого инструмента.
Сначала методология, потом инструменты
Главная ошибка новичка — начать с инструмента. Открыть top, увидеть какое-то число, начать его оптимизировать. Брендан Грегг называет это «уличным фонарём»: ищем там, где светло, а не там, где потеряли.
Есть три методологии, которые стоит держать в голове. Они не конкурируют — они отвечают на разные вопросы.
USE (Utilization, Saturation, Errors) — для ресурсов. Для каждого ресурса (CPU, память, диск, сеть, шины) проверь три вещи: насколько он занят, есть ли очередь ожидающих, и есть ли ошибки. Ключевой и постоянно упускаемый пункт здесь — насыщение. Утилизация CPU 100% сама по себе не проблема (батч-задача и должна жечь CPU); проблема — когда в очереди на выполнение стоит десять потоков и каждый ждёт по 40 мс.
RED (Rate, Errors, Duration) — для сервисов. Сколько запросов, сколько ошибок, какая задержка (обязательно в перцентилях, никогда в среднем).
Off-CPU-анализ — для всего, что не жжёт CPU. Классический профилировщик показывает, где программа тратит такты. Но веб-сервис в 95% случаев тормозит не потому, что считает, а потому что ждёт: диск, сеть, мьютекс, освобождение памяти, планировщик. Обычный флеймграф на это слеп по построению.
Эта диаграмма — самое важное изображение в статье. Почти каждый инструмент, который мы разберём, светит ровно в одно из этих состояний. Если вы ищете проблему не в том состоянии, никакой инструмент не поможет. Подробный разбор самих состояний и работы планировщика — в статье Процессы, потоки и планировщики.
Уровень 0: счётчики. /proc, /sys и почему они почти бесплатны
Ядро и так считает почти всё, что вам нужно: сколько тактов CPU ушло в каждый режим, сколько страниц отдано, сколько блоков прочитано. Эти счётчики обновляются в ходе обычной работы, а /proc просто форматирует их в текст по запросу. Стоимость чтения — один open+read+парсинг, то есть единицы микросекунд. Это единственный уровень наблюдаемости, который можно опрашивать раз в секунду вечно и в проде.
Устройство procfs как файловой системы разбиралось в статье про файловые системы; здесь нас интересует содержание.
Общесистемные счётчики
$ head -2 /proc/stat
cpu 4823941 12043 1204832 98234012 84312 0 42109 92831 0 0
cpu0 601233 1502 150934 12279251 10233 0 5121 11304 0 0
Десять чисел в единицах USER_HZ (обычно 1/100 секунды): user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice. Все %CPU-инструменты — это две выборки этого файла и деление разностей.
Два поля заслуживают отдельного внимания, потому что вокруг них построены два самых живучих заблуждения индустрии.
iowait — это не «диск загружен». Это время, когда CPU был idle и при этом хотя бы один поток на нём ждал блочный ввод-вывод. Если на машине есть чем занять CPU, iowait будет нулём при полностью забитом диске. И наоборот: iowait 40% на простаивающей машине не значит ничего плохого. Атрибуция при этом произвольна — поток мог мигрировать. Реальную загрузку диска смотрят в iostat -x, а честную «нехватку ввода-вывода» — в PSI.
steal — время, украденное гипервизором. Ваша vCPU была готова считать, но физического ядра ей не дали. Если steal стабильно выше 5–10%, никакая оптимизация кода не поможет: вы боретесь с соседями по хосту. Это первое, что надо смотреть на облачной VM, когда «код тот же, а стало медленнее».
Счётчики процесса
$ grep -E 'VmRSS|RssAnon|RssFile|Threads|ctxt' /proc/self/status
VmRSS: 14208 kB
RssAnon: 6912 kB
RssFile: 7296 kB
Threads: 1
voluntary_ctxt_switches: 148
nonvoluntary_ctxt_switches: 12
Разделение voluntary и nonvoluntary — бесплатная диагностика. Много добровольных переключений — процесс постоянно блокируется (ввод-вывод, мьютексы, слишком мелкие read). Много принудительных — процесс упирается в CPU и его вытесняют, машина перегружена.
$ cat /proc/self/schedstat
1284932741 88213004 1043
Три числа: наносекунды на CPU, наносекунды в очереди готовности, число квантов. Второе число — это runqueue latency, прямая метрика перегрузки. Отношение второго к первому больше 0.1 — планировщик не успевает вас пускать.
$ cat /proc/self/io
rchar: 2841923
wchar: 118234
syscr: 412
syscw: 88
read_bytes: 1048576
write_bytes: 0
rchar — сколько байт прошло через read(), read_bytes — сколько реально ушло на блочное устройство. Разница между ними — эффективность страничного кэша для этого конкретного процесса. Очень недооценённая пара чисел.
Для памяти вместо тяжёлого /proc/PID/smaps (он строит запись на каждый VMA — на JVM это тысячи строк) есть smaps_rollup, который отдаёт агрегат:
$ grep -E '^(Rss|Pss|Private|Swap)' /proc/self/smaps_rollup
Rss: 14208 kB
Pss: 9104 kB
Private_Clean: 1024 kB
Private_Dirty: 6144 kB
Swap: 0 kB
Pss (Proportional Set Size) — единственная метрика памяти, которую можно складывать по процессам без двойного учёта: разделяемая страница делится между всеми, кто её отобразил. Если вы суммируете RSS двадцати воркеров с общим форкнутым heap, вы получите число в разы больше реальности.
PSI: правильная замена load average
Load average — метрика 1969 года, и в Linux она к тому же считает не то, что все думают. В классическом Unix (и в FreeBSD, Solaris) load average — число потоков в состоянии Runnable. Linux в 1993 году добавил в неё ещё и TASK_UNINTERRUPTIBLE — потоки, ждущие диск. Поэтому loadavg = 40 на Linux может означать как сорок потоков, дерущихся за CPU, так и сорок потоков, спокойно ждущих ответа от NFS при простаивающем CPU. Это разные аварии, а число одно.
Ответ ядра на эту проблему — PSI (Pressure Stall Information), появившийся в 4.20:
$ cat /proc/pressure/io
some avg10=12.43 avg60=8.91 avg300=4.02 total=48123901
full avg10=6.20 avg60=4.11 avg300=1.98 total=22103882
some — доля времени, когда хотя бы одна задача была застопорена из-за нехватки ресурса. full — доля времени, когда все незаблокированные задачи стояли, то есть машина буквально ничего полезного не делала. Числа — проценты времени, а не абстрактные единицы: «12% времени за последние 10 секунд кто-то ждал диск» — это утверждение, которое можно положить в SLO и в алерт. То же есть для cpu (только some) и memory.
Тот же файл есть у каждой cgroup — /sys/fs/cgroup/<путь>/io.pressure, и это позволяет ответить на вопрос «какой контейнер создаёт давление», который через loadavg не отвечается в принципе.
cgroup v2: где живёт правда про контейнеры
Самая частая продовая загадка последних лет: сервис отвечает медленно, CPU по top — 30%, лимит вроде не достигнут. Ответ почти всегда здесь:
$ cat /sys/fs/cgroup/kubepods/burstable/pod.../cpu.stat
usage_usec 481923004
user_usec 402913882
system_usec 79009122
nr_periods 40122
nr_throttled 8214
throttled_usec 41230009
nr_throttled / nr_periods = 20% — каждый пятый 100-миллисекундный период CFS-квоты процесс насильно останавливали до конца периода. Средняя утилизация при этом честно низкая: он сжигает квоту за 30 мс и стоит 70 мс. Классический паттерн для многопоточных рантаймов (JVM, Go), где GOMAXPROCS/пул потоков не знают о лимите cgroup и запускают работу на всех ядрах сразу. Лечится не увеличением лимита, а согласованием параллелизма с квотой. Подробнее про cgroups — в статье Безопасность и изоляция.
Чеклист первых шестидесяти секунд
Прежде чем брать тяжёлые инструменты, прогоните классический чеклист (в оригинале — статья инженеров Netflix «Linux Performance Analysis in 60,000 Milliseconds»):
uptime # порядок величины нагрузки, тренд по трём числам
dmesg -T | tail -20 # OOM-killer, сбросы TCP, ошибки диска — часто ответ здесь
vmstat 1 5 # r (очередь!), si/so (swap), us/sy/id/wa
mpstat -P ALL 1 3 # перекос по ядрам: одно ядро в полке = однопоточное бутылочное горлышко
pidstat 1 3 # кто именно жжёт CPU, с разбивкой user/system
iostat -xz 1 3 # await, aqu-sz, %util по устройствам
free -m # смотреть на available, а не на free
sar -n DEV 1 3 # сеть: не упёрлись ли в полосу
sar -n TCP,ETCP 1 3 # retrans, passive/active — здоровье TCP
cat /proc/pressure/{cpu,io,memory} # современное дополнение к списку
Отдельно про free: колонка free почти всегда мала, и это нормально — ядро использует свободную память под страничный кэш. Смотреть надо на available: это оценка ядра, сколько можно выделить без свопа. «У меня кончилась память, смотрите — free всего 200 МБ» — самое частое ложное срабатывание в отрасли.
Уровень 1: трассировка системных вызовов
Счётчики говорят «что», трассировка — «почему». Первый шаг вниз — граница системных вызовов: узкое место, через которое программа общается с миром. Если процесс не жжёт CPU, ответ почти наверняка виден здесь.
strace и цена ptrace
$ strace -f -T -e trace=openat,read,write -p 4211
[pid 4213] openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 7 <0.000018>
[pid 4213] read(7, "127.0.0.1 localhost\n", 4096) = 20 <0.000009>
[pid 4213] write(3, "GET /api/v1/items", 17) = 17 <0.000031>
Полезнее всего не сам поток, а сводка:
$ strace -c -f -p 4211
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
71.02 4.812093 1203 4001 fsync
12.44 0.842911 21 40122 write
9.88 0.669204 167 4001 fdatasync
0.31 0.021003 5 4001 4001 access
Вот та самая история из введения: 4001 fsync на 40122 write. И, кстати, 4001 неудачных access — типичный признак того, что приложение перебирает пути в поисках конфига на каждой итерации.
Но за strace надо понимать цену. Он построен на ptrace(2), а тот работает так:
и несколько вызовов ptrace
на каждый отслеженный syscall
Голый write() стоит порядка 100–300 нс. Под strace он стоит десятки микросекунд. Замедление на syscall-интенсивной нагрузке — в 50–500 раз, и это не преувеличение. Отсюда практические правила:
- никогда не запускайте
straceбез фильтра-e trace=...на продовом процессе, обрабатывающем трафик; - современный
straceумеет--seccomp-bpf: фильтр отсеивает неинтересные вызовы в ядре, не будя трассировщик, — накладные расходы падают на порядок; - если нужен просто счётчик — берите
perf traceили eBPF-инструментsyscount, они на порядки дешевле; straceпоследователен: он видит вызовы одного процесса, но не видит, что творится внутри ядра между входом и выходом. Медленныйreadон покажет как медленный, но не скажет, что тот ждалmd-массив на ребилде.
Есть и семантическая ловушка: strace не показывает то, что не является системным вызовом. Обращение к памяти, которое вызвало minor page fault, вызовы, ушедшие через vDSO (clock_gettime, gettimeofday), и всё, что делает io_uring после отправки в очередь, для strace невидимы. Люди регулярно делают вывод «программа ничего не делает», глядя на пустой вывод strace, тогда как программа крутит clock_gettime в цикле через vDSO. Про vDSO и io_uring — в статье Системные вызовы и IPC.
Как это выглядит в других ОС
| Система | Трассировка вызовов | Особенности |
|---|---|---|
| Linux | strace, perf trace, syscount (BCC) |
ptrace или tracepoint syscalls:* |
| FreeBSD | truss, ktrace+kdump, DTrace |
ktrace пишет бинарный лог из ядра — дешевле ptrace |
| OpenBSD | ktrace/kdump, btrace |
DTrace нет; btrace(8) — подмножество bpftrace поверх dt(4) |
| NetBSD | ktrace/kdump, DTrace |
плюс lockstat для блокировок |
| macOS | dtruss, sample, fs_usage |
всё это упирается в SIP: под ним нельзя трассировать системные бинарники |
| Windows NT | ETW (wpr, xperf, PerfView) |
не трассировка вызовов, а событийная шина всего ядра |
Механически интересен именно ktrace в BSD: ядро само пишет записи в файл, не пробуждая трассировщик на каждое событие. Это структурно ближе к ftrace, чем к strace, и на порядок дешевле ptrace. За это платят потерей интерактивности — вы смотрите лог, а не диалог. Философию BSD подробнее разбирает статья Семейство BSD.
Уровень 2: perf и выборка стеков
perf — не один инструмент, а фронтенд к подсистеме perf_events ядра. Она умеет три разные вещи, и их постоянно путают: считать аппаратные события PMU, делать выборку (сэмплирование) стеков по переполнению счётчика и подписываться на статические tracepoint-ы.
perf stat: диагноз по IPC
$ perf stat -d ./bench
Performance counter stats for './bench':
2 041.33 msec task-clock # 0.999 CPUs utilized
8 context-switches # 3.919 /sec
6 610 293 128 cycles # 3.238 GHz
2 190 004 512 instructions # 0.33 insn per cycle
412 003 991 branches # 201.83 M/sec
18 220 411 branch-misses # 4.42% of all branches
241 003 882 L1-dcache-load-misses # 18.02% of all L1-dcache accesses
61 002 004 LLC-load-misses # 71.20% of all LL-cache accesses
2.043 seconds time elapsed
Читается это так. IPC = 0.33 — процессор выполняет треть инструкции за такт при теоретических 4–6. Значит, он не считает, а ждёт. Кого ждёт — говорят следующие строки: 71% промахов последнего уровня кэша уходят в память. Диагноз: программа ограничена памятью, а не вычислениями. Оптимизировать здесь надо раскладку данных (массив структур → структура массивов, уплотнение, локальность), а не количество арифметики. Никакой профилировщик уровня функций этого не скажет — он покажет только, что «горячая функция вот эта».
Полезный ориентир: IPC ниже 1.0 — почти наверняка память или ветвления; IPC выше 2.0 — вы упираетесь в собственно вычисления. Для системного разбора «во что упёрлись» есть методология Top-Down от Intel и утилита toplev из пакета pmu-tools.
perf record: где именно горит
# 99 Гц, а не 100 — чтобы не попасть в резонанс с периодическими задачами
$ perf record -F 99 -a -g --call-graph fp -- sleep 30
$ perf report --stdio --sort=dso,symbol | head -20
# флеймграф
$ perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > cpu.svg
Частота 99 Гц вместо круглых 100 — не суеверие: многие таймеры и цикличные задачи в системе работают на кратных 100 Гц периодах, и выборка на той же частоте систематически попадает в одну и ту же фазу, искажая профиль.
Главная практическая боль perf record — стеки. Их можно собирать тремя способами:
--call-graph fp— по frame pointer. Дёшево и точно, но требует, чтобы код был собран с-fno-omit-frame-pointer. Годами дистрибутивы собирали пакеты без него ради ~1% производительности, и профилирование системных библиотек было невозможно. Ubuntu 24.04 и Fedora 38+ вернули frame pointers по умолчанию — это одно из самых полезных изменений в экосистеме за десятилетие.--call-graph dwarf— копировать кусок стека (по умолчанию 8 КБ) в каждой выборке и раскручивать его офлайн по DWARF. Работает без frame pointer, но объём данных вырастает на порядки.--call-graph lbr— использовать аппаратный буфер последних ветвлений процессора. Почти бесплатно, но глубина ограничена (16–32 записи).
Отдельная беда — символизация: perf видит адреса, а не имена. Для JIT-рантаймов (JVM, Node.js) нужен агент, который пишет /tmp/perf-<pid>.map; для контейнеров — доступ к тем же бинарникам и debuginfo, что внутри. Половина неудачных сессий профилирования заканчивается лесом из [unknown] именно поэтому.
Родственники, о которых мало знают
perf sched record/perf sched latency— сколько каждый поток ждал в очереди готовности. Прямой ответ на вопрос «нам не хватает CPU или мы плохо распараллелены».perf c2c— поиск false sharing: две переменные в одной кэш-линии, которые пишут разные ядра. Проблема, которую невозможно найти чтением кода в большом проекте и которая может стоить кратного падения производительности.perf mem— какие обращения к памяти дорогие, с точностью до инструкции (через PEBS/IBS).perf probe— динамически создать tracepoint на произвольной функции ядра или пользовательского кода.
Права и почему perf «не работает»
$ cat /proc/sys/kernel/perf_event_paranoid
2
Значения: -1 — разрешено всё; 0 — доступны события ядра и CPU; 1 — без трассировки ядра; 2 (типичный дефолт) — только пользовательские события своих процессов; 3 — запрещено непривилегированным вообще. В контейнере дополнительно нужны CAP_PERFMON (или CAP_SYS_ADMIN на старых ядрах) и, для чтения адресов ядра, kernel.kptr_restrict=0. Если perf показывает [unknown] вместо функций ядра — почти всегда дело в этих двух sysctl.
Аппаратные счётчики из своего кода
Иногда нужно измерить не «программу», а конкретный участок. perf_event_open(2) доступен напрямую:
#define _GNU_SOURCE
#include <linux/perf_event.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
/* Обёртки над perf_event_open нет в glibc — зовём syscall напрямую. */
static int perf_open(struct perf_event_attr *pe) {
return (int)syscall(__NR_perf_event_open, pe, /*pid=*/0, /*cpu=*/-1,
/*group_fd=*/-1, /*flags=*/0);
}
int main(void) {
struct perf_event_attr pe;
memset(&pe, 0, sizeof(pe));
pe.type = PERF_TYPE_HARDWARE;
pe.size = sizeof(pe);
pe.config = PERF_COUNT_HW_INSTRUCTIONS;
pe.disabled = 1; /* стартуем выключенным */
pe.exclude_kernel = 1; /* считаем только пользовательский код */
pe.exclude_hv = 1;
int fd = perf_open(&pe);
if (fd < 0) { perror("perf_event_open"); return 1; }
ioctl(fd, PERF_EVENT_IOC_RESET, 0);
ioctl(fd, PERF_EVENT_IOC_ENABLE, 0);
volatile long acc = 0;
for (long i = 0; i < 10000000; i++) acc += i; /* измеряемый участок */
ioctl(fd, PERF_EVENT_IOC_DISABLE, 0);
long long count = 0;
read(fd, &count, sizeof(count));
printf("инструкций: %lld\n", count);
close(fd);
return 0;
}
Так устроены изнутри и perf, и все библиотеки самопрофилирования (PAPI, likwid). Ровно этот приём используют, когда нужно встроить непрерывное измерение IPC в сам сервис и экспортировать его как метрику.
Уровень 3: ftrace — встроенный трассировщик ядра
ftrace появился в 2008 году (Стивен Ростедт) и живёт прямо в ядре, без единой зависимости в userspace. Управление — записью в файлы /sys/kernel/tracing (старый путь /sys/kernel/debug/tracing). Это делает его единственным трассировщиком, доступным на любой встроенной системе, где нет ни perf, ни компилятора BPF.
$ cd /sys/kernel/tracing
# что вообще можно трассировать
$ wc -l available_events available_filter_functions
2183 available_events
62841 available_filter_functions
# 1. Статический tracepoint: все запуски процессов в системе
$ echo 1 > events/sched/sched_process_exec/enable
$ cat trace_pipe
bash-4211 [003] .... 91234.102931: sched_process_exec: filename=/usr/bin/ls pid=4599
# 2. Граф вызовов внутри одной функции ядра, с временами
$ echo function_graph > current_tracer
$ echo vfs_read > set_graph_function
$ echo 1 > tracing_on ; sleep 1 ; echo 0 > tracing_on
$ head -20 trace
3) | vfs_read() {
3) | rw_verify_area() {
3) 0.412 us | security_file_permission();
3) 1.104 us | }
3) | ext4_file_read_iter() {
3) 8.812 us | generic_file_read_iter();
3) 10.203 us | }
3) + 12.664 us | }
function_graph — уникальная способность: он показывает вложенность и длительность каждой функции ядра, работая через -pg-хуки компилятора (mcount/fentry). Это буквально пошаговая отладка ядра без отладчика.
Важное, но малоизвестное: hist-триггеры позволяют агрегировать прямо в ядре, без выгрузки событий наружу:
# Кто и сколько аллоцирует в ядре — гистограмма строится ядром
$ echo 'hist:key=comm:vals=bytes_alloc:sort=bytes_alloc.descending' \
> events/kmem/kmalloc/trigger
$ cat events/kmem/kmalloc/hist
{ comm: nginx } hitcount: 48211 bytes_alloc: 39204112
{ comm: postgres } hitcount: 12043 bytes_alloc: 9821004
Это тот же принцип, который сделает потом eBPF таким эффективным: агрегировать на месте, отдавать наружу итог, а не поток событий. Для повседневной работы поверх ftrace есть удобные обёртки: trace-cmd (CLI) и perf-tools Брендана Грегга — набор скриптов вроде funcgraph, iosnoop, execsnoop на голом shell.
Уровень 4: eBPF — программируемое ядро
eBPF — это виртуальная машина внутри ядра, в которую можно загрузить свою программу, прицепить её к любому событию (kprobe, tracepoint, uprobe, USDT, сетевой путь, LSM-хук) и получить результат через разделяемую с userspace структуру данных (map). Ключевое отличие от всего предыдущего: вы пишете логику фильтрации и агрегации, и она исполняется в ядре.
Почему это безопасно? Перед загрузкой программа проходит верификатор: он доказывает завершаемость (циклы ограничены), проверяет каждое обращение к памяти, отслеживает диапазоны значений регистров. Если доказательство не построилось — программа отвергается. Потом JIT транслирует её в нативный машинный код. Отсюда и стоимость: вызов eBPF-программы на kprobe — сотни наносекунд, а на fentry (прямой трамплин вместо int3-ловушки) — десятки.
завершаемость, границы памяти,
типы, права"} D -- отказ --> E["Ошибка загрузки
с трассой инструкций"] D -- принято --> F["JIT в нативный код"] F --> G["Прикрепление к событию:
kprobe / fentry / tracepoint /
uprobe / USDT / XDP / LSM"] G --> H["Событие срабатывает
в контексте ядра"] H --> I["Программа пишет в map:
hash, гистограмма, per-CPU счётчик"] I --> J["Userspace читает агрегат
раз в секунду"] H --> K["ring buffer:
отдельные события"] K --> J style D fill:#c46a6a,fill-opacity:0.2 style I fill:#7fa87f,fill-opacity:0.2
bpftrace на практике
bpftrace — язык в духе awk/DTrace поверх eBPF. Один вызов — один вопрос.
# Кто открывает файлы и какие
$ bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
printf("%-16s %s\n", comm, str(args->filename)); }'
# Гистограмма задержки блочного ввода-вывода
$ bpftrace -e '
kprobe:blk_account_io_start { @start[arg0] = nsecs; }
kprobe:blk_account_io_done /@start[arg0]/ {
@us = hist((nsecs - @start[arg0]) / 1000);
delete(@start[arg0]); }'
@us:
[16, 32) 812 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[32, 64) 402 |@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[64, 128) 91 |@@@@@@ |
[8192, 16384) 14 | |
Последняя строка — та самая, ради которой всё затевалось: четырнадцать операций по 8–16 мс при типичных 20 мкс. Средняя задержка это спрячет, перцентиль p99 покажет, а гистограмма объяснит, что распределение бимодальное — то есть в системе два разных механизма, а не один медленный.
# Off-CPU: где потоки спят и сколько
$ bpftrace -e '
kprobe:finish_task_switch { @sleep[kstack, comm] = sum(nsecs - @ts[tid]); }'
# Задержка очереди готовности (упрощённый runqlat)
$ bpftrace -e '
tracepoint:sched:sched_wakeup { @q[args->pid] = nsecs; }
tracepoint:sched:sched_switch /@q[args->next_pid]/ {
@lat_us = hist((nsecs - @q[args->next_pid]) / 1000);
delete(@q[args->next_pid]); }'
# Пользовательские функции: сколько раз вызывался malloc и с какими размерами
$ bpftrace -e 'uprobe:/usr/lib/libc.so.6:malloc { @sz = hist(arg0); }'
Готовые инструменты из проекта BCC покрывают большинство типовых вопросов: execsnoop (запуски процессов), opensnoop, biolatency, runqlat, tcplife (жизненный цикл TCP-соединений), offcputime, profile, cachestat, ext4slower (только операции ФС дольше N мс). Каждый — готовый ответ на конкретный вопрос из методологии USE.
CO-RE, BTF и почему это стало продакшн-технологией
Ранний BCC компилировал программу на целевой машине при запуске: нужен был clang, заголовки ядра, десятки мегабайт памяти и секунды на старт. Для агента, который должен работать на десяти тысячах узлов, это неприемлемо.
Решение — CO-RE (Compile Once — Run Everywhere). Ядро экспортирует полное описание своих типов в формате BTF (/sys/kernel/btf/vmlinux). Компилятор помечает обращения к полям структур как перемещаемые, а загрузчик libbpf во время загрузки подставляет актуальные смещения для конкретного ядра. Один бинарник работает на ядрах 5.4 и 6.8, где структуры разъехались.
# посмотреть, что ядро знает о себе
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c | head -40
# что сейчас загружено в систему
$ bpftool prog show
$ bpftool map show
Именно CO-RE превратил eBPF из инструмента отладки в фундамент продуктов: Cilium (сеть и политики), Falco (детект аномалий безопасности), Pixie и Parca/Pyroscope (непрерывное профилирование), Katran (балансировщик Facebook). Сетевую сторону eBPF (XDP, tc) разбирает статья Сетевой стек.
Границы eBPF
Реалистичный взгляд обязателен, иначе получится реклама:
- Верификатор капризен. Сложная программа может быть корректной и всё равно отвергнутой. Обход ограничений (bounded loops, ограничение стека 512 байт) — заметная часть работы разработчика eBPF.
- kprobe хрупок. Внутренние функции ядра не являются стабильным API. Инструмент, цепляющийся к
blk_account_io_start, ломается при обновлении ядра. Статические tracepoint-ы стабильны — предпочитайте их всегда, когда они есть. - Права. Нужен
CAP_BPF+CAP_PERFMON(или root). eBPF — мощнейший вектор атаки, и в защищённых средах его закрывают. - Портируемость. eBPF — линуксизм. Windows-порт (
ebpf-for-windows) существует, но покрывает в основном сетевые хуки. В BSD аналог — DTrace, идейно похожий, но устроенный иначе.
Как эта область развивалась
Отдельного слова заслуживает DTrace (Кэнтрилл, Шапиро, Левенталь, Sun, 2001). Он появился на десять лет раньше и сразу решил задачу целиком: единый язык D для всего стека, гарантия безопасности исполнения в проде, нулевая стоимость выключенных проб. Linux шёл к тому же результату пятнадцать лет и тремя разными путями (SystemTap, ftrace, perf), пока eBPF не собрал всё вместе. DTrace жив в illumos, FreeBSD, NetBSD и macOS; статья Другие ОС разбирает эти системы подробнее. Стоит прочитать оригинальную статью Dynamic Instrumentation of Production Systems — она объясняет постановку задачи лучше любого учебника.
В Windows NT своя, полностью независимая линия: ETW (Event Tracing for Windows), встроенная с NT 5.0. Архитектурно это шина событий с провайдерами и сессиями; ядро и все системные компоненты — провайдеры. Инструменты: wpr/WPA, PerfView, xperf. По охвату ETW сравним с eBPF, но он не программируемый: фильтрация задаётся декларативно, а обработка — в userspace.
Типичные заблуждения, которые дорого стоят
«Средняя задержка выросла до 30 мс». Среднее — почти бесполезная метрика задержки. Смесь из 99% ответов по 1 мс и 1% по 3 секундам даёт те же 30 мс, что и равномерные 30 мс, но это радикально разные системы. Смотрите перцентили, а лучше — гистограммы (heatmap). У Грегга есть каноничный разбор: средняя задержка скрывает бимодальность, а именно бимодальность — почти всегда симптом бага.
«CPU 100% — надо больше ядер». Сначала посмотрите IPC. Если он 0.3, вы упёрлись в память, и удвоение ядер даст меньше 30% прироста, потому что новые ядра будут стоять в той же очереди к памяти.
«Профилировщик показал, что горячая функция — memcpy». Это не ответ, а вопрос. Нужен стек: кто её зовёт. Именно поэтому -g и корректные frame pointers не опциональны.
«Утечка памяти: RSS растёт». RSS растёт и от страничного кэша отображённых файлов, и от роста арен glibc-malloc, и от фрагментации. Проверьте RssAnon в /proc/PID/status, Pss в smaps_rollup и разбивку в /proc/PID/smaps. Разбор устройства аллокаторов — в статье Управление памятью.
«strace ничего не показывает, значит программа зависла». См. выше про vDSO, io_uring и обращение к памяти. Проверьте /proc/PID/stack (нужен root) и /proc/PID/wchan — они скажут, в какой функции ядра поток спит.
«Измерение не влияет на измеряемое». Влияет, и это надо контролировать: под strace нагрузка меняет свой профиль настолько, что race condition исчезает (классический «гейзенбаг»). Флеймграф с --call-graph dwarf может съедать 10% CPU. Всегда знайте цену своего инструмента.
«Load average 20 — авария». На 32-ядерной машине это половина мощности. И, что важнее, в Linux в это число входят D-состояния. Правильный вопрос — не «сколько», а «какого ресурса не хватает»: ответ в /proc/pressure.
Как выбрать инструмент: практическое дерево
pidstat, top"} Q1 -- "да" --> C1{"IPC из perf stat"} C1 -- "< 1.0" --> C2["Ограничены памятью:
perf record + перцентили промахов,
perf c2c для false sharing,
чинить раскладку данных"] C1 -- "> 1.5" --> C3["Ограничены вычислениями:
perf record -F 99 -g,
флеймграф, алгоритмическая оптимизация"] Q1 -- "нет" --> Q2{"Кто-то ждёт?
/proc/pressure"} Q2 -- "cpu pressure" --> D1["Не хватает CPU или квоты:
runqlat, cpu.stat throttled_usec,
schedstat поле 2"] Q2 -- "io pressure" --> D2["Диск:
biolatency, iostat -x,
ext4slower / fileslower"] Q2 -- "memory pressure" --> D3["Память:
vmstat si/so, PSI full,
cachestat, dmesg на OOM"] Q2 -- "давления нет" --> Q3{"Ждём внешнее?"} Q3 -- "да" --> E1["Сеть и блокировки:
tcplife, tcpretrans,
offcputime + флеймграф ожидания"] Q3 -- "непонятно" --> E2["strace -c -f на минуту:
увидеть распределение вызовов,
затем bpftrace на подозрительный"] style C2 fill:#b58a5a,fill-opacity:0.2 style D1 fill:#c46a6a,fill-opacity:0.2 style E1 fill:#6a7fb5,fill-opacity:0.2
Наблюдаемость в проде: что делать постоянно
Разовое расследование — это хорошо, но зрелая практика выглядит иначе.
Непрерывное профилирование. Идея из статьи Google «Google-Wide Profiling: A Continuous Profiling Infrastructure for Data Centers» (Ren et al., 2010): собирать выборки со всего парка машин постоянно, с частотой, дающей пренебрежимый оверхед (доли процента). Тогда вопрос «что изменилось после релиза» решается дифференциальным флеймграфом за минуту, а не расследованием за неделю. Открытые реализации — Parca и Pyroscope, обе на eBPF, обе не требуют изменений в приложении.
Метрики USE в дашборде по умолчанию. Для каждого ресурса — утилизация, насыщение, ошибки. Насыщение почти всегда важнее утилизации, и почти всегда его нет в дашборде.
Алерты на PSI, а не на loadavg. some avg60 > 20% по CPU и io — практичные пороги, которые не срабатывают ложно на батч-нагрузке.
Готовность к расследованию. Пакеты debuginfo, символы, frame pointers, perf внутри образа или sidecar с нужными capabilities, /sys/kernel/tracing смонтирован. Собирать это в момент аварии в три ночи — худшее время.
Один вопрос — один инструмент. Не «включим всё и посмотрим», а гипотеза → измерение → подтверждение или опровержение. Наблюдаемость — это научный метод, а не сбор данных.
Мини-итог
- Наблюдаемость строится слоями по цене: счётчики (
/proc, PSI, cgroup) → выборка (perf record) → трассировка (ftrace, eBPF, strace). Не начинайте с дорогого. /procдаёт почти бесплатные ответы, но требует знания подвохов: iowait не про диск, loadavg в Linux включает D-состояния, RSS складывать нельзя,freeсмотреть надо в колонке available.- PSI — современная и честная метрика нехватки ресурса; она же есть на уровне cgroup и отвечает на вопрос «кто виноват».
straceбесценен для понимания «что программа просит у ядра», но стоит замедления в 50–500 раз.-cи--seccomp-bpfобязательны, для счётчиков берите eBPF.perf— три инструмента в одном: PMU-счётчики (диагноз по IPC), выборка стеков (флеймграф), tracepoint-ы. Без frame pointers и символов он бесполезен.ftrace— единственный трассировщик, доступный везде и без зависимостей;function_graphи hist-триггеры недооценены.- eBPF — программируемая наблюдаемость: агрегация в ядре, безопасность через верификатор, портируемость через BTF/CO-RE. Предпочитайте tracepoint-ы над kprobe.
- On-CPU-профиль отвечает на «где жгут такты», а off-CPU — на «где ждут». Второе для сервисов чаще важнее.
- В BSD роль eBPF играет DTrace, в OpenBSD —
btrace, в macOS — DTrace под ограничениями SIP и Instruments, в Windows — ETW.
Что почитать
- Brendan Gregg. Systems Performance: Enterprise and the Cloud, 2-е изд., 2020 — главная книга по теме, методологии USE и вся карта инструментов Linux. Материалы и инструменты: brendangregg.com.
- Brendan Gregg. BPF Performance Tools, 2019 — практический справочник по bpftrace и BCC.
- Documentation/filesystems/proc.rst — исчерпывающее описание полей
/proc. - Documentation/accounting/psi.rst — PSI из первых рук.
- Documentation/trace/ftrace.rst — ftrace, включая hist-триггеры.
- ebpf.io и bpftrace Reference Guide.
- Cantrill, Shapiro, Leventhal. Dynamic Instrumentation of Production Systems, USENIX ATC 2004 — оригинальная статья про DTrace.
- Ren et al. Google-Wide Profiling, IEEE Micro 2010 — почему профилировать надо всегда и весь парк.
man 2 perf_event_open,man 1 perf,man 2 ptrace,man 8 bpftrace,man 1 ktrace(BSD).
Что дальше
Мы прошли весь путь от загрузки до наблюдения за работающей системой — но всё время смотрели на Linux как на точку отсчёта. Пора увидеть альтернативу: другую инженерную культуру, где ядро и базовая система разрабатываются вместе, документация считается частью кода, а решения принимаются в пользу простоты и правильности, а не совместимости любой ценой.
Семейство BSD: FreeBSD, OpenBSD, NetBSD, DragonFly — философия и различия