Практика показывает, что в современном мире Docker-контейнеров и оркестраторов (Kubernetes, Nomad, etc) проблема с утечкой памяти может быть обнаружена не при локальной разработке, а в ходе нагрузочного тестирования, или даже в production-среде. В этой статье рассмотрим:
-
Причины появления утечек в Python-приложениях.
-
Доступные инструменты для отладки и мониторинга работающего приложения.
-
Общую методику поиска утечек памяти.
У нас есть много фреймворков и технологий, которые уже «из коробки» работают замечательно, что усыпляет бдительность. В итоге иногда тревога поднимается спустя некоторое время после проблемного релиза, когда на мониторинге появляется примерно такая картина:
Утечки плохи не только тем, что приложение начинает потреблять больше памяти. С большой вероятностью также будет наблюдаться снижение работоспособности, потому что GC придется обрабатывать всё больше и больше объектов, а аллокатору Python — чаще выделять память для новых объектов.
Заранее предупрежу, что рассмотренные методы отладки приложения крайне не рекомендованы для использования в production-среде. Область их применения сводится к ситуациям:
-
Есть подозрение на утечку памяти. Причина абсолютно непонятна и проявляется при production-сценариях/нагрузках.
-
Мы разворачиваем приложение в тестовой среде и даем тестовый трафик, аналогичный тому, при котором появляется утечка.
-
Смотрим, какие объекты создаются, и почему память не отдается операционной системе.
Глобально утечка может произойти в следующих местах:
-
Код на Python. Здесь всё просто: создаются объекты в куче, которые не могут быть удалены из-за наличия ссылок.
-
Подключаемые библиотеки на других языках (C/C++, Rust, etc). Утечку в сторонних библиотеках искать гораздо сложнее, чем в коде на Python. Но методика есть, и мы ее рассмотрим.
-
Интерпретатор Python. Эти случаи редки, но возможны. Их стоит рассматривать, если остальные методы диагностики не дали результата.
Подключение к работающему приложению
-
PDB — старый добрый Python Debugger, о котором стали забывать из-за красивого интерфейса для отладки в современных IDE. На мой взгляд, для поиска утечек памяти крайне неудобен.
-
aiomonitor. Отличное решение для асинхронных приложений. Запускается в отдельной корутине и позволяет подключиться к работающему приложению с помощью NetCat. Предоставляет доступ к полноценному интерпретатору без блокировки основного приложения.
-
pyrasite. Запускается в отдельном процессе, и также как aiomonitor не блокирует и не останавливает основной поток, — можно смотреть текущее состояние переменных и памяти. Для работы pyrasite требуется установленный gdb. Это накладывает ограничения на использование, например, в Docker — требуется запуск контейнера с привилегированными правами и включение ptrace.
Утечки памяти: большие объекты
Это самые простые утечки памяти, потому что большие объекты очень легко отфильтровать. Для поиска будем использовать pympler и отладку через aiomonitor.
Запустим в первом окне терминала main.py:
import tracemalloc
tracemalloc.start()
from aiohttp import web
import asyncio
import random
import logging
import sys
import aiomonitor
logger = logging.getLogger(__name__)
async def leaking(app):
"""
Стартап утекающей корутины
"""
stop = asyncio.Event()
async def leaking_coro():
"""
Утекающая корутина
"""
data = []
i = 0
logger.info('Leaking: start')
while not stop.is_set():
i += 1
try:
return await asyncio.wait_for(stop.wait(), timeout=1)
except asyncio.TimeoutError:
pass
# ЗДЕСЬ БУДЕМ УТЕКАТЬ!
data.append('hi' * random.randint(10_000, 20_000))
if i % 2 == 0:
logger.info('Current size = %s', sys.getsizeof(data))
leaking_future = asyncio.ensure_future(asyncio.shield(leaking_coro()))
yield
stop.set()
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
loop = asyncio.get_event_loop()
with aiomonitor.start_monitor(loop=loop):
app = web.Application()
app.cleanup_ctx.append(leaking)
web.run_app(app, port=8000)
И подключимся к нему во втором:
nc 127.0.0.1 50101
Asyncio Monitor: 2 tasks running
Type help for available commands
monitor >>> console
Нас интересует отсортированный дамп объектов GC:
>>> from pympler import muppy
>>> all_objects = muppy.get_objects()
>>> top_10 = muppy.sort(all_objects)[-10:]
>>> top1 = top_10[0]
Мы можем убедиться, что самым большим объектом является наша добавляемая строка:
>>> type(top1)
<class 'str'>
Забавный факт: вызов pprint выводит информацию не в терминальную сессию aiomonitor, а в исходный скрипт. В то время как обычный print ведет себя наоборот.
Теперь возникает вопрос: как же понять, где этот объект был создан? Вы наверняка заметили запуск tracemalloc в самом начале файла, — он нам и поможет:
>>> import tracemalloc
>>> tb = tracemalloc.get_object_traceback(top1)
>>> tb.format()
[' File "main.py", line 41', " data.append('hi' * random.randint(10_000, 20_000))"]
Просто и изящно! Для корректной работы tracemalloc должен быть запущен перед любыми другими импортами и командами. Также его можно запустить с помощью флага -X tracemalloc
или установки переменной окружения PYTHONTRACEMALLOC=1
(подробнее: https://docs.python.org/3/library/tracemalloc.html). Чуть ниже мы рассмотрим другие полезные функции tracemalloc.
Утечки памяти: много маленьких объектов
Представим, что в нашей программе начал утекать бесконечный связный список: много однотипных маленьких объектов. Попробуем отыскать утечку такого рода.
import tracemalloc
tracemalloc.start()
import asyncio
root = {
'prev': None,
'next': None,
'id': 0
}
async def leaking_func():
current = root
n = 0
while True:
n += 1
_next = {
'prev': current,
'next': None,
'id': n
}
current['next'] = _next
current = _next
await asyncio.sleep(0.1)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
with aiomonitor.start_monitor(loop=loop):
loop.run_until_complete(leaking_func())
Как и в прошлом примере, подключимся к работающему приложению, но для поиска маленьких объектов будем использовать objgraph:
>>> import objgraph
>>> objgraph.show_growth()
function 6790 +6790
dict 3559 +3559
tuple 2676 +2676
list 2246 +2246
weakref 1635 +1635
wrapper_descriptor 1283 +1283
getset_descriptor 1150 +1150
method_descriptor 1128 +1128
builtin_function_or_method 1103 +1103
type 949 +949
Во время первого запуск objgraph посчитает все объекты в куче. Дальнейшие вызовы будут показывать только новые объекты. Попробуем вызвать еще раз:
>>> objgraph.show_growth()
dict 3642 +30
Итак, у нас создается и не удаляется много новых маленьких объектов. Ситуацию усложняет то, что эти объекты имеют очень распространенный тип dict. Вызовем несколько раз функцию get_new_ids
с небольшим интервалом:
>>> items = objgraph.get_new_ids()['dict']
>>> # Ждем некоторое время
>>> items = objgraph.get_new_ids()['dict']
>>> items
{4381574400, 4381574720, 4380522368, … }
Посмотрим на созданные объекты более пристально:
>>> from pprint import pprint
>>> # Получим объекты по их id
>>> objects = objgraph.at_addrs(items)
>>> pprint(objects, depth=2)
[{'id': 1077, 'next': {...}, 'prev': {...}},
{'id': 864, 'next': {...}, 'prev': {...}},
{'id': 865, 'next': {...}, 'prev': {...}},
{'id': 866, 'next': {...}, 'prev': {...}},
…]
На данном этапе мы уже можем понять, что это за объекты. Но если утечка происходит в сторонней библиотеке, то наша жизнь усложняется. Посмотрим с помощью вспомогательной функции, какие места в программе наиболее активно выделяют память:
def take_snapshot(prev=None, limit=10):
res = tracemalloc.take_snapshot()
res = res.filter_traces([
tracemalloc.Filter(False, tracemalloc.__file__),
])
if prev is None:
return res
st = res.compare_to(prev, 'lineno')
for stat in st[:limit]:
print(stat)
return res
>>> sn = take_snapshot()
>>> # Немного подождем перед вторым вызовом
>>> sn = take_snapshot(sn):
/Users/saborisov/Work/debug_memory_leak/main.py:25 size=27.8 KiB (+27.8 KiB), count=230 (+230), average=124 B
...
Мы явно видим подозрительное место, на которое следует взглянуть более пристально.
Я бы хотел обратить внимание, что за кадром осталась основная функциональность библиотеки objgraph: рисование графов связей объектов. Пожалуйста, попробуйте его, это фантастический инструмент для поиска хитрых утечек! С помощью визуализации ссылок на объект можно быстро понять, где именно осталась неудаленная ссылка.
Сторонние C-Extensions
Это наиболее тяжелый в расследовании тип утечек памяти, потому что GC работает только с PyObject. Если утекает код на C, отследить это с помощью кода на Python невозможно. Искать утечки в сторонних библиотеках следует, если:
-
Основная куча объектов Python не растет (с помощью objgraph и pympler не удается найти утечки памяти).
-
Общая память приложения на Python продолжает бесконтрольно расти.
Для тестирования создадим небольшой модуль на Cython (cython_leak.pyx):
from libc.stdlib cimport malloc
cdef class PySquareArray:
cdef int *_thisptr
cdef int _size
def __cinit__(self, int n):
# Класс, который создает массив квадратов заданного размера
cdef int i
self._size = n
self._thisptr = <int*>malloc(n * sizeof(int))
for i in range(n):
self._thisptr[i] = i * i
def __iter__(self):
cdef int i
for i in range(self._size):
yield self._thisptr[i]
И установочный файл (setup.py):
from setuptools import setup
from Cython.Build import cythonize
setup(
name='Hello world app',
ext_modules=cythonize("cython_leak.pyx"),
zip_safe=False,
)
Запустим сборку: python setup.py build_ext --inplace
И сделаем скрипт для тестирования утечки (test_cython_leak.py):
from cython_leak import PySquareArray
import random
while True:
a = PySquareArray(random.randint(10000, 20000))
for v in a:
pass
Кажется, все объекты должны корректно создаваться и удаляться. На практике график работы скрипта выглядит примерно так:
Попробуем разобраться в причине с помощью Valgrind. Для этого нам понадобится suppression-файл и отключение Python-аллокатора:
PYTHONMALLOC=malloc valgrind --tool=memcheck --leak-check=full python3 test_cython_leak.py
После некоторого времени работы можно посмотреть отчет (нас интересуют блоки definitely lost):
==4765== 79,440 bytes in 1 blocks are definitely lost in loss record 3,351 of 3,352
==4765== at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==4765== by 0x544D13C: __pyx_pf_11cython_leak_13PySquareArray___cinit__ (cython_leak.c:1420)
==4765== by 0x544D13C: __pyx_pw_11cython_leak_13PySquareArray_1__cinit__ (cython_leak.c:1388)
==4765== by 0x544D13C: __pyx_tp_new_11cython_leak_PySquareArray (cython_leak.c:1724)
Здесь указан наш класс PySquareArray
и утекающая функция cinit
. Детали можно изучить в скомпилированном файле cython_leak.c.
В чем же причина утечки? Конечно, в отсутствии деструктора:
from libc.stdlib cimport malloc, free
...
def __dealloc__(self):
free(self._thisptr)
После повторной компиляции и запуска можно увидеть абсолютно корректную работу приложения:
Заключение
Я бы хотел отметить, что в компании мы считаем нагрузочное тестирование приложения с контролем потребления памяти одним из ключевых Quality Gate перед релизом. Я надеюсь, что этот гайд поможет вам быстрее и проще находить утечки памяти в своих приложениях
Автор: Сергей Борисов