Skip to content
Назад к блогу
Руководства

Приоритет location в Nginx: порядок сопоставления

Как nginx выбирает блок location: точный порядок для =, ^~, ~ и префиксов, а также ловушки, ломающие конфигурацию, — с бесплатным онлайн-тестером.

12 мин чтения

Приоритет location в Nginx: порядок сопоставления

Nginx не читает блоки location сверху вниз и не останавливается на первом подходящем. Именно из этого недопонимания вырастает большинство сообщений об ошибках вида «мой блок location не работает». Для префиксных location положение блока в файле не значит вообще ничего — nginx сравнивает их все и запоминает самое длинное совпадение.

Реальный приоритет location в nginx — это фиксированная последовательность из четырёх шагов:

  1. Точное совпадение. Если location = /path в точности равен URI, nginx берёт этот блок и останавливается. Никакого сравнения префиксов, никаких regex.
  2. Самый длинный префикс. Сравниваются все префиксные location (location /path и location ^~ /path), с которых начинается URI. Самый длинный запоминается, но пока не используется.
  3. Короткое замыкание ^~. Если у запомненного префикса есть ^~, nginx полностью пропускает фазу regex и берёт этот блок.
  4. Regex — в порядке следования в файле. Иначе location с ~ и ~* проверяются в том порядке, в каком они записаны в конфигурации, и побеждает первое совпадение. Если не совпал ни один, используется префикс, запомненный на шаге 2.

Два правила из этого списка тянут в разные стороны: префиксы выбираются по длине независимо от порядка, regex — по порядку независимо от длины. Чтение конфигурации сверху вниз этот конфликт не обнаружит никогда. Если нужен ответ для собственного файла, а не для примеров ниже, вставьте его в бесплатный тестер nginx location. Он повторяет ту же последовательность и показывает, на каком этапе выбыл каждый проигравший блок. Тестер работает в браузере, поэтому вставленная боевая конфигурация не покидает страницу. Каждое правило сопоставления, описанное здесь, проверено на работающем nginx 1.27.5, а не переписано из вторичных статей.

Приоритет location в nginx: краткая сводка

В сопоставлении URI участвуют пять модификаторов и ещё один, который не участвует.

МодификаторСинтаксисСопоставление поОстанавливает фазу regexТипичное применение
=location = /pathРавенствуДаГорячие пути вроде / или /favicon.ico
^~location ^~ /pathНачалу строкиДа, если это самый длинный префиксКаталоги, которые не должны доходить до regex
~location ~ regexPCRE, с учётом регистраНетМаршрутизация по расширению, где регистр важен
~*location ~* regexPCRE, без учёта регистраНетМаршрутизация по расширению, где регистр не важен
(нет)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.htmlBНи один regex не совпал, поэтому берётся запомненный префикс.
/documents/document.htmlCПрефикс длиннее, чем /.
/images/1.gifD^~ выиграл стадию префиксов, поэтому regex не запускался.
/documents/1.jpgERegex обошёл более длинный префикс, у которого не было ^~.

Сравните две последние строки. И /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 и посмотрите, почему каждый блок выиграл или проиграл.

Теги: nginx web-server devops regex configuration

Похожие статьи

Все статьи