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

Комментарии (20)


  1. outlingo
    29.08.2025 13:53

    Выглядит так, словно Роскомнадзор заблокировал вас в Гугле.

    В man systemd.resource-control есть описание опции MemoryMax которая лимитирует использования памяти процессами юнита. Выставляете заданный лимит опираясь на то, сколько памяти позволено отъесть вашему сервису, и система возьмет работу на себя.


    1. eternaladm Автор
      29.08.2025 13:53

      Спасибо за комментарий! С Вашим замечанием я полностью согласен, по-хорошему все рабочие сервера надо грамотно настраивать, но не всегда есть такая возможность. Я работаю не с "рассвета" организации, поэтому частенько приходится пожинать плоды.

      Выгрузить скрипт на рабочую машину - решение крайне спонтанное, этакое "6-е чувство", на всякий случай, перед концом рабочего дня. Я даже предположить не мог, что данный сервер поведёт себя подобным образом, тк за время моей работы не было ни одного подобного инцидента.

      В остальном, Вы правы буквально на все 100%. Обязательно дотянусь до данного сервера. Спасибо!


      1. gerashenko
        29.08.2025 13:53

        А пятничные вечерние деплои не тревожат 6е чувство?


        1. eternaladm Автор
          29.08.2025 13:53

          Спасибо за комментарий! Вообще должны, но обычно всё проходит спокойно, без эксцессов. Данный случай - крайне редкое исключение.


  1. ky0
    29.08.2025 13:53

    Один экземпляр чего-либо, даже не текущего - вот это гарантированный даунтайм. Ну, и "быстро поднятое упавшим не считается" в 2025 году - это только для какой-нибудь госухи ок.


    1. outlingo
      29.08.2025 13:53

      Ну если там нормальный стейтлесный сервис - то в общем то да, быстро перезапущеный не считается упавшим.


      1. ky0
        29.08.2025 13:53

        Ну, да - а пару десятков 500-ых ошибок, отданных клиентам, мы просто заметём под ковёр вместо того, чтобы настроить какой-нибудь из придуманных умными людями вариантов фейловера.


        1. outlingo
          29.08.2025 13:53

          Amazon cloud-way, клиент должен быть готов к ошибкам и уметь ретраиться


          1. mSnus
            29.08.2025 13:53

            Почему AWS? Клиент должен быть всегда готов, что ему временно отрубили интернет в самый интересный момент, мы же все нынче mobile first


            1. Black_Shadow
              29.08.2025 13:53

              Ошибка 500 - это не отрубили интернет.


              1. Kenya-West
                29.08.2025 13:53

                отрубили интернет

                А мы именно так и скажем и на пункт "Прокатило" галочку поставим...


  1. aeder
    29.08.2025 13:53

    Механизм обнаружения утечек памяти в долговременно запущенном ПО - да, обязательно должен быть. Проекты, с которыми сталкивался я - имеют прогнозируемый uptime, измеряемый в годах. Т.е. перезапуск - при штатном сервисном обслуживании. Раз в год, а то и реже.

    Может быть очень медленная утечка памяти, когда проблемы начнут проявляться не через сутки и даже не через неделю.

    Более того, лично я сталкивался с ситуацией, когда утечка памяти начинала проявляться не как "нет памяти/падают процессы" - а замедлением работы системы, т.к. тот же аналог кэша разрастался настолько, что поиск в нём стал занимать существенное время.

    Поэтому - все наши разработки включают механизм, который отслеживает потребление памяти. Чтобы не мучиться с настройкой для каждого процесса - через сутки после запуска берём текущее потребление, удваиваем, добавляем константу - и считаем это жёстким лимитом сверху.


    1. eternaladm Автор
      29.08.2025 13:53

      Спасибо за комментарий!

      Поэтому - все наши разработки включают механизм, который отслеживает потребление памяти. Чтобы не мучиться с настройкой для каждого процесса - через сутки после запуска берём текущее потребление, удваиваем, добавляем константу - и считаем это жёстким лимитом сверху.

      Очень интересный подход, ранее не думал о подобном. Возьму себе на заметку, спасибо!


  1. JBFW
    29.08.2025 13:53

    А ещё бывает прекрасная ситуация, когда сервис - вещь в себе, написанная когда-то, со своими закидонами, которую нельзя исправить, можно только заменить - и вот такое решение, хоть и костыль, прекрасно решает задачу.

    Причем скрипту в общем все равно что именно проверять: свободную память, доступность сервиса и т.д. Не прошла проверка - перезапуск!

    В этом преимущество подобных скриптов перед другими путями решения проблемы - они универсальны по сути.

    А так-то конечно память не должна утекать, программа не должна глючить, сферический конь должен быть помыт, начищен и отполирован, чтобы сверкать в вакууме...


  1. yoda317
    29.08.2025 13:53

    как процессы хаотично умрут от OOM Killer

    Оом не убивает процессы "хаотично". Он убивает либо самого жирного по скорингу, либо процесс который непосредственно вызвал out of memory. Это зависит от значения vm.oom_kill_allocating_task

    0 и умрет самый жирный. 0 по дефолту, если что.

    1 и будет убивать того кто запросил память которой нет.

    При 0, с гарантией будет убиваться процесс с утечкой памяти.


  1. xaqbyca
    29.08.2025 13:53

    А есть же monit, который такое делает искаропки


  1. mSnus
    29.08.2025 13:53

    Всё здорово, но если память сожрёт кто-то другой, скрипт всё равно задушит убьет невинного Питона. Т.е. это соломка, подстеленная именно туда, куда собираешься упасть.

    Ещё бы одну строчку, чтобы убивать именно то, что занимает большего всего памяти..


    1. eternaladm Автор
      29.08.2025 13:53

      Спасибо за комментарий! Согласен с Вами всецело, но мои подозрения упали именно на Питона после деплоя. Почему-то «почувствовал», что подобное может случиться.

      По-хорошему подобный подход грамотнее реализовывать через опцию MemoryMax, был комментарий выше на данную тему.


  1. xhumanoid
    29.08.2025 13:53

    https://docs.gunicorn.org/en/stable/settings.html#max-requests

    Автоматический рестарт воркера после обработки N запросов, причём так как это делает сам gunicorn, то он сразу снимает нагрузку с воркера и не отправляет туда больше запросов, а потом уже делает его рерстарт. В этом случае сервис у вас остаётся доступным всегда

    Пару раз пока искали утечки приходилось этот "костыль" использовать


  1. hazard2005
    29.08.2025 13:53

    Я бы запустил все это в докер и тот сам бы перезапускал бы процесс в случае самостоятельного падения или по причине утечки