Привет, Хабр! Статья не входила в планы, пишу с чувством лёгкой сюрреалистичности. В воскресенье утром наш основной 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), тонкости ОС и инструменты для эффективной работы.