АйТи Фреш
Главная / Статьи / Linux, Docker и DevOps
Linux, Docker и DevOps

Запрос /api/report.json отдаёт 404 из папки фронтенда: как nginx выбирает location и зачем API нужен ^~

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~13 мин чтения
Стрелка на путях уводит запрос API в склад статики — метафора перехвата location в nginx
Длинный префикс /api/ не победитель, пока его не закрепит ^~.

Если /api/report.json отвечает 404 от nginx и не доходит до приложения, почти всегда его перехватил regex статики с json в списке расширений. Самый длинный префикс /api/ не финальный победитель: после него nginx проверяет регулярные выражения. Лечится модификатором ^~ на /api/. Ниже — алгоритм по шагам, кейс, диагностика и ловушки вложенных location.

Почему длинный location /api/ проигрывает regex статики

Поломка выглядит невинно. API проксируется в приложение, файлы с известными расширениями nginx отдаёт сам. Потом кто-то добавляет json в список статики — ради manifest.json или файлов локализации. После этого новый endpoint /api/report.json начинает возвращать 404, устаревший JSON с диска или HTML от SPA. В логе нет адреса upstream, приложение молчит. Разработчик меняет местами два location, перезагружает nginx — ничего не меняется. Такие разборы маршрутизации — обычная часть нашей работы по сопровождению веб-серверов на open-source, и я каждый раз начинаю с одного и того же объяснения.

Вот сокращённый конфиг, на котором ошибка воспроизводится:

server {
    listen 443 ssl;
    server_name portal.example.com;
    root /srv/portal/frontend;

    location /api/ {
        proxy_pass http://127.0.0.1:3000;
    }

    location ~* \.(?:css|js|png|svg|woff2|json)$ {
        try_files $uri =404;
        expires 30d;
    }
}

Для URI /api/report.json подходят два кандидата. Префикс /api/ действительно длиннее, но это победа только среди префиксных location, и поиск на ней не заканчивается. nginx запоминает /api/, затем проверяет регулярные выражения и находит окончание .json. Итог — блок статики, а путь к файлу строится как /srv/portal/frontend/api/report.json. Файла там нет, отсюда 404.

Главная ошибка мышления — читать location как правила межсетевого экрана сверху вниз. У nginx другой алгоритм: порядок префиксных location на приоритет не влияет, а порядок регулярных — влияет. Поэтому перестановка /api/ выше или ниже regex ничего не лечит. В лучшем случае вы потеряете час, в худшем — случайно поменяете порядок двух regex и получите вторую неисправность.

Самый длинный префикс — не окончательный победитель. Без = или ^~ его ещё может перехватить совпавший regex.
Памятка: Почему длинный location /api/ проигрывает regex статики — схема
Памятка: Почему длинный location /api/ проигрывает regex статики. Открыть схему в полном размере

Как nginx выбирает location: алгоритм по шагам

Я объясняю алгоритм по документации ngx_http_core_module. Сопоставление идёт с нормализованным URI: декодированы последовательности %XX, разрешены компоненты «.» и «..», при merge_slashes on сжаты повторные слеши. Аргументы запроса в выборе не участвуют: /api/report.json?month=09 и /api/report.json?draft=1 проходят один и тот же поиск. $request_uri хранит исходную строку с аргументами, а $uri — текущий нормализованный URI, который может измениться после внутреннего перенаправления.

Дальше четыре шага. Точное совпадение с = завершает поиск сразу. Если его нет, nginx находит самый длинный подходящий префикс и запоминает его. Если у этого префикса стоит ^~, регулярные выражения не проверяются. Иначе регулярные location проверяются в порядке записи, первое совпадение побеждает; если ничего не совпало, используется запомненный префикс. Директивы из разных совпавших блоков не складываются: nginx выбирает один итоговый контекст, поэтому proxy_pass из /api/ в блок статики не попадёт.

Свежая деталь для тех, кто обновляется на mainline. В ветке 1.31.5 появились predicate locations — location вида location $variable, которые проверяются после регулярных выражений и до возврата к запомненному префиксу. ^~ отключает и их. В стабильной ветке 1.30 (актуальная на 23.09.2026 — 1.30.5) этого нет, и классический алгоритм прежний. Но если вы на 1.31.x и кто-то добавил предикатный location, помните, что это ещё один претендент на ваш /api/.

^~ — не регулярное выражение, а модификатор префиксного location. Писать после него шаблон вроде ^/api/.*$ не нужно и нельзя: он будет понят как буквальная строка.
Запрос /api/report.json отдаёт 404 из папки фронтенда: как nginx выбирает location и зачем API нужен ^~ — схема
Схема к статье. Открыть схему в полном размере
Схема алгоритма выбора location в nginx: точное совпадение, длинный префикс, ^~, регулярные выражения
Регулярные выражения проверяются после префиксов и перехватывают запрос, если нет = или ^~.

Как я исправляю маршрутизацию API модификатором ^~

Минимальное исправление — пометить API-префикс модификатором ^~. Когда /api/ оказывается самым длинным совпавшим префиксом, nginx не проверяет regex и отправляет запрос в приложение. Я выбираю именно это решение для пространства имён API: правило выражает архитектурное намерение, защищает текущие и будущие endpoint и не требует перечислять расширения. Точный location = /api/report.json тоже сработает, но превратит конфиг в каталог методов API — это быстро становится неподдерживаемым.

upstream portal_api {
    server 127.0.0.1:3000;
    keepalive 16;
}

server {
    listen 443 ssl;
    server_name portal.example.com;
    root /srv/portal/frontend;

    location ^~ /api/ {
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://portal_api;
    }

    location ^~ /assets/i18n/ {
        try_files $uri =404;
        expires 1h;
    }

    location ~* \.(?:css|js|mjs|png|jpe?g|svg|webp|woff2?)$ {
        try_files $uri =404;
        expires 30d;
    }
}

У proxy_pass здесь нет URI-части после имени upstream, поэтому бэкенд получит /api/report.json как есть. Если написать proxy_pass http://portal_api/;, совпавший префикс /api/ заменится слешем, и приложение получит /report.json. Это отдельное правило формирования URI, а не выбор location, но эти темы постоянно путают — подробно я разбирал его в статье про то, как слеш в proxy_pass ломает API. Обратите внимание и на пару proxy_http_version 1.1 с пустым Connection: без неё keepalive к upstream не работает.

Второй шаг я делаю всегда: убираю json из глобального regex статики. Файлы переводов и манифесты живут под выделенным префиксом ^~ /assets/i18n/ или описаны точным location = /manifest.json. Тогда защита двусторонняя: API закрыт от regex, а широкий список расширений не перехватывает JSON в других динамических разделах. Риск тут не только в 404. Если в каталоге фронтенда случайно лежит старый файл с тем же путём, клиент получит 200 с неверными данными — и это хуже, чем честная ошибка.

Не добавляйте ^~ ко всем префиксам механически. Он отключает regex-переопределение, и для каталога загрузок это может обойти общий запрет на исполнение .php или другое защитное правило.
Памятка: Как я исправляю маршрутизацию API модификатором ^~ — схема
Памятка: Как я исправляю маршрутизацию API модификатором ^~. Открыть схему в полном размере
Сравнение конфига nginx до и после: location ^~ /api/ и json вне regex статики
Два изменения закрывают проблему с обеих сторон: API защищён, статика сужена.

Вложенные location и внутренние перенаправления: где ^~ не спасает

С ^~ есть тонкость, на которой спотыкаются и опытные администраторы: его действие ограничено уровнем вложенности. Допустим, ^~ стоит только на вложенном /api/private/, родительский /api/ обычный, а на уровне server объявлен regex для json. Внутри /api/ регулярные выражения действительно не проверяются, но на уровне server родительский префикс ^~ не имеет — и внешний regex всё равно проверяется и перехватывает запрос. Конфиг выглядит убедительно, а работает не так, как задумано:

location /api/ {
    location ^~ /api/private/ {
        proxy_pass http://portal_api;
    }
}

location ~* \.json$ {
    root /srv/portal/frontend;
}

Моя позиция: маршрутизацию API держу плоской и ставлю ^~ на верхнюю границу пространства имён. Внутри обычно дополнительные location не нужны — авторизация, методы и конкретные пути принадлежат приложению. Если вложенность неизбежна, проверяйте не только самый глубокий блок, но и каждого префиксного предка, на уровне которого есть конкурирующие regex. Иногда продублировать пару директив прозрачнее, чем построить изящное дерево, которое команда не может объяснить без схемы.

Вторая ловушка — внутренние перенаправления. index, try_files, rewrite и error_page могут сменить URI и запустить выбор location заново. try_files $uri /index.html в SPA после промаха отправит запрос на /index.html, и браузер покажет Unexpected token '<' — вместо JSON пришёл HTML. По документации ngx_http_log_module запрос журналируется в контексте location, где обработка закончилась, поэтому в логе вы увидите успешный ответ статики, хотя спрашивали API. ^~ над /api/ предотвращает исходный перехват, но fallback на index.html внутри API-блока всё равно проверьте.

Статус 200 не доказывает правильную маршрутизацию. Для API проверяйте Content-Type, начало тела ответа и $upstream_addr.

Разбор из практики: электронный журнал музыкальной школы

Музыкальная школа «Крещендо», 17 рабочих мест в администрации и учительской, плюс преподаватели, которые заходят в электронный журнал из дома. Портал — SPA на статике и сервис на Node.js 24 LTS, слушающий 127.0.0.1:3000; всё на одной виртуальной машине 2 vCPU / 4 ГБ под nginx 1.30. Нагрузка скромная: около 20 запросов в минуту днём и до 150 в последние дни месяца, когда завуч выгружает отчёт посещаемости. Именно новый GET /api/report.json и сломался: через внешний адрес он отдавал 404, а прямой curl на порт 3000 стабильно давал 200 за 120–170 мс.

Причина нашлась в свежем релизе фронтенда: в общий regex статики добавили json ради файлов переводов, а API остался обычным location /api/. Перестановка блоков, которую успел попробовать разработчик, ожидаемо не помогла. Я начал с выгрузки реально загруженной конфигурации, потому что чтение одного файла из sites-enabled часто обманывает — нужная директива приезжает из include:

nginx -v
nginx -T 2>&1 | less
curl -sS -D - -o /dev/null https://portal.example.com/api/report.json
curl -sS -D - -o /dev/null http://127.0.0.1:3000/api/report.json

Затем временно добавил в оба конкурирующих блока разные заголовки X-Debug-Location с параметром always. Ответ пришёл с меткой статики, а $upstream_addr в логе был дефисом — upstream не вызывался. Спор закончился за пять минут. Про то, почему add_header в location ведёт себя неочевидно и как не потерять заголовки безопасности при таких вставках, у меня есть отдельный разбор add_header в location.

Исправление заняло меньше часа вместе с проверкой: API-блок стал location ^~ /api/, json ушёл из общего regex, для переводов появился ^~ /assets/i18n/. После nginx -t — плавный reload и матрица из 16 запросов: API с .json и без расширения, в разном регистре, с аргументами, реальные и отсутствующие файлы статики. Пересобирать или перезапускать приложение не пришлось. За следующие две недели через /api/ прошло около 9 400 запросов, ни одного попадания в статику, p95 держался около 180 мс. Это не история про ускорение — производительность была исправна с самого начала. Мы сделали маршрутизацию однозначной.

Удалите X-Debug-Location после проверки: внутренние имена маршрутов незачем показывать внешнему клиенту.
Дерево диагностики nginx: как по curl и $upstream_addr найти перехват location статикой
Два запроса и одна переменная в логе показывают, где теряется API-запрос.

Как проверить изменение и не устроить новую аварию

Перед правкой я всегда запускаю nginx -T, а не читаю знакомый файл глазами: ключ -T проверяет конфигурацию и выводит её целиком вместе с include. После изменения — nginx -t, и только потом nginx -s reload или systemctl reload nginx. По документации reload плавный: мастер проверяет новый конфиг, запускает новые рабочие процессы, а старым даёт завершить текущие запросы; если конфиг применить не удалось, работа продолжается со старым. Но синтаксически верный location вполне может отправлять трафик не туда, так что тесты это не отменяет.

Для расследования держу отдельный формат лога, где видно, куда ушёл запрос:

log_format routing '$request_id $request_uri uri=$uri status=$status '
                   'upstream=$upstream_addr rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/routing.log routing;

Минимальная матрица: /api/report.json, /api/report.JSON, /api/report, /api/, /api без слеша, существующий и отсутствующий asset. Для /api без слеша nginx сам вернёт 301 на /api/ — документация описывает это поведение для префикса со слешем на конце и proxy_pass. Если API принимает POST, тестируйте и его: редирект со сменой URI на POST ломает клиентов. Смотрите Content-Type и начало тела, а не только код. Если перед nginx стоит ещё один прокси, сверьте и то, какой адрес клиента попадает в лог, — реальный IP за прокси нужен, чтобы расследование вообще имело смысл.

Приоритеты такие. Первое — непересекающиеся пространства имён: ^~ /api/ для приложения, ^~ /assets/ для файлов. Второе — сузить regex или отказаться от него там, где хватает префикса. Третье — добавить тест маршрутизации в проверку перед релизом фронтенда. На микроскопическую экономию времени поиска location можно не тратить силы: на портале в пару десятков запросов в минуту её не измерить. Важно другое — чтобы любой разработчик мог взять URI, пройти алгоритм на бумаге и получить тот же блок, что выберет nginx.

Итоговое правило: ^~ ставится на архитектурную границу /api/, а не как пластырь для одного report.json. Тогда новый endpoint с .xml, .zip или .json не зависит от очередной правки списка статики.
Порядок действий: Как проверить изменение и не устроить новую аварию — схема
Порядок действий: Как проверить изменение и не устроить новую аварию. Открыть схему в полном размере

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

Поможет ли перенести location /api/ ниже правила статики?

Нет. Префиксные location выбираются по длине, а не по порядку. После этого nginx всё равно проверяет regex. Защищайте API модификатором ^~ или точным совпадением, если речь об одном URI.

Чем location ^~ /api/ отличается от location ~ ^/api/?

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

Почему после исправления бэкенд получает /report.json вместо /api/report.json?

Из-за слеша в proxy_pass. В location /api/ запись proxy_pass http://backend/; заменяет совпавший /api/ на /, а proxy_pass http://backend; без URI передаёт исходный путь.

Действует ли вложенный ^~ на внешние регулярные выражения?

Нет. Он отключает проверку regex только на своём уровне. Если родительский префикс без ^~, регулярные выражения уровня server всё равно проверяются и могут перехватить запрос.

Как понять, какой location выбран, по логу?

Добавьте формат лога с $request_uri, $uri и $upstream_addr или временный заголовок X-Debug-Location с always. Дефис в $upstream_addr значит, что запрос до proxy_pass не дошёл.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи