open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 12 · 04

Постмортемы инцидентов: утечка, зависание, лавина рестартов — и ремесло blameless-разбора

Три инцидента: безразмерный lru_cache течёт 40 МБ/ч до OOMKill (находит diff tracemalloc); синхронный requests.get морозит весь цикл событий (py-spy видит socket.recv); двухминутная загрузка модели плюс liveness равно CrashLoopBackOff. Blameless-постмортемы чинят системы.

PY Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Шесть недель никто не разбирался, почему поды прайсинга перезапускаются примерно каждые тридцать часов. Рестарты были «бесплатными» — Kubernetes заменял под, трафик перебалансировался, пейдж не срабатывал. Потом рестарт попал на пик трафика Чёрной пятницы: замена стартовала холодной, с пустыми кешами, p99 ушёл к четырём секундам, и конверсия чекаута падала одиннадцать минут. График в постмортеме был идеальной лестницей: RSS растёт на 40 МБ в час, линейно, тридцать часов, затем OOMKill, и снова. Каждая ступень этой лестницы была видна на дашборде, о котором никого не пейджили. Этот урок препарирует три продакшен-инцидента от начала до конца — утечку (безразмерный lru_cache с ключами из параметров запроса), зависание (один синхронный requests.get, замораживающий целый цикл событий) и лавину рестартов (двухминутная загрузка модели против тридцатисекундной liveness-пробы) — каждый через одну рамку: обнаружение, ущерб, корневая причина, смягчение, предотвращение. А затем само ремесло: постмортем, заканчивающийся фразой «инженер будет внимательнее», не диагностировал ничего. Отказ допустила система; меняйте систему.

Утечка: RSS растёт 40 МБ/ч, OOMKill каждые ~30 часов

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

Корневая причина: @lru_cache(maxsize=None) на функции прайсинга, в чьи аргументы входил кортеж фильтров запроса. Почти уникальные ключи на запрос — кеш почти никогда не попадал, только рос. Воркер живёт неделями; ничто не вытесняет; gc бессилен, потому что каждая запись сильно ссылается по дизайну. Инструмент доказательства — tracemalloc: снапшоты в двух точках времени, diff по строкам:

import tracemalloc
tracemalloc.start(25)                  # хранить 25 кадров на аллокацию

snap1 = tracemalloc.take_snapshot()    # час 1 — триггер через админ-эндпоинт
# ... проходят часы ...
snap2 = tracemalloc.take_snapshot()    # час 5
for stat in snap2.compare_to(snap1, "lineno")[:5]:
    print(stat)
# pricing/cache.py:41: size=2.9 GiB (+2.7 GiB), count=38M (+36M)
#   словарь lru_cache — ключ (user_id, query, tuple(filters))

Смягчение против предотвращения: рециклинг max_requests останавливает OOMKill-ы уже сегодня — честно подписанный костыль. Предотвращение структурно: ограниченные кеши (maxsize= число, которое вы можете защитить, или TTL-кеш там, где допустима несвежесть), алерт RSS-на-воркер, срабатывающий на тренд (40 МБ/ч в течение 3 часов), а не на обрыв, и правило ревью: maxsize=None плюс ключи формы запроса не проходит никогда.

Викторина

RSS линейно растёт ~40 МБ/ч, под получает OOMKill каждые ~30 ч. Какая улика указывает на безразмерный lru_cache и почему gc вас не спасает?

Зависание: все async-воркеры заморожены, CPU около нуля

Обнаружение: p99 уходит вертикально, каждый запрос таймаутится, и контринтуитивный сигнал — CPU около нуля. Крутящийся цикл жёг бы CPU; замороженный — припаркован в сисколле. Инструмент, превращающий это из спекуляции в улику, — py-spy dump: он подключается к живому pid без рестарта и без инструментирования:

$ py-spy dump --pid 1
Thread 0x7F4A (active): "MainThread"
    recv (ssl.py:1233)
    ...
    get (requests/api.py:73)
    fetch_rates (helpers/fx.py:12)      # тремя слоями хелперов ниже
    convert (handlers/checkout.py:88)   # async def — но блокируется прямо здесь

Корневая причина: синхронный requests.get вкрался в async-обработчик за тремя слоями хелперов — автор fetch_rates ни разу не видел async def в своём файле. Цикл кооперативен и однопоточен: блокирующий сисколл никогда не уступает планировщику, поэтому каждая корутина этого воркера — сотни в полёте — ждёт вместе с ним (правило мостов python/11, теперь в виде аварии). Каждый воркер, задевший этот путь, замерзает так же; флот умирает воркер за воркером по мере того, как трафик находит маршрут.

Предотвращение: lint/import-правило, запрещающее синхронные HTTP-клиенты (requests, голый urllib3) в async-модулях — закреплённое в CI, а не в памяти ревьюеров; запасной выход await asyncio.to_thread(...) там, где синхронная библиотека неизбежна; async-нативный клиент (httpx) как дефолт; и нагрузочный тест, дёргающий медленный upstream, потому что на p50 этот баг невидим.

Лавина рестартов: здоровый бинарь, полный отказ

Цепочка причины: релиз добавил двухминутную загрузку ML-модели на старте. Liveness-проба — initialDelaySeconds: 30 — начинала падать до конца загрузки; Kubernetes убивал под посреди загрузки; замена начинала ту же двухминутную загрузку; снова убита. CrashLoopBackOff (состояние Kubernetes, когда под перезапускается снова и снова с экспоненциальной выдержкой) по всему флоту — от бинаря без единого бага. У обнаружения сигнатура, которую стоит запомнить: циклы рестартов с чистыми логами — ни трейсбека, ни ошибки: процесс убили, он не падал, — плюс события отказов проб в kubectl describe.

Предотвращение: startup-проба — liveness приостановлена, пока она не пройдёт; она существует ровно для медленно стартующих приложений; ленивая или фоновая загрузка модели с выключенным readiness до прогрева (трафик ждёт, никто не умирает); и бюджет времени старта, проверяемый в CI — тест, валящий сборку, когда старт переходит N секунд, потому что регрессии времени загрузки приходят тихо, по одной зависимости за раз.

Ремесло: как пишется постмортем

У трёх инцидентов общий скелет, и скелет — это и есть ремесло. Таймлайн с таймстемпами — когда началось, когда обнаружили, когда смягчили; разрыв обнаружения (шесть недель у утечки) — сам по себе вывод. Blameless — не вежливость, а качество данных: инженер, переключивший флаг, расскажет, как всё было, только если документ нельзя использовать против него. Пять «почему», нацеленных на систему: не «почему инженер добавил lru_cache», а «почему наше ревью, наши алерты и наши лимиты все вместе позволили неограниченному росту уехать в прод и работать шесть недель». Action items — это изменения кода или конфига с владельцами и сроками: заведённый алерт, добавленная проба, влитое lint-правило. «Быть внимательнее» не встречается ни в одном компетентном постмортеме; если человеческая оплошность вызывает отказ — система уже была сломана.

Викторина

Все async-воркеры перестают отвечать; CPU около нуля. py-spy dump показывает поток цикла внутри socket.recv под requests.get. Почему один вызов заморозил сотни запросов в полёте?

Вспомните перед уходом
  1. 01
    Проведите инцидент с утечкой через полную рамку: сигнал обнаружения, механизм корневой причины, процесс работы с tracemalloc и разделение смягчение/предотвращение.
  2. 02
    Сопоставьте зависание и лавину рестартов: сигнатуры обнаружения, корневые причины и пакет предотвращения для каждого.
Итог

Три инцидента — одна аналитическая рамка. Утечка: lru_cache(maxsize=None) с ключами из параметров запроса превратил долгоживущий воркер в односторонний клапан памяти — 40 МБ в час, линейно с трафиком, OOMKill каждые тридцать часов, шесть недель невидимости, потому что рестарты казались бесплатными. Линейная лестница — сигнатура класса: накопление живых ссылок, где gc бессилен по дизайну; diff снапшотов tracemalloc превращает подозрение в файл и строку. Зависание: один синхронный requests.get тремя слоями хелперов ниже async def припарковал целый цикл событий в socket.recv — сотни замороженных корутин на воркер, ноль CPU, и py-spy dump на живом pid как инструмент улик без рестарта; предотвращение — lint-правило в CI, выходы через to_thread и async-нативный клиент, потому что память ревьюеров не масштабируется. Лавина рестартов: двухминутная загрузка модели против тридцатисекундной liveness-пробы дала CrashLoopBackOff от бинаря без багов — сигнатура: чистые логи плюс события проб; фикс: startup-пробы и ленивая загрузка за readiness-гейтом; страховка от регрессий: бюджет старта в CI. Ремесло связывает всё: точные таймлайны, где разрыв обнаружения — сам по себе вывод; blameless-рамка, потому что страх портит данные; пять «почему», направленных на систему, допустившую отказ; и action items, которые компилируются, — алерт, проба, lint-правило — и никогда обещание быть внимательнее. Теперь, когда вы увидите лестницу RSS, замёрзшие воркеры при нулевом CPU или под, перезапускающийся с чистыми логами, — вы будете знать, какой инструмент взять и какой вопрос задать системе, а не человеку.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.