Раскрываем возможности map в nginx

map — мощная директива, которая может сделать ваши конфиги простыми и понятными.
Возможно, это самая недооцененная директива, из за того, что не все знают всех её возможностей.
Она в компактной форме помогает обрабатывать переменные, GET параметры, заголовки, куки и наборы бекендов (upstream).
Попробую раскрыть её возможности хабрапользователям.

Для простоты, в примерах директива map будет соседствовать с директивами из location.
В реальном конфиге место map — в блоке http.
Краткое описание map
Это директива, которая устанавливает значение одной переменной (правой), в зависимости от другой (левой).
Выглядит вот так:
map $arg_one $var_two {
    "one"      "two";
}

В левой части можно использовать регулярные выражения, включая именованные выделения.
В правой части могут быть строки, переменные и regexp выделения из левой части.
Директива map описывается в блоке http.
Полное описание можно найти в документации.

Замена if на map


Особенности if в nginx
if в nginx реализован по своему, достаточно неочевидно.
Вкратце это описано на специальной странице.
Насколько я понимаю, if в nginx — это из разряда, когда мир крутится вокруг вас, а не наоборот.
На каждый if в location, nginx генерирует у себя два конфига, с if=true и с if=false.
Плюс, некоторые директивы ведут себя странно, или вообще не работают рядом с if в одном location.
Поэтому при работе с if всегда есть шанс совсем не того поведения, которое вы ожидали.
Для гарантированного поведения, if лучше заменять на map.

Рассмотрим примеры замен, от простого к сложному.

Установить переменной одно значение

Для этого у вас, скорее всего, уже написана такая конструкция:
if ($arg_one = "one") {
    set $var_two "two";
}

Вы можете это сделать с помощью map:
map $arg_one $var_two {
    "one"    "two";
}

И в нужном месте просто использовать $var_two.
Даже в таком простом случе у map есть плюсы:
  • не важно, сколько ещё if, или других директив окажется в location, эта переменная получит свое значение;
  • для nginx это менее затратно, так как значение переменной будет вычислено только в момент использования;

Установить переменной одно из нескольких значений

Предположу, что для этого уже прибегают к map, но иногда можно встретить вариант с несколькими if:
if ($arg_one = "one") {
    set $var_two "two";
}

if ($arg_one = "three") {
    set $var_two "four";
}

Вы можете это сделать с помощью map:
map $arg_one $var_two {
    "one"    "two";
    "three"  "four";
}

Уже виден выигрыш по компактности и читаемости.
Если нужно будет ещё редактировать условия, вам нужно поправить строчку в map.

Вложенные if (зависимость от нескольких условий)

Я находил в списках рассылки nginx вопросы насчет вложенных if, когда нужно учитывать несколько условий.
Вложенные if делать нельзя, можно сделать костыль из нескольких if.
А можно написать один map.
Возможно, вы не знаете, но в исходной части (где первая переменная) можно указывать не одну переменную, а целый текст, содержащий несколько переменных, в кавычках.
Например, вам нужно блокировать пользователей с user-agent «HackYou», долбящихся «POST» запросом по адресу "/admin/some/url".

Это, в принцципе, можно сделать с помощью if:
if ($http_user_agent ~ "HackYou") {
    set $block "A";
}

if ($method = "POST") {
    set $block "${block}B";
}

if ($uri = "/admin/some/url") {
    set $block "${block}C";
}

if ($block = "ABC") {
    return 403;
}

Вы можете это сделать с помощью map:
map "$http_user_agent:$method:$uri" $block {
    "HackYou:POST:/admin/some/url"  "1";
}

if ($block) {
    return 403;
}

Двоеточие — просто для удобства восприятия.
В этом примере возникает «точка невозврата» в сторону map.
Если количество условий растет (например, несколько user-agent), реализовать их набором if уже не получится, или это будет слишком громоздкая конструкция.

http заголовки


Если вам нужно добавлять заголовки в зависимости от каких-то условий, с if это может стать проблемой.

Например, такая конструкция:
if ($arg_a = "1") {
    add_header X-one "one";
}
add_header X-two "two";

