Профилируем утечку памяти в Python на проде с memray — и авария, которую мы устроили по дороге
Бэкенд падал по OOM каждые 10–15 часов. Почасовые снимки memray на живом поде нашли утечку в библиотечных middleware, а не в коде приложения — после того как первая попытка положила прод на семь минут.
На одной продуктовой платформе в Gambchamp Python-бэкенд регулярно ловил OOM-kill. Лимит — 1 GiB, под жил 10–15 часов, память росла плавно, и он умирал с кодом 137. Разработчикам нужны были конкретные строки кода, а не «оно растёт», поэтому мы решили снять профиль memray прямо на живом продовом поде — одна реплика, реальный трафик.
Сначала — измерить наклон
Прежде чем что-то подключать, мы померили утечку снаружи. Удерживаемая память росла примерно на 28 МБ в час, анонимная — на 48 МБ в час (310 → 503 МБ за четыре часа). От старта около 350 МБ до лимита в 1 ГБ — примерно десять часов, ровно интервал между OOM. Значит, это одна ровная утечка, а не всплеск.
Первая попытка положила прод
Мы пропатчили Deployment, чтобы приложение стартовало под memray run, и взяли приём из старого ранбука: держать контейнер живым через sleep infinity и снять liveness-пробу, чтобы профилировщик не убили.
memray упал с segfault (--trace-python-allocators не дружит с greenlet и asyncpg). Приложение умерло вместе с ним — а контейнер остался «живым» в sleep, и kubelet не увидел, что перезапускать. Прод лежал около семи минут.
Исправление простое и теперь стало правилом: обёртка заканчивается exec python main.py, а liveness-проба остаётся. Умер профилировщик — умер контейнер, и Kubernetes делает свою работу.
Другие грабли того же вечера:
- режим
--aggregateдержит агрегацию в памяти процесса, поэтому после segfault выходной файл оказался пустым — двенадцать минут профиля потеряны. На продовых сервисах — только обычный режим; - startup-проба по умолчанию убила первый под: под memray старт медленнее;
- HPA добавил вторую реплику из-за CPU, который съедали наши же выгрузки снимков, а потом при scale-down удалил под. Аннотация
controller.kubernetes.io/pod-deletion-costна профилируемом поде защищает его, не трогая автоскейлинг.
Почасовые срезы вместо одного длинного прогона
Обрезанный файл memray всё равно читается, так что ждать конца прогона не нужно. Мы снимали срез раз в час и сравнивали их по местам аллокации. Diff убирает всё, что выделено при старте, и оставляет только то, что продолжает расти. Пять срезов за четыре часа показали три линейных источника:
json.loadsвнутри multiprocess-режимаprometheus_client— 67 МБ и рост около 13,6 МБ в час; вся группа Prometheus — около 94 МБ, 22 % всей удерживаемой памяти;- brotli ASGI middleware, создающий компрессор на каждый запрос, — около 4 МБ в час;
- наша собственная функция маскировки payload в логирующем middleware, копирующая тело запроса целиком перед логированием, — около 1 МБ за вызов.
Главная находка — первая. Multiprocess-режим перечитывает и заново парсит свои mmap-файлы на каждый скрейп, а сервис работает в один процесс. Multiprocess-режим ему вообще не нужен.
Ни один источник не был ручкой приложения. Поэтому рост был ровным и не коррелировал ни с одним маршрутом.
Убрать за собой оказалось сложнее, чем снять профиль
Мы передеплоили через CI и решили, что патча больше нет. Он остался. helm upgrade сбрасывает только поля, которые задаёт чарт. Команда memray, лишний volume, grace period и аннотация на эвикцию добавлялись через kubectl patch и в чарте не значились. Сервис продолжил работать под профилировщиком, но уже со штатным лимитом 1 GiB — хуже, чем было. Помог второй, добивающий патч и сверка живой спеки с бэкапом поле за полем. Теперь проверяем PID 1 и отсутствие volume профиля, а не grep memray по /proc (твой собственный grep находит сам себя).
Выводы
- Никогда не держите контейнер живым через
sleep, пока настоящий процесс работает под профилировщиком. Заканчивайте обёрткуexecи оставляйте liveness. - Снимайте срезы периодически и сравнивайте по местам аллокации: так стартовые аллокации отделяются от утечки.
- Проверяйте, сколько стоит режим библиотеки, прежде чем его включать: multiprocess-метрики в однопроцессном сервисе — это 22 % памяти.
- После любого отката сверяйте живую спеку с бэкапом.
helm upgradeне удаляет то, что добавилkubectl patch. - Базовые 350 МБ при лимите в 1 ГБ оставляют мало запаса даже после фикса — пересматривайте лимит через пайплайн, а не патчем.