Разбор инцидента: как одна строка на Python положила сервис
Замена dict на defaultdict в кеше выглядела безобидной. Через сорок минут после деплоя сервис ел всю память и уходил в OOM-kill каждые несколько минут. Разбираем, что произошло и как это ловить.
В 14:12 мониторинг показал рост RSS у четырёх подов с 400 МБ до 1,8 ГБ за десять минут. К 14:20 kubelet начал убивать контейнеры по OOM, рестарт не помогал: под поднимался, за пару минут снова упирался в лимит памяти и падал. Пятая нода держалась дольше — на неё не попал свежий трафик. Причиной оказалась одна строка в модуле кеширования справочников, которую в тот день выкатили как «мелкую оптимизацию».
Сервис — синхронное Flask-приложение (Python 3.11, gunicorn с sync-воркерами), которое отдаёт агрегированные данные по товарам. Внутри был самописный кеш-словарь: ключ — идентификатор категории, значение — распарсенный ответ. Ниже — что именно сломалось, как мы это локализовали и почему обычные метрики не показали проблему сразу.
Что было в коде до и после
Исходный вариант выглядел так — обычный словарь с явной проверкой на промах:
# было: явный промах кеша
category_cache = {}
def get_category_stats(category_id):
if category_id in category_cache:
return category_cache[category_id]
stats = build_stats(category_id) # тяжёлый расчёт
category_cache[category_id] = stats
return stats
Автор «оптимизации» захотел убрать ветвление и заодно упростить код в нескольких местах, где к кешу обращались по неизвестному заранее ключу. Он заменил обычный словарь на defaultdict:
from collections import defaultdict
# стало: defaultdict с фабрикой
category_cache = defaultdict(build_stats_empty)
def get_category_stats(category_id):
return category_cache[category_id]
С build_stats_empty вместо реального расчёта — это была отдельная упрощённая правка, но суть в другом. Проблема не в фабрике, а в самом обращении по ключу.
Почему это уронило память
У обычного словаря чтение по отсутствующему ключу — это промах: значение не создаётся, ключ не добавляется. У defaultdict любое обращение через квадратные скобки d[key] к отсутствующему ключу создаёт запись: вызывается фабрика, результат кладётся в словарь. Это документированное поведение, а не баг.
Ключом была категория, но в приложение приходили запросы, где идентификатор формировался из пользовательского ввода — фильтры, поисковые срезы, склейки id. Раньше несуществующий ключ просто давал промах и пересчёт. Теперь каждый уникальный ключ, включая мусорные и никогда не повторяющиеся, оседал в словаре навсегда. Кеш перестал быть кешем и превратился в неограниченно растущий лог всех запросов.
Обычный словарь на чтение отсутствующего ключа отвечает промахом. defaultdict на то же самое отвечает записью. Разница в одну букву в типе — и структура из ограниченной по объёму превращается в бесконечно растущую.
При RPS около 200 и высокой доле уникальных ключей словарь рос примерно на десятки тысяч записей в минуту. Каждая запись — не пустая: значение содержало распарсенную статистику на несколько килобайт. Отсюда линейный рост RSS и OOM через 30–40 минут после деплоя.
Как локализовали
Стандартные дашборды показывали симптом (память растёт, поды падают), но не причину. Помогло вот что.
- Сопоставили время инцидента с деплоем. Рост RSS начался в пределах минуты после выката ревизии. Это сразу сузило поиск до одного pull request, а не до всего сервиса.
- Сняли heap-снимок живого процесса. Подключились к работающему воркеру через
py-spy dump --pid PID, чтобы увидеть стек, и параллельно черезtracemallocпосмотрели, какие объекты занимают память. Верхнюю строку занимал один словарь. - Посчитали размер кеша. Добавили временный лог
len(category_cache)раз в минуту. Число росло монотонно и не выходило на плато — для настоящего кеша справочников это невозможно, категорий конечное число. - Прочитали диф. После того как метрика указала на конкретный словарь, диф из четырёх строк объяснил всё.
Ключевой сигнал — не абсолютный размер, а отсутствие плато. Кеш справочника обязан упираться в потолок: категорий столько, сколько их в базе. Если len() растёт линейно и не останавливается, значит, ключи не переиспользуются, и это либо утечка, либо неверная структура.
tracemalloc на живом сервисе
Включать tracemalloc заранее на всём процессе дорого, поэтому в проде держат минимальный вариант — снимок по требованию. Схематично:
import tracemalloc
tracemalloc.start()
# ... сервис поработал под нагрузкой ...
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics('lineno')[:10]:
print(stat)
Верхняя строка выдачи указывала на файл и номер строки, где значения клались в словарь. Дальше уже смотрели на тип структуры глазами.
Где такой класс ошибок ломается первым
Инцидент не про defaultdict как таковой — про неограниченный рост структуры, которую все считают ограниченной. Один и тот же сценарий повторяется в разных обёртках:
- Ключ приходит из пользовательского ввода. Пока ключей конечное число (категории, статусы, регионы), кеш ограничен. Как только в ключ попадает произвольная строка, множество ключей становится бесконечным.
- Кеш без вытеснения. Любой
dictкак кеш без ограничения размера — потенциальная утечка. Для ограниченного справочника это нормально, для запросов пользователей — нет. - Долгоживущий процесс. На sync-воркере gunicorn процесс живёт часами. У короткого скрипта та же ошибка незаметна: он завершится раньше, чем память кончится.
- Мутирующие методы вроде
setdefaultиdefaultdict. Они меняют структуру во время чтения. Обращение, которое выглядит как «просто посмотреть», на деле пишет.
Чем это отличалось бы под asyncio
На асинхронном воркере с одним процессом и общим event loop структура была бы одна на весь процесс, и рост шёл бы ещё быстрее относительно одного пода. На нескольких sync-воркерах gunicorn каждый держит свою копию словаря, поэтому падали они не одновременно — это дополнительно сбивало с толку на первых минутах.
Сравнение подходов к кешу справочника
| Подход | Ограничен по памяти | Риск утечки на произвольном ключе | Когда уместен |
|---|---|---|---|
| Обычный dict с явной проверкой | Нет, но не растёт на промахах | Средний: растёт, если ключей бесконечно много | Небольшой фиксированный справочник |
| defaultdict | Нет | Высокий: пишет на любом чтении | Только когда ключи заведомо ограничены |
| functools.lru_cache(maxsize=N) | Да, вытесняет старое | Низкий | Чистая функция с хешируемыми аргументами |
| Внешний кеш (Redis, memcached) с TTL | Да, TTL и eviction | Низкий | Общий кеш на несколько процессов и подов |
Для нашего случая правильным было либо вернуть явную проверку и добавить потолок, либо перейти на lru_cache с maxsize, если функция чистая. Внешний кеш решил бы и проблему рассинхрона между воркерами, но это уже более крупная переделка.
Что мы поменяли после
- Откатили ревизию. Первое действие в инциденте — вернуть предыдущую версию, разбор потом. Память стабилизировалась через минуту после отката.
- Ограничили кеш. Заменили на
functools.lru_cache(maxsize=1024)для функции расчёта — она оказалась чистой относительно своих аргументов. - Добавили метрику размера. Начали экспортировать длину кеша в мониторинг и повесили алерт на монотонный рост без плато, а не только на абсолютный RSS.
- Правило на ревью. Замена
dictнаdefaultdictв коде, где ключ может прийти извне, теперь помечается как замечание с явной формулировкой: «defaultpict пишет на чтение — подтверди, что множество ключей ограничено».
Что настроить дальше
Если у вас долгоживущий Python-сервис с самописными кешами, начните с двух вещей. Первое — экспортируйте len() каждой такой структуры в метрики и следите не за пиком, а за формой кривой: настоящий кеш выходит на плато, утечка растёт линейно. Второе — держите под рукой py-spy для снятия стека с живого процесса без рестарта и умейте включать tracemalloc по требованию. На проде смотрите на RSS в разрезе времени с момента последнего деплоя: если рост стартует ровно после выката, ищите причину в дифе, а не в инфраструктуре.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: collections.defaultdict — Python documentation, tracemalloc — Trace memory allocations
Частые вопросы
Значит, defaultdict — плохая структура, которой надо избегать?
А если у меня Python 3.12 или другая версия — поведение отличается?
Почему обычные алерты на память не поймали проблему заранее?
Почему поды падали не одновременно, а по очереди?
Как воспроизвести такое до прода, а не ловить в бою?
lru_cache точно безопасен в такой ситуации?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.