load-tester Все курсы

✻ Урок 11.4 · Тема 11: Поиск узких мест

Память и утечки: тест на выносливость

⏱ 2.5 ч

Зачем это нужно

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

Все тесты прошлых уроков короткие: минута, три минуты. За это время сервис выглядит прекрасно. Но есть проблемы, которые за минуту не видны: система медленно деградирует. Это утечка памяти (программа берёт память и не отдаёт), рост очереди, диск, забитый логами. Для них придуман тест на выносливость (soak test, «пропитка»): нагрузка умеренная, но долгая, час или сутки. Он ищет не предел, а то, что со временем что-то растёт без остановки.

Шаг проекта: ты включаешь в «Магазине» утечку (LEAK_ENABLED=1, имитация релиза с багом) и запускаешь 20-минутный soak на 40 запросах в секунду. Потом строишь график памяти, считаешь скорость утечки, предсказываешь падение до того, как оно случится, и сравниваешь с контрольным прогоном без утечки. Результат пойдёт в ~/perf-lab/11-bottlenecks/04-memory-soak.md.

Что нужно знать

  • Метод «симптом, гипотеза, проверка, одно изменение» и скрипты bn.js, set-env.sh, run.sh: урок 11.1.
  • Типы тестов и профиль soak (ровная нагрузка долго): урок 8.3.
  • Лимит памяти контейнера и сигнал 137: урок 5.4; процессы и память в Linux: урок 1.3.
  • Метрики cAdvisor, predict_linear, rate: урок 7.2, урок 7.4; алерты и работа с инцидентом: урок 7.6.
  • Контейнер shop имеет mem_limit: 512m, в compose.yaml стоит restart: unless-stopped, поэтому после убийства Docker поднимет контейнер сам.

Картина целиком

Ванна с открытым краном и маленьким сливом. Пока воды мало, разницы никто не замечает. Если измерять уровень минуту, увидишь «ничего не происходит», а если час, увидишь прямую линию вверх, и в какой-то момент вода переливается через край. Утечка памяти работает так же: каждый запрос оставляет в памяти небольшой «осадок», который приложение не освобождает.

flowchart TD
    A["Каждый запрос<br>оставляет 10 КБ"] --> B["Память растёт<br>линейно"]
    B --> C["Достигла лимита<br>контейнера 512 МБ"]
    C --> D["ОС убивает процесс<br>OOM, код 137"]
    D --> E["Docker перезапускает<br>контейнер, всё сначала"]

Пока память не кончилась, ответы не ухудшаются вообще: полупустой шкаф ищет вещи так же быстро, как пустой. Потом приходит обрыв: процесс убит мгновенно, а Docker поднимает его заново секунд за двадцать. Поэтому soak ищут не по задержке (график p95 ровный), а по наклону графика памяти.

Теория

Память процесса: что растёт нормально, что нет

Память сервиса выросла вдвое за десять минут. Это уже утечка? Не обязательно, и ошибиться тут легко в обе стороны.

Посмотри на рабочий стол. Утром он пуст, к обеду на нём бумаги, чашки, ноутбук (прогрев). Дальше беспорядок остаётся постоянным: что-то берёшь, что-то убираешь. А теперь представь, что каждый час на столе остаются два стакана, и никто их не уносит. Это утечка.

Программа берёт память у операционной системы и по мере работы возвращает. В Python память освобождается сама: объект, на который никто не ссылается, убирает сборщик мусора. Утечка (memory leak) возникает, когда ссылка остаётся: объект никому не нужен, но лежит в списке, словаре или кэше без ограничения, и сборщик считает его нужным. Здоровая память ведёт себя одним из трёх способов. Это «прогрев и плато» (первые минуты растёт, потом ровно), «пила» (растёт и падает, в среднем ровно) или «ступени» (растёт при новых видах нагрузки). Утечка это линия, которая растёт без остановки и не возвращается, а её наклон зависит только от числа запросов.

В «Магазине» утечка устроена просто. При LEAK_ENABLED=1 прослойка (middleware, код, который проходят все запросы) добавляет в глобальный список блок bytearray(10 * 1024) (кусок памяти в 10 КБ), и список никогда не чистится. Расчёт: 40 запросов в секунду × 10 КБ = 400 КБ в секунду, это 24 МБ в минуту. Мониторинг тоже проходит через прослойку. Prometheus (система, собирающая метрики) стучится раз в 5 секунд. Docker проверяет, что сервис жив, тоже раз в 5 секунд, и вместе это около 0,4 запроса в секунду, то есть 14 МБ в час. Контейнер стартует с памятью около 118 МБ (это показывает docker stats сразу после запуска), лимит 512 МБ (mem_limit в compose.yaml), значит, на утечку остаётся 394 МБ. Нагрузка даёт 24 МБ в минуту, мониторинг около 0,25, итого около 24,5. 394 / 24,5 МБ в минуту даёт примерно 16 минут.

Прикинь сам: сколько проживёт контейнер при 10 запросах в секунду вместо 40?

10 × 10 КБ = 100 КБ в секунду, это 6 МБ в минуту, около 6,4 МБ с мониторингом. 394 / 6,4 это 61 минута, то есть больше часа. Время до обрыва обратно пропорционально нагрузке.

Серая «пила» здесь здоровая память, она колеблется вокруг ровной линии, а красная прямая это утечка. Точка пересечения с лимитом показывает время OOM. Подвинь нагрузку: наклон меняется. Подвинь лимит: сдвинется только дедлайн, наклон останется.

Осторожно: утечку путают с кэшем. Кэш тоже растёт, но у него есть предел (число элементов, время жизни), и график выходит на плато. Ещё путают «память не упала после теста» с утечкой. Python часто придерживает освобождённую память про запас и не возвращает системе, и график остаётся высоким. Но при повторном таком же прогоне нового подъёма нет.

Проверь понимание: за 10 минут теста память выросла со 120 до 280 МБ, потом четыре минуты стоит ровно на 280. Утечка?

Ответ

Скорее всего нет: рост с плато это прогрев (кэши, пул соединений). Утечка не останавливается, пока идёт нагрузка. Подожди ещё и прогони второй раз: если память ползёт вверх после плато или каждый прогон поднимает «потолок», тогда утечка.

Главное: здоровая память выходит на плато или ходит «пилой», а утечка растёт без остановки с наклоном, который зависит от числа запросов.

Допустим, утечка есть и память растёт. Чем это кончается?

OOM: что происходит, когда память кончилась

Сервис молча исчез, а в логе нет ни одной ошибки. Так выглядит смерть от нехватки памяти.

Представь лифт с ограничением по весу. Пока груз растёт, он едет, а на пределе срабатывает предохранитель: не плавное замедление, а резкая остановка.

У контейнера лимит mem_limit: 512m. Его держит ядро Linux (механизм cgroup, группа контроля ресурсов): оно считает память всех процессов контейнера. Когда сумма подходит к лимиту, ядро сначала выбрасывает то, что можно освободить (файловый кэш). Если не хватает, включается OOM-killer (Out Of Memory killer, «убийца при нехватке памяти»): он выбирает процесс и посылает ему сигнал SIGKILL (9), который нельзя перехватить. Процесс исчезает без записи в свой лог: не успевает ни закрыть соединения, ни написать «умираю». Код выхода равен 128 + 9 = 137, это «137» в статусе контейнера.

Теперь вопрос, что будет после убийства. Это решает настройка restart. У сервисов стенда стоит restart: unless-stopped, поэтому Docker поднимет контейнер сам: память обнулится, и сервис будет падать циклически, раз в 16 минут. Перезапуск это маскировка, а не лечение: сервис «ожил», причина на месте, пользователи каждый раз получают разрыв соединений.

Вот что ты увидишь после обрыва:

  • docker compose ps shop показывает Up 2 minutes (healthy) у контейнера, который давно должен был жить: он недавно перезапущен;
  • docker inspect shop-shop-1 | jq '.[0].RestartCount' даёт растущее число перезапусков, а OOMKilled, скорее всего, уже false: флаг описывает новый запуск. Сам факт OOM остался в docker events (событие oom, затем die с exitCode=137) и в метрике container_oom_events_total;
  • в Prometheus up{job="shop"} на 10-20 секунд станет 0, но алерт ShopDown (он срабатывает, если сервис недоступен) ждёт минуту (for: 1m), поэтому он, скорее всего, не сработает. Тихую утечку ловят запросы changes(container_start_time_seconds{container_label_com_docker_compose_service="shop"}[1h]) и increase(container_oom_events_total{container_label_com_docker_compose_service="shop"}[1h]);
  • в логе приложения перед концом ничего особенного: «логи чистые» не значит «всё было хорошо»;
  • в k6 на время перезапуска (около 20 секунд) идут connection refused, потом ответы возвращаются, и память снова растёт.

Прикинь сам: алерт ждёт минуту, а контейнер перезапускается за 20 секунд. Сработает ли он?

Нет: за 20 секунд условие не продержится 60. Именно поэтому утечка «тихая», и на неё нужны отдельные проверки: перезапуски и OOM-события.

Осторожно: код 137 путают с ошибкой приложения (код 1) и ищут исключение в логе, а его нет. Ещё путают OOM контейнера (лимит cgroup) и OOM всей машины, когда не хватает памяти у хоста: там убиваются разные процессы.

Главное: OOM-killer убивает процесс мгновенно и без следа в его логе, код выхода 137, а restart: unless-stopped лишь прячет проблему.

Теперь ясно, что искать. Как поставить такой тест?

Тест на выносливость: как он устроен

Короткая поездка не покажет, что при долгой езде перегревается двигатель. Для такой проверки нужен длинный пробег на умеренной скорости, а не гонка. Это и есть soak.

Его профиль: ровная нагрузка около 70% от предела (у нас 40 из 60), но долго. Нагрузка должна быть ниже предела, иначе очереди накопятся, и ты будешь мерить перегрузку, а не утечку. У «Магазина» после исправлений предел около 60 визитов в секунду, мы берём простой маршрут (карточка товара), который не упирается в базу. Длительность должна хотя бы немного превышать время, за которое проблема проявится. Для утечки с обрывом через 16 минут берём 20 минут, а на боевых системах часы и сутки. Смотреть надо на тренды, то есть на то, куда идёт график: память, соединения, очередь, открытые файлы, диск. Ничего не должно расти без остановки, кроме того, что растёт по смыслу (размер таблицы заказов).

Форма нагрузки ровная и долгая, сама по себе скучная. Информативны графики рядом: память, соединения, диск.

План нашего soak: сценарий product из bn.js (наш скрипт k6 из урока 11.1; карточка товара, один запрос на итерацию), постоянная частота 40 запросов в секунду, 20 минут. Токены не нужны: они берутся один раз в setup(), и час жизни токена (SESSION_TTL=3600) нас не касается. Запускай такой тест в фоне, чтобы не держать терминал.

Осторожно: soak путают со stress. Stress это нагрузка выше предела на короткое время, soak это ниже предела на долгое. И не жди от soak «красного p95»: пока утечка не мешает ответам, он ровный до самого обрыва.

Проверь понимание: почему для soak берут около 70% от предела, а не 100%?

Ответ

На пределе очередь растёт сама, и по графикам не отличить перегрузку от утечки. Ниже предела всё, что растёт, растёт по своей причине.

Главное: soak это умеренная нагрузка надолго, и смотрят в нём на наклон трендов, а не на задержку.

Мы ищем утечку памяти. Но течь может и что-то другое.

Что ещё растёт со временем, кроме памяти

Если смотреть только память, остальные утечки пропустишь. Что ещё накапливается, показывает таблица.

Что растёт Откуда берётся Где смотреть
Открытые соединения Не возвращены в пул занятых соединений (shop_db_pool_size - shop_db_pool_available) всё больше, а после нагрузки их число не возвращается к исходному; смотри и shop_db_pool_waiting
Файловые дескрипторы (номера, под которыми процесс держит открытые файлы и соединения) Незакрытые файлы и сокеты, то есть сетевые соединения (лимит ulimit -n, обычно 1024) process_open_fds (стандартная метрика клиентов Prometheus; /metrics стенда её не отдаёт, там считают записи в /proc/<PID>/fd внутри контейнера), при исчерпании ошибка Too many open files
Потоки Создаются и не завершаются число потоков в py-spy dump
Диск Логи без ротации (ротация это удаление или сжатие старых файлов логов), временные файлы node_filesystem_avail_bytes из node-exporter
Очередь или таблица Задачи приходят быстрее, чем обрабатываются длина очереди, размер таблицы
Время запроса Таблица пухнет, индекс деградирует p95 растёт медленно при той же нагрузке

В нашем soak течёт только память, поэтому остальные графики нужны как контроль. Если соединения, диск и p95 на месте, ты можешь утверждать, что проблема в памяти, а не искать её в трёх местах. Хороший отчёт содержит таблицу «что смотрели» с пометкой «растёт / не растёт».

Осторожно: рост таблицы orders не тревожит, каждый тест создаёт заказы. Тревожит время запроса, которое растёт при той же нагрузке: тогда виноват индекс или статистика.

Главное: soak ищет любой накапливающийся ресурс, поэтому рядом с памятью смотри соединения, дескрипторы, диск и p95.

Мы знаем, что смотреть. Как предсказать, когда рост кончится бедой?

Как предсказать падение: производная и predict_linear

Видеть, что память растёт, мало. Нужно знать когда она кончится: через 16 минут или через две недели. От ответа зависит срочность.

Как с бензобаком: по скорости убывания и остатку считаешь, на сколько хватит. PromQL умеет оценивать наклон. Функция deriv(m[10m]) (производная: скорость роста; запись [10m] значит «возьми значения за последние 10 минут») даёт среднюю скорость изменения метрики за 10 минут (байт в секунду). Функция predict_linear(m[10m], 3600) предсказывает значение через 3600 секунд, если рост продолжится по прямой:

predict_linear(container_memory_working_set_bytes{container_label_com_docker_compose_service="shop"}[10m], 3600)

Здесь container_memory_working_set_bytes это рабочий набор из урока 7.4: память контейнера без давно не нужного кэша файлов. Из метрик cAdvisor она ближе всех к тому, что контейнеру нужно, поэтому её и сравнивают с лимитом. Метрика container_memory_usage_bytes включает весь кэш файлов и растёт даже без утечки, для прогноза она не годится. Если ответ больше лимита, рост, скорее всего, кончится убийством. Прогноз приблизительный: в рабочем наборе остаётся немного кэша, который ядро при нехватке ещё освободит, и падение может наступить чуть позже расчёта. Для нашей утечки это почти не важно: утекает память самого процесса, её ядро не освободит. Можно и просто делить остаток на скорость, как мы делаем ниже, но функция считает это сама, и на ней строят алерты.

Посчитаем на soak. Через 5 минут память 240 МБ (старт 118 МБ плюс 24,5 МБ в минуту, 5 минут). deriv за последние 4 минуты даёт 0,41 МБ в секунду, это 24,5 МБ в минуту. Запас: 512 − 240 = 272 МБ.

Прикинь сам: сколько минут осталось до OOM?

272 / 24,5 это 11,1 минуты. Пять минут уже прошло, 5 + 11 это шестнадцатая минута, как и в первом расчёте. Простая арифметика превращает «что-то растёт» в «через 11 минут упадёт». Типичный алерт делают так же: predict_linear(...[1h], 4*3600) > лимит предупреждает за четыре часа.

Осторожно: не бери слишком короткое окно в начале теста, там идёт прогрев, и прогноз завышен. Смотри последние 10 минут, а не первые. Линейный прогноз верен, пока нагрузка постоянна: она выросла вдвое, и утечка идёт вдвое быстрее.

Главное: predict_linear по working_set превращает рост в срок: «упадём через столько-то минут».

Срок известен. Осталось найти источник.

Как локализовать утечку и что делать

Нашёл утечку, а чинить нечего, пока не знаешь, где она. Сужай по шагам:

  1. Подтверди, что это утечка: растёт только вверх, без плато, наклон пропорционален числу запросов (поменяй нагрузку и проверь).
  2. Найди, от чего зависит. Прогони отдельно разные маршруты: если растёт при любом, утечка в общем коде (прослойка, логирование, метрики), если при одном, в его обработчике.
  3. Возьми профилировщик памяти: в Python есть встроенный tracemalloc (запоминает, где выделена память, и по снимкам показывает, что выросло), есть внешние memray и objgraph. Сравни снимки до и после: увидишь строку, выделившую больше всего.
  4. Исправь код: чисти, ограничивай размер, закрывай.
  5. Повтори soak: наклон должен стать нулевым.

В нашем стенде шаг 2 уже дан: утечка в прослойке и растёт от любого запроса, даже от /healthz. Проверка: останови нагрузку на минуту. Наклон не обнулится, останется маленький (0,4 запроса в секунду от мониторинга, 14 МБ в час). Значит, утечка привязана к запросам вообще, а не к бизнес-логике. Лечение здесь LEAK_ENABLED=0, в настоящем проекте это правка кода и релиз.

Осторожно: перезапуск по расписанию, restart: unless-stopped и увеличенный лимит не лечат, а лишь откладывают, поэтому в отчёте их записывают как временные меры. Вызов gc.collect() тоже не поможет: сборщик освобождает только то, на что нет ссылок, а у утечки ссылка есть.

Главное: утечку локализуют по шагам (подтвердить, от чего зависит, снимки памяти, исправить, повторить soak), а перезапуск её только прячет.

Вернёмся к распродаже: теперь ты умеешь поймать тихую ночную смерть сервиса за двадцать минут теста, а не за сутки, и назвать срок до падения заранее.

Практика

Стенд с мониторингом. В этом уроке вход не нужен, берём сценарий product. Для удобства Grafana открыта на http://localhost:3000. Перед началом проверь, что всё вернулось к исправленному виду из прошлого урока: grep -E '^(LEAK_ENABLED|DB_POOL_MAX|BUG_N_PLUS_ONE)=' ~/learning/load-tester/project/shop/.env должен показать LEAK_ENABLED=0, DB_POOL_MAX=15, BUG_N_PLUS_ONE=0.

1. Контрольный soak: без утечки

Сначала проверяем, как выглядит здоровая память. Запусти 20-минутный прогон в фоне:

cd ~/perf-lab/11-bottlenecks
./run.sh soak-control SCENARIO=product RATE=40 DURATION=20m > /dev/null 2>&1 &
echo "soak-control запущен: $(date +%T)"

Разбор: & в конце запускает команду в фоне, > /dev/null 2>&1 отправляет вывод на экран «в никуда» (результат всё равно сохранится в файл ~/perf-lab/results/11-soak-control.txt скриптом run.sh). В течение 20 минут открой Grafana (Explore) или promq.sh и смотри память:

P=~/perf-lab/scripts/promq.sh; S='container_label_com_docker_compose_service'
$P "container_memory_working_set_bytes{$S=\"shop\"} / 1024 / 1024"

