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