Долгоживущий процесс на сервере имеет неприятное свойство: он может месяцами работать без единой ошибки в логах, а потом внезапно выясняется, что за сутки он съедает ещё двадцать мегабайт памяти, и через неделю планировщик OOM Killer придёт за самым "прожорливым". Администратор смотрит на график RSS, который упорно ползёт вверх, и задаёт естественный вопрос: где течёт и как это доказать разработчикам. Статья разбирает практический цикл диагностики утечки памяти на живом Linux-сервере: от наблюдения за RSS и чтения /proc/PID/smaps через pmap до прогона под valgrind с полной проверкой утечек и профилированием кучи через massif. Все команды приведены с реальными выводами и построчным разбором.

Откуда берётся рост RSS и почему это не всегда утечка

RSS (Resident Set Size) показывает, сколько физических страниц памяти сейчас занято процессом. Смотреть его проще всего так:

$ ps -o pid,ppid,rss,vsz,etime,cmd -p 24119
    PID    PPID   RSS      VSZ     ELAPSED CMD
  24119       1 184320 1215480  12-04:11:07 /usr/sbin/api-worker --config /etc/api.ini

Здесь RSS равен 184320 КБ, то есть около 180 МБ. VSZ больше гигабайта, но пугаться не стоит: виртуальный размер включает зарезервированные, но не выделенные диапазоны и отображённые файлы. Тревожный признак именно в динамике: если записывать RSS каждые пять минут (while true; do ps -o rss= -p 24119 >> /var/log/rss.log; sleep 300; done), и линия монотонно растёт сутками без плато, вероятность утечки высока.

Однако рост RSS бывает и без утечки в классическом смысле. Типичные причины: фрагментация кучи glibc (malloc не возвращает память ядру при free из-за trim threshold), распухшие кэши и пулы соединений в самом приложении, файловые отображения mmap, которые ядро честно покрывает страничным кэшем. Поэтому прежде чем тащить процесс в valgrind, стоит понять, какие именно сегменты растут. Для этого служат pmap и /proc/PID/smaps.

Снимок карты памяти через pmap и детальный разбор smaps

Команда pmap -x PID показывает карту адресного пространства в расширенном формате:

$ pmap -x 24119 | head -20
Address           Kbytes     RSS   Dirty Mode  Mapping
000055f1c3a20000    1212     988       0 r-x-- api-worker
000055f1c3d4f000       8       8       8 r---- api-worker
000055f1c3d51000       4       4       4 rw--- api-worker
000055f1c3d52000   87244   87116   87116 rw---   [ anon ]
00007f2c50000000   65536   65536   65536 rw---   [ anon ]
00007f2c54000000   65536   60104   60104 rw---   [ anon ]
00007f2cd67e1000   40960       8       0 r-x-- libc-2.31.so
...
total          1215480  184312  171244

Разбор по столбцам: Address - начало диапазона в виртуальном адресном пространстве; Kbytes - виртуальный размер сегмента; RSS - сколько реально в физической памяти; Dirty - страницы, которые процесс изменил и которые не вытеснишь без записи; Mapping - откуда сегмент взялся. В примере видно большую анонимную область [ anon ] сразу после конца бинарника: это куча, выросшая до 87 МБ. Два сегмента по 64 МБ с адресов, кратных 0x4000000, - типичные арены glibc malloc для многопоточных процессов (создаются по mmap размером 64 МБ на арену). Если именно они растут, проблема почти наверняка в выделениях malloc/new внутри приложения.

Для более точной картины читают /proc/PID/smaps - там у каждого сегмента детальная статистика:

$ awk "/^7f2c50000000/,/^$/" /proc/24119/smaps
7f2c50000000-7f2c54000000 rw-p 00000000 00:00 0
Size:              65536 kB
Rss:               65536 kB
Pss:               65536 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:     65536 kB
Referenced:        65536 kB
Anonymous:         65536 kB
AnonHugePages:         0 kB

Ключевые строки: Private_Dirty = 65536 kB - вся арена грязная и приватная, ядро не может её вернуть; Anonymous - память без файловой подложки, то есть результат malloc. Растущие сегменты с растущим Private_Dirty и Anonymous - почти математическое доказательство утечки кучи. Для сводной статистики удобен smem:

$ smem -r -P "api-worker"
  PID User     Command     Swap      USS      PSS      RSS
24119 root     /usr/sbin/api-work  0  183.9M  183.9M  184.3M

USS (Unique Set Size) - память, которую не разделяет ни один другой процесс; её рост от перезапуска к перезапуску и есть та самая "эффективная" утечка. PSS у smem учитывает разделяемые библиотеки пропорционально, поэтому для одиночного сервиса USS и PSS часто совпадают.

Подтверждение утечки через valgrind с полной проверкой

pmap говорит "где", но не говорит "кто выделил". Тут в игру вступает valgrind. Серьёзный нюанс: запускать продакшен-процесс под valgrind нельзя, он замедляет исполнение в 20-50 раз, поэтому диагностику делают на стенде с порцией тех же запросов. Канонический запуск:

valgrind --tool=memcheck \
  --leak-check=full \
  --show-leak-kinds=all \
  --track-origins=yes \
  --log-file=/tmp/vg-%p.log \
  /usr/sbin/api-worker --config /etc/api.ini

Опции: --leak-check=full заставляет memcheck при завершении процесса искать каждый недостижимый блок; --show-leak-kinds=all печатает все категории (definite, indirect, possible, still reachable), а не только умолчальные; --track-origins=yes дорого стоит, но показывает, где родилась неинициализированная память; %p в имени лога подставляет PID. Типичный итог работы:

==24119== LEAK SUMMARY:
==24119==    definitely lost: 14,512,880 bytes in 90,705 blocks
==24119==    indirectly lost: 3,118,144 bytes in 97,442 blocks
==24119==      possibly lost: 0 bytes in 0 blocks
==24119==    still reachable: 2,048,576 bytes in 128 blocks

"definitely lost" 14,5 МБ в 90 тысяч блоков - утечка по самый не хочу: на блок ни один указатель не ссылается, память физически невозвратна до конца жизни процесса. "indirectly lost" обычно дети потерянных корней в деревьях структур. "still reachable" на размер в районе 2 МБ часто глобальные кэши и пул буферов, их можно игнорировать. А вот как выглядит виновное место:

==24119== 160 bytes in 1 blocks are definitely lost in loss record 12,042
==24119==    at 0x4C2BBAF: malloc (vg_replace_malloc.c:299)
==24119==    by 0x408F21: session_create (session.c:118)
==24119==    by 0x40A133: handle_request (router.c:342)
==24119==    by 0x409C7E: worker_loop (worker.c:87)

Стек читается снизу вверх: worker_loop вызвал handle_request, тот создал сессию, malloc на строке 118 вернул 160 байт, и ни один путь больше не ведёт к этому блоку. Честно говоря, половина всех реальных утечек выглядит именно так: объект сессии, дескриптор соединения или контекст запроса, который забыли освободить на одной из веток ошибок. Если бинарник собран без отладочных символов, в стеке будут только адреса, поэтому для диагностики держат пакет с debuginfo той же сборки.

Профилирование кучи через massif и работа с живым процессом

Когда перезапуск под valgrind невозможен вообще, есть два обходных пути. Первый - massif, профилировщик кучи из того же комплекта:

valgrind --tool=massif --time-unit=ms --max-snapshots=200 \
  --massif-out-file=/tmp/massif.out /usr/sbin/api-worker
ms_print /tmp/massif.out | less

massif периодически делает снимки кучи, и график по времени показывает, какая функция-выделитель растит память. В отчёте виден "peak snapshot" и дерево аллокаций с процентами:

 99.31% (48,200,112B) (heap allocation functions) malloc/new/new[]
 ->86.44% (41,955,328B) 0x408F21: session_create (session.c:118)
 | ->86.44% (41,955,328B) 0x40A133: handle_request (router.c:342)

Если 86% прироста идёт из одной строки, поиск считай закончен. Опция --time-unit=ms удобна для коротких прогонов; для длинных лучше --time-unit=B, единицы времени в байтах выделенной памяти.

Второй путь - исследовать живой процесс через gdb без перезапуска:

gdb -p 24119 -batch \
  -ex "set pagination off" \
  -ex "info proc mappings" \
  -ex "dump binary memory /tmp/heap.bin 0x55f1c3d52000 0x55f1c9285000" \
  -ex detach

Дамп кучи затем можно прогонять strings-ом и искать характерные строки утёкших объектов: идентификаторы сессий, URL, ключи кэша. По сути это "биопсия" памяти на живом пациенте: процесс на время останавливается ptrace-ом, поэтому на нагруженной машине делайте это в часы низкой нагрузки. Компактная и безопасная альтернатива - /proc/24119/smaps_rollup, который даёт сводку по всему процессу, и grep VmRSS /proc/24119/status вместе с VmData, где VmData - размер сегмента данных и кучи; именно VmData растёт при утечках malloc, а VmStk и VmExe остаются почти неизменными.

Типичные виновники утечек в серверном коде

Опыт показывает, что в C и C++ сервисах утечки концентрируются вокруг предсказуемых мест. Первый номер в хит-параде - ранний выход из функции по ошибке, когда три выделенных ресурса освобождаются, а четвёртый, выделенный последним, забывается. Второй - коллекции: элемент вставили в хэш-таблицу или очередь, а удаление реализовали только для счастливого пути. Третий - библиотечные контексты: SSL_CTX, курсоры базы данных, XML-документы, у которых освобождение парное (SSL_free после SSL_new), и пропуск пары не виден без профилировщика. Четвёртый - кэши без политики вытеснения: технически память не потеряна, valgrind покажет "still reachable", но RSS растёт так же уверенно. Вот канонический пример ветки ошибки:

session_t *session_create(const request_t *req)
{
    session_t *s = malloc(sizeof(*s));          // блок 160 байт
    if (!s) return NULL;
    s->buf = malloc(BUF_SIZE);                   // второй блок
    if (!s->buf) { free(s); return NULL; }
    if (parse_headers(req, s->buf) != 0)
        return NULL;                             // утечка: ни s, ни s->buf не освобождены
    return s;
}

Именно такой фрагмент и породил 90 тысяч потерянных блоков из отчёта выше: каждая сломанная сессия оставляла по два блока в куче. Лечится либо цепочкой goto cleanup, либо RAII-обёртками в C++, либо атрибутом __attribute__((cleanup)). После исправления стоит перепрогнать valgrind и добиться нуля в строке "definitely lost": промежуточные "still reachable" от глобальных пулов допустимы, а вот десятки килобайт потерянного на прогон - повод не выкатывать релиз.

Пошаговый план администратора при подозрении на утечку

Проверенный на практике порядок действий:

  1. Зафиксировать симптом: собрать RSS и VmData процесса за 24 часа с шагом 5 минут и убедиться в монотонном росте без плато;
  2. Снять карту памяти командой pmap -x PID и сводку smem, определить, растут ли анонимные сегменты (куча и арены) или файловые отображения;
  3. Проверить VmData в /proc/PID/status - рост кучи подтверждает утечку выделений;
  4. Воспроизвести на стенде под valgrind --leak-check=full --show-leak-kinds=all с типичной нагрузкой не менее часа;
  5. Отправить разработчикам отчёт с loss records и стеками; на стенде дополнительно прогнать massif для подтверждения динамики;
  6. Как временный костыль в продакшене настроить перезапуск по лимиту памяти, например MemoryMax=2G в сервисе systemd вместе с Restart=always, пока выходит исправление.

Профилактика и технические меры против утечек

Полный набор мер делится на три уровня. На уровне кода: санитайзеры в CI, сборка тестов с -fsanitize=address,leak, обязательный прогон valgrind на регрессионных сценариях, правило одного владельца освобождения для каждого буфера. На уровне glibc: уменьшение числа арен через переменную MALLOC_ARENA_MAX=2 для многопоточных сервисов (по умолчанию арен до 8 на ядро, и каждая держит свои 64 МБ), настройка MALLOC_TRIM_THRESHOLD_ и MALLOC_MMAP_THRESHOLD_, если фрагментация путает картину. На уровне эксплуатации: мониторинг не только RSS, но и VmData и количества анонимных страниц; алерт на скорость прироста, например больше 50 МБ в сутки при стабильной нагрузке; регулярная выгрузка smem в систему мониторинга.

Ещё одна рабочая привычка - периодическое снятие smaps с продакшен-процессов и архивация: через месяц по двум снимкам можно простым diff увидеть, какой сегмент вырос, и сопоставить это с релизом. Арифметика неумолима: утечка в 100 байт на запрос при миллионе запросов в сутки даёт около 95 МБ ежедневного прироста, и рано или поздно она всплывёт. Гораздо дешевле её найти, пока график RSS ещё влезает в один экран.

Утечка памяти - не приговор и не загадка: это конкретный malloc без пары, у которого есть адрес, стек и история. pmap покажет, где болит, valgrind назовёт виновника по имени, massif подтвердит динамику, а gdb и /proc позволят работать прямо на живом процессе. Дальше остаётся самое простое - попросить разработчиков добавить один free в нужное место и настроить мониторинг, чтобы в следующий раз об этом узнал график, а не OOM Killer.