Приложение падает только под нагрузкой в понедельник утром
Есть баг хуже любого краша со стектрейсом — тот, который воспроизводится строго по расписанию. Приложение падает каждый понедельник около десяти утра, а во вторник в тот же час — ничего, работает как часы. В четверг вечером вы пытаетесь повторить проблему нагрузочным тестом — и снова тишина. Это не мистика и не "нестабильный сервер", это почти всегда конкретный количественный лимит, который упирается в потолок именно в момент реального пика. Ниже — как его найти, а не гадать.
Содержание
- Почему этот баг такой неудобный
- Две проигрышные стратегии, которые пробует почти каждая команда
- Методология: ловите метрики в момент пика, а не после
- Сравнение пика и тишины бок о бок
- Типичные находки: где именно упираются под нагрузкой
- Накопительная деградация за выходные: почему падает именно в понедельник, а не во вторник
- Как закрыть найденную причину
Почему этот баг такой неудобный
Обычный краш воспроизводится по требованию: запустил — упало, поправил — не падает. Инцидент "падает только под нагрузкой" ведёт себя иначе — он воспроизводится только при стечении условий, которые вы не полностью контролируете:
- реальный трафик после выходных выше среднего в 3-8 раз (у многих B2B-сервисов и корпоративных приложений именно так — люди возвращаются с выходных и разом открывают то, что откладывали);
- к моменту пика могли накопиться побочные эффекты выходных — не вычищенные сессии, разросшиеся временные таблицы, недокачанные очереди;
- нагрузка идёт не одним типом запросов, а смесью — синхронные API-вызовы, фоновые джобы по расписанию, батчи, которые тоже запланированы на утро понедельника.
В "тихое" время ни одно из этих условий не выполняется одновременно, поэтому всё работает идеально — и это самое обманчивое свойство такого бага. Кажется, что раз в тишине всё в порядке, значит с кодом всё в порядке. На деле код в порядке ровно до тех пор, пока не упрётся в свой лимит — а лимит виден только тогда, когда к нему подошли вплотную.
Две проигрышные стратегии, которые пробует почти каждая команда
Стратегия "само пройдёт". После первого падения по понедельникам приложение просто перезапускают, инцидент закрывают как единичный сбой, и через неделю всё повторяется. Проблема в том, что при накопительной деградации (о ней ниже) промежуток между инцидентами может со временем сокращаться — сначала падает раз в две недели, потом каждый понедельник, потом ещё и по пятницам вечером. Игнор не устраняет причину, а откладывает её на всё более короткий срок.
Стратегия "воспроизведём искусственной нагрузкой". Команда запускает ab, wrk, k6 или locust, гоняет тысячи запросов в секунду — и приложение не падает. Это не значит, что бага нет: искусственный тест почти никогда не повторяет реальный профиль нагрузки. Синтетические запросы обычно однородны (один и тот же эндпоинт, одна и та же нагрузка на БД), а в реальности пиковый трафик — это смесь чтений, записей, авторизаций, фоновых крон-задач и, возможно, ещё не остывшего кэша после рестарта на выходных. Если тест не бьёт именно в то узкое место, которое давит в реальности, он проходит зелёным, а проблема остаётся.
Вывод из обеих стратегий один: угадывать бесполезно. Нужно смотреть на метрики в момент реального пика — не постфактум, не в лаборатории, а прямо когда происходит инцидент.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверМетодология: ловите метрики в момент пика, а не после
Если вы уже пережили пару таких инцидентов и знаете примерное время — у вас есть огромное преимущество: можно подготовиться заранее и собрать снимок состояния системы именно в узком окне, когда всё случается.
1. Настройте непрерывный сбор метрик, который переживёт выходные.
Если метрики уже идут в Prometheus/Grafana — считайте полдела сделано, смотрите пункт 2. Если нет — минимальный вариант на скорую руку:
# на сервере, вешаем на cron каждую минуту с пятницы по понедельник
* * * * * /usr/local/bin/snapshot.sh >> /var/log/snapshot.log 2>&1
#!/usr/bin/env bash
# snapshot.sh
echo "=== $(date -Iseconds) ==="
ss -s
echo "-- time-wait count --"
ss -tan state time-wait | wc -l
echo "-- top by rss --"
ps -eo pid,rss,cmd --sort=-rss | head -10
echo "-- load --"
cat /proc/loadavg
echo "-- fd used by main process --"
ls /proc/$(pgrep -f myapp | head -1)/fd 2>/dev/null | wc -l
Это грубо, но лучше грубый снимок, чем ничего. Правильный вариант — поднять node_exporter + Prometheus заранее, чтобы иметь исторический ряд, а не разрозненные строчки в логе.
2. Снимайте состояние приложения, а не только ОС. Метрик уровня системы (CPU, память, load average) часто недостаточно — они покажут, что "что-то не так", но не покажут что именно. Нужен снимок изнутри процесса в момент пика:
- для Python —
py-spy dump --pid <PID>илиpy-spy record -o profile.svg --pid <PID> --duration 60прямо во время инцидента; - для Node.js —
node --profзаранее при старте, либо аварийныйkill -USR2 <PID>там, где это настроено для снятия heap snapshot; - для Java/JVM —
jstack <PID>иjmap -histo <PID>в момент проблемы, они покажут, на чём именно заблокированы потоки; - для Go — встроенный
pprof:curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt.
Ключевое условие — снимать это нужно именно в окне инцидента, а не через час после того, как всё уже перезапустили и картина смылась. Если вы не успеваете среагировать вручную — держите наготове простой цикл в отдельном терминале, который раз в 10 секунд проверяет load average и снимает дамп при превышении порога (if (( $(cut -d' ' -f1 /proc/loadavg) > 8 )); then py-spy dump --pid $(pgrep -f myapp) > dump_$(date +%s).txt; fi), запущенный на весь понедельник до конца прогнозируемого окна — так дамп снимется, даже если инженер не успел отреагировать сам.
3. Смотрите на прикладной уровень, а не только на инфраструктурный. Часто ключевая метрика — не CPU и не память, а число активных соединений с БД, число занятых воркеров приложения, длина очереди на входе. Это надо логировать отдельно, например периодическим запросом к БД:
-- PostgreSQL: сколько соединений активно прямо сейчас и на что они ждут
SELECT state, wait_event_type, count(*)
FROM pg_stat_activity
GROUP BY state, wait_event_type
ORDER BY count(*) DESC;
-- MySQL: то же самое через processlist
SELECT command, state, count(*)
FROM information_schema.processlist
GROUP BY command, state;
Запускайте такой запрос по крону в лог-файл каждую минуту с пятницы — в понедельник у вас будет ряд значений, а не одна точка.
Сравнение пика и тишины бок о бок
Когда снимки за момент падения и за спокойный вторник у вас на руках, сведите их в одну таблицу — разница почти всегда бросается в глаза сразу:
| Метрика | Спокойный вторник, 10:00 | Пик, понедельник, 10:00 |
|---|---|---|
| Активные соединения к БД | 12 из 100 | 100 из 100 (упор в лимит) |
| Свободные воркеры приложения | 6 из 8 | 0 из 8 |
| RSS процесса | 480 МБ | 3.1 ГБ |
| TIME_WAIT сокетов | ~40 | ~28000 |
| Открытых файловых дескрипторов | 210 | 1020 из 1024 (ulimit) |
| p95 времени ответа | 90 мс | 6200 мс |
Как только строчка "уперлись в X из X" находится — это и есть ваш подозреваемый. Не факт, что он один: иногда за упором в пул соединений скрывается более глубокая причина (например, соединения не возвращаются в пул из-за забытого finally), но сама точка упора видна сразу при таком сравнении.
Типичные находки: где именно упираются под нагрузкой
Наиболее частые количественные лимиты, которые не заметны в тишине и вскрываются на пике:
Пул соединений к базе данных. Классика: max_connections в PostgreSQL/MySQL или pool_size в клиентской библиотеке (SQLAlchemy, HikariCP, pg-pool) настроены на разумное с виду число, но при реальном пике одновременных запросов больше, чем свободных соединений — новые запросы встают в очередь на получение соединения, и вся цепочка начинает копить задержку, пока не наступает таймаут или OOM от накопившихся в памяти ожидающих запросов. Смотрите на это через connection pooling: зачем нужен и как настроить и через типичную ошибку MySQL "too many connections" — оба материала разбирают именно этот класс проблем подробно.
Лимит одновременных обработчиков в конфиге приложения. Gunicorn с workers=4, threads=2 даёт максимум 8 одновременных запросов в обработке — девятый и далее просто ждут. Node.js-приложение без кластеризации использует одно ядро, и при вычислительно тяжёлом запросе (парсинг, шифрование, генерация PDF) весь event loop блокируется, и следующие запросы копятся в очереди. Проверьте формулу вашего стека:
максимум одновременных запросов = workers × threads (для Gunicorn/uWSGI)
максимум одновременных соединений = worker_connections × worker_processes (для nginx)
Сравните это число с реальным количеством одновременных запросов на пике (его можно оценить как RPS × среднее время ответа, закон Литтла) — если оценка близка к настроенному лимиту или превышает его, вот и находка.
Лимит открытых файловых дескрипторов. ulimit -n по умолчанию часто равен 1024, и на спокойном трафике этого хватает с большим запасом. На пике с ростом числа одновременных соединений (каждое — минимум один дескриптор, плюс файлы логов, плюс соединения к БД и внешним API) лимит может быть исчерпан, и новые accept() начинают падать с EMFILE. Проверить фактический лимит процесса можно через cat /proc/<PID>/limits.
Ephemeral-порты и TIME_WAIT. Если приложение открывает много коротких исходящих соединений (к БД, к внешним API, к кэшу) без keep-alive, каждое закрытое соединение зависает в TIME_WAIT на несколько десятков секунд. При достаточной интенсивности пул доступных портов (/proc/sys/net/ipv4/ip_local_port_range) исчерпывается, и новые исходящие соединения не могут установиться. В тишине этот пул никогда не подходит к границе, на пике — вполне может.
Накопительная деградация за выходные: почему падает именно в понедельник, а не во вторник
Отдельный и коварный класс причин — деградация, которая копится не за минуты, а за дни, и именно поэтому проявляется строго в понедельник после двух суток простоя, а не в любой случайный день с похожей нагрузкой:
- утечка памяти или соединений, которая не успевает набрать критическую массу в будни. Если приложение теряет, скажем, по 50 МБ памяти в час из-за не закрытых как следует соединений или растущего кэша без вытеснения, за рабочий день это может остаться незамеченным (перезапуск на деплое каждый вечер стирает накопленное). Но если на выходных деплоев не было и процесс не перезапускался двое суток, накопленная утечка достигает предела памяти ровно к моменту, когда сверху наваливается ещё и пиковый трафик понедельника — комбинация добивает процесс, и срабатывает OOM killer или воркер падает по своему внутреннему лимиту памяти;
- разросшиеся временные данные. Cron-задачи, которые не удаляют вовремя старые сессии, временные файлы или записи из очереди, за выходные накапливают заметно больше "мусора", чем за одну ночь буднего дня — и запрос, который в обычный день сканирует тысячу строк, в понедельник сканирует сто тысяч;
- холодный кэш после длительного простоя. Если кэш (Redis, in-memory LRU) не переживает рестарт или просто протухает по TTL за выходные, в понедельник утром первая волна запросов идёт мимо кэша прямо в БД — то, что обычно гасится кэшем, в понедельник создаёт кратковременный, но острый всплеск нагрузки на БД ровно в момент, когда трафик и так на максимуме;
- отложенные фоновые задачи, которые выполняются именно в понедельник утром. Крон-джобы на генерацию отчётов, рассылку уведомлений, синхронизацию с внешними системами часто специально запланированы на начало недели — и они накладываются на органический пиковый трафик, создавая суммарную нагрузку выше, чем кто-либо тестировал по отдельности.
Проверить гипотезу "накопительная утечка" просто: посмотрите тренд RSS процесса или числа открытых соединений за несколько дней подряд в Grafana, не только за момент падения. Если график монотонно растёт с пятницы до понедельника без плато — это она.
Как закрыть найденную причину
Конкретный фикс зависит от находки, но общие принципы одни:
- Пул соединений к БД — увеличьте
pool_size/max_connectionsс запасом, но добавьте таймаут ожидания соединения и алерт на 80% занятости пула, а не ждите 100%. Бездумное увеличение лимита без анализа того, почему соединения не освобождаются вовремя, часто просто отодвигает падение на следующий более крупный пик. - Число воркеров/потоков — считайте нужное значение по формуле Литтла (RPS × среднее время ответа) с запасом 30-50% и проверьте, что серверу физически хватает CPU/памяти на такое число процессов — иначе
workersвверх просто переносит проблему в память или CPU. - Утечка, копящаяся за выходные — временно спасает плановый перезапуск раз в сутки (это костыль на время расследования), а настоящее решение — найти источник через профилирование: дампы кучи,
tracemallocдля Python, heap snapshot для Node. - Лимит файловых дескрипторов или портов — поднимите
ulimit -nиnet.ipv4.ip_local_port_rangeчерез systemd-юнит или/etc/security/limits.conf, добавьте keep-alive для исходящих соединений, чтобы не плодить TIME_WAIT без необходимости. - Во всех случаях — алертите не на факт падения (это уже поздно), а на приближение к найденному лимиту. Мониторинг ошибок в момент, когда они происходят, тоже ускоряет расследование — см. настройку Sentry, она покажет точный момент и контекст запроса, а не только факт исключения постфактум.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли просто увеличить все лимиты с запасом и не искать точную причину?
Можно, и иногда это временно снимает симптом. Но если в основе лежит утечка ресурса, увеличенный лимит лишь отодвигает следующее падение — деградация продолжит копиться и упрётся в новый, более высокий потолок. Без выявления источника утечки проблема гарантированно вернётся.
Почему нагрузочное тестирование в лаборатории не воспроизводит проблему?
Синтетическая нагрузка обычно однородна, а в реальности пик — это смесь типов запросов, накопленное за выходные состояние (память, кэш, временные данные) и совпадение органического трафика с плановыми фоновыми задачами. Тест, повторяющий только RPS, но не эту структуру, может не попасть в узкое место.
Где начинать снимать метрики, если инцидент ещё не разбирался?
С уровня ОС — ss -s, load average, RSS процесса: дёшево настроить, не требует знания архитектуры. Дальше сужайтесь до прикладного уровня: пул соединений, число воркеров, очереди. Первый снимок редко даёт полный ответ, но указывает направление для следующего.
Поможет ли просто более мощный сервер?
Снимет симптом, если причина в нехватке физических ресурсов. Но если дело в лимите конфигурации (max_connections, worker_connections, ulimit) или в утечке в коде, железо не поможет, пока не подняты сами лимиты или не закрыта утечка.
Как часто повторять этот разбор после фикса?
Хотя бы раз — снять те же метрики на следующем пике и убедиться, что найденная цифра держит запас минимум 30-40%. Разовое отсутствие падения ещё не доказывает, что причина устранена: возможно, пик просто был ниже обычного.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →