MAATRIX / Блог / Комментарий в конфиге оказался директивой: сутки искали, почему лимит не работает

Комментарий в конфиге оказался директивой: сутки искали, почему лимит не работает

MAATRIX

Лимит запросов на API стоял в конфиге чёрным по белому — 50 в минуту с одного IP. А трафик шёл в разы больше, и ни одного 429-го в логах. Первая мысль — сломался код лимитера. Вторая — разъехались счётчики в Redis. Обе не подтвердились. Настоящая причина оказалась там, где никто не подумал бы искать: строка, которую один из инженеров считал безобидным комментарием для памяти, парсер прочитал как часть значения. Ниже — как мы это раскапывали, какие версии отбросили по пути и что теперь стоит в проде вместо старой схемы.

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

Схема простая и типичная для VPS-инфраструктуры среднего размера: nginx как реверс-прокси перед тремя инстансами API-сервиса, перед каждым инстансом — собственный маленький демон-лимитер (systemd-юнит), который считает запросы по IP через общий Redis и режет всё, что превышает порог. Значение порога задаётся переменной окружения RATE_LIMIT_PER_MIN, которую systemd подтягивает директивой EnvironmentFile=/etc/ratelimiter/limits.env в юните.

Двадцатого августа был всплеск паразитного трафика — несколько IP молотили API в разы чаще обычного. Дежурный инженер быстро занизил лимит с 500 до 50 запросов в минуту прямо на сервере, через ssh, и — чтобы не забыть вернуть значение обратно — приписал пояснение в том же файле:

RATE_LIMIT_PER_MIN=50 # снизили из-за спайка ботов 20.08, вернуть 500 после 01.09 — Игорь

Прошла неделя. Первого сентября лимит должны были вернуть на 500, но в перегруженный день это выпало из фокуса. Второго сентября начался новый всплеск — уже без спайка ботов, просто органический рост трафика от партнёрской интеграции. И тут выяснилось, что лимит в 50 запросов в минуту не режет вообще ничего: ни один клиент не получал 429, при этом счётчики в мониторинге показывали кратно больше 50 запросов в минуту с отдельных IP.

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

Первым делом посмотрели дашборд запросов в Grafana — количество запросов в минуту на конкретные IP действительно в разы превышало настроенный порог, но ни одной отметки 429 за последние две недели не было вообще. Это сразу отмело версию «лимит просто немного мягкий» — он не срабатывал ни разу, ни на ком.

Дальше — Redis, где лимитер хранит счётчики окон:

redis-cli -n 2 keys "ratelimit:*" | wc -l
0

Ключей не было вообще. Это уже конкретная зацепка: лимитер не просто пропускает превышения — он даже не пытается вести счёт. Логи самого демона тоже молчали — ни ошибок, ни предупреждений, обычный уровень info с сообщениями о старте и штатных heartbeat-проверках раз в минуту. Ни один сигнал не указывал прямо на причину — пришлось идти по гипотезам.

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

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

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

Гипотеза 1: сломался код при последнем деплое

Первая версия — кто-то выкатил битую сборку лимитера, где логика проверки лимита случайно закомментирована или обёрнута в фичефлаг, который выключен по умолчанию. Проверили:

git -C /opt/ratelimiter log --oneline -10
systemctl show ratelimiter.service --property=ExecStart,FragmentPath
sha256sum /opt/ratelimiter/bin/ratelimiter

Последний деплой сервиса был больше трёх недель назад, задолго до начала проблем, хэш бинарника совпадал с тем, что в git-теге релиза. Гипотезу закрыли — код не менялся и заведомо не мог стать причиной.

Гипотеза 2: разъехались счётчики между тремя инстансами

Вторая версия — каждый из трёх инстансов лимитера смотрит в свой собственный Redis (например, из-за неверного REDIS_URL после переезда одного из хостов), и порог формально применяется, но делится на троих, а значит фактически утраивается. Проверили переменные на каждом хосте:

for h in api-1 api-2 api-3; do
  ssh "$h" 'systemctl show ratelimiter.service --property=MainPID --value' | \
  xargs -I{} ssh "$h" 'tr "\0" "\n" < /proc/{}/environ | grep REDIS_URL'
done

Все три инстанса смотрели в один и тот же Redis, все три успешно подключались (это подтвердил redis-cli client list на самом Redis — три активных соединения с ожидаемых IP). Разъезда конфигурации между инстансами не было — гипотезу тоже отбросили.

Гипотеза 3: балансировщик обходит лимитер стороной

Третья версия — часть трафика идёт мимо лимитера напрямую на API-инстансы, минуя прокси-цепочку, например, через отдельный upstream в nginx, добавленный для внутренних проверок и случайно оставленный доступным снаружи. Прогнали трафик с тестового внешнего IP и трассировали через nginx -T полный собранный конфиг, проверили все server и location блоки на предмет альтернативных путей к API. Лишних маршрутов не нашли — весь внешний трафик действительно проходил через лимитер. Оставалась одна возможность: сам лимитер получает не то значение порога, которое написано в файле.

Настоящая причина: строка стала частью значения, а не осталась комментарием

Ключевой инструмент здесь — посмотреть не файл на диске, а то, что реально загружено в окружение работающего процесса:

PID=$(systemctl show ratelimiter.service --property=MainPID --value)
tr '\0' '\n' < /proc/$PID/environ | grep RATE_LIMIT

Результат:

RATE_LIMIT_PER_MIN=50 # снизили из-за спайка ботов 20.08, вернуть 500 после 01.09 — Игорь

Вот она. Директива EnvironmentFile= в systemd — не парсер .env-файлов вроде тех, что используют некоторые библиотеки в приложениях. У неё простое и жёсткое правило (см. man systemd.exec): строка целиком либо комментарий, если первый непробельный символ — # или ;, либо строка вида KEY=VALUE, где всё после = и до конца строки — это значение. Никакого вычленения «хвостового» комментария после значения systemd не делает. Наш # снизили из-за спайка... не был отдельной строкой — он стоял после =, значит стал частью значения переменной.

Дальше — код самого лимитера, который читал эту переменную при старте:

try:
    limit = int(os.environ.get("RATE_LIMIT_PER_MIN", "500"))
except ValueError:
    limit = None  # лимит выключен, чтобы не блокировать легитимный трафик

if limit is not None:
    enforce_rate_limit(limit)

int("50 # снизили из-за спайка...") кидает ValueError. Ветка except не падает и не пишет ошибку в лог уровня error — она сознательно понижает серьёзность до штатного поведения, потому что этот except появился после совсем другого инцидента месяцем раньше: тогда битое значение лимита уронило весь сервис при старте, и решение «при любой ошибке парсинга просто не включать лимит» казалось безопасным компромиссом. На практике это тихо превратило защитный механизм в дыру: сервис не падал, стартовал штатно, но переставал защищать API — и делал это без единой строки в логах, которая объясняла бы почему.

Отдельный неприятный нюанс: сама правка вносилась вручную на сервере, в обход пайплайна деплоя и git — поэтому в репозитории конфиг-шаблонов лимит по-прежнему значился как 500, и при беглом ревью «что поменялось в коде» никто не заподозрил файл, которого формально «не существовало» с точки зрения истории изменений. О том, почему прямые правки на проде — это системная проблема, а не разовая небрежность, отдельно писали в разборе антипаттерна с правкой конфигов на живом сервере.

Почему это не поймали раньше

Мониторинг в этой инфраструктуре следил за пятисотыми ошибками, временем ответа и общей доступностью — но не за соотношением фактического трафика к настроенному порогу лимитера. 429 никто специально не алертил: логика была «если лимитер работает — эндпоинт просто отвечает медленнее под нагрузкой ботов, а не падает», и это не считалось поводом для алерта. В итоге отключение rate limiting выглядело как «трафик немного вырос», а не как «защитный механизм не работает уже две недели».

Второй пробел — при старте сервиса нигде не логировалось эффективное, уже распарсенное значение лимита. Если бы в логах при каждом рестарте появлялась строка вида rate limit configured: DISABLED (parse error), инцидент нашли бы за минуты, а не за сутки разбора гипотез.

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

Правки касались трёх уровней: формата конфигов, поведения приложения при ошибке и того, что именно проверяет мониторинг.

Формат конфигурационных файлов. Договорились: в любых EnvironmentFile=, читаемых systemd, комментарий — это всегда отдельная строка, начинающаяся с #, и никогда не хвост после значения. Пояснения вроде «снизили из-за спайка, вернуть после такого-то числа» теперь пишутся строкой выше, а не после =:

# снизили из-за спайка ботов 20.08, вернуть 500 после 01.09 — Игорь
RATE_LIMIT_PER_MIN=50

Добавили короткий CI-шаг, который перед выкладкой любого *.env-файла в системный каталог проверяет каждую непустую, не-комментарную строку регулярным выражением на отсутствие # после первого = — простая защита от повторения именно этой ошибки.

Поведение при ошибке конфигурации. Убрали тихий фолбэк «не смогли распарсить — выключаем защиту». Теперь при ошибке парсинга критичных для безопасности параметров (лимиты, таймауты авторизации, списки разрешённых источников) сервис явно логирует ошибку уровнем critical и либо использует последнее валидное значение из кеша, либо отказывается стартовать — тот самый крах, которого раньше боялись, стал осознанным выбором вместо скрытой деградации. При старте сервис теперь всегда пишет в лог все эффективные значения ключевых параметров:

INFO  effective config: rate_limit_per_min=500 source=env valid=true

Что проверяет мониторинг. Лимитер стал экспортировать метрику configured_rate_limit в Prometheus рядом с фактическим requests_per_minute_by_ip. Правило алерта — если у какого-то IP наблюдаемая частота запросов стабильно выше настроенного лимита без соответствующего роста количества 429-х ответов, это триггерит алерт «rate limiting не применяется» вне зависимости от того, упал сервис или нет. Отдельно добавили синтетическую проверку сразу после каждого рестарта юнита: скрипт бьёт по тестовому эндпоинту N+5 запросов за минуту с фиктивного IP и проверяет, что хотя бы один ответ — 429; если нет, алерт уходит немедленно, а не спустя дни. О том, как вообще устроен этот механизм и какие у него есть варианты реализации — от token bucket до leaky bucket — можно почитать в общем разборе как работает rate limiting.

Заодно пересмотрели, что вообще хранится рядом с боевыми переменными окружения — секреты, пороги и произвольные заметки инженеров вперемешку в одном файле сами по себе повышают шанс подобной путаницы; часть значений вынесли отдельно по тем же принципам, что описаны в материале про управление секретами в окружении контейнеров.

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

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

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

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

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

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

Почему systemd вообще не ругается на такую строку?

Формально она валидна: это строка KEY=VALUE, где значение — произвольная последовательность символов, включая #. EnvironmentFile= — это не парсер с проверкой типов, он не знает, что переменная должна быть числом, и не обязан угадывать, где заканчивается «смысловое» значение, а где начинается ваш комментарий.

Как быстро проверить, что реально загружено в окружение процесса, а не то, что написано в файле на диске?

tr '\0' '\n' < /proc/<PID>/environ — самый надёжный способ увидеть именно то, что получил работающий процесс, а не то, что вы думаете, что туда положили. PID можно получить через systemctl show <unit> --property=MainPID --value. Это стоит держать в шпаргалке рядом с любым сервисом, который читает конфиг из переменных окружения.

А в Docker Compose или в .env-файлах приложения комментарии после значения работают так же?

Не факт — разные парсеры (systemd, docker compose, библиотеки вроде dotenv в разных языках) ведут себя по-разному: где-то хвостовой комментарий действительно отрезается, где-то нет, где-то поведение зависит от того, взято ли значение в кавычки. Не полагайтесь на память или на поведение, увиденное в другом проекте, — проверьте конкретный парсер на тестовом файле, прежде чем класть в него комментарии рядом со значениями.

Если убрать тихий фолбэк и сервис откажется стартовать при битом конфиге — не станет ли только хуже?

Смотря для чего. Для параметров, от которых зависит безопасность и защита от перегрузки, явный отказ старта с понятной ошибкой в логе — почти всегда лучше, чем сервис, который работает, но незаметно не делает того, что должен. Хуже всего — не выбор между «упасть» и «молчать», а отсутствие сигнала о том, что вообще произошло.

Стоит ли вообще хранить такие пояснения в конфиге, а не в системе версионирования?

Если конфиг лежит в git и правки идут через пайплайн — пояснение уместнее в сообщении коммита или в тикете, а не в самом файле. Комментарий в файле имеет смысл только как краткая метка для тех, кто открывает файл на живом сервере, и должен оставаться синтаксически безопасным — отдельной строкой, без риска попасть в чужое значение.

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

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

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