Приоритет location в Nginx: порядок сопоставления
Nginx не читает блоки location сверху вниз и не останавливается на первом подходящем. Именно из этого недопонимания вырастает большинство сообщений об ошибках вида «мой блок location не работает». Для префиксных location положение блока в файле не значит вообще ничего — nginx сравнивает их все и запоминает самое длинное совпадение.
Реальный приоритет location в nginx — это фиксированная последовательность из четырёх шагов:
- Точное совпадение. Если
location = /pathв точности равен URI, nginx берёт этот блок и останавливается. Никакого сравнения префиксов, никаких regex. - Самый длинный префикс. Сравниваются все префиксные location (
location /pathиlocation ^~ /path), с которых начинается URI. Самый длинный запоминается, но пока не используется. - Короткое замыкание
^~. Если у запомненного префикса есть^~, nginx полностью пропускает фазу regex и берёт этот блок. - Regex — в порядке следования в файле. Иначе location с
~и~*проверяются в том порядке, в каком они записаны в конфигурации, и побеждает первое совпадение. Если не совпал ни один, используется префикс, запомненный на шаге 2.
Два правила из этого списка тянут в разные стороны: префиксы выбираются по длине независимо от порядка, regex — по порядку независимо от длины. Чтение конфигурации сверху вниз этот конфликт не обнаружит никогда. Если нужен ответ для собственного файла, а не для примеров ниже, вставьте его в бесплатный тестер nginx location. Он повторяет ту же последовательность и показывает, на каком этапе выбыл каждый проигравший блок. Тестер работает в браузере, поэтому вставленная боевая конфигурация не покидает страницу. Каждое правило сопоставления, описанное здесь, проверено на работающем nginx 1.27.5, а не переписано из вторичных статей.
Приоритет location в nginx: краткая сводка
В сопоставлении URI участвуют пять модификаторов и ещё один, который не участвует.
| Модификатор | Синтаксис | Сопоставление по | Останавливает фазу regex | Типичное применение |
|---|---|---|---|---|
= | location = /path | Равенству | Да | Горячие пути вроде / или /favicon.ico |
^~ | location ^~ /path | Началу строки | Да, если это самый длинный префикс | Каталоги, которые не должны доходить до regex |
~ | location ~ regex | PCRE, с учётом регистра | Нет | Маршрутизация по расширению, где регистр важен |
~* | location ~* regex | PCRE, без учёта регистра | Нет | Маршрутизация по расширению, где регистр не важен |
| (нет) | location /path | Началу строки | Нет | Общая маршрутизация по пути |
@ | location @name | Никогда не сопоставляется с URI | — | Цели для error_page и try_files |
Точное совпадение бьёт всё остальное, regex бьёт префикс, а префиксы конкурируют между собой по длине. Единственное исключение — ^~, и оно уже, чем кажется.
Читать таблицу как рейтинг можно только с оговоркой. ^~ стоит в ней выше ~, но блок ^~ регулярно проигрывает regex, потому что модификатор учитывается только у того префикса, который уже выиграл по длине. Об этом ниже.
Алгоритм выбора из четырёх шагов
Порядок сопоставления location в nginx проще всего усвоить на конфигурации, которая задействует все правила сразу. Это пример из документации nginx, и запомнить его целиком дешевле, чем каждый раз выводить правила заново:
server {
location = / { return 200 "A\n"; }
location / { return 200 "B\n"; }
location /documents/ { return 200 "C\n"; }
location ^~ /images/ { return 200 "D\n"; }
location ~* \.(gif|jpg|jpeg)$ { return 200 "E\n"; }
}
Пять запросов — пять разных ответов:
| Запрос | Победитель | Почему |
|---|---|---|
/ | A | Точное совпадение. Поиск завершается сразу. |
/index.html | B | Ни один regex не совпал, поэтому берётся запомненный префикс. |
/documents/document.html | C | Префикс длиннее, чем /. |
/images/1.gif | D | ^~ выиграл стадию префиксов, поэтому regex не запускался. |
/documents/1.jpg | E | Regex обошёл более длинный префикс, у которого не было ^~. |
Сравните две последние строки. И /images/, и /documents/ — префиксы; оба совпадают и оба оказываются самым длинным совпадением для своего запроса. Но один запрос достаётся префиксному блоку, а другой уходит в regex: всё решает ^~ у одного из них.
Шаг 2 — как раз то место, которое пропускают: nginx не использует самый длинный префикс, он его запоминает. Блок остаётся кандидатом, и фаза regex ещё может забрать запрос себе. Поиск завершают только шаги 1, 3 и 4.
Почему «самый длинный префикс» измеряется в символах, а не в сегментах пути
Сравнение префиксов — обычное сравнение строк. Оно не знает, что / разделяет сегменты пути, и не останавливается на границе. Возьмём такую конфигурацию:
server {
location /static { }
location /static/ { }
}
Запрос /staticfoo обслуживает location /static. URI начинается с этих семи символов, значит, совпадение есть. /static/ не совпадает вовсе, потому что в этой позиции нет слеша. Запрос /static/x уходит в другую сторону и достаётся /static/ — более длинному из двух.
Следствие в том, что location /static владеет также /static-backup, /staticfiles и всем остальным, что случайно начинается с тех же букв. Если имелся в виду каталог, поставьте завершающий слеш и добавьте точный location для пути без него:
server {
location /static/ { root /var/www; }
location = /static { return 301 /static/; }
}
В большинстве руководств примеры путей аккуратные и такого не показывают, поэтому проблема так часто проходит код-ревью. Тестер nginx location показывает каждый совпавший блок и число символов, по которым он совпал, так что префикс, проглатывающий соседей, видно сразу.
Что на самом деле означает ^~ (и чего не означает)
Обычное описание модификатора ^~ в nginx звучит так: «он даёт блоку приоритет над regex». Это достаточно близко к правде, чтобы быть опасным.
Что ^~ делает на самом деле: во время сравнения префиксов — ровно ничего. Он не удлиняет совпадение, не поднимает блок над другими префиксами и не меняет то, какой префикс будет запомнен. Он проверяется после, у единственного префикса, который уже выиграл по длине. Если у победителя есть ^~, фаза regex пропускается. Если нет — фаза regex отрабатывает как обычно.
А значит, более длинный обычный префикс молча его отключает:
server {
location ^~ /a/ { }
location /a/b/ { }
location ~ \.php$ { }
}
Запрос /a/b/x.php обрабатывает ~ \.php$. Самый длинный совпавший префикс — /a/b/, именно его nginx и запоминает; отсутствие модификатора разрешает фазу regex; regex совпадает первым и забирает запрос. Блок ^~ по-прежнему лежит в файле, по-прежнему выглядит защитным и не имеет к этому запросу никакого отношения.
Замените URI на /a/x.php — и та же конфигурация ведёт себя совершенно иначе: теперь самое длинное совпадение это ^~ /a/, фаза regex пропускается, побеждает блок ^~. Файл тот же, запрос похожего вида, а результат обратный.
Практических последствий у этого хватает. Канонический сценарий для ^~ — не пустить интерпретатор в каталог, доступный на запись:
server {
location ^~ /uploads/ { }
location ~ \.php$ { fastcgi_pass unix:/run/php-fpm.sock; }
}
Уберите ^~ — и запрос /uploads/evil.php уйдёт прямо в PHP-FPM. Именно по этой схеме устроена длинная череда отчётов «загрузка файла превращается в RCE», и разница между уязвимой и безопасной конфигурацией — два символа. Поэтому же важен случай «побеждённого ^~»: добавление более длинного обычного префикса вроде location /uploads/thumbs/ снова открывает дыру для всего, что лежит под ним, а diff, который это делает, выглядит совершенно безобидно.
Область действия у модификатора тоже ограничена. ^~ подавляет только regex, объявленные на его собственном уровне; он никогда не подавляет regex, вложенные внутрь собственного блока, а ^~ на вложенном location не защитит от regex, объявленного на уровне server. Переключите модификатор в тестере nginx location и посмотрите, как меняется победитель: каждый пропущенный regex он помечает отдельно, а не убирает из таблицы.
Location с regex: порядок важнее специфичности
В regex-варианте location ~ означает сопоставление с учётом регистра, а ~* — без учёта. Префиксное сопоставление, наоборот, в Linux всегда чувствительно к регистру. (На файловых системах без учёта регистра, например в macOS, nginx сравнивает префиксы без учёта регистра и заставляет каждый regex-location вести себя как ~*. Если разработка идёт на Mac, а деплой — на Linux, эта разница способна прятать сломанное правило вплоть до релиза.)
Правило, на котором спотыкаются чаще всего, звучит так: regex проверяются в том порядке, в каком записаны в файле конфигурации, и первое совпадение завершает поиск. Ни специфичность, ни длина, ни якоря на порядок не влияют.
server {
location ~ ^/a { }
location ~ ^/a/b/c$ { }
}
Запрос /a/b/c забирает ~ ^/a. Второй блок совпадает с URI точно, он гораздо строже — и не выполнится вообще ни для одного запроса. Это мёртвая конфигурация, которую nginx -t принимает без единого слова.
Отсюда привычка: располагать regex от самого частного к самому общему и держать список коротким. Широкий шаблон ближе к началу делает недостижимым всё, что ниже, и такая регрессия обычно приезжает не с новой функциональностью, а с уборкой конфигурации: перестановка строк на ревью выглядит косметикой.
Якоря заслуживают такого же внимания. У location ~ /admin нет якоря ни с одной стороны, поэтому он ищет в любом месте URI и спокойно совпадает с /public/admin/x. Если имеется в виду начало, пишите ~ ^/admin. Якорь только в конце, как в ~ \.php$, — нормальная и правильная запись для маршрутизации по расширению.
Несколько деталей PCRE, в которых интуиция, воспитанная на JavaScript, ошибается:
- nginx компилирует шаблоны location через PCRE без режимов UTF и multiline, поэтому шаблоны работают с байтами, а
^привязан только к началу URI. - В PCRE
$совпадает и непосредственно перед завершающим переводом строки. URI, оканчивающийся на%0A, всё ещё удовлетворяет\.php$— известный способ проскользнуть мимо правил, завязанных на расширение файла. - В PCRE полно конструкций без аналога в JavaScript: атомарные группы
(?>…), сверхжадные квантификаторыa*+, встроенные модификаторы вроде(?i), POSIX-классы вроде[[:alpha:]]и escape-последовательности\A,\z,\K,\Q…\E. - Шаблон с
{или}нужно брать в кавычки.location ~ ^/a{2}$не загружается с ошибкойunknown directive "2}$", потому что фигурная скобка завершила токен. Пишитеlocation ~ "^/a{2}$".
Захватывающие группы работают ровно так, как хочется, и $1 и далее доступны внутри блока:
upstream backend {
server 127.0.0.1:8080;
}
server {
location ~ ^/user/(\d+)/profile$ {
proxy_pass http://backend/profiles/$1;
}
}
Если отлаживается сам шаблон, а не его позиция в файле, сначала проверьте его в тестере Regex; синтаксис подробно разобран в шпаргалке Regex.
Шаг, который все пропускают: нормализация URI
Прежде чем обратиться хоть к одному location, nginx переписывает цель запроса. Шаблоны сравниваются с нормализованным путём, а не с байтами, которые пришли по сети. В руководствах этот шаг обычно пропускают, а объясняет он неожиданно много случаев вида «мой location не совпадает».
Нормализация делает четыре вещи: отделяет строку запроса, декодирует процентные последовательности в пути, разрешает сегменты . и .. и схлопывает повторяющиеся слеши.
| Цель запроса | Нормализованный $uri | Примечание |
|---|---|---|
//a//x | /a/x | Повторяющиеся слеши объединены |
/a/../b/x | /b/x | .. разрешён до сопоставления |
/a/b%2F..%2Fzz | /a/zz | %2F декодируется в настоящий разделитель и участвует в разрешении |
/a/%2e%2e/b/x | /b/x | %2E декодируется в точку, которая тоже участвует |
/a%20b/x | /a b/x | %20 становится настоящим пробелом |
/a+b/x | /a+b/x | + в пути — не пробел |
/a?x=/b | /a | Строка запроса отделяется первой |
/a%3Fx=1 | /a?x=1 | %3F остаётся литералом; строка запроса пуста |
Исключение составляют три символа: %25, %23 и %3F выводятся дословно и заново не интерпретируются. Поэтому у /a%3Fx=1 знак вопроса оказывается внутри пути, а в $args не попадает ничего.
Две разновидности целей до выбора location вообще не доходят. Сегменты .., поднимающиеся выше корня, и некорректная escape-последовательность вроде %00 отклоняются с кодом 400 ещё до начала сопоставления.
Главный вывод касается безопасности. Если блок location используется как граница контроля доступа, записанный в нём путь сравнивается с разрешённым путём:
server {
location /a/ { }
location /b/ { }
}
Запрос /a/b%2F..%2Fzz не остаётся внутри /a/b/. Он нормализуется в /a/zz и попадает в location /a/. Рассуждения об исходной цели вместо $uri дают здесь неверный ответ, а у «неверного ответа» в контексте контроля доступа есть вполне конкретное название. Прежде чем полагаться на location /admin как на защиту, убедитесь, каким получается нормализованный путь: тестер nginx location показывает исходную цель, нормализованный $uri и отделённую строку запроса тремя отдельными строками, а если нужно разобраться только с самим кодированием, URL декодер и кодировщик справится с этим отдельно.
Отсюда же следует, что строка запроса в сопоставлении не участвует никогда. location /search?q= не может совпасть с запросом /search?q=1, потому что выбор видит только /search. Чтобы ветвиться по параметру, читайте $arg_name внутри блока.
Пять конфигураций, которые работают не так, как кажется
Блок ^~ проигрывает более длинному обычному префиксу
Симптом: каталог с ^~ выглядит защищённым, а запросы внутри него всё равно обрабатывает regex.
Причина: ^~ учитывается только у того префикса, который уже выиграл по длине. Запоминается более длинный обычный префикс, а он ничего не подавляет.
Решение: поставить ^~ и на более длинный префикс либо убрать этот префикс совсем.
# Broken: /a/b/x.php goes to the regex
location ^~ /a/ { }
location /a/b/ { }
location ~ \.php$ { }
# Fixed: /a/b/x.php goes to ^~ /a/b/
location ^~ /a/ { }
location ^~ /a/b/ { }
location ~ \.php$ { }
Префикс без завершающего слеша перехватывает соседей
Симптом: блок, задуманный для одного каталога, обслуживает ещё и пути, которые просто начинаются с тех же букв.
Причина: префиксное сопоставление сравнивает символы, а не сегменты пути, поэтому location /app совпадает и с /application.
Решение: написать завершающий слеш и добавить location = /app, если путь без слеша тоже нужно обработать.
Точный regex стоит ниже широкого
Симптом: точное правило не срабатывает никогда, и нигде не появляется ошибки. Причина: regex проверяются в порядке следования в файле, и первое совпадение завершает поиск, поэтому всё, что ниже широкого шаблона, недостижимо. Решение: переместить частный шаблон выше широкого или сузить широкий якорем.
Regex, записанный после ^~
Симптом: правило deny загружается без ошибок и ничего не блокирует.
Причина: ^~ принимает литеральный префикс, а не шаблон. nginx не возражает; блок просто никогда не совпадает с URI.
Решение: использовать модификатор regex.
# Broken: matches nothing, loads without error
location ^~ "\.php$" { deny all; }
# Fixed
location ~ \.php$ { deny all; }
Расчёт на то, что строка запроса участвует
Симптом: location, содержащий ?, не совпадает никогда.
Причина: строка запроса отделяется при нормализации, и выбор location идёт по одному только пути.
Решение: сопоставлять по пути и проверять $arg_name внутри блока.
location /search {
if ($arg_q = "") { return 400; }
}
Отладка: как узнать, какой блок победил на самом деле
Отладочный лог даёт авторитетный ответ, но и настроить его труднее всего: нужна сборка с поддержкой отладки, поэтому сначала проверьте:
nginx -V 2>&1 | grep -o with-debug
Затем включите его и найдите строку, в которой назван выбранный блок:
error_log /var/log/nginx/debug.log debug;
grep "using configuration" /var/log/nginx/debug.log
Пробы через response header работают быстрее и обходятся без отладочной сборки. Пометьте каждого кандидата и посмотрите, что вернётся в header ответа:
location ^~ /uploads/ {
add_header X-Debug-Location "uploads-caret" always;
return 204;
}
location ~ \.php$ {
add_header X-Debug-Location "php-regex" always;
return 204;
}
curl -sI --path-as-is 'http://localhost/uploads/evil.php' | grep -i x-debug-location
--path-as-is здесь важен: без него curl услужливо разрешает .. за вас, и в итоге проверяется совсем не тот URI, который имелся в виду. Если команда собирается более сложная, генератор команд cURL напишет флаги за вас, а шпаргалка по curl закроет остальное. Когда вместо нужного header проба возвращает редирект или 404, справочник HTTP-коды состояния обычно подскажет, какой модуль его выдал.
nginx -T печатает полностью собранную конфигурацию, вместе со всеми файлами из include. Только так и выясняется, какой порядок у regex получается после сборки шести файлов. С порядком строк в том файле, который правят, он совпадает редко.
nginx -T | grep -n "location"
Воспроизведите проблему в тестере nginx location, прежде чем править сервер. Итерации по неразвёрнутой конфигурации быстрее цикла с перезагрузкой, а таблица решения прямо называет этап, на котором выбыл каждый блок.
Вложенные location, try_files и что они не меняют
Вложенные location выполняют тот же алгоритм уровнем ниже. Как только префиксный location побеждает, nginx спускается в его потомки и повторяет поиск там, а значит, вложенный regex проверяется раньше regex родительского уровня:
server {
location ~ \.php$ { }
location /a/ {
location ~ \.php$ { return 200 "nested\n"; }
}
}
/a/x.php обрабатывает вложенный блок. У вложенности есть и менее очевидный эффект: она способна сделать недостижимым префикс, который глобально длиннее, потому что спуск идёт только в победителя своего уровня. Если location /a/bb/ вложен в location /a/, а по соседству на внешнем уровне стоит location /a/b, то запрос /a/bb/x уйдёт в /a/b. Внешнее сравнение происходит первым, и выигрывает его /a/b. Спуск, который ничего не нашёл, тоже не откатывается назад: запрос остаётся у родителя.
try_files и rewrite в выборе не участвуют. Они выполняются внутри уже победившего блока и не могут задним числом этот выбор изменить. Если запрос вообще не доходит до блока с try_files, директива не имеет значения, и виноват обычно regex ~ \.php$, забирающий запрос раньше, чем до префиксного блока дойдёт очередь. Единственное исключение: внутренний редирект (rewrite … last или переход через error_page) перезапускает сопоставление, поэтому переписанный URI снова разрешается по списку location с самого начала.
Остаётся 301, который приходит при запросе каталога без завершающего слеша. Сопоставление тут ни при чём: такой редирект порождают два разных механизма. Если у location, имя которого оканчивается на /, есть proxy_pass или другая директива *_pass, то запрос того же пути без слеша получает 301 прямо во время выбора — до вычисления любых regex и с сохранением строки запроса:
server {
location /user/ { proxy_pass http://backend/; }
}
# GET /user?x=1 -> 301 to /user/?x=1
Добавление location = /user этот редирект подавляет. Отдельно от этого модуль статических файлов выдаёт собственный 301, когда путь разрешается в реальный каталог на диске; это зависит уже от файловой системы, а не от конфигурации.
FAQ
Какие есть пять модификаторов location в nginx?
Пять модификаторов location в nginx — это = для точного совпадения, ^~ для префикса, который пропускает фазу regex, отсутствие модификатора для обычного префикса, ~ для regex с учётом регистра и ~* для regex без учёта регистра. Шестая форма, location @name, в сопоставлении URI не участвует вовсе и существует только как цель для error_page и try_files.
Ускоряет ли nginx точное совпадение =?
Location с точным совпадением завершает поиск немедленно, пропуская и перебор префиксов, и вычисление всех regex. Экономия реальна, но слишком мала, чтобы её заметить. Писать такой блок имеет смысл для endpoint, которые получают тысячи запросов в секунду: health check или /favicon.ico. Десяток блоков = для обычных страниц добавит в конфигурацию больше сложности, чем выигрыша.
Можно ли писать модификатор location без пробела, например ~*^/api?
Да. location ~*^/api/ и location ~* ^/api/ означают ровно одно и то же: nginx отрезает модификатор от начала имени и сопоставляет сначала самый длинный модификатор, поэтому ~* распознаётся раньше ~. Пробел всё же лучше ставить: слитый модификатор выглядит частью шаблона, и на ревью его легко прочитать неверно.
Чем root отличается от alias внутри блока location?
root дописывает к каталогу весь URI, а alias заменяет им совпавший префикс. С location /static/ { root /var/www; } запрос /static/x.css ищет файл /var/www/static/x.css; замените на alias /var/www/assets/; — и поиск пойдёт по пути /var/www/assets/x.css. С alias завершающий слеш нужно либо поставить и у location, и у пути, либо не ставить нигде.
Может ли один запрос совпасть больше чем с одним блоком location?
Совпасть могут несколько блоков, но обрабатывает запрос ровно один. nginx сравнивает все префиксные location и, если нужно, все regex-location, а затем отдаёт запрос единственному победителю. Директивы от проигравших блоков не наследуются: всё, что нужно везде, должно жить на уровне server или http либо повторяться.
Подскажет ли nginx -t, какой location совпадёт?
Нет. nginx -t проверяет синтаксис и корректность конфигурации; он никогда не моделирует запрос, поэтому о порядке сопоставления не сообщает ничего. Чтобы выяснить, какому блоку достаётся URI, читайте отладочный лог, добавьте временный header в ответ или вставьте конфигурацию в тестер nginx location и посмотрите, почему каждый блок выиграл или проиграл.