Привет, Хабр! Статья не входила в планы, пишу с чувством лёгкой сюрреалистичности. В воскресенье утром наш основной API-гейтвей пережил маленькую апокалиптическую битву с памятью и выиграл без моего участия. Делюсь с Вами, как небольшой скрипт, на который я не возлагал абсолютно никаких надежд, отработал аварию.
Введение
У нас есть «боевой» сервер api-prod-01
. Задача — быть главным API‑гейтвеем: принимает входящие запросы от мобильных приложений и сайта, ответственный за аутентификацию и прочие нужды. На нём работает связка Ngnix и кастомного Python‑приложения на Gunicorn.
Началось всё с типичной проблемы для понедельника, которая случилась в воскресенье... После пятничного деплоя в одном из воркеров Gunicorn начала медленно (но очень «верно»...) утекать память. Безусловно, свободная оперативная память на сервере закончилась, что спровоцировало «пробуждение» линускового OOM Killer (Out‑of‑Memory Killer) — механизм, который убивает процессы, чтобы спасти систему от полного падения. Этот «товарищ» не разбирается, что бьёт и зачем, поэтому вполне мог попасть в критически важные процессы. Фактически, гарантированный «даунтайм».
В пятницу, я словно почувствовал, что стоит перестраховаться и закинуть этот скрипт на сервер (сам скрипт вытащен с личной VPS). Не было каких‑то предпосылок, как и не было уверенности, что в случае «аварии» — скрипт решит проблему. Но всё оказалось наоборот.
Решение
Я не изобретал каких-то сложных систем. Всё, что было нужно - детектировать проблему и дать системе шанс попробовать спасти себя самостоятельно. Логика очень простая:
Ловить момент, когда память на исходе (
< 100
)Принудительно рестартнуть виновный сервис (в моем случае - Gunicorn), который можно подозревать в утечке
Детально записать все действия в лог. Это главный отчёт для "разбора полётов", дабы избежать подобное в дальнейшем
Код "тихого героя":
#!/bin/bash
# Сторожевой пёс для api-prod-01
# Назначение: отслеживает нехватку памяти и перезапускает gunicorn,
# предотвращая срабатывание OOM Killer и даунтайм API
set -euo pipefail
# --- Конфиг ---
THRESHOLD_MB=100 # Критический порог свободной памяти в МБ
SERVICE_NAME="gunicorn-api.service" # Сервис, который утекает
LOG_FILE="/var/log/api-oom-watchdog.log"
SERVER_NAME="api-prod-01" # Имя сервера для логов
# --- Функции ---
log_message() {
local message="$1"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$SERVER_NAME] $message" | tee -a "$LOG_FILE"
}
# --- Логика работы ---
log_message "INFO: Start memory check."
# Получаем количество свободной памяти в мегабайтах
free_mb=$(free -m | awk '/Mem:/ {print $7}')
if [ "$free_mb" -lt "$THRESHOLD_MB" ]; then
# Тревога! Память на исходе.
log_message "CRITICAL: Free memory is critically low: ${free_mb}MB. OOM Killer is near."
log_message "ACTION: Attempting to restart service '$SERVICE_NAME' to release memory."
# Попытка вежливо перезапустить сервис
if systemctl restart "$SERVICE_NAME"; then
log_message "SUCCESS: Service '$SERVICE_NAME' restarted successfully."
# Записываем итоговый статус сервиса и памяти после перезапуска
systemctl status "$SERVICE_NAME" --no-pager -l >> "$LOG_FILE"
free -m | awk '/Mem:/ {printf "MEMORY STATUS: Total: %sMB, Used: %sMB, Free: %sMB\n", $2, $3, $7}' >> "$LOG_FILE"
log_message "INFO: Crisis averted. The API gateway remains online."
else
log_message "FAILURE: Failed to restart '$SERVICE_NAME'. Manual intervention required!"
exit 1
fi
else
log_message "INFO: Memory OK. Free: ${free_mb}MB."
fi
Разберём основные моменты кода с пояснением:
"Ремень безопасности", предотвращающий выполнение скрипта в неопределенном состоянии:
set -euo pipefail
Где:
-e
- немедленный выход при любой ошибке-u
- запрет на использование необъявленных переменных-o pipefail
- возврат кода ошибки пайплайна, не только последней команды
"Умное" определение свободной памяти:
free_mb=$(free -m | awk '/Mem:/ {print $7}')
Где:
free -m
- показывает память в мегабайтахawk '/Mem:/ {print $7}'
- извлекает именно свободную память (столбец 7)
Мониторинг вместо реакции на аварию:
if [ "$free_mb" -lt "$THRESHOLD_MB" ]; then
Где:
Скрипт предотвращает, а не исправляет уже случившуюся проблему
Порог в 100 МБ выбран до предположительного срабатывания OOM Killer
Перезапуск сервиса:
systemctl restart "$SERVICE_NAME"
Где:
Сервис перезапускается до того, как процессы хаотично умрут от OOM Killer
Важное уточнение! Это костыльное решение, запущенное "на всякий случай", основанное исключительно на предположениях, что сервис мог съесть всю память
Основа скрипта - логирование:
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$SERVER_NAME] $message" | tee -a "$LOG_FILE"
Где:
Временные метки - для анализа закономерностей
tee -a
- вывод в консоль и файл одновременноПосле перезапуска записывает итоговое состояние системы
Примерная схема работы (для лучшего понимания):
[Запуск] -> [Проверка памяти] - [Достаточно?] -> Да -> [Завершение]
|
Нет -> [Перезапуск сервиса] -> [Успех?] -> Да -> [Логирование]
|
Нет -> [Тревога] -> [Выход с ошибкой]
Как это работает в действительности?
Главная особенность - скрипт не работает сам по себе, а лишь тихо висит в cron
и запускается каждые 5 минут.
crontab -e
и добавляем строчку:
*/5 * * * * root /usr/local/bin/
api-oom-watchdog.sh
Давайте посмотрим сценарий работы подобного "решения":
04:00 - скрипт глянул память, свободно 120МБ. "Memory OK", запишет в лог.
04:25 - память кончается из-за утечки в воркере Gunicorn, свободно всего 85МБ, OOM Killer тихо потирает руки.
04:26 - запускается скрипт из
cron
.Он видит, что
85 < 100
и срабатывает условие. Подробно записывает в лог критическое состояние.Командой останавливает и заново запускает
gunicorn-api.service
.Память освобождается, OOM Killer грустно засыпает. Скрипт логирует успех, фиксирует статус сервиса и состояние памяти.
Nginx продолжал работу, лишь малая часть запросов приходила с ошибкой 502, пока перезапускался Gunicorn. Обошлось без полного даунтайма.
Итог
В понедельник, после пятничного деплоя, на всякий случай первым делом проверил логи и увидел хронологию ночного инциндента. На моё огромное удивление, не было звонков, разбирательств, был лишь отчёт в /var/log/api-oom-watchdog.log
, который продемонстрировал героическое мужество и спас меня от ночных звонков.
Этот скрипт - самый настоящий костыль, заброшенный на сервер "на всякий случай". Это не решение, ни в коем случае. Решение - найти и пофиксить утечку памяти в коде. Но данный костыль позволил серверу "остаться на плаву" и дал мне время на спокойный фикс, позволил избежать "сверхурочной" работы ночью.
Кстати, проблема была вот в чём:
Кстати, проблема оказалась достаточно банальна... В пятницу был деплой "фичи", которая добавила новый атрибут в объект сессии. Из-за ошибки в логике этот атрибут никогда не удалялся и не инвалидировал старые записи. В результате кэш, который жил пару часов (но не в этот раз), начал бесконечно расти, накапливая сессии за 2 дня. К ночи воскресенья он достиг критической массы. Скрипт, перезапустивший службу, очистил кэш, благодаря чему у нас было время найти и починить логику инвалидации.
Я приведу пример кода, который далёк от нашего, но чётко P. S. В моей группе в Телеграмм разбираем практические кейсы: скрипты (Python/Bash/PowerShell), тонкости ОС и инструменты для эффективной работы. описывающий суть проблемы:
from cachetools import TTLCache
import datetime
# Кеш на 1000 элементов с TTL 1 час
session_cache = TTLCache(maxsize=1000, ttl=3600)
def update_user_session(user_id: int, new_data: dict):
"""Обновляем данные сессии пользователя"""
# Ключ - ID пользователя
cache_key = f"user_{user_id}"
# PROBLEM: Если ключ уже есть в кеше - мы ДОБАВЛЯЕМ данные,
# но не обновляем время жизни существующей записи правильно
if cache_key in session_cache:
current_data = session_cache[cache_key]
current_data.update(new_data) # Просто обновляем данные :)
# TTL не обновляется автоматически при таком подходе :)
else:
# Создаем новую запись
session_cache[cache_key] = new_data
Исправленный вариант:
from cachetools import TTLCache
import datetime
# Кеш на 1000 элементов с TTL 1 час
session_cache = TTLCache(maxsize=1000, ttl=3600)
def update_user_session(user_id: int, new_data: dict):
"""Обновляем данные сессии пользователя"""
cache_key = f"user_{user_id}"
# SOLUTION: Явно обновляем запись - это сбрасывает TTL
if cache_key in session_cache:
current_data = session_cache[cache_key]
current_data.update(new_data)
# Ключевой момент: перезаписываем значение
session_cache[cache_key] = current_data # TTL сбрасывается (как оказывается всё просто :) )
else:
session_cache[cache_key] = new_data
P. S. В моей группе в Телеграм разбираем практические кейсы: скрипты (Python/Bash/PowerShell), тонкости ОС и инструменты для эффективной работы.
Комментарии (20)
ky0
29.08.2025 13:53Один экземпляр чего-либо, даже не текущего - вот это гарантированный даунтайм. Ну, и "быстро поднятое упавшим не считается" в 2025 году - это только для какой-нибудь госухи ок.
outlingo
29.08.2025 13:53Ну если там нормальный стейтлесный сервис - то в общем то да, быстро перезапущеный не считается упавшим.
ky0
29.08.2025 13:53Ну, да - а пару десятков 500-ых ошибок, отданных клиентам, мы просто заметём под ковёр вместо того, чтобы настроить какой-нибудь из придуманных умными людями вариантов фейловера.
outlingo
29.08.2025 13:53Amazon cloud-way, клиент должен быть готов к ошибкам и уметь ретраиться
mSnus
29.08.2025 13:53Почему AWS? Клиент должен быть всегда готов, что ему временно отрубили интернет в самый интересный момент, мы же все нынче mobile first
Black_Shadow
29.08.2025 13:53Ошибка 500 - это не отрубили интернет.
Kenya-West
29.08.2025 13:53отрубили интернет
А мы именно так и скажем и на пункт "Прокатило" галочку поставим...
aeder
29.08.2025 13:53Механизм обнаружения утечек памяти в долговременно запущенном ПО - да, обязательно должен быть. Проекты, с которыми сталкивался я - имеют прогнозируемый uptime, измеряемый в годах. Т.е. перезапуск - при штатном сервисном обслуживании. Раз в год, а то и реже.
Может быть очень медленная утечка памяти, когда проблемы начнут проявляться не через сутки и даже не через неделю.
Более того, лично я сталкивался с ситуацией, когда утечка памяти начинала проявляться не как "нет памяти/падают процессы" - а замедлением работы системы, т.к. тот же аналог кэша разрастался настолько, что поиск в нём стал занимать существенное время.
Поэтому - все наши разработки включают механизм, который отслеживает потребление памяти. Чтобы не мучиться с настройкой для каждого процесса - через сутки после запуска берём текущее потребление, удваиваем, добавляем константу - и считаем это жёстким лимитом сверху.
eternaladm Автор
29.08.2025 13:53Спасибо за комментарий!
Поэтому - все наши разработки включают механизм, который отслеживает потребление памяти. Чтобы не мучиться с настройкой для каждого процесса - через сутки после запуска берём текущее потребление, удваиваем, добавляем константу - и считаем это жёстким лимитом сверху.
Очень интересный подход, ранее не думал о подобном. Возьму себе на заметку, спасибо!
JBFW
29.08.2025 13:53А ещё бывает прекрасная ситуация, когда сервис - вещь в себе, написанная когда-то, со своими закидонами, которую нельзя исправить, можно только заменить - и вот такое решение, хоть и костыль, прекрасно решает задачу.
Причем скрипту в общем все равно что именно проверять: свободную память, доступность сервиса и т.д. Не прошла проверка - перезапуск!
В этом преимущество подобных скриптов перед другими путями решения проблемы - они универсальны по сути.
А так-то конечно память не должна утекать, программа не должна глючить, сферический конь должен быть помыт, начищен и отполирован, чтобы сверкать в вакууме...
yoda317
29.08.2025 13:53как процессы хаотично умрут от OOM Killer
Оом не убивает процессы "хаотично". Он убивает либо самого жирного по скорингу, либо процесс который непосредственно вызвал out of memory. Это зависит от значения vm.oom_kill_allocating_task
0 и умрет самый жирный. 0 по дефолту, если что.
1 и будет убивать того кто запросил память которой нет.
При 0, с гарантией будет убиваться процесс с утечкой памяти.
mSnus
29.08.2025 13:53Всё здорово, но если память сожрёт кто-то другой, скрипт всё равно
задушитубьет невинного Питона. Т.е. это соломка, подстеленная именно туда, куда собираешься упасть.Ещё бы одну строчку, чтобы убивать именно то, что занимает большего всего памяти..
eternaladm Автор
29.08.2025 13:53Спасибо за комментарий! Согласен с Вами всецело, но мои подозрения упали именно на Питона после деплоя. Почему-то «почувствовал», что подобное может случиться.
По-хорошему подобный подход грамотнее реализовывать через опцию MemoryMax, был комментарий выше на данную тему.
xhumanoid
29.08.2025 13:53https://docs.gunicorn.org/en/stable/settings.html#max-requests
Автоматический рестарт воркера после обработки N запросов, причём так как это делает сам gunicorn, то он сразу снимает нагрузку с воркера и не отправляет туда больше запросов, а потом уже делает его рерстарт. В этом случае сервис у вас остаётся доступным всегда
Пару раз пока искали утечки приходилось этот "костыль" использовать
hazard2005
29.08.2025 13:53Я бы запустил все это в докер и тот сам бы перезапускал бы процесс в случае самостоятельного падения или по причине утечки
outlingo
Выглядит так, словно Роскомнадзор заблокировал вас в Гугле.
В man systemd.resource-control есть описание опции MemoryMax которая лимитирует использования памяти процессами юнита. Выставляете заданный лимит опираясь на то, сколько памяти позволено отъесть вашему сервису, и система возьмет работу на себя.
eternaladm Автор
Спасибо за комментарий! С Вашим замечанием я полностью согласен, по-хорошему все рабочие сервера надо грамотно настраивать, но не всегда есть такая возможность. Я работаю не с "рассвета" организации, поэтому частенько приходится пожинать плоды.
Выгрузить скрипт на рабочую машину - решение крайне спонтанное, этакое "6-е чувство", на всякий случай, перед концом рабочего дня. Я даже предположить не мог, что данный сервер поведёт себя подобным образом, тк за время моей работы не было ни одного подобного инцидента.
В остальном, Вы правы буквально на все 100%. Обязательно дотянусь до данного сервера. Спасибо!
gerashenko
А пятничные вечерние деплои не тревожат 6е чувство?
eternaladm Автор
Спасибо за комментарий! Вообще должны, но обычно всё проходит спокойно, без эксцессов. Данный случай - крайне редкое исключение.