RE:NODE

VDS13 мин чтения

Reverse proxy на nginx: server block, TLS и websocket

Ставим nginx перед приложением: минимальный server block, заголовки прокси, certbot и продление, websocket, лимиты загрузки и причины ошибок 502 и 504.

0 прочтений

Ваше приложение слушает 127.0.0.1:3000. Посетители набирают имя и ждут порт 443 с замочком. Nginx - это двенадцать строк конфигурации, которые связывают эти два факта, и, написав их один раз, вы будете писать их для каждого сервиса, который когда-либо развернёте. Это руководство - рабочая версия таких строк: server block, который проксирует правильно, четыре заголовка, нужных приложению, сертификат, который продлевается сам, и отдельная настройка для websocket и загрузки файлов, которую по умолчанию никто не включает.

Везде предполагается одна машина с root - VDS или выделенный сервер - на которой nginx стоит системным пакетом, а приложение работает рядом как служба systemd или контейнер. Если вам нужна концептуальная версия того, что делает reverse proxy и зачем он нужен, она в статье что делает reverse proxy. Здесь - конфигурация.

Где nginx хранит настройки#

В Debian и Ubuntu /etc/nginx/nginx.conf содержит глобальные настройки и заканчивается двумя строками include. Одна подключает /etc/nginx/conf.d/*.conf, другая - /etc/nginx/sites-enabled/*. По соглашению на каждый сайт пишут файл в sites-available, а нужные включают симлинком в sites-enabled, так что отключить сайт - это убрать ссылку, а не удалять файл.

bash
$ sudo apt install -y nginx$ sudo nano /etc/nginx/sites-available/app.conf$ sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/$ sudo rm /etc/nginx/sites-enabled/default$ sudo nginx -t && sudo systemctl reload nginx

В RHEL, Rocky и Alma деления на sites-available нет: всё лежит в /etc/nginx/conf.d/ с расширением .conf. Сами server block'и при этом одинаковые.

Всю работу делают три команды. nginx -t разбирает всю конфигурацию и сообщает файл и номер строки любой ошибки; перезагружать без неё не стоит никогда. systemctl reload nginx перечитывает конфигурацию, не обрывая соединений: старые рабочие процессы дообрабатывают текущие запросы и завершаются, а новые стартуют уже с новой конфигурацией. nginx -T печатает всю собранную конфигурацию вместе со всеми include - так находят случайный файл, который вас переопределяет.

Сайт по умолчанию лучше удалить или заменить. Он отвечает на порту 80 для любого имени хоста, которое не подошло ни к одному другому блоку, а значит, запрос про домен, о котором вы никогда не слышали, получит вашу страницу по умолчанию. Аккуратнее поставить блок-ловушку, который закрывает соединение без ответа:

/etc/nginx/sites-available/catchall.conf
server {    listen 80 default_server;    listen [::]:80 default_server;    server_name _;    return 444;}

444 - код, специфичный для nginx, он означает «закрыть соединение без ответа». default_server может быть только у одного блока на порт, а второй вызовет duplicate default server при проверке конфигурации.

Самый маленький работающий server block#

/etc/nginx/sites-available/app.conf
server {    listen 80;    listen [::]:80;    server_name app.example.com;    location / {        proxy_pass http://127.0.0.1:3000;        proxy_http_version 1.1;        proxy_set_header Host              $host;        proxy_set_header X-Real-IP         $remote_addr;        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;        proxy_set_header X-Forwarded-Proto $scheme;        proxy_set_header X-Forwarded-Host  $host;    }}

Это полноценный рабочий reverse proxy. Часть с TLS через минуту добавит certbot. Несколько вещей здесь неочевидны.

proxy_http_version 1.1 должна стоять с самого начала. По умолчанию nginx говорит с бэкендом по HTTP/1.0, что ломает keepalive до бэкенда и делает невозможным переключение на websocket. Недостатков у этой настройки нет.

server_name - это то, как nginx выбирает блок, сравнивая имя с заголовком Host. Несколько имён в одном блоке пишутся через пробел: server_name example.com www.example.com;. Запрос, у которого Host ничему не соответствует, попадает в сервер по умолчанию, и потому блок-ловушка выше не лишний.

$proxy_add_x_forwarded_for дописывает адрес клиента к уже существующему заголовку X-Forwarded-For, а не заменяет его. Это правильно, когда перед вами стоит ещё один прокси, и дыра, когда его нет: клиент может прислать свой заголовок, и вы передадите его дальше. Если перед nginx ничего нет, используйте proxy_set_header X-Forwarded-For $remote_addr; и уберите неоднозначность.

Косая черта в конце proxy_pass#

Это самая частая ошибка в nginx, и она молчит, пока какой-нибудь маршрут не отдаст 404.

nginx
# Passes /api/users through unchanged: backend sees /api/userslocation /api/ {    proxy_pass http://127.0.0.1:8000;}# Strips the location prefix: backend sees /userslocation /api/ {    proxy_pass http://127.0.0.1:8000/;}

Правило: если в proxy_pass есть часть URI - всё, что стоит после хоста и порта, включая одинокую /, - nginx заменяет ею совпавший префикс location. Если части URI нет, URI запроса передаётся без изменений. Один символ решает, увидит ваш API /api/users или /users.

Что бы вы ни выбрали, приложение должно с этим согласиться. Приложение, которое строит ссылки в расчёте на то, что живёт в корне, выдаст битые адреса, если отдавать его под /api/, и потому размещать приложения на поддоменах обычно проще, чем на путях. И учтите, что такая подмена работает только для префиксных location: совмещение URI в proxy_pass с регулярным location - ошибка конфигурации, которую nginx отвергает при проверке.

TLS через certbot и что на самом деле нужно для продления#

bash
$ sudo apt install -y certbot python3-certbot-nginx$ sudo certbot --nginx -d app.example.com$ sudo certbot renew --dry-run

Плагин для nginx читает ваш server block, получает сертификат, правит блок, добавляя слушатель TLS и редирект с порта 80, и устанавливает задачу продления. В результате получается вот что:

nginx
server {    server_name app.example.com;    listen 443 ssl;    listen [::]:443 ssl;    http2 on;    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;    include /etc/letsencrypt/options-ssl-nginx.conf;    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;    location / {        proxy_pass http://127.0.0.1:3000;        # ... the header lines from above    }}server {    listen 80;    server_name app.example.com;    return 301 https://$host$request_uri;}

http2 on; отдельной директивой - синтаксис начиная с nginx 1.25.1. В более старых сборках это параметр строки listen - listen 443 ssl http2; - и смешение двух стилей даёт предупреждение об устаревании, а не ошибку.

Чтобы сертификат можно было выпустить или продлить, должны выполняться три условия, и любая ошибка certbot сводится к одному из них:

  1. DNS-имя указывает на эту машину. Выпуск доказывает, что имя под вашим контролем, ответом на проверку по этому имени. Сначала направьте запись A, дождитесь, пока она разойдётся, и только потом запускайте certbot. Типы записей и ожидание TTL описаны в статье DNS-записи простыми словами.
  2. Порт 80 открыт и доходит до nginx. Проверка HTTP-01 забирает файл из /.well-known/acme-challenge/ по обычному HTTP, даже если сайт перенаправляет всё на HTTPS. Закрытый порт 80 в UFW - самая частая причина сорванного продления, которую люди устраивают себе сами. Блок редиректа от certbot путь проверки обрабатывает правильно; если вы пишете редирект вручную, исключите этот location.
  3. Nginx перезагружается после продления. Пакеты certbot устанавливают deploy hook, который это делает. Если вы продлеваете каким-то другим способом, добавьте --deploy-hook "systemctl reload nginx", потому что nginx держит старый сертификат в памяти, пока ему не скажут иначе.

Продление запускается дважды в сутки из таймера systemd и действует только тогда, когда до истечения сертификата остаётся меньше тридцати дней, так что у случайного сбоя есть запас в несколько недель. Проверить, что таймер взведён, можно командой systemctl list-timers | grep certbot. Что именно доказывает проверка, разобрано в статье HTTPS и Let's Encrypt простыми словами.

WebSocket, потоковые ответы и таймауты#

Websocket начинается как обычный HTTP-запрос с Connection: Upgrade и Upgrade: websocket. Nginx по умолчанию не пересылает hop-by-hop заголовки, поэтому переключение по дороге гибнет, пока вы не скажете иначе. Стандартный приём - map на уровне http, чтобы запросы без websocket остались нетронутыми:

/etc/nginx/conf.d/upgrade.conf - http context
map $http_upgrade $connection_upgrade {    default upgrade;    ''      close;}
in the location block
location /socket.io/ {    proxy_pass http://127.0.0.1:3000;    proxy_http_version 1.1;    proxy_set_header Upgrade    $http_upgrade;    proxy_set_header Connection $connection_upgrade;    proxy_read_timeout 3600s;    proxy_send_timeout 3600s;}

proxy_read_timeout по умолчанию равен 60 секундам, и отсчитывается он между чтениями, а не на всё соединение. Поэтому простаивающий websocket обрывается каждую минуту, порождая цикл переподключений, который выглядит в точности как сбой сети. Увеличьте таймаут на маршрутах websocket и посылайте ping на уровне приложения каждые двадцать - тридцать секунд, чтобы соединение никогда не простаивало достаточно долго.

Server-sent events и любой потоковый ответ требуют ещё одной строки. По умолчанию nginx буферизует ответы бэкенда, поэтому поток удерживается, пока не накопится достаточно данных, и клиент видит долгую тишину, а потом всё сразу:

nginx
location /events {    proxy_pass http://127.0.0.1:3000;    proxy_http_version 1.1;    proxy_buffering off;    proxy_cache off;    proxy_read_timeout 24h;}

Приложение может и само отключить буферизацию для отдельного ответа, отправив X-Accel-Buffering: no; это аккуратнее, когда потоковых endpoint'ов немного. Про проблему sticky-сессий, которая возникает, когда экземпляров бэкенда больше одного, рассказано в статье WebSocket за reverse proxy.

Несколько приложений за одним nginx#

Смысл прокси в том, что один адрес и один порт обслуживают любое число сервисов. Разделить их можно двумя способами, и почти всегда лучше поддомены:

nginx
# By hostname: two server blocks, two certificates, no path rewritingserver { server_name app.example.com; location / { proxy_pass http://127.0.0.1:3000; } }server { server_name api.example.com; location / { proxy_pass http://127.0.0.1:8000; } }
nginx
# By path: one certificate, but the backend must know it lives under /api/location /api/ { proxy_pass http://127.0.0.1:8000/; }location /     { proxy_pass http://127.0.0.1:3000; }

Среди префиксных location побеждает самый длинный префикс, поэтому для запроса /api/users /api/ выигрывает у / независимо от порядка в файле. Регулярные location, записываемые как location ~ ^/pattern, проверяются в порядке файла и имеют приоритет над префиксными, и это веский повод использовать их как можно меньше.

Для бэкенда, к которому вы будете держать keep-alive, объявите upstream. Две дополнительные строки обязательны: keepalive до upstream требует HTTP/1.1 и пустого заголовка Connection, иначе nginx шлёт Connection: close в каждом запросе, и пул не используется никогда.

nginx
upstream app {    server 127.0.0.1:3000;    keepalive 32;}server {    location / {        proxy_pass http://app;        proxy_http_version 1.1;        proxy_set_header Connection "";    }}

Upstream с несколькими строками server даёт балансировку round-robin, а ip_hash или least_conn - альтернативы, backup - резервный узел. Если приложение работает в контейнере, прокси может обращаться к нему по имени контейнера, а не по порту; такую схему, где порт наружу публикует только прокси, показывает статья Docker Compose для небольших стеков.

Unix-сокет - тоже допустимый upstream: он чуть быстрее loopback TCP и недоступен из сети: proxy_pass http://unix:/run/app/app.sock;. Убедитесь, что пользователь nginx может читать сокет.

Статические файлы, загрузки и лимиты#

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

nginx
location /static/ {    alias /srv/app/static/;    access_log off;    expires 30d;    add_header Cache-Control "public, immutable";}client_max_body_size 25m;gzip on;gzip_types text/plain text/css application/json application/javascript image/svg+xml;server_tokens off;

alias заменяет совпавший префикс location указанным путём; root дописывает к нему URI целиком. Оба верны в разных ситуациях, а если их перепутать, получится 404 с очень понятной строкой в error.log, где виден путь, который nginx пытался открыть. Читайте эту строку, а не гадайте.

client_max_body_size по умолчанию равен 1 МБ, что меньше, чем ждёт большинство форм загрузки, и превышение даёт 413 ещё до того, как запрос дойдёт до приложения. Поставьте наибольший размер загрузки, который вы готовы принять, и не больше. expires и Cache-Control на файлах с хешем в имени - бесплатная производительность; на файлах, чьи имена не меняются, это способ месяц отдавать устаревшее, так что будьте уверены, какой у вас случай: где проходит граница, аккуратно показано в статье заголовки HTTP-кэширования простыми словами.

Одно предостережение про add_header: он не накапливается между уровнями. Заголовок, добавленный в блоке location, заменяет, а не дополняет заголовки, добавленные в родительском блоке server. Если вы задали заголовки безопасности глобально, а потом добавили заголовок кэширования в одном location, заголовки безопасности из этого location пропадут. Повторите их или аккуратно используйте параметр always и проверяйте через curl -I.

Ограничение частоты запросов - это две директивы, и на endpoint входа оно того стоит:

nginx
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;location /login {    limit_req zone=login burst=10 nodelay;    proxy_pass http://127.0.0.1:3000;}

Учтите, что ограничение идёт по $binary_remote_addr - по адресу, который видит nginx. Если к nginx проксирует что-то ещё, это один адрес на всех. Либо используйте модуль real IP - set_real_ip_from с диапазоном вышестоящего прокси и real_ip_header X-Forwarded-For, - либо ограничивайте по переменной, выведенной из пересланного заголовка. Как выбирать числа, разобрано в статье лимиты запросов и злоупотребления.

502, 504, 413 и остальное#

Ответ на любой вопрос здесь начинается в /var/log/nginx/error.log. Для почти любого сбоя nginx пишет конкретную причину, включая точный upstream, к которому он обращался, и полученный errno.

502 Bad Gateway. Nginx не смог получить корректный ответ от бэкенда. В журнале ошибок будет connect() failed (111: Connection refused) - приложение не запущено или сидит на другом порту, - либо no live upstreams, либо 104: Connection reset by peer от бэкенда, упавшего посреди запроса. Проверьте процесс командой sudo ss -lntp, потом его собственные журналы. Если приложение работает в контейнере, а nginx - на хосте, приложение должно слушать адрес, доступный хосту, а не loopback контейнера.

504 Gateway Timeout. Бэкенд принял соединение и не ответил за время proxy_read_timeout. Это медленный запрос, а не неисправность прокси. Найдите медленный endpoint, прежде чем увеличивать таймаут: увеличение обычно лишь переносит сбой в браузер.

413 Request Entity Too Large. Это client_max_body_size. См. выше.

404 на маршрутах, которые существуют. Косая черта в конце proxy_pass либо alias против root. Журнал ошибок печатает путь, который пытались открыть.

Цикл редиректов. Приложение принудительно включает HTTPS, проверяя схему собственного соединения, а она - обычный HTTP, потому что TLS завершил nginx. Передайте X-Forwarded-Proto и включите в фреймворке настройку доверенного прокси; редирект не убирайте.

У каждого посетителя в журнале приложения один и тот же IP. Приложение не читает X-Forwarded-For. У каждого фреймворка для этого есть переключатель, и по веским причинам он выключен по умолчанию.

`nginx -t` проходит, а изменение ничего не сделало. Первым сработал другой server block, обычно сервер по умолчанию, либо вы правили sites-available и так и не создали симлинк. nginx -T | grep server_name покажет все блоки, которые nginx действительно загрузил.

Permission denied при подключении к upstream в RHEL или Rocky. Это SELinux. Лечится одной строкой sudo setsebool -P httpd_can_network_connect 1, а убедиться, что причина была в этом, поможет sudo ausearch -m avc -ts recent.

Запускайте nginx и приложение как отдельные службы, чтобы каждую можно было перезапустить в одиночку; unit-файл, включая строки Restart= и After=network.target, которые решают, что поднимется после перезагрузки, есть в статье службы systemd для ваших приложений.

Если ничего из этого вы делать не хотите, стоит знать, что это не обязательно: в тарифы RE:NODE для приложений и сайтов входит слот прокси, который выполняет ту же работу. Вы направляете запись A на адрес, который показывает панель, сертификат выпускается и продлевается автоматически в окне 21 день, а настоящий адрес клиента приходит в X-Forwarded-For. Запускать nginx самому стоит ради всего того, чего управляемый прокси намеренно не делает - собственных location, зон ограничения частоты, нескольких приложений на путях, upstream через Unix-сокет, - а для этого нужна машина с root.

FAQ#

Нужен ли nginx, если приложение само умеет отдавать HTTPS?

Обычно да, и не ради скорости. Прокси держит сертификат вне вашего приложения, позволяет запускать несколько сервисов на одном адресе и порту, даёт одно место для ограничений частоты и размера загрузок и даёт перезапускать приложение, не роняя слушающий сокет. Для единственного внутреннего сервиса в частной сети его можно пропустить.

Nginx или Caddy?

Caddy получает сертификаты автоматически, без плагина, и у него гораздо более короткий файл конфигурации, поэтому для простого сайта он удобнее. У nginx больше настроек, намного больше документации и примеров, и именно его предполагает большинство руководств и хостинговых стеков. Любой ответ хорош; nginx вы будете встречать чаще.

Почему приложение видит IP nginx, а не посетителя?

Потому что соединение с ним установил nginx. Передавайте X-Forwarded-For и X-Forwarded-Proto, затем включите в фреймворке настройку доверенного прокси, чтобы он их читал. Безусловно доверять этим заголовкам небезопасно, поэтому по умолчанию эта настройка выключена.

Может ли nginx проксировать игровой сервер?

Не как HTTP-прокси. Игры используют собственные протоколы, чаще всего поверх UDP, а proxy_pass говорит по HTTP. У nginx есть модуль stream для простой пересылки TCP и UDP, и игровой трафик через него пройти может, но это отдельный блок, и маршрутизации по имени хоста, ради которой HTTP-проксирование и полезно, он не даёт.

Как проверить конфигурацию, не сломав работающий сайт?

sudo nginx -t разбирает всё и отказывается перезагружаться при синтаксической ошибке, так что сломанный файл не может положить сайт через reload. Чтобы проверить поведение, а не синтаксис, добавьте новый server block на запасное имя, проверьте его командой curl -H 'Host: new.example.com' http://127.0.0.1/, а потом переведите на него настоящее имя.

Делать reload или restart?

Reload. Он сохраняет открытыми слушающие сокеты и позволяет существующим соединениям завершиться на старых рабочих процессах, так что ничего не обрывается. Restart нужен, только когда вы меняете то, что nginx читает при запуске, например пользователя, от которого он работает, или набор слушающих портов в некоторых конфигурациях.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000