Topleaked: инструмент ловли утечек памяти

    image

    История, как это часто бывает, началась с того, что упал один из сервисов на сервере. Точнее процесс был убит мониторингом за превышение использования памяти. Запас должен был быть многократным, а значит у нас утечка памяти.
    Есть полный дамп памяти с отладочной информацией, есть логи, но воспроизвести не получается. То ли утечка безумно медленная, то ли сценарий зависит от погоды на Марсе. Словом, очередной баг, который не воспроизводится тестами, но встречается в дикой природе. Остаётся единственная реальная зацепка — дамп памяти.


    Идея


    Исходный сервис был написан на C++ и Perl, хотя это и не играет особой роли. Всё описанное ниже применимо практически к любому языку.


    Наш процесс из постановки задачи должен был укладываться в пару сотен мегабайт оперативной памяти, а завершён был за превышение 6 гигабайт. Значит большая часть памяти процесса — утекшие объекты и их данные. Нужно только выяснить, объектов какого типа больше всего было в памяти. Разумеется, никакого списка объектов с информацией о типах в дампе нет. Отследить взаимосвязи и построить граф, как это делают сборщики мусора, практически невозможно. Но нам требуется в этой бинарной мешанине не разбираться, а посчитать, каких объектов больше. У объектов нетривиальных классов есть указатель на таблицу виртуальных методов, и у всех объектов одного и того же класса этот указатель одинаковый. Сколько раз указатель на vtbl класса встречается в памяти — столько объектов этого класса было создано.


    Кроме vtbl есть и другие часто встречающиеся последовательности: константы, которыми инициализируются поля, HTTP заголовки в фрагментах строк, указатели на функции.
    Если повезёт найти указатель, то мы можем с помощью gdb понять, на что он указывает (если конечно есть debug символы). В случае данных можно попробовать посмотреть на них и понять, где такое используется. Забегая вперёд, замечу, что бывает и то и другое и, по фрагменту строки вполне можно понять, какая эта часть протокола, и куда нужно копать дальше.


    Идея подсмотрена, а первая реализация нагло скопирована со stackoverflow. https://stackoverflow.com/questions/7439170/is-there-a-way-to-find-leaked-memory-using-a-core-file


    hexdump core.10639 | awk '{printf "%s%s%s%s\n%s%s%s%s\n", $5,$4,$3,$2,$9,$8,$7,$6}' | sort | uniq -c | sort -nr | head 

    Скрипт поработал минут 15 на нашем дампе, выдал кучу строк, и … ничего. Ни одного указателя, ничего полезного.


    Разбираемся


    Stackoverflow-driven development имеет свои недостатки. Нельзя просто так скопировать скрипт и надеяться, что всё будет работать. В этом конкретном скрипте в глаза сразу бросаются какие-то перестановки байт. Также возникает вопрос, почему перестановки по 4. Не надо быть суперспециалистом, чтобы понять, что подобные перестановки зависят от платформы: битности и порядка байт.


    Чтобы понять в точности, как надо искать, требуется разобраться в формате файла дампа памяти, LITTLE- и BIG-endian, а можно просто по разному переставлять байтики в найденных кусочках и отдавать gdb. О чудо! В прямом порядке байт gdb видит символ и говорит, что это указатель на функцию!


    В нашем случае это был указатель на одну из функций чтения и записи в openssl буферы. Для кастомизации ввода и вывода там используется сишный “ООП” подход — структура с набором указателей на функции, которая является своего рода интерфейсом или скорее vtbl. Этих структур с указателями и оказалось безумно много. Пристальный взгляд на код, отвечающий за установку этих структур и создание буферов, позволил быстро найти ошибку. Как выяснилось, на стыке C++ и С не было никаких RAII объектов и в случае возникновения ошибки ранний return не оставлял шансов на освобождение ресурсов. Грузить сервис некорректными ssl рукопожатиями никто своевременно не догадался, вот и пропустили. Как набрать 6 гигабайт некорректных ssl рукопожатий тоже интересно, но, как говорится, это совершенно другая история. Проблема решена.


    topleaked


    Скрипт оказался полезным, но всё же для частого использования у него есть серьёзные недостатки: очень медленный, платформенно зависимый, позже окажется, что ещё и файлы дампов бывают с разными смещениями, сложно интерпретировать результаты. Задача копания в бинарном дампе плохо ложится на bash, поэтому я сменил язык программирования на D. Выбор языка на самом деле обусловлен эгоистичным желанием писать на любимом языке. Ну а рационализация выбора такая: скорость и потребление памяти критичны, поэтому нужен нативный компилируемый язык, а на D банально быстрее писать, чем на C или C++. Позже в коде это будет хорошо видно. Так родился проект topleaked.


    Установка


    Бинарных сборок нет, поэтому так или иначе понадобится собрать проект из исходников. Для этого потребуется компилятор D. Варианта три: dmd — референсный компилятор, ldc — основанный на llvm и gdc, входящий в gcc, начиная с 9-й версии. Так что, возможно, вам не придётся ничего устанавливать, если есть последний gcc. Если же устанавливать, то я рекомендую ldc, так как он лучше оптимизирует. Все три можно найти на официальном сайте.
    Вместе с компилятором поставляется пакетный менеджер dub. При помощи него topleaked устанавливается одной командой:


    dub fetch topleaked

    В дальнейшем для запуска будем использовать команду:


    dub run topleaked -brelease-nobounds -- <filename> [<options>...]

    Чтобы не повторять dub run и аргумент компилятора brelease-nobounds можно скачать исходники с гитхаба и собрать запускаемый файл:


    dub build -brelease-nobounds

    В корне папки проекта появится запускаемый topleaked.


    Использование


    Возьмём простую C++ программу с утечкой памяти.


    #include <iostream>
    #include <assert.h>
    #include <unistd.h>
    
    class A {
        size_t val = 12345678910;
        virtual ~A(){}
    };
    
    int main() {
        for (size_t i =0; i < 1000000; i++) {
            new A();
        }
        std::cout << getpid() << std::endl;
        sleep(200);
    }

    Завершим её через kill -6 , чем получим дамп памяти. Теперь можно запустить topleaked и посмотреть на результаты

    ./toleaked -n10 leak.core

    Опция -n — размер топа, который нам нужен. Обычно имеют смысл значения от 10 до 200, в зависимости от того, как много "мусора" найдётся. Формат вывода по умолчанию — построчный топ в человекочитаемом виде.


    0x0000000000000000 : 1050347
    0x0000000000000021 : 1000003
    0x00000002dfdc1c3e : 1000000
    0x0000558087922d90 : 1000000
    0x0000000000000002 : 198
    0x0000000000000001 : 180
    0x00007f4247c6a000 : 164
    0x0000000000000008 : 160
    0x00007f4247c5c438 : 153
    0xffffffffffffffff : 141

    Пользы от него мало, разве что мы можем увидеть число 0x2dfdc1c3e, оно же 12345678910, встречающееся миллион раз. Уже этого могло бы хватить, но хочется большего. Для того, чтобы увидеть имена классов утекших объектов, можно отдать результат в gdb простым перенаправлением стандартного потока вывода на вход gdb с открытым файлом дампа. -ogdb — опция меняющая формат на понятный gdb.


    $ ./topleaked -n10 -ogdb /home/core/leak.1002.core | gdb leak /home/core/leak.1002.core
    ...<много текста от gdb при запуске>
    #0  0x00007f424784e6f4 in __GI___nanosleep (requested_time=requested_time@entry=0x7ffcfffedb50, remaining=remaining@entry=0x7ffcfffedb50) at ../sysdeps/unix/sysv/linux/nanosleep.c:28
    28      ../sysdeps/unix/sysv/linux/nanosleep.c: No such file or directory.
    (gdb) $1 = 1050347
    (gdb) 0x0:      Cannot access memory at address 0x0
    (gdb) No symbol matches 0x0000000000000000.
    (gdb) $2 = 1000003
    (gdb) 0x21:     Cannot access memory at address 0x21
    (gdb) No symbol matches 0x0000000000000021.
    (gdb) $3 = 1000000
    (gdb) 0x2dfdc1c3e:      Cannot access memory at address 0x2dfdc1c3e
    (gdb) No symbol matches 0x00000002dfdc1c3e.
    (gdb) $4 = 1000000
    (gdb) 0x558087922d90 <_ZTV1A+16>:       0x87721bfa
    (gdb) vtable for A + 16 in section .data.rel.ro of /home/g.smorkalov/dlang/topleaked/leak
    (gdb) $5 = 198
    (gdb) 0x2:      Cannot access memory at address 0x2
    (gdb) No symbol matches 0x0000000000000002.
    (gdb) $6 = 180
    (gdb) 0x1:      Cannot access memory at address 0x1
    (gdb) No symbol matches 0x0000000000000001.
    (gdb) $7 = 164
    (gdb) 0x7f4247c6a000:   0x47ae6000
    (gdb) No symbol matches 0x00007f4247c6a000.
    (gdb) $8 = 160
    (gdb) 0x8:      Cannot access memory at address 0x8
    (gdb) No symbol matches 0x0000000000000008.
    (gdb) $9 = 153
    (gdb) 0x7f4247c5c438 <_ZTVN10__cxxabiv120__si_class_type_infoE+16>:     0x47b79660
    (gdb) vtable for __cxxabiv1::__si_class_type_info + 16 in section .data.rel.ro of /usr/lib/x86_64-linux-gnu/libstdc++.so.6
    (gdb) $10 = 141
    (gdb) 0xffffffffffffffff:       Cannot access memory at address 0xffffffffffffffff
    (gdb) No symbol matches 0xffffffffffffffff.
    (gdb) quit

    Читать не очень просто, но возможно. Строки вида $4 = 1000000 отражают позицию в топе и количество найденных вхождений. Ниже идут результаты запуска x и info symbol для значения. Тут мы можем видеть, что миллион раз встречается vtable for A, что соответствует миллиону утекших объектов класса A.

    Для анализа части файла (если он слишком велик) добавлены опции offset и limit — начиная откуда и сколько байт читать.


    Результат


    Получившаяся утилита заметно быстрее скрипта. Всё ещё приходится подождать, но уже не в масштабах похода за чаем, а нескольких секунд, прежде чем на экране появится топ. Я совершенно уверен, что алгоритм можно значительно улучшить, а тяжёлые операции ввода и вывода заметно оптимизировать. Но это дело будущего развития, уже сейчас всё неплохо работает.


    Благодаря опции -ogdb и перенаправлению в gdb мы сразу получаем имена и значения, иногда даже номера строк, если повезло попасть на функцию.


    Очевидным, но весьма неожиданным, следствием лобового решения оказалась кроссплатформенность. Да, про порядок байт topleaked не знает, но так как он не разбирает формат файла, а просто читает файл побайтово, он может применяться на Windows или любой системе с любым форматом дампа памяти. Требуется только чтобы данные были выровнены внутри файла.


    Язык D


    Хотелось бы отдельно отметить опыт разработки подобной программы на D. Первая работающая версия была написана за считанные минуты. Надо сказать, что до сих пор основной алгоритм занимает всего три строки:


    auto all = input.sort;
    ValCount[] res = new ValCount[min(all.length, maxSize)];
    return all.group.map!((p) => ValCount(p[0],p[1]))
              .topNCopy!"a.count>b.count"(res, Yes.sortOutput);
    

    Всё благодаря ленивым диапазонам и наличию готовых алгоритмов над ними в стандартной библиотеке, таких как group и topN.


    Уже позже поверх нарос разбор аргументов командной строки, форматирование вывода и всё то, что хоть и многословно, но тоже пишется быстро. Разве что чтение файла получилось каким-то странным, выбивающимся из общего стиля.


    В последней на данный момент версии появился флаг --find для обычного поиска подстроки, который вообще не имеет отношения к частоте. Из-за этой мелочи код ещё заметно увеличился в размерах, но с большими шансами фича будет удалена и код вернётся в своё исходное простое состояние.


    Итого, по трудозатратам сравнимо со скриптовыми языками, а по производительности значительно лучше. Потенциально можно довести до предельно возможного, так как одинаковый код на C и D будет работать одинаково с одной и той же скоростью.


    Показания и противопоказания к применению


    • Topleaked нужен для поиска утечек, когда есть только дамп памяти текущего процесса, но нет возможности воспроизвести под санитайзером.
    • Это не ещё один valgrind и не претендует на динамический анализ.
    • Интересным исключением из предыдущего замечания могут быть временные утечки. То есть память освобождается, но слишком поздно (при остановке сервера, например). Тогда можно снять дамп в нужный момент и проанализировать. Valgrind или asan, работающие в момент завершения процесса, с такой задачей справляются хуже.
    • Только 64-битный режим. Поддержка других битностей и порядка байт отложена на будущее.

    Известные проблемы


    При тестировании использовались файлы дампов, полученные посылкой сигнала процессу. С такими файлами всё работает хорошо. При снятии дампа командой gcore пишутся какие-то другие ELF заголовки и происходит смещение на неопределённое количество байт. То есть значения указателей не выровнены на 8 в файле, поэтому получаются бессмысленные результаты. Для решения введена опция offset — читать файл не сначала, а сместившись на offset байт (обычно 4).
    Для решения планирую добавить чтение результата objdump -s из stdin. Ну или подключить libelf и разобрать его самостоятельно, но это убьёт “кроссплатформенность”, а stdout это более гибко и ближе к unix way.


    Ссылки


    Проект на гитхабе
    Компиляторы D
    Исходный вопрос на stackoverflow

    Средняя зарплата в IT

    120 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 5 953 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 6

      +1
      полезная вещь
        +3
        Прикольная идея поиска виноватых по количеству их копий.
          0
          Это ооочень старая идея, ЕМНИП, впервые я прочел о ней в блоге Реймонда Чена примерно 15 лет назад, но, я думаю, такая техника поиска утечек существовала и гораздо ранее этой публикации :)
          0

          Чем вам не угодил valgrind?

            +2
            Возможно, я чего-то о нём не знаю, но как без возможности воспроизвести проблему тестом, воспользоватся valgrind'ом? Когда есть, что запустить, чтобы воспроизвести утечку, valgrind гораздо лучше, но что делать, когда она никак не воспроизводится? Что он может сделать с одним лишь дампом?
              +2

              Ммм… Согласен, как-то невнимательно прочитал статью в первый раз.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое