Тихий герой воскресного утра: как bash-скрипт спас нас от OOM Killer

от автора

Привет, Хабр! Статья не входила в планы, пишу с чувством лёгкой сюрреалистичности. В воскресенье утром наш основной API-гейтвей пережил маленькую апокалиптическую битву с памятью и выиграл без моего участия. Делюсь с Вами, как небольшой скрипт, на который я не возлагал абсолютно никаких надежд, отработал аварию.

Введение

У нас есть «боевой» сервер api-prod-01. Задача — быть главным API‑гейтвеем: принимает входящие запросы от мобильных приложений и сайта, ответственный за аутентификацию и прочие нужды. На нём работает связка Ngnix и кастомного Python‑приложения на Gunicorn.

Началось всё с типичной проблемы для понедельника, которая случилась в воскресенье… После пятничного деплоя в одном из воркеров Gunicorn начала медленно (но очень «верно»…) утекать память. Безусловно, свободная оперативная память на сервере закончилась, что спровоцировало «пробуждение» линускового OOM Killer (Out‑of‑Memory Killer) — механизм, который убивает процессы, чтобы спасти систему от полного падения. Этот «товарищ» не разбирается, что бьёт и зачем, поэтому вполне мог попасть в критически важные процессы. Фактически, гарантированный «даунтайм».

В пятницу, я словно почувствовал, что стоит перестраховаться и закинуть этот скрипт на сервер (сам скрипт вытащен с личной VPS). Не было каких‑то предпосылок, как и не было уверенности, что в случае «аварии» — скрипт решит проблему. Но всё оказалось наоборот.

Решение

Я не изобретал каких-то сложных систем. Всё, что было нужно — детектировать проблему и дать системе шанс попробовать спасти себя самостоятельно. Логика очень простая:

  1. Ловить момент, когда память на исходе (< 100)

  2. Принудительно рестартнуть виновный сервис (в моем случае — Gunicorn), который можно подозревать в утечке

  3. Детально записать все действия в лог. Это главный отчёт для «разбора полётов», дабы избежать подобное в дальнейшем

Код «тихого героя»:

#!/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 &gt;&gt; "$LOG_FILE"         free -m | awk '/Mem:/ {printf "MEMORY STATUS: Total: %sMB, Used: %sMB, Free: %sMB\n", $2, $3, $7}' &gt;&gt; "$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

Давайте посмотрим сценарий работы подобного «решения»:

  1. 04:00 — скрипт глянул память, свободно 120МБ. «Memory OK», запишет в лог.

  2. 04:25 — память кончается из-за утечки в воркере Gunicorn, свободно всего 85МБ, OOM Killer тихо потирает руки.

  3. 04:26 — запускается скрипт из cron.

  4. Он видит, что 85 < 100 и срабатывает условие. Подробно записывает в лог критическое состояние.

  5. Командой останавливает и заново запускает gunicorn-api.service.

  6. Память освобождается, OOM Killer грустно засыпает. Скрипт логирует успех, фиксирует статус сервиса и состояние памяти.

  7. 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/