Разбираемся в HTTP прокси NGINX, балансировке нагрузки, буферизации и кешировании

В этой мы рассмотрим возможности сервера NGINX в http проксировании, что помогает перенаправлять запросы на бекэнд сервера для дальнейшей обработки. Довольно часто Nginx настраивают в качестве реверсивного прокси для упрощения масштабирования инфраструктуры или для перенапраления запросов на сервера, которые не предназначены для работы при большой нагрузке.

Также мы затронем каким образом можно осуществить масштабирование при помощи встроенных в Nginx средствах балансировки и каким образом буферизация и кеширование помогут вам улучшить работоспособность прокси.

Общая информация по прокси

Если раньше вы сталкивались только с простыми односерверными настройками, то наверняка у вас возникает вопрос: «А зачем вообще перенаправлять запросы?».

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

Другой причиной может служит ситуация, когда ваш бекэнд-сервер не может обрабатывать поступающий к нему напрямую запрос. Многие фреймворки имеют в своем составе веб-сервера, но им, как правило, далеко до возможностей Nginx. Разместив Nginx до этих серверов ускорит работу и повысит безопасность вашего приложения.

Проксирование в Nginx работает путем изменения полученного запроса и передачи его серверам, отвечающим за его обработку. Результат отправляется обратно Nginx, который в свою очередь отвечает клиенту. В роли тех самых «других» серверов могут выступать удаленные или локальные машины, а иногда очередные виртуальные сервера, созданные в пределах Nginx. Сервера, на которые nginx перенаправляет запросы называются вышестоящими серверами (upstream servers).

Nginx способен перенаправлять запросы используя http(s), FastCGI, uwsgi или memcached протоколы посредством различных наборов директив для каждого типа прокси. В этой статье мы остановимся на протоколе http. Nginx отвечает за передачу запроса в таком виде, в котором вышестоящий сервер способен его понять.

Разбираем базовый проход через http прокси

Самый простой пример проксирования — это передача запроса одному серверу, который способен общаться посредством http протокола. Такой тип проксирования называется proxy pass, который управляется при помощи одноименной директивы proxy_pass.

Эта директива чаще всего встречается в контексте location. Также она применяется в блоке if того же контекста и контекста limit_except. Когда запрос соответствует адресу, в котором указана директива proxy_pass, то он перенаправляется на заданный URL адрес.

Давайте взглянем на пример:

# server context
location /match/here {
    proxy_pass http://example.com;
}
# ...

В этом примере в конце описания сервера не указан URI адрес. При получении запроса, соответствующего этой настройке, он будет без изменения передан вышестоящему серверу.

Например, при обращении к /match/here/please, URI будет отправлен по адресуhttp://example.com/match/here/please.

Приведем другой пример:

# server context
location /match/here {
    proxy_pass http://example.com/new/prefix;
}
. . .

Здесь мы уже видим URI. В таком случае часть запроса, соответствующая location заменяется на URI. То есть запрос к /match/here/please будет передан в виде http://example.com/new/prefix/please. /match/here/please заменяется на /new/prefix/please. Это следует всегда помнить.

Такая замена не всегда возможна. В таком случае URI игнорируется и вышестоящий сервер получает либо оригинальный запрос, либо измененный другими директивами.

Например, при использовании регулярных выражений, Nginx не может определить какая именно часть URI соответствует выражению и перенаправляет оригинальный запрос. Так же, например, при использовании директивы rewrite в пределах того же location. В таком случае сервер получит переписанный uri.

Разбираемся как Nginx обрабатывает заголовки

Стоит понимать, что одного только URI не достаточно, чтобы вышестоящий сервер корректно обработал запрос. Перенаправленный nginx запрос будет немного видоизменен. Больше всего будут затронуты заголовки запроса.

При передаче запроса Nginx вносит необходимые изменения в заголовки:

Nginx убирает все пустые заголовки. Нет никакого смысла загружать сервер пустыми заголовками.

По-умолчанию, все заголовки со знаком нижнего подчеркивания nginx расценивает как некорректные и удаляет их. Если же вы хотите сохранить такие заголовки, то вам необходимо установить значение «on» директиве «underscores_in_headers». Заголовок «Host» переписывается на значение, указанное в переменной $proxy_host. Это будет IP адрес или имя домена и номер порта вышестоящего сервера.

Заголовок Connection изменяется на значение close. Этот заголовок используется для определения состояния соединения между двумя участниками. В данном примере Nginx дает знать следующему серверу, что как только тот ответит на запрос, соединение закроется. Первое, на что стоит обратить внимание, это тот факт, что если мы хотим чтобы какой-то заголовок не дошел до конечной точки, то ему следует задать значение пустой строки. Такие заголовки полностью фильтруются сервером.

Так же запомните, если ваше приложения работает с нестандартными заголовками, то убедитесь, что они не содержат символ нижнего подчеркивания. Если вам все же нужны такие заголовки, то измените значение директивы underscores_in_headers на on. Если вы этого не сделаете, то nginx просто посчитает эти заголовки ошибочными и не пропустит их. Одним из самых важных заголовком является host. Как сказано выше, его значение заменяется на значение переменной $proxy_host, которое указывает либо на ip адрес, либо на имя хоста и номер порта вышестоящего сервера указанного в директиве proxy_pass. Такое поведение определено по-умолчанию, так как это значение единственное, в котором Nginx уверен, что получит ответ от вышестоящего сервера.

Часто используемые значения заголовка host:

  • $proxy_host: изменяет значение заголовка хост на комбинацию ip адрес или имя домена и номера порта, взятого из определения proxy_pass. Nginx считает такой подход наиболее безопасным, но, как правило, этого не всегда достаточно.
  • $http_host: заменяет заголовок host, но значение того же заголовка, полученного от клиента. Значения заголовков, полученных от киента всегда доступны через определенные переменные. Имя переменной начинается с приставки $http_ и заканчивается именем заголовка в нижнем регистре, где все тире заменены на нижнее подчеркивание. Следует помнить, что не всегда переменная $http_hostдоступна. В случае её отсутствия запрос может не пройти.
  • $host: эта переменная может принимать следующие значения: имя хоста из строки запроса, заголовок host из запроса клиента или имя сервера соответствующего запроса.

Чаще всего вы будете назначать заголовку host значение переменной $host. Такой подход более практичен и, как правило, вышестоящий сервер получает полностью заполненый заголовок host.

Установка или переназначение заголовков

Для изменения или создания заголовков прокси соединения воспользуйтесь директивой proxy_set_header. Например, чтобы изменить заголовок host и добавить несколько часто встречающихся заголовков при проксировании, мы можем поступить следующим образом:

# server context
location /match/here {
    proxy_set_header HOST $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://example.com/new/prefix;
}
# ...

Заголовок host соответствующего запроса будет изменен на значение переменной $host, которое должно содержать изначально запрошенный хост. Заголовок X-Forwarded-Proto дает вышестоящему серверу знать о том, какая схема была использована при изначальном запросе (http/https).

X-Real-IP имеет значение IP адреса клиента. Заголовок X-Forwarded-For содержит список прокси серверов, по которым прошел запрос до настоящего момента. В данном примере мы присваиваем ему значение переменной $proxy_add_x_forwarded_for. Она содержит в себе полученный заголовок X-Forwarder-For плюс добавляет свой сервер в этот список.

Конечно, есть смысл перенести директиву proxy_set_header в раздел серверного или http контекста, чтобы можно было ссылаться на неё из разных мест настройки.

# server context
proxy_set_header HOST $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_Header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location /match/here {
    proxy_pass http://example.com/new/prefix;
}
location /different/match {
    proxy_pass http://example.com;
}

Определение вышестоящего контекста для балансировки прокси соединений

В предыдущем примере мы рассмотрели как создать простое прокси http соединение до бекэнд сервера. Nginx позволяет нам указывать целый пул серверов, которым можно переадресовывать запросы.

Эту возможность можно использовать при помощи директивы upstream задав ей список доступных серверов. Такая настройка подразумевает, что каждый сервер из этого списка способен полностью ответить на поступивший к нему запрос. То есть мы можем легко масштабировать свою инфраструктуру без лишних проблем. Директива upstream должна быть указана в контексте http настройки nginx сервера.

Разберем простой пример:

# http context
upstream backend_hosts {
    server host1.example.com;
    server host2.example.com;
    server host3.example.com;
}
server {
    listen 80;
    server_name example.com;
    location /proxy-me {
        proxy_pass http://backend_hosts;
    }
}

В этом примере мы создали контекст upstream по имени backend_hosts. После его определения, мы можем обращаться к нему как обычному доменному имени. Как вы видите, в пределах блока server все запросы, поступающие на example.com/proxy-me мы передаем на только что созданный пул бекэнд-сервером. В пределах этого списка, сервер выбирается при помощи настраиваемого алгоритма. По-умолчанию используется алгоритм round-robin, то есть серверы выбираются последовательно.

Изменение алгоритма балансировки

Изменить алгоритм в пределах пула серверов можно при задании определенных флагов и директив:

  • round robin: алгоритм балансировки используемый по-умолчанию, если не указан никакой другой. Каждый сервер из контекста выбирается по очереди.
  • least_conn: каждое новое соединение будет передано тому серверу, у которого на данный момент меньше всего активных соединений. Такой подход полезен если ваше приложение часто использует постоянные соединения.
  • ip_hash: выбор сервера происходит в соответствии ip адресу клиента. В качестве ключа используются первые три октета. В результате один и тот же клиент работает с одним сервером, что помогает поддерживать сессию
  • hash: этот алгоритм применяется при memcache проксировании. Серверы делятся на группы основываясь на независимом хеш ключе, который может состоят из текста, переменных или различных комбинаций. Только этот метод предполагает получение данных от пользователя в виде ключа.

При изменении алгоритма балансировки блок настройки принимает следующий вид:

# http context
upstream backend_hosts {
    least_conn;
    server host1.example.com;
    server host2.example.com;
    server host3.example.com;
}
# ...

В этом примере сервер будет выбран исходя из количества имеющихся соединений. Можно было бы так же использовать директиву ip_hash, для обеспечения работоспособности сессии.

Что же касается алгоритма hash, то вам следует указать ключ, который может принимать любое значение:

# http context
upstream backend_hosts {
    hash $remote_addr$remote_port consistent;
    server host1.example.com;
    server host2.example.com;
    server host3.example.com;
}
# ...

В этом случае сервер будет выбран при помощи ip адреса клиента и порта. Также мы добавили необязательный параметр consistent, который задает использование ketama consistent алгоритм кеширования. В общем это означает, что при изменении вышестоящих серверов, кеш претерпит минимальные изменения.

Указание весов серверов при балансировки

При задании списка серверов, каждый сервер имеет равнозначный вес. То есть каждый сервер может и должен обрабатывать одну и ту же степень нагрузки (принимая во внимания алгоритм балансировки). Но вы так же можете задать каждому серверу определенный вес.

# http context
upstream backend_hosts {
    server host1.example.com weight=3;
    server host2.example.com;
    server host3.example.com;
}
# ...

В этом примере сервер host1.example.com будет получать в три раза больше запросов, чем два других сервера. По-умолчанию значение веса для каждого сервера устанавливается в единицу.

Использованию буферов для освобождения бекэнд серверов

Главный вопрос при проксировании это насколько изменится скорость работы при добавлении дополнительного сервера. Переход на большее или меньшее количество серверов может быть значительно смягчен при помощи системы буферизации и кеширования nginx.

При передачи запроса серверу следует учитывать скорость обоих соединений:

соединение клиент — nginx сервер соединение nginx сервер — бекэнд сервер

Nginx способен изменять свое поведение в зависимости от того, какое из соединений вы планируете оптимизировать.

Без использования буферов данные поступившие от прокси сервера сразу же возвращаются клиенту. Если вы отталкиваетесь от того, что клиентское соединение достаточно быстрое, то буферизацию можно отключить. При использовании буферов Nginx какое-то время хранит ответ полученный от бекэнд сервера и потом отправляет его клиенту. Если клиент не достаточно быстр, то nginx закрывает соединение с бекэнд-сервером как можно быстрее, а данные отправляет клиенту как только тот будет готов их принять.

По-умолчанию Nginx использует буферизацию, так как скорость работы клиентов сильно различается. Мы можем изменять работу буферов, применяя определенный набор директив. Их можно указывать в контекстах server, http или location. Следует помнить что директивы size обрабатываются для каждого запроса отдельно, поэтому с ними главное не перестараться, иначе вы рискуете значительно понизить работоспособность вашего сервера.

