Долгоживущий процесс на сервере имеет неприятное свойство: он может месяцами работать без единой ошибки в логах, а потом внезапно выясняется, что за сутки он съедает ещё двадцать мегабайт памяти, и через неделю планировщик 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" от глобальных пулов допустимы, а вот десятки килобайт потерянного на прогон - повод не выкатывать релиз.
Пошаговый план администратора при подозрении на утечку
Проверенный на практике порядок действий:
- Зафиксировать симптом: собрать RSS и VmData процесса за 24 часа с шагом 5 минут и убедиться в монотонном росте без плато;
- Снять карту памяти командой pmap -x PID и сводку smem, определить, растут ли анонимные сегменты (куча и арены) или файловые отображения;
- Проверить VmData в /proc/PID/status - рост кучи подтверждает утечку выделений;
- Воспроизвести на стенде под valgrind --leak-check=full --show-leak-kinds=all с типичной нагрузкой не менее часа;
- Отправить разработчикам отчёт с loss records и стеками; на стенде дополнительно прогнать massif для подтверждения динамики;
- Как временный костыль в продакшене настроить перезапуск по лимиту памяти, например 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.