MAATRIX / Блог / Сообщения без TTL копились три месяца и съели всю память брокера

Сообщения без TTL копились три месяца и съели всю память брокера

MAATRIX

Сервер с брокером сообщений медленно, но верно съедал память три месяца подряд, и никто этого не замечал, пока RabbitMQ не начал блокировать publisher-ы по flow control и очередь запросов на запись не встала колом. История поучительная: причина оказалась не в утечке в коде и не в конфиге ядра, а в одной очереди, для которой политика TTL просто никогда не применялась — из-за несовпадения имени по маске. Если у вас есть очередь ретраев, dead letter exchange или просто «временный» буфер сообщений — прочитайте до конца, там чек-лист, как проверить у себя за пять минут.

Что сломалось

Стек простой: бэкенд на Node.js кладёт задачи на доставку вебхуков во внешние системы в RabbitMQ, воркеры на другом сервере их разбирают. Всё крутилось на выделенном сервере под очереди — 8 ГБ RAM, RabbitMQ как systemd-сервис, рядом Redis под кеш сессий. Несколько месяцев всё было тихо, а потом в течение одной недели участились жалобы: часть вебхуков доставлялась с задержкой в десятки минут, изредка API-запросы на публикацию сообщений подвисали на 5-10 секунд вместо обычных миллисекунд.

Пиковый инцидент случился ночью: RabbitMQ ушёл в high memory watermark, включился flow control, publisher-соединения начали блокироваться, а часть запросов от бэкенда — копиться в очереди на самом бэкенде, что дальше срикошетило в таймауты у клиентов. Сервис не упал полностью, но фактически встал: новые задачи не принимались, воркеры не могли достучаться до части очередей. systemctl status rabbitmq-server показывал сервис живым, но в логе — сплошные memory resource limit alarm set и blocking all publishers.

Первая реакция дежурного — рестарт RabbitMQ. Помогло на несколько часов, потом всё повторилось. Это и стало сигналом, что дело не в разовом всплеске нагрузки, а в чём-то, что копится системно.

Что показывали логи и метрики

В Grafana была метрика node_memory_MemAvailable_bytes с прицепленным алертом на 15% свободной памяти — она сработала, но с опозданием: сама метрика показывала плавное, почти линейное снижение доступной памяти на протяжении примерно трёх месяцев, без резких скачков. Это сразу отсекло версию «кто-то залил сервер разовым большим импортом» — процесс был растянут во времени и монотонен.

# быстрый снимок памяти на сервере
free -h
cat /proc/meminfo | grep -E 'MemAvailable|Cached|Slab'
vmstat 1 5

Смотрели и метрики самого RabbitMQ через rabbitmq_prometheus плагин — rabbitmq_process_resident_memory_bytes тоже плавно рос вместе с общим потреблением RAM на хосте, то есть память съедал именно процесс beam.smp (Erlang VM брокера), а не что-то постороннее. Это уже сужало круг подозреваемых, но не называло виновника напрямую — RabbitMQ хранит в памяти и метаданные очередей, и сами сообщения (для классических очередей без lazy-режима), и внутренние буферы соединений, так что рост резидентной памяти сам по себе ни на что конкретное не указывал.

Отдельно смотрели dmesg и journalctl -k на предмет OOM-killer — их не было: до полного исчерпания памяти не доходило, RabbitMQ сам включал защитный watermark раньше, чем ядро начинало убивать процессы. Это, кстати, единственная причина, почему сервис не падал наглухо, а просто «тормозил» — защитный механизм сработал штатно, просто симптом (блокировка публикаций) выглядел как баг, а не как ожидаемое поведение при нехватке памяти.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Версии, которые не подтвердились

Первая версия — утечка памяти в самом Node.js-бэкенде, который публикует сообщения. Проверили: pm2 monit и снятый heap snapshot показывали стабильный профиль памяти процесса, без роста между рестартами. Отдельно погоняли методику ловли утечки за неделю до падения на графиках RSS процесса — картина ровная, версию закрыли.

Вторая версия — недооценка page cache: администратор предположил, что free -h просто неверно считает свободную память, а на деле всё в порядке, потому что Linux агрессивно кеширует диск. Проверили через MemAvailable (а не MemFree) — именно этот показатель учитывает реально доступную для приложений память с учётом вытесняемого кеша, и он тоже падал. Значит, дело не в интерпретации метрики.

Третья версия — Redis, который крутился на том же сервере под кеш сессий, вышел за maxmemory и тянет память сверх лимита. Проверили redis-cli info memoryused_memory был стабилен и укладывался в заданный maxmemory 512mb, maxmemory-policy allkeys-lru работал штатно, эвикшены шли по графику. Не Redis.

Четвёртая версия — контейнеры. Часть сопутствующих сервисов крутилась в Docker, и была гипотеза, что где-то не выставлены лимиты и контейнер разрастается бесконтрольно. Свели все контейнеры в docker stats — суммарное потребление контейнеров было небольшим и стабильным, а RabbitMQ на этом сервере был вообще не в Docker, а нативным systemd-сервисом. Тему лимитов на контейнеры разбирали отдельно в статье про лимиты CPU и памяти в Docker — полезно, но не в этом случае.

Пятая версия — slab-аллокатор ядра и дисковые кеши файловой системы разрослись из-за большого числа мелких файлов (RabbitMQ пишет данные очередей на диск как индекс + сегменты). Проверили slabtop и du -sh /var/lib/rabbitmq — размер на диске рос, но не аномально, и slab был в пределах нормы для системы с таким количеством соединений.

К этому моменту прошло два дня разбора, и ни одна внешняя по отношению к RabbitMQ версия не подтвердилась. Оставалось признать: искать нужно внутри самого брокера.

Как нашли настоящую причину

Первым делом посмотрели список очередей с сортировкой по памяти:

rabbitmqctl list_queues name messages messages_ready messages_unacknowledged memory \
  --formatter=pretty_table | sort -k5 -n -r | head -20

В топе оказалась одна очередь — webhook.retry — с несколькими миллионами сообщений в статусе ready и памятью, исчисляемой гигабайтами. Остальные рабочие очереди по сравнению с ней были почти пустыми: десятки-сотни сообщений, обычная рабочая картина для активной обработки задач.

Дальше — классика разбора инцидента: смотрим, откуда берутся сообщения и почему их никто не читает.

rabbitmqctl list_consumers | grep webhook.retry

Список консьюмеров для webhook.retry оказался пустым. Очередь пополнялась (в неё продолжали публиковаться сообщения через dead letter exchange основной очереди доставки), но никто их не забирал — ни один воркер не был подписан на неё уже несколько месяцев.

rabbitmqctl list_policies
rabbitmqctl list_queues name policy

list_policies показывал политику retry-ttl с шаблоном имени ^retry\. — то есть она должна была применяться к очередям, чьё имя начинается с retry.. А фактическая очередь называлась webhook.retry — начинается с webhook, а не с retry. Маска регулярного выражения просто не совпадала с реальным именем очереди, и политика TTL/max-length на неё никогда не накладывалась. Отсюда и заголовок: сообщения без TTL копились ровно столько, сколько эта нестыковка оставалась незамеченной.

Почему несовпадение прожило три с лишним месяца

История с миграцией. Изначально ретраи неудачных доставок вебхуков делались прямо в коде воркера — с экспоненциальной задержкой и лимитом попыток, без отдельной очереди. Примерно за четыре месяца до инцидента эту логику вынесли в RabbitMQ через dead letter exchange: основная очередь webhook.delivery при исчерпании x-death пересылает сообщение в очередь ретраев, которую по изначальному плану собирались назвать retry.webhook, и политика retry-ttl с маской ^retry\. была заведена заранее, ещё на этапе проектирования.

При реализации разработчик развернул именование в обратном порядке — webhook.retry вместо retry.webhook, потому что в остальном проекте принят паттерн «домен.действие» (webhook.delivery, webhook.retry, payment.charge и так далее), и по code style так было последовательнее. Ревьюер проверил логику ретраев, но не сверил фактическое имя очереди с уже существующей политикой — это два разных файла конфигурации (декларация очереди в коде приложения и политика в rabbitmqctl/Terraform-модуле для RabbitMQ), и связи между ними никто явно не тестировал.

Консьюмер для очереди ретраев на старте был — он забирал сообщения, ждал x-message-ttl (которая, как уже понятно, не применялась) и пробовал доставку повторно. Спустя пару месяцев эндпоинт одного из крупных партнёров, до которого чаще всего не докатывались вебхуки, был официально отключён, воркер-консьюмер для его специфичных ретраев убрали как ненужный, а сама очередь и общий поток dead-letter в неё — не тронули, потому что формально это было «общая» очередь ретраев для всех партнёров, не только для отключённого. По факту же именно оставшийся широкий поток сообщений (без TTL, без потребителя после чистки консьюмеров, без ограничения длины) и стал тем самым медленным натёком, который в сумме за три месяца вылился в гигабайты резидентной памяти брокера.

Отдельная деталь, которая усложнила диагностику: мониторинг на сервере был настроен на уровне хоста (CPU, RAM, диск) и на уровне отдельных бизнес-очередей вроде webhook.delivery, но не покрывал «служебные» очереди вроде retry/DLX — их никто не включил в дашборд, потому что на момент настройки мониторинга такой очереди ещё не существовало. Это ровно тот случай, который описан в статье про мониторинг, который никто не смотрит: метрика физически собиралась (Prometheus видел rabbitmq_queue_messages_ready по всем очередям через exporter), но ни один дашборд и ни один алерт на неё не смотрели — сигнал был, но его не читали.

Что изменили после инцидента

Первым делом — почистили саму очередь. Прежде чем чистить, вытащили полезную часть на всякий случай: выгрузили содержимое через rabbitmqadmin export в файл для архива (несколько миллионов сообщений даже в сжатом виде заняли заметный объём на диске, но это разовая операция), после чего очередь очистили:

rabbitmqctl purge_queue webhook.retry

Дальше — политика, но уже с маской, которая реально покрывает существующие очереди, а не design-документ трёхмесячной давности:

rabbitmqctl set_policy retry-ttl "^(retry\.|webhook\.retry$)" \
  '{"x-message-ttl":86400000,"x-max-length":50000,"overflow":"reject-publish"}' \
  --apply-to queues --priority 10

Три параметра здесь принципиальны. x-message-ttl в миллисекундах ограничивает время жизни сообщения в очереди сверху (в нашем случае — сутки, дальше ретраить смысла нет). x-max-length — жёсткий потолок числа сообщений, чтобы даже при поломке TTL или при аномальном всплеске очередь не выросла бесконтрольно. overflow: reject-publish определяет, что делать при достижении лимита — новые сообщения будут отклоняться с ошибкой у publisher-а, а не тихо вытеснять старые (для ретраев это осознанный выбор: лучше явная ошибка публикации, чем молчаливая потеря части сообщений).

Дальше — то, что должно было стоять с самого начала: алерт именно на глубину очередей, а не только на память хоста.

# пример правила для Prometheus Alertmanager
- alert: RabbitMQQueueTooDeep
  expr: rabbitmq_queue_messages_ready > 20000
  for: 15m
  labels:
    severity: warning
  annotations:
    summary: "Очередь {{ $labels.queue }} превысила 20000 сообщений"

Порог в 20 тысяч выбран с запасом от рабочего максимума в 50 тысяч по политике — чтобы алерт срабатывал раньше, чем очередь дойдёт до reject-publish, и было время отреагировать руками. Отдельно почитайте про то, как выбирать пороги алертов так, чтобы они не превращались в фоновый шум — этому посвящена статья про пороги алертов, которые не бесят: слишком чувствительный порог задолбает дежурных ложными срабатываниями, слишком грубый — не даст времени среагировать до аварии.

Ещё два процессных изменения. Во-первых, завели скрипт-аудит, который раз в неделю по крону сверяет список реально существующих очередей RabbitMQ со списком политик и явно репортит в чат очереди, на которые не наложена ни одна политика TTL/max-length — чтобы повторное несовпадение маски не жило месяцами незамеченным. Во-вторых, в чек-лист код-ревью для изменений, затрагивающих очереди сообщений, добавили отдельный пункт: «имя очереди явно проверено против существующих политик командой rabbitmqctl list_queues name policy» — простая ручная проверка, которая в прошлый раз заняла бы секунды, если бы про неё вспомнили.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, нет ли у меня такой же проблемы прямо сейчас?

Выполните rabbitmqctl list_queues name messages memory --formatter=pretty_table и отсортируйте по памяти или числу сообщений. Если какая-то очередь сильно выделяется на фоне остальных — сверьте её имя с rabbitmqctl list_policies и убедитесь, что политика TTL/max-length реально к ней применяется, а не просто существует где-то в системе.

Что будет, если просто добавить серверу памяти вместо исправления политики?

Симптом отступит на время, пропорциональное запасу RAM, но накопление продолжится с той же скоростью — раз в несколько месяцев вы окажетесь в той же точке, только с более дорогим сервером. Апгрейд памяти имеет смысл как временная мера на время расследования, но не как решение.

Какой TTL ставить для очереди ретраев, чтобы не потерять нужные сообщения?

Общего правильного числа нет — ориентируйтесь на бизнес-логику: сколько по времени имеет смысл пытаться доставить сообщение повторно. Для вебхуков это часто от нескольких часов до суток; для критичных финансовых событий TTL может быть не нужен вовсе, а вместо него — ручная разборка dead letter очереди с алертом на любое непустое состояние.

Можно ли жёстко ограничить память самого RabbitMQ, чтобы он не мог съесть весь сервер?

Да, параметр vm_memory_high_watermark в rabbitmq.conf задаёт долю от системной RAM (или абсолютное значение), после которой брокер включает flow control. Это защищает от полного исчерпания памяти и падения соседних сервисов, но не решает первопричину — вы просто раньше увидите деградацию вместо аварии. Лимиты на очереди (TTL, max-length) должны стоять в любом случае.

Актуально ли это для Kafka или NATS, а не только для RabbitMQ?

Механизм разный, но грабля та же — в Kafka вместо TTL сообщений есть retention.ms на уровне топика, и топик без явно заданного retention может расти неограниченно на диске. В NATS JetStream похожая история с лимитами на стрим. Общий принцип один: у любой очереди или топика, куда что-то публикуется, должен быть явный ответ на вопрос «а что произойдёт, если это никто не будет читать» — TTL, лимит по размеру или и то, и другое сразу.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →