Правило nginx поставили выше нужного, и весь трафик ушёл в заглушку
В пятницу вечером в поддержку начали падать тикеты: «не могу оформить заказ», «сайт открывается, но всё как будто зависло на заглушке». Мониторинг молчал — везде зелёный статус 200. А по факту весь боевой трафик, включая обращения к API, полтора часа улетал на статическую HTML-страницу «сайт на техническом обслуживании». Разбираемся, как одна строчка в конфиге nginx умеет тихо подменить собой всё приложение, и почему её не видно ни в одном стандартном алерте.
Содержание
Что сломалось
Стек простой и типичный для среднего проекта на VPS: nginx как reverse proxy перед бэкендом на Node (порт 3000) и статикой фронтенда, всё в одном server-блоке. За неделю до инцидента команда готовила короткое техническое окно на субботу: на время миграции базы нужно было показывать пользователям страницу «Мы обновляемся, вернёмся через час» вместо обычного приложения.
Решение сделали быстро — через отдельный location с regex, который «оборачивает» вообще любой путь:
location ~ ^/.* {
root /var/www/stub;
try_files /index.html =404;
}
location ~* ^/(api|app)/ {
proxy_pass http://backend_app;
}
location / {
root /var/www/frontend;
try_files $uri /index.html;
}
Блок со заглушкой добавили первым, потому что «он должен сработать раньше остальных» — логика была верной для субботней миграции: заглушка правда должна была перехватывать всё. После миграции этот location не убрали и не закомментировали — просто оставили в конфиге, посчитав, что раз реального трафика он не «включает» никакими условиями, вреда не будет. Но условия там и не было — регулярное выражение ^/.* матчит абсолютно любой URI, включая /api/orders, /api/health, /app/checkout. С понедельника блок продолжал забирать себе весь трафик, просто этого никто не заметил сразу, потому что миграция закончилась успешно, а сайт «открывался» — просто не тот сайт.
Заметили не сразу: HTML-страница заглушки выглядела прилично, отдавала 200, грузилась быстро (статика, никакого backend). Первые тикеты списали на «пользователь что-то делает не так», пока количество жалоб не стало заметным на дашборде поддержки.
Что видели в логах и метриках
Первым делом посмотрели на графики — и не увидели ничего аномального. Запросов столько же, сколько обычно, код ответа почти везде 200, время ответа nginx низкое, даже ниже обычного. Проверка доступности (curl -I https://example.com/) тоже возвращала 200 OK. По всем метрикам «первого уровня» — сайт жив.
Разница обнаружилась только когда посмотрели на access-лог с расширенным форматом, где логируется, к какому апстриму реально ходил запрос:
log_format upstream_debug '$remote_addr - $status "$request" '
'upstream=$upstream_addr rt=$upstream_response_time';
127.0.0.1 - 200 "GET /api/orders HTTP/1.1" upstream=- rt=-
127.0.0.1 - 200 "GET /api/health HTTP/1.1" upstream=- rt=-
127.0.0.1 - 200 "GET / HTTP/1.1" upstream=- rt=-
Поле upstream=- означало, что nginx вообще не ходил в backend_app — ни разу за последние полтора часа, хотя обычно там десятки запросов в секунду. Это и была первая настоящая зацепка: приложение отвечает не оно, а сам nginx статикой. Подробнее о том, как вообще читать access- и error-лог и отличать «сервис не отвечает» от «сервис не спрошен», разбирали в статье как читать логи и находить причину сбоя.
Второй важный сигнал — метрики самого backend-процесса (CPU, память, число активных соединений) были неестественно ровными, будто приложение простаивает. При обычной нагрузке процесс «дышит» — то у него больше соединений, то меньше. Здесь график лёг в почти прямую линию на уровне холостого хода. Простой сервис не выглядит так, если на него правда идёт трафик.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКакие гипотезы отбросили
Прежде чем добраться до правильного ответа, проверили несколько версий, которые в первые минуты казались более вероятными, чем «кто-то не убрал старый location».
- Упал бэкенд. Зашли на сервер, проверили процесс напрямую:
curl http://127.0.0.1:3000/api/health— ответ пришёл мгновенно и корректный. Приложение живо, просто до него никто не достучался снаружи. Гипотезу закрыли за минуту. - Проблема в свежем деплое приложения. Накануне действительно выкатывали небольшой релиз backend. Проверили журнал деплоя и логи приложения (
journalctl -u backend-app -n 200) — ошибок нет, релиз прошёл штатно, а по времени не совпадает с началом жалоб (жалобы начались на день позже релиза). - CDN или прокси-кеш отдаёт устаревшую версию. Перед nginx стоит CDN, поэтому первая мысль — где-то закешировалась старая страница. Проверили заголовки ответа (
curl -v) на предметcache-controlиage, затем обратились напрямую к origin по IP с явнымHost-заголовком, минуя CDN — заглушка отдавалась и напрямую с origin. Значит, дело не в кеше, проблема на самом сервере. - Истёк или неправильно подхватился TLS-сертификат. Проверили
openssl s_client -connect example.com:443— сертификат в порядке, до истечения больше месяца, цепочка валидна. Не то. - DNS указывает не туда. Резолвили домен с нескольких точек — везде корректный IP боевого сервера. Не то.
- Бан по rate limiting или сработал fail2ban. Проверили
fail2ban-client status nginx-limit-reqи логи WAF — блокировок по нужным IP не было, да и проблема касалась всех пользователей подряд, а не конкретных адресов.
Ни одна из этих версий не объясняла главного — почему при полностью рабочем бэкенде и здоровой инфраструктуре ответ шёл откуда-то ещё. Оставалось предположить, что дело в том, что стоит перед бэкендом — то есть в самом nginx.
Как nginx на самом деле выбирает location
Здесь стоит остановиться и разобрать механику, которая и стала первопричиной — потому что интуиция «правила читаются сверху вниз, как в firewall» работает для nginx только частично, и это регулярно ломает конфиги, даже у опытных админов.
Порядок, в котором nginx выбирает location для конкретного запроса, такой:
- Точное совпадение
location = /путь— если есть, побеждает сразу, поиск прекращается. - Префиксные локации (
location /api/) — среди них ищется самая длинная совпадающая по префиксу, но результат ещё не финальный. - Если у найденной префиксной локации есть модификатор
^~, поиск сразу прекращается — regex-локации даже не рассматриваются. - Если модификатора
^~нет, nginx проверяет все regex-локации (~и~*) в том порядке, в котором они написаны в конфиге, и берёт первую совпавшую. - Если ни одна regex-локация не подошла, в силу вступает та самая длинная префиксная локация из шага 2.
Ключевой момент — пункт 4. Regex-локации не выбираются по специфичности и не учитывают длину совпадения, как это происходит с префиксами. Побеждает первая, которая совпала по тексту конфига сверху вниз. Именно поэтому фраза «поставили правило выше нужного» — это не образное выражение, а буквальное описание бага: location ~ ^/.* был физически выше в файле, чем location ~* ^/(api|app)/, и поскольку регулярка ^/.* матчит вообще всё, до второго блока dispatch просто никогда не доходил — не важно, что он более специфичный по смыслу.
Второй нюанс, который здесь тоже сыграл бы, даже если поменять блоки местами: обычная location /api/ без ^~ в принципе не может «победить» ни одну regex-локацию, даже если написана раньше. Regex-локации как класс имеют приоритет над префиксными без ^~, независимо от порядка в файле. Это и есть первая ошибочная гипотеза, которую в командe тоже успели обсудить: «раз мы напишем location /api/ пораньше, он перехватит запрос первым» — нет, не перехватит, пока рядом существует совпадающая regex-локация.
В чём была реальная причина
Собрали в один экран три факта: upstream=- в логах на всех путях, здоровый бэкенд при прямом обращении, и заглушка, отдающаяся даже при обходе CDN. Это прямо указывало на nginx. Дальше — не гадать по одному файлу, а выгрузить эффективный, полностью склеенный конфиг, каким его видит сам процесс:
nginx -T | grep -n "location"
Флаг -T не просто печатает nginx.conf, а разворачивает все include, что критично: реальные правила лежали не в одном файле — маршрутизация была раскидана по sites-available/example.com.conf и подключаемому snippets/maintenance.conf, и порядок include в основном файле определял итоговый порядок location-блоков ничуть не хуже, чем если бы их вписали руками подряд. В выводе nginx -T regex-локация заглушки оказалась строго раньше блока /(api|app)/ — то есть первопричина подтвердилась документально, а не по догадке.
Отдельно стоит сказать про человеческий фактор: блок заглушки добавляли в спешке перед выходными, «на глаз», без code review — обычная ситуация, когда конфиг правят прямо на проде, минуя пул-реквест и nginx -t в CI. Про то, почему это системно опасная привычка и чем она регулярно аукается, есть отдельный разбор — конфиги правятся на проде: антипаттерн и его цена. В данном случае цепочка была именно такой: срочная правка → нет отдельного flag/условия на включение заглушки → нет ревью, которое бы задало вопрос «а regex здесь точно должен матчить вообще всё?» → после планового окна блок никто не убрал, потому что он не считался «включённым» — визуально в коде не было ничего, что бы сигнализировало о его активности.
Что изменили после инцидента
Сначала — экстренный фикс: убрали regex-локацию заглушки из основного конфига, проверили синтаксис и перечитали конфигурацию без даунтайма:
nginx -t && nginx -s reload
Дальше — три структурных изменения, чтобы этот класс ошибок не повторялся.
Защитили критичные префиксы модификатором ^~. Теперь даже если кто-то добавит широкий regex выше по файлу, до /api/ он не доберётся — поиск остановится на шаге 3, не дойдя до списка regex-локаций:
location ^~ /api/ {
proxy_pass http://backend_app;
}
location ^~ /app/ {
proxy_pass http://backend_app;
}
Сузили саму заглушку до явного и предсказуемого условия. Если такая страница нужна снова, она должна включаться флагом, а не постоянным присутствием в конфиге, и матчить только то, что реально нужно:
location = /maintenance.html {
root /var/www/stub;
}
location / {
if (-f /var/www/maintenance.flag) {
return 503;
}
error_page 503 /maintenance.html;
root /var/www/frontend;
try_files $uri /index.html;
}
Здесь важно не переусердствовать с if внутри location — в nginx это известное больное место, и внутри блоков с proxy_pass if ведёт себя не всегда предсказуемо. Поэтому условие оставили только там, где не смешивается с проксированием, а логику отключения вынесли на уровень наличия/отсутствия файла-флага, который легко снять одной командой.
Добавили smoke-test сразу после reload в CI/CD. Маленький скрипт бьёт по ключевым маршрутам сразу после выката конфига и валит пайплайн, если хоть один вернул не то, что ожидалось:
#!/usr/bin/env bash
set -e
declare -A checks=(
["/api/health"]="200"
["/api/orders"]="200"
["/"]="200"
)
for path in "${!checks[@]}"; do
code=$(curl -s -o /dev/null -w "%{http_code}" "https://example.com${path}")
body=$(curl -s "https://example.com${path}" | head -c 200)
if [[ "$code" != "${checks[$path]}" ]] || echo "$body" | grep -qi "техническое обслуживание"; then
echo "FAIL: $path -> $code, body похож на заглушку"
exit 1
fi
done
echo "OK: все маршруты отвечают ожидаемым содержимым"
Проверка не просто на код ответа (заглушка тоже отдаёт 200), а и на содержимое тела — именно этого не хватало обычному мониторингу доступности, который смотрел на / и получал честные 200 всё время инцидента.
Также сделали постоянной практику держать в лог-формате поля $upstream_addr и $upstream_response_time на всех продовых серверах — это дёшево по ресурсам и один раз уже спасло полтора часа диагностики. Хороший разбор того, как в принципе структурировать такие расследования и не терять время на дубли гипотез, — в статье как писать разбор инцидента.
Отдельно завели чек-лист ревью для правок nginx-конфига: любой новый location с regex должен явно объяснять в комментарии, зачем ему нужен широкий шаблон, и почему он не может быть точечным (=) или префиксным с ^~. Для тестирования правок перед выкатом на прод стали поднимать отдельный staging-инстанс — на practice это оказалось дешевле, чем ещё раз потерять время поддержки на разбор похожего инцидента.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему мониторинг не заметил проблему сразу?
Потому что заглушка отдавала корректный HTTP 200 и валидный HTML — с точки зрения простого uptime-чекера, который проверяет только код ответа на /, сайт был полностью здоров. Проблема была в содержимом и в том, что запросы не доходили до backend, а это внешний чек по коду ответа не видит.
Regex-локация всегда побеждает префиксную, даже если написана в самом низу файла?
Да, если у префиксной локации нет модификатора ^~. Порядок в файле важен только среди самих regex-локаций — там побеждает первая совпавшая сверху вниз. Единственный способ гарантированно защитить префикс от любых regex — модификатор ^~.
Можно ли было заметить проблему раньше без разбора инцидента?
Да — если бы в лог-формате логировались $upstream_addr и $upstream_response_time, аномалия («backend вообще не получает запросов») была бы видна на первом же дашборде, а не только при ручном чтении логов.
Стоит ли вообще делать страницу техобслуживания через nginx location, а не на уровне приложения?
Это рабочий подход, но только если включение строго условно (файл-флаг, переменная из map) и location максимально узкий. Постоянно присутствующий в конфиге широкий regex — потенциальная бомба замедленного действия, даже если сейчас он ничего не перехватывает.
Как быстро проверить реальный порядок location-блоков, если конфиг разбит на несколько файлов через include?
Через nginx -T — команда разворачивает все include в один эффективный конфиг в том порядке, в котором его видит сам процесс. Смотреть только на один файл в редакторе недостаточно, если маршрутизация раскидана по snippets.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →