В прошлом году мы столкнулись с классической проблемой: один из наших C++ микросервисов начал потреблять всё больше памяти, а пиковая задержка выросла с 50 до 400 мс. Традиционные профайлеры вроде perf или gperftools давали приличный оверхед — 5–15% на CPU, что на проде с 10k RPS было неприемлемо. Перекомпиляция с флагами debug тоже не вариант — мы работали с собранными бинарниками без отладочных символов. Именно тогда я начал разбираться с eBPF.
Что такое eBPF и почему это важно
eBPF (extended Berkeley Packet Filter) — это механизм ядра Linux, который позволяет безопасно выполнять пользовательские программы внутри пространства ядра. Проще говоря, вы пишете маленькую программу на подобии C, ядро её верифицирует (проверяет, что нет бесконечных циклов, нет неконтролируемых доступов к памяти) и запускает на определённом хуке: системный вызов, точка трейсинга, сетевой пакет. Оверхед при этом минимальный — часто менее 1%.
Для меня самым большим открытием стало то, что eBPF не требует изменения кода приложения. Вы просто подключаетесь к ядру и наблюдаете. Никаких перекомпиляций, никаких рестартов. В продакшене — это золотой стандарт.
Традиционное профилирование и его проблемы
Давайте честно: perf record -g — отличный инструмент. Но на проде у нас есть ограничения. Во-первых, high-frequency sampling даёт оверхед на CPU. Во-вторых, perf работает на уровне процесса, а мы часто хотим видеть картину целиком — все процессы, все контейнеры. В-третьих, если у вас stripped binary, без separate debug info профайлер покажет только адреса, а не имена функций. eBPF решает все эти проблемы, потому что работает на уровне ядра.
Инструменты: bpftrace и bcc
Два основных инструмента для eBPF-профилирования: bpftrace и bcc. bpftrace — это как awk для eBPF: пишете однострочник или короткий скрипт, и он работает. bcc — это набор Python/C++ инструментов для более сложных сценариев. Я в основном использую bpftrace для ad-hoc задач и bcc — когда нужен полноценный инструмент.
Трейсинг аллокаций памяти в C++
Вот реальный скрипт bpftrace, которым я нашёл утечку памяти в нашем сервисе обработки изображений. Скрипт перехватывает malloc и free, агрегирует размеры аллокаций по callstack-ам:
#!/usr/bin/env bpftrace
uprobe:/usr/lib/x86_64-linux-gnu/libc.so.6:malloc
{
@allocs[ustack] = sum(arg0);
@alloc_count[ustack] = count();
}
uprobe:/usr/lib/x86_64-linux-gnu/libc.so.6:free
/arg0 != 0/
{
@frees[ustack] = count();
}
interval:s:10
{
printf("=== Memory profile (10s) ===\n");
print(@allocs);
printf("\n=== Allocation counts ===\n");
print(@alloc_count);
clear(@allocs);
clear(@alloc_count);
}Запускаем: sudo bpftrace memory.bt — и через 10 секунд видим, что 80% аллокаций идут из одной функции ImageProcessor::decodeFrame(), причём размер растёт со временем. Классическая утечка: мы парсили JPEG-фреймы и не освобождали промежуточные буферы при определённом типе ошибки.
Поиск утечки: от гипотезы к фиксации
Когда скрипт показал, что ImageProcessor::decodeFrame() аллоцирует 2–4 ГБ за час работы без освобождения, я пошёл в код. Оказалось, при ошибке декодирования мы делали return -1; без вызова free() для временного буфера. Тривиальный баг, но найти его без eBPF было бы сложно — на проде утечка проявлялась только при определённом паттерне входных данных.
// Было (утечка при ошибке):
int ImageProcessor::decodeFrame(const uint8_t* data, size_t len) {
uint8_t* buf = (uint8_t*)malloc(len * 2);
if (!buf) return -1;
int rc = jpeg_decode(data, len, buf, len * 2);
if (rc != 0) {
return -1; // УТЕЧКА: buf не освобождён!
}
processed_frames_.push_back({buf, len * 2});
return 0;
}
// Стало (фикс):
int ImageProcessor::decodeFrame(const uint8_t* data, size_t len) {
uint8_t* buf = (uint8_t*)malloc(len * 2);
if (!buf) return -1;
int rc = jpeg_decode(data, len, buf, len * 2);
if (rc != 0) {
free(buf);
return -1;
}
processed_frames_.push_back({buf, len * 2});
return 0;
}Трейсинг Kotlin/JVM через системные вызовы
С Kotlin-сервисами ситуация интереснее. JVM — это чёрный ящик для eBPF, но мы можем трейсить то, что делает JVM: системные вызовы, файловый I/O, сетевые операции. Например, скрипт для отслеживания I/O-операций конкретного Java-процесса:
#!/usr/bin/env bpftrace
tracepoint:syscalls:sys_enter_read
/pid == 12345/
{
@read_bytes[comm, kstack] = sum(args->count);
}
tracepoint:syscalls:sys_enter_write
/pid == 12345/
{
@write_bytes[comm, kstack] = sum(args->count);
}
tracepoint:syscalls:sys_enter_openat
/pid == 12345/
{
printf("OPEN: %s (pid=%d, tid=%d)\n",
str(args->filename), pid, tid);
}Этот скрипт помог нам понять, что Kotlin-сервис, который должен был работать с Redis через пул соединений, на самом деле открывал новые TCP-соединения на каждый запрос. Проблема была в конфигурации HikariCP — connection timeout был слишком маленьким, и соединения закрывались быстрее, чем переиспользовались. Без eBPF мы бы копали в сторону самого приложения, а проблема была в инфраструктурном слое.
Latency tracing: латентность по системным вызовам
Ещё один полезный паттерн — замер времени выполнения системных вызовов. Вот скрипт, который показал нам, что fdatasync иногда занимает до 200 мс на нашем хранилище:
#!/usr/bin/env bpftrace
tracepoint:syscalls:sys_enter_fdatasync
{
@start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_fdatasync
/@start[tid]/
{
$latency = (nsecs - @start[tid]) / 1000000;
if ($latency > 10) {
printf("SLOW fsync: pid=%d tid=%d latency=%dms comm=%s\n",
pid, tid, $latency, comm);
}
@hist = hist($latency);
delete(@start[tid]);
}Оказалось, что EBS-том на AWS иногда «проваливается» в запись. Мы решили проблему переходом на io_uring и добавлением write-back кэширования для некритичных данных.
Ограничения eBPF
eBPF — не серебряная пуля. Во-первых, нужна достаточно свежая версия ядра (5.4+ для базовых вещей, 5.8+ для bpftrace-программ с пользовательскими uprobe). Во-вторых, verifier eBPF ограничивает сложность программ — нельзя сделать слишком сложную логику. В-третьих, для JVM вы не увидите Java-стеки напрямую — только kernel stack и системные вызовы. Для Java-стеков лучше использовать async-profiler, который тоже использует eBPF под капотом.
Когда eBPF, а когда традиционные профайлеры
Мой опыт говорит так: если нужно быстрое диагностирование в проде без остановки — eBPF. Если нужна детальная профилировка конкретной функции с точностью до инструкции — perf + flamegraph. Если проблема в Java-коде (алгоритмическая сложность, GC-паузы) — async-profiler или JFR. eBPF — это ваш швейцарский нож для системного уровня, но он не заменяет прикладные профайлеры, он их дополняет.
В нашем случае eBPF стал стандартной частью toolchain-а. У нас есть набор готовых bpftrace-скриптов в репозитории ops-tools, и любой инженер может запустить их на проде за 30 секунд. Это изменило культуру диагностики — мы перестали бояться смотреть под капот в продакшене.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье