Конфигурация веб-сервера выглядит статичной, пока не понадобится обработать два похожих запроса по-разному. Дальше начинается набор условий и дублирование блоков. Переменные решают задачу иначе: значение вычисляется для каждого запроса отдельно, и одна строка заменяет десяток частных случаев. Разбираем, какие переменные доступны, как создавать свои и где механизм даёт наибольшую пользу.
Как устроены переменные и почему значение вычисляется по требованию
Переменная это место, куда подставляется значение, вычисленное в момент обработки запроса. Обозначается знаком доллара и именем без пробелов.
Ключевое свойство, отличающее переменные от констант: значение не вычисляется заранее. Оно считается лениво, при первом обращении внутри конкретного запроса, и запоминается до конца его обработки. Отсюда два важных следствия.
Первое: обращение к переменной, которая в данном запросе не имеет смысла, не приводит к ошибке. Пустое значение или прочерк это нормальное поведение.
Второе: стоимость вычисления платится при каждом обращении. Часть переменных дешёвая, поскольку значение уже известно серверу, а часть требует дополнительной работы, например поиска по таблице или обращения к данным соединения. В жарких местах конфигурации это имеет значение.
Проверить, что переменная существует, можно по списку в документации или простым экспериментом с журналом:
log_format vars 'host=$host uri=$uri req=$request_uri '
'method=$request_method status=$status '
'bytes=$body_bytes_sent time=$request_time';
access_log /var/log/nginx/vars.log vars;
Один запрос показывает реальные значения всех перечисленных переменных, и это самый быстрый способ разобраться в различиях похожих имён.
Основные группы встроенных переменных и различия между похожими именами
Встроенные переменные удобно держать в голове группами по происхождению значения, а не алфавитным списком.
Группа соединения описывает, кто подключился и как. Сюда относятся remote_addr с адресом клиента, remote_port, server_addr и server_port, а также binary_remote_addr, представляющая адрес в компактном двоичном виде.
Группа запроса описывает, что именно спросили. request_method, request_uri, uri, args, request_length, http_user_agent и произвольные заголовки вида http_имя_заголовка.
Группа ответа содержит status, body_bytes_sent, bytes_sent, request_time и upstream_response_time.
Группа внутреннего состояния сервера включает document_root, request_filename, hostname, nginx_version, а также connection и connection_requests для учёта в рамках одного соединения.
Часть переменных появляется только при определённых условиях. Префикс upstream заполняется при проксировании, arg_ содержит параметры строки запроса, cookie значения кук, а sent_http_ даёт доступ к заголовкам формируемого ответа.
Три пары имён путают чаще всего, и различие между ними принципиальное.
request_uri содержит исходный адрес запроса вместе со строкой параметров, без нормализации и без учёта внутренних перенаправлений. uri содержит нормализованный путь без параметров и может меняться в процессе обработки, например при внутреннем перенаправлении или при подстановке индексного файла. args содержит только строку параметров.
host и http_host похожи, но берутся из разных мест и обрабатываются по-разному. Переменная host отдаёт предпочтение имени сервера из строки запроса, затем значению заголовка Host, затем имени сервера, соответствующему адресу, при этом имя приводится к нижнему регистру и без номера порта. Переменная http_host это заголовок Host в том виде, в каком его прислал клиент, включая порт и исходный регистр.
request_filename и document_root связаны с путями на диске и зависят от директив root и alias, поэтому их значения меняются при переходе между блоками location.
Создание собственных переменных через set и вычисляемых через map
Директива set присваивает значение переменной в момент выполнения и работает внутри блоков server, location и if:
location /api/ {
set $tenant "default";
proxy_set_header X-Tenant $tenant;
proxy_pass http://backend;
}
Ограничение у set важное: директива выполняется на своей фазе обработки, поэтому присвоенное значение доступно не всегда там, где ожидается. Ловушка чаще всего возникает при использовании set внутри блока if.
Директива map создаёт переменную, значение которой вычисляется по другому значению через таблицу соответствий. Объявляется она в контексте http и действует глобально:
map $http_user_agent $is_bot {
default 0;
"~*curl" 1;
"~*python" 1;
"~*bot" 1;
}
map $host $tenant {
default shared;
~^(?<name>.+)\.example\.com$ $name;
}
Возможности map шире подстановки. Префикс с тильдой включает регулярное выражение, именованные группы в нём становятся отдельными переменными, параметр hostnames включает сопоставление по маскам имён, а volatile указывает, что значение не стоит кэшировать.
Преимущество map перед набором условий в том, что таблица строится один раз, поиск в ней быстрый, а конфигурация остаётся читаемой.
Директива geo решает родственную задачу и строит значение по адресу клиента:
geo $rate_limit_class {
default "strict";
192.168.0.0/16 "off";
10.0.0.0/8 "off";
}
Поиск по поддереву адресов выполняется быстро даже на больших списках, поэтому geo подходит для различения внутренних и внешних клиентов.
Переменные в маршрутизации запросов и динамические пути к файлам
Самое частое применение переменных это динамическое построение адреса внутреннего узла и пути к файлу.
Проксирующая директива принимает переменную в качестве адреса:
map $arg_target $upstream_addr {
default "";
"a" 10.0.1.10:8080;
"b" 10.0.1.11:8080;
}
server {
location /route {
if ($upstream_addr = "") {
return 404;
}
proxy_pass http://$upstream_addr;
}
}
Когда адрес задан переменной, сервер не определяет имя узла на этапе разбора конфигурации и требует явного преобразователя имён:
resolver 127.0.0.53 valid=30s ipv6=off;
Без директивы запрос завершится ошибкой, а сообщение в журнале окажется неочевидным. Параметр valid задаёт время жизни записи в кэше.
Вторая область применения, построение пути к файлу по частям запроса. Так устроены хранилища, где имя файла вынесено в адрес:
location ~ ^/files/(?<year>\d{4})/(?<month>\d{2})/(?<name>[a-z0-9_]+)$ {
alias /srv/files/$year/$month/$name.dat;
}
Регулярное выражение создаёт три именованные группы, которые подставляются в путь. Конструкция заменяет десятки отдельных блоков location.
Различать root и alias в таких схемах обязательно. Директива root добавляет путь к адресу запроса, а alias заменяет его. Ошибка в выборе приводит к удвоению части пути, и в журнале это выглядит как обращение к несуществующему каталогу.
Ограничение доступа и частоты запросов по вычисляемому ключу
Ограничение частоты строится вокруг зоны памяти, где для каждого значения ключа хранится счётчик. Ключ задаётся переменной, и от выбора переменной зависит всё поведение механизма.
limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
server {
location /login {
limit_req zone=perip burst=10 nodelay;
limit_conn connperip 3;
proxy_pass http://backend;
}
}
Использование binary_remote_addr вместо remote_addr не случайно. Двоичное представление адреса занимает четыре или шестнадцать байт, тогда как текстовое до сорока пяти, и при одинаковом размере зоны в двоичном варианте помещается в несколько раз больше состояний.
Если сервер работает за обратным прокси, адрес клиента в remote_addr будет адресом самого прокси, и ограничение превратится в блокировку всех сразу. Лечится это извлечением исходного адреса из заголовка:
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Директива real_ip_header указывает, откуда брать настоящий адрес, а real_ip_recursive заставляет пропускать адреса из списка доверенных и брать первый недоверенный. Без параметра recursive в цепочке из нескольких прокси значением окажется адрес ближайшего.
Доверять заголовку можно только от известных источников, иначе любой клиент подставит нужный адрес и обойдёт ограничение. Именно для этого служит список set_real_ip_from, и пренебрегать им не стоит.
Ключом может служить не только адрес. Для защиты отдельного раздела удобно ограничивать частоту по сочетанию адреса и пути, а для служебных запросов применять более мягкий режим:
map $uri $limit_key {
default $binary_remote_addr;
~^/healthcheck$ "";
}
limit_req_zone $limit_key zone=adaptive:10m rate=10r/s;
Пустое значение ключа отключает учёт для соответствующих запросов, что проще и надёжнее, чем исключение блока location из-под действия директивы.
Отладка значений через журнал, заголовки и типовые ошибки
Работа с переменными затруднена тем, что ошибка в значении не видна снаружи. Запрос либо уходит не туда, либо возвращается не тот код, а причина скрыта в промежуточном вычислении.
Самый надёжный способ диагностики, вывод значений в журнал. Формат собирают под задачу и включают только для нужного раздела:
log_format debug_vars '$remote_addr "$request" status=$status '
'tenant=$tenant upstream=$upstream_addr '
'key=$limit_key rt=$request_time';
location /api/ {
access_log /var/log/nginx/api-debug.log debug_vars;
}
Второй способ, временная выдача значения в заголовке ответа. Результат виден сразу в инструментах браузера:
add_header X-Debug-Tenant $tenant always;
Параметр always обязателен, иначе заголовок добавится только к успешным ответам. В рабочей конфигурации оставлять его нельзя, поскольку он раскрывает устройство маршрутизации.
Третий способ, проверка разбора конфигурации и просмотр вычисленного дерева:
nginx -t
nginx -T | grep -A5 'map \$host'
Перечислим ошибки, которые встречаются чаще всего:
- Обращение к переменной до её присваивания, поскольку set выполняется на своей фазе, а часть директив вычисляется раньше, и в журнале значение оказывается пустым;
- Использование текстового remote_addr в качестве ключа зоны ограничения, из-за чего зона заполняется в несколько раз быстрее и вытесняет рабочие записи;
- Отсутствие директивы resolver при подстановке имени узла через переменную, что приводит к ошибке во время запроса, а не при проверке конфигурации;
- Путаница между uri и request_uri, из-за которой строка параметров теряется при перенаправлении или удваивается при проксировании;
- Слепая передача в прокси переменной, которая может оказаться пустой, поскольку внутренний узел в этом случае получает адрес без обязательной части и отвечает ошибкой;
- Регулярное выражение в map без префикса с тильдой, из-за чего сопоставление идёт как точное совпадение со строкой и правило не срабатывает никогда.
Количество переменных в одной конфигурации стоит ограничивать. Соблазн выразить через них всю логику велик, но после определённого предела конфигурация перестаёт читаться. Если для понимания одного блока location нужно держать в голове больше трёх значений, часть логики стоит перенести в явные условия.
Отдельная область, где переменные оказываются почти единственным инструментом, это построение адреса перенаправления с сохранением параметров. Прямая подстановка строки запроса в новое место позволяет переносить клиента между разделами, не теряя исходный адрес:
location /old/ {
return 301 /new$request_uri;
}
Переменная request_uri содержит исходный путь со строкой параметров, поэтому перенаправление сохраняет и то, и другое. Замена на uri привела бы к потере параметров.
Приём работает и при переезде между доменами, когда нужно сохранить путь, но заменить имя:
server {
server_name old.example;
return 301 https://new.example$request_uri;
}
Переменные дают то, чего не даёт статичная конфигурация: одну строку правила вместо десятка частных случаев. Цена за это необходимость понимать порядок вычисления, и она окупается при первой же правке, когда вместо изменения пяти блоков достаточно поправить одну строку в таблице соответствий.