Память течёт только на проде: как ловить то, что не воспроизводится
Процесс на боевом сервере медленно, но неумолимо съедает память — RSS растёт часами или сутками, пока не прилетает OOM killer или сервис не начинает захлёбываться в свопе. Вы поднимаете точную копию окружения локально, гоняете нагрузочные тесты на стейджинге, повторяете те же запросы — и ничего. Память стабильна. Проблема существует только там, где её труднее всего изучать — на работающем проде, который нельзя просто взять и остановить. Дальше разберём, почему так происходит и как расследовать утечку, не дожидаясь, пока она станет воспроизводимой.
Содержание
- Почему прод и тест — это разные системы, а не разные окружения одной системы
- Почему попытки воспроизвести локально почти всегда проваливаются
- Правильный подход: не копировать прод, а исследовать сам прод
- Как снять снимок памяти, не останавливая сервис
- Сравнение двух снимков во времени — вот где находится ответ
- Частая находка: утечку триггерит редкое сочетание параметров запроса
Почему прод и тест — это разные системы, а не разные окружения одной системы
Первая ошибка — считать, что прод и стейджинг отличаются только конфигом и масштабом. На деле это разные системы с точки зрения паттернов использования памяти, даже если код и версии рантайма идентичны.
На проде одновременно происходит то, что почти невозможно честно смоделировать синтетическим тестом:
- Реальное распределение входных данных. Синтетические тесты гоняют однородный трафик — одни и те же payload'ы и размеры запросов. Прод получает длинный хвост: аномально большие JSON, редкие комбинации полей, битые заголовки, которые кто-то всё равно смог отправить.
- Реальная последовательность действий пользователей. Утечка может триггериться не одним запросом, а цепочкой: пользователь начал сессию, не закрыл её, вернулся через сорок минут — и объект, который должен был освободиться по таймауту, остаётся висеть, потому что таймаут отменился раньше срока. Синтетический скрипт с постоянным интервалом между запросами такую цепочку не создаёт.
- Реальную конкурентность. На стейджинге редко гоняют сотни одновременных соединений с распределением по времени прибытия пачками, а не равномерно. Часть утечек — это гонки данных, проявляющиеся только при определённом уровне параллелизма: условие, срабатывающее раз на десять тысяч вызовов, на тесте с двадцатью потоками может не сработать никогда.
- Долгий аптайм. Утечка в 200 КБ за час — ничто в двухчасовом нагрузочном тесте и катастрофа за две недели работы прод-инстанса. Тестовые сценарии физически не длятся достаточно долго, чтобы медленный линейный рост стал заметен на фоне шума.
Важно принять это как факт, а не как временную неудачу в попытках повторить баг. Смысл не в том, чтобы "ещё постараться" и воссоздать точные условия прода — часто это невозможно в принципе, потому что прод формируется живыми людьми, а не скриптом. Мы уже разбирали общий разбор утечек памяти на сервере — здесь сфокусируемся именно на непроизводимом случае.
Почему попытки воспроизвести локально почти всегда проваливаются
Если вы уже потратили день-два на попытки повторить утечку локально — вот типичные причины, почему это не сработает, даже если очень стараться:
- Вы тестируете код, а не рантайм-окружение целиком. Локально у вас может стоять другая версия сборщика мусора, другой аллокатор (glibc malloc vs jemalloc vs tcmalloc — поведение фрагментации у них разное). Иногда это вообще не утечка, а фрагментация кучи, которая на одном аллокаторе компактится, а на другом — нет.
- Вы не воспроизводите фон. На проде рядом с вашим процессом работают логирование, метрики, соседние контейнеры на той же ноде. Память, которая "должна" быть освобождена ядром обратно процессу, может не возвращаться из-за фрагментации на уровне ОС — это тоже выглядит как утечка, но воспроизводится только при похожей плотности соседей.
- У вас недостаточно длинный прогон. Тест на час не покажет утечку с накоплением 50 МБ в сутки — это ниже уровня шума GC-пауз и обычных колебаний.
- Вы упрощаете входные данные ради удобства теста. Реальные пользователи присылают Unicode с сюрпризами, вложенные структуры на пределе глубины, повторяющиеся ключи в JSON — именно такие крайние случаи чаще всего и держат ссылку на объект, которую забыли обнулить.
Если после разумных попыток (не бесконечных) воспроизвести локально не получилось — это не признак того, что вы делаете что-то не так. Это сигнал сменить стратегию: с "давайте повторим баг" на "давайте изучим то место, где баг уже есть".
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПравильный подход: не копировать прод, а исследовать сам прод
Ключевая смена оптики: если баг живёт только на проде — идите на прод, а не пытайтесь построить его миниатюрную копию. Современные инструменты для большинства языковых рантаймов (компилируемых и с виртуальными машинами, интерпретируемых с обвязкой на С-расширениях) умеют снимать снимок состояния памяти работающего процесса без его остановки и без заметного влияния на обслуживание запросов. Это принципиально меняет экономику расследования: вам не нужно останавливать сервис, не нужно ждать окна на мейнтенанс, не нужно рисковать даунтаймом ради диагностики.
Общий принцип работы таких инструментов один и тот же независимо от языка:
- Они подключаются к уже запущенному процессу (по PID, через отладочный сокет, через встроенный HTTP-эндпоинт для диагностики или через сигнал ОС), а не запускают его заново с профилировщиком "внутри".
- Снимок делается либо мгновенно (копия страниц памяти через механизм ОС вроде copy-on-write), либо через встроенный в рантайм экспорт состояния кучи, который на короткое время приостанавливает только сборщик мусора (доли секунды), а не весь процесс обработки запросов.
- Результат — файл (дамп кучи, снимок аллокаций, sampling-профиль), который потом анализируется офлайн отдельным инструментом, уже без связи с боевым процессом.
Практически это означает, что вы можете снять диагностику с прод-инстанса под реальной нагрузкой и не сломать сервис — если делать это разумно (не на самом нагруженном инстансе из десяти, не в момент пикового трафика, с запасом по памяти на ноде для временного дампа).
Предосторожности перед снятием дампа на живом проде:
# убедиться, что на диске есть место под дамп
df -h /var/tmp
# убедиться, что у процесса есть запас по памяти
# (снятие дампа может на короткое время увеличить потребление)
free -h
# сузить круг: снимать с одного инстанса из пула, не со всех сразу
# если сервис за балансировщиком — временно вывести инстанс из ротации
# (health-check down), снять дамп, вернуть обратно
Если это отдельный VPS без балансировщика — хотя бы посмотрите текущую загрузку перед снятием дампа, чтобы не наложить диагностику поверх и без того нагруженного момента (uptime, vmstat 1 5).
Как снять снимок памяти, не останавливая сервис
Не буду называть конкретный инструмент "единственно верным" для вашего стека — для разных языков и платформ существуют свои механизмы, и лучше свериться с документацией именно вашего рантайма (версии за пару лет успевают поменять флаги и способ подключения). Но сама последовательность действий почти всегда одна:
- Определите PID процесса на проде.
ps aux | grep -i <имя-процесса>
# или, если процесс запущен через systemd
systemctl show <имя-юнита> -p MainPID
- Проверьте базовые метрики памяти процесса до снятия дампа — это точка отсчёта, с которой вы будете сравнивать:
cat /proc/<PID>/status | grep -E 'VmRSS|VmSize|VmSwap'
pmap -x <PID> | tail -1
- Снимите дамп/профиль через штатный механизм рантайма. Большинство современных рантаймов дают один из вариантов: управляющий сигнал, который триггерит запись дампа кучи на диск; встроенный диагностический эндпоинт (локальный HTTP или Unix-сокет); либо CLI-утилита из поставки рантайма, подключающаяся к процессу по PID как отладчик — без его остановки.
Если штатного механизма нет — крайний, но рабочий вариант: снять дамп на уровне ОС без остановки процесса:
gcore -o /var/tmp/dump_$(date +%s) <PID>
gcore берёт снимок адресного пространства процесса через ptrace, процесс на секунду-две приостанавливается ядром на время копирования, но не завершается и не перезапускается — после снятия дампа он продолжает работать как ни в чём не бывало. Для тяжело нагруженных процессов эта пауза заметна, поэтому это резервный вариант, а не первый выбор.
- Сохраните дамп с явным таймстампом в имени — вам понадобится минимум два снимка с разницей во времени, и без внятного именования вы их перепутаете:
mv /var/tmp/dump_1234 /var/tmp/heapdump-prod-web1-2026-08-20-1400.dump
- Скопируйте дамп с прод-сервера для офлайн-анализа, не анализируйте его прямо на боевой машине — сам анализ бывает ресурсоёмким:
scp prod-web1:/var/tmp/heapdump-prod-web1-2026-08-20-1400.dump ./
Сравнение двух снимков во времени — вот где находится ответ
Один снимок памяти сам по себе малополезен — он показывает состояние в моменте, но не говорит, что растёт. Ответ даёт дифференциальный анализ: два дампа с разницей в несколько часов (или дней, если утечка медленная), сравненные между собой.
Что именно сравнивать:
- Количество живых объектов по типам (или по типам структур данных, если речь о некучевых утечках — например, о накоплении записей в внутреннем кэше или очереди). Если между снимками T1 и T2 количество объектов одного конкретного типа выросло на порядок сильнее остальных — это ваш подозреваемый.
- Суммарный удерживаемый размер (retained size), а не просто количество штук — иногда растёт не количество объектов, а размер одного и того же объекта (например, никогда не усекаемый буфер или список, в который только добавляют).
- Путь удержания (что именно держит ссылку) — большинство инструментов анализа дампов строят граф "кто на кого ссылается" и показывают путь от корня (глобальная переменная, статический кэш, обработчик события, который забыли отписать) до объекта-виновника. Это и есть настоящая находка — не "утекает тип X", а "тип X утекает, потому что на него держит ссылку кэш Y, из которого записи никогда не удаляются".
Таблица того, на что чаще всего указывает разница между снимками:
| Что растёт между T1 и T2 | Частая причина |
|---|---|
| Количество объектов одного бизнес-типа (сессии, соединения, задачи) | Забыли снять подписку/обработчик или закрыть ресурс в одном из веток кода |
| Размер одной коллекции (список, словарь, кэш) | Нет верхней границы или TTL у кэша, либо ключ кэша построен так, что почти каждый запрос создаёт новый уникальный ключ |
| Количество отложенных задач/таймеров/промисов | Где-то создаётся таймер/задача, которая должна была быть отменена, но условие отмены не выполняется на одном из путей |
| Буферы ввода-вывода, строки | Не закрытые соединения или потоки, накопление недочитанных данных в буфере при определённом типе клиента |
Если у вас нет возможности снять более одного дампа за разумное время (утечка растёт за недели, а не за часы) — снимайте профиль sampling-типа (периодическую выборку аллокаций) с интервалом в несколько часов на протяжении суток. Это даёт представление о тренде без ожидания критического уровня памяти. Тем, кто уже сталкивался с похожей задачей "поймать до падения", может пригодиться разбор о том, как ловить утечку за неделю до падения.
Частая находка: утечку триггерит редкое сочетание параметров запроса
После десятков подобных расследований закономерность видна отчётливо: чаще всего утечка на проде оказывается не общей проблемой архитектуры, а конкретным путём в коде, который срабатывает при редком сочетании условий — редком ровно настолько, чтобы не встретиться ни в одном синтетическом тесте, но неизбежно встречающемся раз в несколько тысяч запросов на реальном трафике.
Типичные примеры такого рода находок:
- Обработчик ошибки для конкретного типа исключения не освобождает ресурс, который освобождается во всех остальных ветках, — а это исключение возникает у одного запроса из десятков тысяч (например, при определённой битой кодировке во входных данных), которого на тесте просто не сгенерировали.
- Кэш ключуется по значению, которое теоретически должно быть из ограниченного набора, но на практике иногда приходит "грязным" (лишний пробел, другой регистр, случайный UUID вместо enum) — и каждый такой запрос создаёт новую запись, которая никогда не вытеснится, потому что политика вытеснения кэша рассчитана на конечное число уникальных ключей.
- Клиент разорвал соединение именно в узком окне между началом обработки запроса и записью в лог — и "подвешенный" объект контекста запроса, который в норме освобождается при штатном завершении, при этом тайминге не освобождается.
- Retry-логика клиента или прокси при определённом коде ошибки создаёт дублирующиеся долгоживущие задачи на сервере, потому что дедупликация retry рассчитана на заголовок, которого этот конкретный клиент не присылает.
Общее у всех этих случаев: воспроизвести их синтетическим тестом с равномерным трафиком практически нереально, потому что вероятность попадания в нужную комбинацию по-настоящему низкая — но при миллионах реальных запросов в сутки "низкая вероятность" превращается в "происходит регулярно". Именно поэтому анализ живого прода — не компромиссный, а единственно осмысленный метод в таких случаях: вы ищете не общую закономерность, а конкретную редкую ветку, и увидеть её можно только там, где она реально срабатывает.
Когда дифференциальный анализ дампов указал на конкретный тип или структуру — следующий шаг практичнее делать не через новые эксперименты с памятью, а через прицельный код-ревью всех мест, где создаются объекты этого типа, с вопросом "во всех ли ветках выполняется освобождение". Самое дорогое в этом расследовании — не найти причину в коде, а понять, на какой тип объектов вообще смотреть; а параллельно стоит проверить логи за тот же период — редкая ветка кода обычно оставляет и свой редкий след в логах.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли снимать дамп памяти на проде без риска уронить сервис?
Да, при разумных предосторожностях: место на диске под файл дампа, запас по памяти на ноде, снятие дампа с одного инстанса из пула, а не со всех разом, и в идеале — временный вывод инстанса из ротации балансировщика. Полная остановка процесса не требуется.
Что делать, если снять второй дамп негде — утечка растёт неделями?
Используйте sampling-профиль аллокаций вместо полного дампа кучи: он даёт представление о тренде без ожидания критического уровня памяти.
А если утечка проявляется на одном конкретном инстансе из десяти одинаковых?
Это сигнал в пользу редкого сочетания условий на трафике — вероятно, именно на этот инстанс балансировщик направляет клиентов с особым паттерном запросов. Снимайте дамп с него, не с "типичного".
Стоит ли продолжать пытаться воспроизвести баг локально параллельно с анализом прода?
Можно держать это как фон, но не как условие для начала анализа дампов.
Нужно ли особое ПО на сервере заранее?
Обычно нет — у большинства рантаймов механизм диагностики уже встроен, важно только знать правильный флаг или сигнал для конкретной версии. Проверить это стоит заранее, до инцидента.
Что если после сравнения двух дампов ничего явно не растёт?
Либо интервал между снимками был слишком коротким, либо утечка не в куче, а в других ресурсах — файловых дескрипторах, сокетах. Проверьте /proc/<PID>/status (VmSwap) и число открытых дескрипторов (ls /proc/<PID>/fd | wc -l) отдельно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →