Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров8.5K

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

Теги:
Хабы:
+12
Комментарии22

Публикации

Ближайшие события