proxy_buffering: включает или отключает буферизацию для контекста. По-умолчание — on. proxy_buffers: директива указывает количество (первый аргумент) и размер (второй аргумент) буферов. По-умолчанию директива имеет значение равное 8 буферам с размером одной страницы памяти (4k или 8k). Повышение количества буферов поднимает объем хранимой в буферах информации.

proxy_buffer_size: начальная часть ответа от бекэнд-сервера, включающая в себя заголовки, хранится в буферах отдельно от остальной информации. Эта директива указывает размер буфера именно для этой части ответа. По-умолчанию имеет значение идентичное proxy_buffers, но так как в этом буфере хранятся только заголовки, то его можно уменьшать.

proxy_busy_buffer_size: эта директива указывает максимально возможное количество занятых буферов. Хотя клиент может читать информацию только из одного буфера за подход, буферы образуют очередь для отправки данных клиентам. Таким образом она указывает на объем информации в буфере, которая имеет такое состояние.

proxy_max_temp_file_size: максимальный размер временного файла на диске на каждый запрос. Они создаются в случае, если ответ бекэнд сервера не помещается в один буфер. proxy_temp_file_write_size: максимально возможный объем информации, которая записывается за один подход в файл, если ответ сервера не помещается в буфер. proxy_temp_path: путь к месту на диске, где Nginx разрешается хранить временные файлы, если ответ не умещается в буфер.

Как вы видите, nginx предлагает нам довольно широкий выбор по настройке буферизации. В большинстве случаев вам не придется все их использовать, но помнить о них следует. Наверное, самыми часто используемыми будут proxy_buffers и proxy_buffer_size. Приведем пример, где мы увеличиваем количество буферов для работы с запросами и уменьшаем размер буфера для хранения заголовков:

# server context
proxy_buffering on;
proxy_buffer_size 1k;
proxy_buffers 24 4k;
proxy_busy_buffers_size 8k;
proxy_max_temp_file_size 2048m;
proxy_temp_file_write_size 32k;
location / {
    proxy_pass http://example.com;
}

Если же вы считаете, что соединение и машины ваших клиентов достаточно быстры, вы можете полностью отключить буферизацию. Хотя даже в таком случае nginx будет использовать буферы если соединение с бекэнд-серверами окажется быстрее, чем с клиентами, но ответную информацию он будет отправлять клиентам сразу по мере её поступления. Если же клиент все-такие окажется медлительным, то соединение с бекэнд сервером не будет закрыто до тех пор, пока клиент не примет от него информацию. При выключенной буферизации обрабатывается только директива roxy_buffer_size.

# server context
proxy_buffering off;
proxy_buffer_size 4k;
location / {
    proxy_pass http://example.com;
}

Настройка прокси кеширования для уменьшения времени ответа

Буферизация помогает ослабить нагрузку на бекэнд-сервера, тем самым обработать большее число запросов. Nginx также предлагает механизм для кеширования ответов от бекэнд-серверов. Что помогает вообще отказаться от обращению к вышестоящим серверам при повторных запросах.

Настройка прокси кеша

Для настройки кеширования содержимого ответов от серверов мы будет использовать директиву proxy_cache_path. Она задает пространство для хранения кеша. Её следует задавать в контексте http.

В следующем примере мы выполним необходимые настройки для поднятия системы кеширования:

# http context
proxy_cache_path /var/lib/nginx/cache levels=1:2 keys_zone=backcache:8m max_size=50m;
proxy_cache_key "$scheme$request_method$host$request_uri$is_args$args";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;

Директива proxy_cache_path указывает на каталог для хранения файлов кеша. В примере мы указали каталог по адресу /var/lib/nginx/cache. Если такой каталог не существует, то его следует создать с необходимыми правами.

sudo mkdir -p /var/lib/nginx/cache
sudo chown www-data /var/lib/nginx/cache
sudo chmod 700 /var/lib/nginx/cache

Пареметр level= указывает организацию кеша. Nginx будет создавать ключ кеша исходя из хеша значения ключа (настраивается ниже). Мы указали уровни таким образом, что получим каталог, название которого состоит из одного символа и вложенный в него каталог с названием из двух символов. Вам скорее всего не придется вдаваться в эти подробности, но эти параметры помогают Nginx быстрее найти нужную информацию.

Параметр keys_zone= задает имя зоны кеширования, в нашем случае это backcache. Здесь мы также указываем объем хранимой мета-информации (8 МБ в нашем случае). На 1МБ Nginx хранит примерно 8000 записей. Параметр max_size указывает максимально хранимый размер кешированных данных.

Директива proxy_chache_key задает ключ для хранения кешированных значений. Он же применяется при поиске данных в кеше. Мы используем комбинацию из схемы соединения, метода, хоста и URI.

Директива proxy_cache_valid может быть указана несколько раз. Она указывает срок хранения данных в зависимости от кода ответа. В нашем примере мы храним данные в течение 10 минут для удачных и перенаправленных ответов и всего лишь одну минуту для ответов со статусом 404.

Итак мы настроили зону кеширования, но не указали Nginx, когда именно применять кеширование.

Эта информация указывается в контексте location для бекэнд серверов:

# server context
location /proxy-me {
    proxy_cache backcache;
    proxy_cache_bypass $http_cache_control;
    add_header X-Proxy-Cache $upstream_cache_status;
    proxy_pass http://backend;
}
# ...

При использовании директивы proxy_cache мы указываем, что зона backcache должна быть использована для данного контекста. Таким образом nginx перед тем как обратиться к бекэнд серверу проверит наличие ответа в кеше.

Директива proxy_chache_bypass принимает значение переменной $http_cache_control. Эта переменная содержит информацию о том, запросил ли клиент свежую, не кешированную версию ответа. При использовании этой директивы Nginx будет корректно обрабатывать запросы такого типа. Никаких дальнейших настроек не для этой цели не требуется.

Так же мы добавили дополнительный заголовок X-Proxy-Cache, задав ему значение переменной $upstream_cache_status. Таким образом мы видим взяли ли мы ответ из кеша, получили свежий вариант или клиент указал, что ему не нужен кешированный ответ. При отладке приложения такая информация незаменима.

Нюансы кешированных ответов

Кеширование значительно повышает скорость работы вашего прокси-сервера. Но не стоит забывать о нескольких моментах.

Во-первых, любая информация, касающаяся лично пользователей ни в коем случае не должна находится в кеше. Чтобы пользователи не получали в ответ информацию о других пользователях. Если же ваш сайт полностью статичен, то вас эта проблем не коснется. Если на вашем сайте присутствуют динамические элементы, то им следует уделить особое внимание. То, как вы будете решать эту проблему, зависит от бекэнд-сервера. Для личной информации следует использовать заголовок Cache-Control, задав ему значения no-cache, no-store или private исходя из самих данных.

No-cache: указывает, что ответ не должен отправляться до тех пор, пока сервер не проверит не изменились ли данные на бекэнд-сервере. Такое значение используется при работе с динамическими данными. Хешированные метаданные заголовка Etag проверяются при каждом запросе. Если бекэнд отдает такие же значения, то тогда данные отправляются из кеша.

No-store: в этом случае ни при каких условиях данные не должны храниться в кеше. Такой подход является самым безопасным при работе с личными данными.

private: данные не должны кешироваться в общем пространстве кеша. При таком подходе, например, браузер пользователя может кешировать данные в то время как, прокси сервер нет.

Public: данные разрешается кешировать везде.

Существует связанный с кешем заголовок max-age, который указывает время хранения кеша в секундах.

Значение этого заголовка зависит от чувствительности данных. При правильном использовании личные данные будут в безопасности, а часто изменяемое содержимое останется актуальным.

Если же и на бекэнде вы используете nginx, то следует использовать директиву expires, которая задает значение max-age для заголовка Cache-Control:

location / {
    expires 60m;
}
location /check-me {
    expires -1;
}

В этом примере первый блок допускает хранение кеша в течение часа. Второй же задает заголовку Cache-Control значение no-cache. Для внесения других изменений пользуйтесь директивой add_header:

location /private {
    expires -1;
    add_header Cache-Control "no-store";
}