Будет отдавать только один заголовок (X-one, если arg_a = true, и X-two, если false).
Это недостаток add_header и разработчики не будут это исправлять.
А если у вас несколько if с заголовками, может и не получиться добавить несколько разных заголовков одновременно.

Но тут на помощь приходит map:
map $arg_a $header_one {
    "1"    "one";
}

add_header X-one $header_one;
add_header X-two "two";

Несколько заголовков — несколько map.
Если переменная будет пустой, nginx просто не создаст заголовок.
Вообще, в случае с if может помочь модуль headers_more, он лишен недостатка add_header насчет if.
Модуль headers_more интересен сам по себе, с его помощью можно гибко управлять любыми заголовками, как ответа, так и запроса (на бекенд).
В паре с директивой map этот модуль может реализовывать многие хотелки, включая генерацию нескольких кук.

Выбор upstream


В директивах типа proxy_pass (fastcgi_pass и прочие *_pass), где можно указывать upstream в качестве бекенда, можно использовать переменные.
Т.е. работает такое определение:
proxy_pass http://$php_backend;

Об этом сказано в документации:
В этом случае имя сервера ищется среди описанных групп серверов и если не найдено, то определяется с помощью resolver’а.

В паре с map, это дает нам поле для фантазии.
Вот грубый пример — допустим, нам нужно такое:
Есть кука userid.
Если в её значении первое число от 0 до 4, то посылать запросы на upstream old_php_backend.
Если с 5 до 9 — на new_php_backend.
Если куки нет, или она пустая, или первый символ — не число, то на default_php_backend.

Обычно делается с помощью if, rewrite и нескольких location:
location /some/url/ {
    if ($cookie_userid ~ "~^[0-4]") { rewrite ^(.+)$ /old/$1 last; }
    if ($cookie_userid ~ "~^[5-9]") { rewrite ^(.+)$ /new/$1 last; }

    proxy_pass http://default_php_backend;
}

location /old/some/url/ {
    internal;
    rewrite    ^/old/(.+)$ $1 break;
    proxy_pass http://old_php_backend;
}

location /new/some/url/ {
    internal;
    rewrite    ^/new/(.+)$ $1 break;
    proxy_pass http://new_php_backend;
}

Использование map все упрощает:
map $cookie_userid     $php_backend {
    "~^[0-4]"          "old_php_backend";
    "~^[5-9]"          "new_php_backend";
    default        "default_php_backend";
}

location /some/url/ {
    proxy_pass http://$php_backend;
}

Это действительно работает, применяется на нагруженном сервисе, и с этим не наблюдается проблем.
Главное — не забывать про default, чтобы всегда было, куда направить запрос.
Этот прием можно применять и с geo/split_clients.
Например, в split_clients выделить 1% запросов и направить их на отдельный бекенд для тестов.

Переменную из map можно использовать в другой map


Такой код работает:
map $arg_a $var_a {
    "0"    "1";
}

map $var_a $var_b {
    "1"    "2";
}

map $var_b $var_c {
    "2"    "3";
}

Когда вы обратитесь к $var_c, последовательно сработает три директивы map.
При GET параметре a=0, $var_c будет содержать «3».
nginx прожевывает цепочку из 12 map'ов, дальше я не пробовал.
Можете попробовать, ради интереса, узнать максимальную длинну цепочки.
Обычно мне хватает пары map'ов, которые зависят друг от друга.
Мне это бывает полезно для формирования сложных заголовков и кук средствами nginx (когда надо добавить текст в заголовок, на бекенд отправить один заголовок, а клиенту — другой).
Это может пригодиться, как развитие примера с вложенными if.
В одном map вычисляем $var_a, в другом map вычисляем $var_b, а третий map зависит от "$var_a:var_b".

Так же map работает с перменными из geo и split_clients.
В geo и split_clients результирующей переменной можно присвоить только простую строку, нельзя использовать переменные или регулярные выражения.
Если вам, в зависимости от ip, нужно что-то посложнее простой строки, geo+map помогут вам.
А связка split_clients+map поможет вам, например, гибко изменить заголовки для 1% пользователей.

Например, так:
split_clients "${remote_addr}XXX" $test_percent {
    1%                            "1";
    *                             "0";
}

map "$test_percent:$http_user_agent" $test_mobile_users {
    "~*^1:.*iphone.*"                  "X-tester: iphone";
}

more_set_input_headers $test_mobile_users;

Если ip пользователя попал в тестовый 1% пользователей, и в его user-agent есть слово iphone, то вместе с запросом на бекенд отправляется заголовок «X-tester: iphone».
Разработчикам остается отреагировать на этот заголовок и отдавать тестовую версию сайта для айфонов.

Заключение


Как видите, map помогает делать сложную логику малым количеством команд.
Она позволяет избавиться от if в большинстве случаев.
А совместно с другими директивами, творит хитрые преобразования в несколько строк.
Я надеюсь, эти возможности помогут вам, с одной стороны, сократить конфиги, а с другой — реализовать хитрые хотелки.
  • +91
  • 80.4k
  • 8
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 8

    +4
    Спасибо, отличная статья!
      0
      Как будет выглядеть в мапе такая конструкция?
      	location /eset_upd {
                      if ($http_user_agent ~ .*BPC.[3].*) {
                      rewrite ^(.*) /eset_upd/v3/update.ver break;
                      }
                      if ($http_user_agent ~ .*BPC.[4].*) {
                      rewrite ^(.*) /eset_upd/v4/update.ver break;
                      }
      		if ($http_user_agent ~ .*BPC.[5].*) {
                      rewrite ^(.*) /eset_upd/v5/update.ver break;
                      }
                      if ($http_user_agent ~ .*BPC.[6].*) {
                      rewrite ^(.*) /eset_upd/v6/update.ver break;
                      }
                      if ($http_user_agent ~ .*BPC.[7].*) {
                      rewrite ^(.*) /eset_upd/v7/update.ver break;
                      }
      
        0
        Если я правильно все понял, то так:
        location /eset_upd {
            map "$http_user_agent rewrite ^(.*) {
                    ".*BPC.[3].*" "/eset_upd/v3/update.ver";
        	    ".*BPC.[4].*" "/eset_upd/v4/update.ver";
        	    ".*BPC.[5].*" "/eset_upd/v5/update.ver";
        	    ".*BPC.[6].*" "/eset_upd/v6/update.ver";
        	    ".*BPC.[7].*" "/eset_upd/v7/update.ver";
        	}
        }
        

        Поправьте, если не так.
          +1
          Да, только map должен быть в секции http.
          И после $http_user_agent нужно указать свою переменную, в которую и запишется выбранное значение.
          +4
          В секции http:
          map $http_user_agent $go {
              "~BPC.\[(?P<1>[3-7])\]" $1;
          }
          

          В конфиге виртуального хоста:
          location /eset_upd {
              if ($go) { rewrite ^ /eset_upd/v$go/update.ver break; }
          }
          

          Название переменной $go — от фонаря.
          В регулярке необязательно указывать ".*" в начале и в конце.
          (?P<1>[3-7]) — создается переменная «1», со значением, которое попадет в фильтр [3-7]
          В результате в переменной $go будет номер версии
          rewrite ^ — это короткая форма, когда из урла не нужно ничего брать.
        • UFO just landed and posted this here
            0
            Возможно, над этой конструкцией нужно посидеть пару минут и обмозговать :)
            Но это уже хитрая логика, которую нужно как-то описывать в конфиге.
            Думаю, такая реализация через map читабельнее, чем 3-4 if в location :)
            Прошу заметить, что такой подход рассчитан на масштабирование — если вам нужно будет добавить url, с map вам нужно добавить одну строчку.
            Но может быть, это можно описать проще, буду рад узнать другой вариант.

            0
            Главное — не забывать про default, чтобы всегда было, куда направить запрос.

            Подскажите пожалуйста, можно ли написать default так, чтобы запрос шел на случайный сервер а не всегда на один и тот же?

            Only users with full accounts can post comments. Log in, please.