Загрузка
Все статьи

eBPF: профилирование C++ и Kotlin сервисов в продакшене без оверхеда

Как мы внедрили eBPF для профилирования высоконагруженных C++ и Kotlin сервисов в продакшене без остановки и перекомпиляции. Реальные скрипты bpftrace, поиск утечек памяти и трейсинг JVM-системных вызовов.

В прошлом году мы столкнулись с классической проблемой: один из наших 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 секунд. Это изменило культуру диагностики — мы перестали бояться смотреть под капот в продакшене.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

CMake: модульная архитектура для большого C++ проекта — без ad-hoc include путей

Как мы реорганизовали CMake-структуру проекта на 15+ модулей, избавились от хаотичных include_directories и перешли на современный CMake с target-based зависимостями.

20 июня 2026 10 мин

Kotlin + C++: реальный кейс — видеопроцессинг на сервере через JNI

Как мы построили сервер видеотранскодинга на Kotlin REST API + C++ FFmpeg через JNI. Прокачка JNI_OnLoad, передача фреймов через DirectByteBuffer, обработка ошибок и бенчмарки.

19 июня 2026 13 мин

C++: потоки и потокобезопасность — мьютексы, atomics и deadlock-ы на практике

Разбираем многопоточность в C++ на реальных примерах из продакшена: мьютексы, lock_guard, unique_lock, std::atomic, deadlock-ы и как их избежать. Сравнение с корутинами Kotlin.

18 июня 2026 11 мин