Запускай команду раз в пару минут, значения такие:

  118.4
  121.0
  119.7
  122.2
  120.5

Как читать вывод: память колеблется около 120 МБ (разброс 2-4 МБ: «пила» сборщика мусора и буферов), общего роста нет. Так выглядит здоровый soak.

Типичные ошибки: run.sh выдаёт connection refused значит, что стенд не запущен; память растёт в первые 2-3 минуты на 10-20 МБ значит, что идёт прогрев (нормально), смотри с третьей минуты.

2. Включаем утечку: как будто «релиз с багом»

После контрольного прогона (когда он закончится) имитируем плохой релиз. Одно изменение:

./set-env.sh LEAK_ENABLED=1

Запиши гипотезу до запуска:

Если приложение течёт по 10 КБ на запрос, то при 40 запросах в секунду память растёт на 24 МБ в минуту, стартует около 118 МБ и достигает лимита 512 МБ примерно на 16-17-й минуте. Если рост окажется выходить на плато, это не утечка.

Теперь 20-минутный прогон (дольше предсказанного падения):

./run.sh soak-leak SCENARIO=product RATE=40 DURATION=20m > /dev/null 2>&1 &
echo "soak-leak запущен: $(date +%T)"

3. Наблюдение: график, наклон, прогноз

Пока идёт прогон, в Grafana (Explore, источник Prometheus) построй график: container_memory_working_set_bytes{container_label_com_docker_compose_service="shop"} / 1024 / 1024. Через 5 минут снимай три показания:

$P "container_memory_working_set_bytes{$S=\"shop\"} / 1024 / 1024"
$P "deriv(container_memory_working_set_bytes{$S=\"shop\"}[4m]) * 60 / 1024 / 1024"
$P "predict_linear(container_memory_working_set_bytes{$S=\"shop\"}[4m], 600) / 1024 / 1024"

Разбор: первая строка это текущая память в МБ. Вторая deriv(...) скорость роста в байтах в секунду, умноженная на 60 даёт «за минуту» и деление на 1024 дважды переводит в МБ. Третья predict_linear(..., 600) это прогноз на 600 секунд (10 минут) вперёд, в МБ.

  241.8
  24.6
  487.3

Как читать вывод: память 242 МБ, растёт на 24,6 МБ в минуту (это 40 запросов в секунду по 10 КБ плюс запросы мониторинга), прогноз через 10 минут 487 МБ: ещё ниже лимита, но через 11 минут будет 512. Расчёт до OOM: (512 − 242) / 24,6 = 11 минут. Если твои значения близки, гипотеза держится.

Для убедительности проверь, что наклон привязан к числу запросов. Хватит одной минуты проверки: deriv на текущем RPS 40 даёт 24,6, а число запросов Prometheus тоже знает: sum(rate(http_requests_total[1m])) вернёт около 40,4 (чуть больше заданных 40: идут и сопутствующие запросы). 24,6 МБ в минуту / (40,4 × 60) = 10,4 КБ на запрос: ровно 10 КБ плюс накладные расходы на список. Это количественное подтверждение механизма.

Смотри: две линии на одном графике. Серая ровная, красная идёт вверх почти прямой и без остановки. Задержки (p95) в это время обеих одинаковые, около 12 мс: они бы не выдали проблему.

4. Что случилось: OOM

На шестнадцатой-семнадцатой минуте прогон на считанные секунды начнёт сыпать ошибками (длительность зависит от старта сервиса и проверки здоровья, измерь свою): Docker убьёт контейнер и поднимет его снова. К концу прогона (20 минут) контейнер уже живёт несколько минут. Проверь:

cd ~/learning/load-tester/project/shop
docker compose ps shop
docker inspect shop-shop-1 | jq '.[0] | {RestartCount, State: (.State | {Status, ExitCode, OOMKilled})}'
docker events --since 30m --until 1s --filter container=shop-shop-1 --filter event=oom --filter event=die
NAME         IMAGE       COMMAND                  SERVICE   CREATED          STATUS                    PORTS
shop-shop-1  shop-shop   "/entrypoint.sh"         shop      28 minutes ago   Up 4 minutes (healthy)

{
  "RestartCount": 1,
  "State": {
    "Status": "running",
    "ExitCode": 0,
    "OOMKilled": false
  }
}

2026-10-03T10:41:07.114Z container oom 3f9a0c1d2b44 (image=shop-shop, name=shop-shop-1)
2026-10-03T10:41:07.131Z container die 3f9a0c1d2b44 (exitCode=137, image=shop-shop, name=shop-shop-1)

Как читать вывод: контейнер создан 28 минут назад, а работает всего 4: значит, он перезапускался. RestartCount: 1. OOMKilled: false и ExitCode: 0 относятся уже к новому запуску, поэтому по ним убийство не докажешь: доказательство в docker events, где стоят oom и сразу die с exitCode=137. В логе приложения перед концом ничего нет: процесс убит мгновенно, без записи. Это наблюдение новичков сбивает с толку.

В Prometheus сначала проверь, что алерт ShopDown, скорее всего, не сработал: curl -s localhost:9090/api/v1/alerts | jq '.data.alerts[].labels.alertname'. Простой 15-20 секунд короче его выдержки в минуту. Зато видно перезапуск и OOM:

curl -s -G localhost:9090/api/v1/query --data-urlencode 'query=increase(container_oom_events_total{container_label_com_docker_compose_service="shop"}[30m])' | jq -r '.data.result[0].value[1]'
1

Это число OOM-убийств за 30 минут (у тебя может быть 1 или дробное значение вроде 1.0003, increase экстраполирует). Если пусто: метрика называется иначе в твоей версии cAdvisor, посмотри curl -s localhost:8080/metrics | grep oom (порт cAdvisor может быть закрыт с хоста, тогда docker compose exec или страница /graph в Prometheus).

Типичные ошибки: RestartCount: 0: прогон ещё не дошёл до предела, подожди; на Docker Desktop для Mac память виртуальной машины может быть меньше 512 МБ, и убьёт раньше (проверь настройки Resources).

Результат прогона в k6:

grep -E "http_req_failed|iterations|connection refused" ~/perf-lab/results/11-soak-leak.txt | head -5

Ожидаем небольшую долю ошибок, порядка процента (несколько секунд или десятков секунд перезапуска из 20 минут) и сообщения connection refused. Если бы политики перезапуска не было, ошибок было бы около 20% (последние 4 минуты из 20).

5. Исправление и повторный soak

Исправление в стенде это выключатель (в настоящем проекте была бы правка кода). Одно изменение:

cd ~/perf-lab/11-bottlenecks
./set-env.sh LEAK_ENABLED=0
./run.sh soak-fixed SCENARIO=product RATE=40 DURATION=20m > /dev/null 2>&1 &

Через 20 минут снимай deriv: он должен быть около нуля (±0,2 МБ в минуту), память около 120 МБ, ошибок нет. Это замыкает цикл: симптом (рост памяти), гипотеза (10 КБ на запрос), проверка (наклон, число запросов), одно изменение, повторный прогон, итог (наклон нулевой). Без повторного прогона исправление не доказано.

6. Вывод и коммит

Допиши ~/perf-lab/11-bottlenecks/04-memory-soak.md:

# 11.4. Память: soak

## Условия
SCENARIO=product, 40 запросов/с, 20 минут, mem_limit 512 МБ, исправленная БД.

## Контроль (без утечки)
Память около 120 МБ ±3, наклон 0.

## С LEAK_ENABLED=1
Старт 118 МБ, рост 24,6 МБ/мин (10,4 КБ на запрос), прогноз в минуту 5: падение через 11 минут.
Факт: OOM на 16-й минуте (событие `oom` в `docker events`, exitCode=137), Docker перезапустил контейнер сам (RestartCount=1), простой порядка десятков секунд (замерь), ошибок в k6 около процента. p95 всё время около 12 мс. Алерт ShopDown не сработал (простой короче его `for: 1m`), перезапуск виден только по `container_oom_events_total`.

## Исправление
LEAK_ENABLED=0 (в настоящем проекте: правка кода). Повторный soak: наклон 0, ошибок нет.

## Временные меры (не лечат)
restart: unless-stopped (уже в стенде), лимит памяти больше: только отсрочка.
cd ~/perf-lab
git add 11-bottlenecks results/11-soak-*.txt
git commit -m "11.4: soak и утечка памяти"
git push

Сломай и почини

Поломка: повтори падение и посмотри на него глазами дежурного, который про утечку не знает. Контейнер перезапускается сам, значит, «чинить перезапуском» не нужно: он уже так «починен». Включи утечку и запусти длинный прогон:

cd ~/perf-lab/11-bottlenecks
./set-env.sh LEAK_ENABLED=1
./run.sh soak-restart SCENARIO=product RATE=40 DURATION=40m > /dev/null 2>&1 &

Что произойдёт: сервис упадёт на 16-й минуте, Docker поднимет его, и память начнёт расти сначала. Ты увидишь «зубья пилы» с периодом 16 минут: память растёт, падает до 118, растёт снова. RestartCount растёт. Ошибок в k6 мало (около 20 секунд простоя на каждый перезапуск), и кажется, что проблема «решена». Но каждый раз пользователи получают разрыв соединений, запросы в полёте обрываются (сами токены лежат в Redis и перезапуск переживают), а ночью никто не заметит повторяющегося падения: ShopDown с выдержкой в минуту молчит, потому что сервис быстро возвращается.

Задача: найди следы перезапусков тремя способами (docker inspect, docker events, запрос в Prometheus) и придумай алерт, который бы их заметил. Подсказка: нужен счётчик перезапусков за час, а не состояние «недоступен сейчас».

Диагностика: docker inspect shop-shop-1 | jq '.[0].RestartCount' показывает число перезапусков, а запрос changes(container_start_time_seconds{container_label_com_docker_compose_service="shop"}[1h]) в Prometheus показывает частоту. Зубчатый график памяти с периодом, постоянным при данной нагрузке, это сигнатура «утечка плюс перезапуск».

Решение: перезапуск не лечит, а прячет. Верни LEAK_ENABLED=0 (./set-env.sh LEAK_ENABLED=0), дождись окончания прогона или останови его. Правило для отчёта: политика перезапуска нужна всегда как страховка, но алерт на число перезапусков обязателен, иначе утечка станет невидимой. Пример правила: increase(container_oom_events_total{container_label_com_docker_compose_service="shop"}[1h]) > 0 с важностью warning.

Сначала найди перезапуски сам тремя способами из задачи, потом спроси нейросеть про текст алерта на их число. Правило проверь в Prometheus: запрос должен вернуть значение, когда контейнер перезапускался за последний час.

ИИ в помощь

Нейросеть быстро набросает алерт и объяснит коды выхода, но формулы и имена метрик cAdvisor она нередко путает. Общие правила: ИИ-помощник.

Задача: отличить утечку от обычного роста памяти.

Стенд «Магазин» в Docker, контейнер shop, soak-тест 40 минут, 40 запросов в секунду. Память контейнера
(container_memory_working_set_bytes), по точкам раз в минуту: <вставь значения в МБ>.
Лимит памяти <вставь>. Это утечка или обычный рост кэша? Объясни, как по наклону прямой оценить,
через сколько минут будет OOM, покажи расчёт по шагам и что ещё проверить, прежде чем сказать «утечка».

Проверь ответ: пересчитай сам: (лимит - текущая память) / наклон в МБ в минуту. Сверь с графиком в Grafana и с docker inspect (RestartCount). Типичные ошибки: арифметика с перепутанными единицами (МБ и МиБ, байты и мегабайты), совет смотреть container_memory_usage_bytes вместо working_set (в нём кэш файлов), вывод об утечке по одной точке.

Задача: написать алерт на частые перезапуски.

Prometheus 3.15, метрики cAdvisor. Напиши правило алерта: контейнер сервиса shop (метка
container_label_com_docker_compose_service="shop") перезапускался хотя бы раз за последний час,
severity warning, с аннотацией summary. Объясни каждую часть выражения и чем changes() отличается от increase().

Проверь ответ: проверь выражение в http://localhost:9090 во время перезапуска и проверь файл правил командой promtool check rules. Типичная ошибка: имя метрики из другого экспортёра или алерт на «недоступен сейчас», который молчит, потому что сервис быстро возвращается.

Словарик урока

Термин Простыми словами
Утечка памяти Программа берёт память и не освобождает: ссылка остаётся, хотя объект не нужен
Soak test (тест на выносливость) Умеренная нагрузка долго: ищет медленную деградацию
working_set Память контейнера без давно не нужного кэша файлов: лучшая оценка близости к лимиту и OOM, но приблизительная
OOM-killer Механизм ядра, убивающий процесс, когда память кончилась
OOMKilled Отметка Docker, что контейнер убит по лимиту памяти (после автоперезапуска может быть уже сброшена)
Код выхода 137 128 + 9: процесс убит сигналом SIGKILL
cgroup Механизм ядра Linux, ограничивающий и считающий ресурсы группы процессов
deriv Функция PromQL: скорость изменения метрики за окно
predict_linear Функция PromQL: значение метрики через заданное время при линейном росте
Плато Выход графика на ровную линию после роста (прогрев, кэш с лимитом)
Политика restart Что Docker делает с остановившимся контейнером: перезапускает или нет
Предиктивный алерт Предупреждение по прогнозу: «упадёт через N часов»
tracemalloc Встроенный в Python инструмент: показывает, где выделена память

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ.

1. [junior] [часто] Что такое soak-тест и что он находит?

Ответ

Ровная умеренная нагрузка долгое время (часы, сутки). Находит проблемы, проявляющиеся со временем: утечки памяти, рост очередей и соединений, забитый диск, истекающие токены и сертификаты. Короткий тест их не покажет.

Что хотят услышать: умеренная нагрузка, долго, тренды, примеры проблем.

Красный флаг: путают с стресс-тестом (нагрузка выше предела на короткое время).

2. [junior] [часто] Как отличить утечку памяти от обычного роста?

Ответ

Обычный рост (прогрев, кэш) выходит на плато или имеет лимит. Утечка обычно растёт монотонно, не останавливается, а на стенде наклон зависит от числа запросов, и повторные прогоны поднимают потолок. Проверяют длительным прогоном и сравнением наклона при разной нагрузке.

Что хотят услышать: плато против бесконечного роста, зависимость от нагрузки.

Красный флаг: «память выросла, значит утечка» после двух минут теста.

3. [junior] [часто] Что означает код выхода 137 у контейнера?

Ответ

Процесс убит сигналом SIGKILL (128 + 9). Чаще всего это OOM-killer: контейнер превысил лимит памяти. Подтверждение: OOMKilled: true в docker inspect (если контейнер не перезапущен, иначе флаг сброшен: смотри docker events и RestartCount). В логе приложения следов не будет: процесс не успевает ничего написать.

Что хотят услышать: 128 + 9, OOM, проверка OOMKilled, docker events и RestartCount, отсутствие записей в логе.

Красный флаг: искать исключение в логе приложения.

4. [junior] Какую метрику памяти контейнера сравнивать с лимитом?

Ответ

container_memory_working_set_bytes: память контейнера без давно не нужного кэша файлов, лучшая доступная оценка близости к OOM. usage_bytes включает весь файловый кэш, который ядро выбросит при нехватке, и даёт ложную тревогу. Оценка по working_set приблизительная: в ней остаётся недавний кэш, который ядро тоже может освободить, а OOM-kill приходит, только когда освобождать больше нечего.

Что хотят услышать: working set, почему не usage и что прогноз по нему ориентировочный.

Красный флаг: брать usage без разбора.

5. [middle] Как предсказать, когда сервис упадёт по памяти?

Ответ

Измерить наклон: deriv(memory[10m]), остаток до лимита разделить на наклон. В Prometheus: predict_linear(memory[1h], N), и если ответ больше лимита, OOM вероятен, если тренд сохранится. Окно брать после прогрева, нагрузка должна быть стабильной.

Что хотят услышать: производная или predict_linear, оговорки про прогрев и постоянство нагрузки.

Красный флаг: «посмотрим по графику на глаз».

6. [middle] Почему перезапуск контейнера не лечит утечку?

Ответ

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

Что хотят услышать: маскировка, цикл, алерт на перезапуски.

Красный флаг: «настроим перезапуск по расписанию, и всё».

7. [middle] Как найти место утечки в Python-приложении?

Ответ

Сузить: от каких запросов растёт. Затем профилировщик памяти (tracemalloc со снимками до и после, memray, objgraph): они показывают строки кода, где выросла память. Исправить (ограничить кэш, чистить структуру), повторить soak и убедиться, что наклон нулевой.

Что хотят услышать: локализация, инструменты, повторный soak.

Красный флаг: gc.collect() как лечение.

8. [middle] Какой длины должен быть soak-тест и какая нагрузка?

Ответ

Нагрузка 60-70% от предела (чтобы не копились очереди), длительность в несколько раз больше, чем нужно проблеме проявиться: для утечки с OOM через 16 минут минимум 20-30, на практике часы или сутки. Смотреть тренды памяти, соединений, диска, а не только p95.

Что хотят услышать: ниже предела, долго, тренды.

Красный флаг: soak на 5 минут.

9. [на скорость] Чему равен код выхода при SIGKILL?

Ответ

137 (128 + 9).

10. [на скорость] Какая функция PromQL оценивает скорость роста метрики?

Ответ

deriv (для gauge), а прогноз на будущее даёт predict_linear.

11. [на скорость] Задержки в утечке растут?

Ответ

Обычно нет, пока память не кончилась. Утечку видно по тренду памяти, не по p95.

Проверено на версиях

Docker Compose v2, контейнер shop с mem_limit: 512m, Prometheus 3.15 (deriv, predict_linear), cAdvisor, Grafana 13.2, k6 2.3. Числа получены расчётом по коду стенда (10 КБ на запрос), на твоём ноутбуке старт памяти и время падения могут отличаться на несколько минут. Октябрь 2026.

Итог урока: ты умеешь

  • Отличать утечку от прогрева и кэша по форме графика.
  • Проектировать soak: нагрузка ниже предела, длительность с запасом, метрики-тренды.
  • Считать скорость утечки и предсказывать время до OOM по deriv и predict_linear.
  • Узнавать OOM по коду 137, событию oom в docker events и RestartCount (после перезапуска OOMKilled может быть уже false) и понимать, почему в логе пусто.
  • Объяснять, почему перезапуск маскирует, а не лечит, и ставить алерт на перезапуски.
  • Подтверждать исправление повторным soak с нулевым наклоном.

Дальше: урок 11.5. Кэш, внешние зависимости, таймауты и ретраи, последний урок темы: ускоряем «Магазин» кэшем и ломаем его медленной оплатой.

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 11 урок 11.4 2.5 ч курс 0/0 ← → уроки