MAATRIX / Блог / Утечка памяти в приложении: как поймать за неделю до падения

Утечка памяти в приложении: как поймать за неделю до падения

MAATRIX

Приложение падает раз в несколько дней, вы перезапускаете его — и всё снова работает неделю или две, а потом повторяется. Это не случайность и не «глюк» — это утечка памяти, и её видно на графике мониторинга задолго до краха, если знать, куда смотреть. Разберём, как отличить утечку от нормальной работы по форме графика, локализовать виновника без специфичных для языка инструментов и выбрать между временной затычкой и реальным фиксом.

Как это выглядит на графике мониторинга

Первый и главный вопрос — не «сколько памяти занято сейчас», а «как меняется потребление во времени». Здоровое приложение потребляет память волнами: график идёт вверх на пике нагрузки, вниз в затишье, и колеблется вокруг некоторого среднего уровня — условно, между 40% и 60% выделенного лимита в зависимости от трафика. Это нормально, даже если амплитуда колебаний выглядит пугающе на неопытный взгляд.

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

Второй диагностический признак — цикличность падений. Если сервис умирает не в случайные моменты, а с более-менее постоянным интервалом (условно, раз в 4-6 дней, если у вас именно такой паттерн) — это почти наверняка означает, что процесс каждый раз доходит до одного и того же потолка памяти с одной и той же скоростью накопления. Совпадение интервала между падениями — сильная улика в пользу утечки, а не случайного всплеска нагрузки.

# быстрый снимок текущего состояния, чтобы понять точку старта
free -h
ps aux --sort=-%mem | head -15

Но один снимок ничего не докажет — нужен именно график за дни, а не команда, выполненная один раз. Если у вас ещё нет накопительного мониторинга, это стоит исправить в первую очередь: без истории потребления по каждому процессу вы всегда будете реагировать постфактум, когда OOM killer уже выбрал жертву. Разворачивается это не за один день, поэтому лучше настроить Grafana и Prometheus на сервере заранее, а не после первого инцидента.

Почему это можно поймать за неделю, а не ждать падения

Ключевая идея профилактического подхода: утечка — это не бинарное состояние «есть или нет», а тренд с предсказуемой траекторией. Если процесс стабильно прибавляет условные N мегабайт в час, вы можете экстраполировать график вперёд и заранее посчитать, когда он упрётся в лимит контейнера, cgroup или физическую память сервера. Это будет ориентир, а не точная дата — реальная скорость роста зависит от нагрузки, но сам факт монотонного роста уже даёт запас времени на реакцию.

Практическая польза в том, что вы действуете не в панике посреди ночи после алерта «сервис недоступен», а спокойно, в рабочее время, с полной картиной перед глазами: успеваете снять дамп памяти, пока процесс ещё жив, проверить гипотезу на staging и выкатить фикс до очередного цикла падения.

Настройте алерт не только на абсолютное значение («памяти занято больше 90%»), но и на скорость роста — на многих системах мониторинга можно построить правило по производной метрики за окно времени. Алерт вида «потребление процесса X выросло более чем на 15% за последние 6 часов без снижения» ловит утечку на раннем этапе, когда абсолютные цифры ещё не выглядят угрожающе, — это полезнее статического порога, который срабатывает уже тогда, когда падение практически неизбежно.

Второй приём — вести журнал перезапусков: фиксируйте время каждого падения или ручного рестарта хотя бы в текстовом файле. Через два-три цикла появится эмпирическая оценка интервала между падениями, а значит ориентир, сколько дней есть до следующего инцидента. Если сервис регулярно требует диагностики в момент, когда он уже начал деградировать, полезно иметь под рукой мониторинг доступности сайта, который сообщит о проблеме раньше пользователей.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как локализовать: какой процесс и какой именно ресурс течёт

Когда тренд подтверждён, следующий шаг — понять, какой конкретно процесс виноват, и что именно он копит: саму память процесса (RSS), файловые дескрипторы, сетевые соединения или память ядра под конкретную подсистему. Общий подход — не привязанный к языку — такой.

Начните с ранжирования процессов по памяти в динамике, а не разово:

# снимок раз в интервал, чтобы увидеть рост, а не статику
watch -n 60 'ps aux --sort=-%mem | head -15'

# то же самое, но с записью в файл для последующего анализа
while true; do
  date >> mem_log.txt
  ps aux --sort=-%mem | head -10 >> mem_log.txt
  sleep 300
done

Так вы получаете временной ряд, а не точку — это нужно, чтобы отличить процесс, который просто много ест под нагрузкой, от процесса, который непрерывно растёт независимо от неё. Если у вас несколько инстансов одного сервиса (воркеров), сравнивайте их между собой: если один растёт заметно быстрее остальных при равномерном распределении запросов — это указывает на утечку, привязанную к конкретному соединению или сессии, которая осела именно в этом процессе.

Отдельно проверьте, не растёт ли число открытых файловых дескрипторов или сетевых соединений — это частый спутник утечки памяти, а иногда и её первопричина:

# количество открытых файловых дескрипторов у процесса
ls -1 /proc/<PID>/fd | wc -l

# сетевые соединения, которые держит процесс
ss -tnp | grep <PID>

# то же в динамике, чтобы увидеть рост, а не статичное число
watch -n 60 'ls -1 /proc/<PID>/fd | wc -l'

Если это число тоже монотонно растёт, ищите причину именно там: соединения, которые открываются, но не закрываются, — почти всегда более локализуемая проблема, чем расплывчатая «утечка в коде».

Для более глубокого профилирования у каждого языка и рантайма свой инструментарий: у одних есть встроенные снимки кучи, у других — сторонние профилировщики памяти, у третьих — расширения для трассировки аллокаций. Общий принцип одинаков для всех: снять два снимка состояния памяти с разницей в несколько часов (или дней, если утечка медленная) под сопоставимой нагрузкой и сравнить разницу — именно в diff видно, какие объекты, буферы или структуры данных накапливаются, а не освобождаются. Одного снимка недостаточно — как и с графиком памяти, важна динамика между двумя точками.

Если сервис работает в контейнере, не забывайте, что лимит, в который он упрётся, — это лимит cgroup, а не физическая память хоста, и это стоит учитывать при чтении графиков. Подробнее о том, как это настроено и куда смотреть, — в статье про лимиты CPU и памяти в Docker.

Типичные причины утечек на уровне приложения

Когда виновный процесс найден, дальше нужно понять, что именно он копит. Есть несколько типовых сценариев, которые повторяются в самых разных приложениях независимо от языка.

Незакрытые соединения и файловые дескрипторы. Приложение открывает соединение с базой данных, внешним API или файл, но по какой-то ветке кода (чаще всего — обработка ошибки, где забыли finally/defer/close в блоке исключения) не закрывает его. Каждое такое соединение занимает память под буферы и сам дескриптор, а поскольку закрытия не происходит, сборщик мусора (если он есть в языке) не может их освободить — на объект всё ещё есть ссылка со стороны операционной системы. Со временем таких висящих соединений накапливаются сотни и тысячи.

Растущий кеш без вытеснения (eviction). Разработчик добавляет кеш в памяти для ускорения — словарь, map, локальную структуру, куда складываются результаты вычислений или запросов. На старте это ускоряет работу, но если у кеша нет ограничения по размеру или политики вытеснения старых записей (LRU, TTL, максимальное число элементов), он растёт вместе с разнообразием входных данных и никогда не уменьшается. Это одна из самых частых причин «медленной, но верной» утечки — код работает корректно с точки зрения логики, просто у структуры данных нет верхней границы.

Накопление подписчиков на события. В приложениях с событийной моделью (обработчики событий, паттерн observer, EventEmitter и его аналоги) подписка на событие создаёт ссылку от источника событий на обработчик. Если код регистрирует новый обработчик на каждый запрос, сессию или соединение, но никогда не отписывается, каждый цикл жизни объекта оставляет висящую ссылку. Сам объект давно должен был быть удалён, но жив, потому что на него ссылается список подписчиков — классическая утечка через удержание ссылок, которая особенно коварна тем, что не проявляется на маленьких нагрузочных тестах и растёт только на реальном трафике за дни.

Циклические ссылки и утечки вне управляемой памяти. В языках, где основной механизм сборки мусора — подсчёт ссылок, два объекта, ссылающиеся друг на друга, могут не освобождаться, даже если снаружи на них никто не ссылается. А если приложение вызывает нативные библиотеки или работает с ресурсами напрямую через биндинги, утечка может быть вообще вне зоны ответственности сборщика мусора языка — он исправно чистит свои объекты, а память всё равно растёт из-за нативного кода под капотом. Это самый сложный для диагностики случай: стандартные инструменты профилирования управляемой памяти его просто не видят.

Отдельно стоит база данных и веб-сервер как частые источники подобных проблем — там свои типовые причины и настройки (кеш планов запросов, буферы соединений, конфигурация воркеров), и если утечка обнаружена именно в СУБД или в веб-сервере, а не в вашем прикладном коде, разумнее сразу смотреть профильный материал по конкретному сервису, а не искать баг в собственной кодовой базе.

Временное решение: плановый рестарт по расписанию

Пока причина не найдена или фикс не готов, самый практичный костыль — плановый перезапуск сервиса до того, как он дойдёт до потолка памяти. Если по журналу падений вы знаете, что процесс стабильно упирается в лимит через условные 6-7 дней, поставьте перезапуск раз в 3-4 дня — с запасом, оставляющим время на реакцию, если рост вдруг ускорится.

# перезапуск сервиса каждую ночь в 4:00, пока причина утечки не устранена
0 4 * * * systemctl restart my-app.service

Если приложение работает под systemd, у юнита можно сразу ограничить память и настроить поведение при превышении лимита, чтобы падение было управляемым, а не через OOM killer в произвольный момент:

[Service]
MemoryMax=1500M
MemoryHigh=1200M
Restart=on-failure
RestartSec=5

MemoryHigh — мягкий порог, после которого система начинает активнее прижимать процесс по памяти (throttling), а MemoryMax — жёсткий потолок, при превышении которого ядро убьёт процесс контролируемо, через cgroup, а не дожидаясь общего OOM killer на уровне хоста. Это не решает утечку, но делает её последствия предсказуемыми и локальными, а не общесистемным инцидентом, который может задеть соседние сервисы. Подробнее о том, как ядро выбирает жертву в момент нехватки памяти и как на это повлиять, — в статье как ядро выбирает жертву OOM killer.

Если сервис состоит из нескольких воркеров, у многих серверов приложений есть встроенная настройка — максимальное число обработанных запросов или время жизни воркера, после которого он перезапускается автоматически. Это по сути тот же плановый рестарт, но на уровне отдельного воркера, а не всего сервиса, и он менее заметен для пользователей, потому что остальные воркеры продолжают отвечать на запросы во время ротации одного из них.

Важно понимать границы этого решения: плановый рестарт маскирует симптом, но не устраняет причину. Если утечка ускоряется (например, из-за роста базы пользователей или объёма данных), интервал между безопасными перезапусками будет сокращаться, и в какой-то момент костыль перестанет справляться. Рестарт по расписанию — это способ выиграть время на нормальную диагностику и фикс, а не постоянная архитектура эксплуатации. Если вы фиксируете, что частота нужных перезапусков растёт со временем, это дополнительный сигнал, что реальную причину пора найти, а не продолжать сокращать интервал.

Реальный фикс: от гипотезы к исправлению

Настоящее решение начинается с того, что вы нашли конкретный источник утечки одним из способов выше — не закрытый ресурс, безлимитный кеш, накопленные подписчики или нативная утечка. Дальше путь довольно стандартный.

Если это незакрытый ресурс — добавьте явное закрытие в блок, гарантированно выполняющийся при любом исходе (включая обработку ошибок), и задайте таймаут на стороне клиента, чтобы зависшие соединения закрывались сами. Если это безлимитный кеш — задайте явный лимит по размеру или количеству записей и политику вытеснения; большинство библиотек кеширования уже поддерживают это из коробки, часто не хватает только передать параметр. Если это подписчики на события — добавьте симметричную отписку в том же месте жизненного цикла объекта, где происходит подписка, а где язык поддерживает слабые ссылки — используйте их, чтобы подписка сама не мешала сборке мусора.

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

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Память растёт, но сервис ещё не падал — стоит ли уже беспокоиться?

Да. Монотонный рост без возврата к базовому уровню — это утечка независимо от того, дошла ли она уже до критической точки. Чем раньше вы это заметите по графику, тем больше времени на спокойную диагностику вместо аварийного разбора после падения.

Как отличить утечку от того, что приложению просто нужно больше памяти под выросшую нагрузку?

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

Обязательно ли нужен профилировщик конкретного языка, чтобы найти утечку?

Нет, для локализации виновного процесса достаточно системных инструментов (ps, /proc, сетевые соединения) и наблюдения в динамике. Специализированный профилировщик нужен на следующем шаге — когда процесс уже найден и нужно понять, что именно внутри него растёт.

Плановый рестарт — это плохая практика?

Как постоянное решение — да, потому что интервал между безопасными перезапусками может со временем сокращаться. Как временная мера на период диагностики — вполне разумно, особенно если она сопровождается управляемым лимитом памяти на уровне systemd или контейнера, а не бесконтрольным падением через OOM killer.

Сколько нужно наблюдать после фикса, чтобы быть уверенным, что утечка устранена?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →