SSD Nodes Learn 🎉 VPS від $5.50/міс
Посібники Matt ConnorВід Matt Connor

Конфігурація reverse proxy nginx: proxy_pass і WebSocket

Налаштуйте reverse proxy nginx в Ubuntu 24.04: розберіть proxy_pass, 4 заголовки, WebSocket, кінцеві слеші та ліміти завантаження файлів.

Конфігурація reverse proxy nginx

Reverse proxy nginx приймає запити на портах 80 і 443 та передає кожен із них застосунку, який уже прослуховує локальний порт. Потім він повертає відповідь цього застосунку браузеру. Конфігурація складається з одного блоку server і є короткою. Майже вся складність міститься в п’яти або шести рядках. Вони повідомляють застосунку, хто був фактичним клієнтом і який протокол використав цей клієнт.

Усе нижче налаштовується з нуля на Ubuntu 24.04 з використанням пакета nginx із репозиторію дистрибутива. Вихідна умова — застосунок, який уже відповідає на 127.0.0.1:3000. Якщо ви ще не обрали reverse proxy, спочатку прочитайте порівняння nginx, Caddy і Traefik. Далі показано, як виглядає конфігурація nginx, рядок за рядком.

Виконуйте ці конфігурації на власному сервері. Перед перезавантаженням перевіряйте кожну зміну за допомогою sudo nginx -t і читайте виведені результати.

Де nginx зберігає конфігурацію в Ubuntu

sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/

Основний файл — /etc/nginx/nginx.conf. У ньому глобальні параметри задаються в блоці http { }, після чого підключаються два каталоги: /etc/nginx/conf.d/*.conf і /etc/nginx/sites-enabled/*. В Ubuntu та Debian для кожного сайту створюють окремий файл у /etc/nginx/sites-available/ і вмикають його символьним посиланням у /etc/nginx/sites-enabled/. Видалення символьного посилання вимикає сайт, але зберігає файл.

Дві директиви, які використовуються далі, працюють лише в контексті http і ніколи — всередині блоку server: map і upstream. Розмістіть їх в окремому файлі в /etc/nginx/conf.d/, оскільки цей каталог підключається на рівні http.

Пакет постачається з увімкненим сайтом default. Його позначено як default_server. Це означає, що він відповідає на будь-який запит, у якому заголовок Host не відповідає жодному server_name у вашій конфігурації. Поки цей сайт увімкнений, запит із невідомим доменним ім’ям потрапляє на нього, а не до вашого застосунку. Видаліть символьне посилання, коли власний сайт почне працювати.

sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Найменший server block для проксіювання одного застосунку

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Збережіть його як /etc/nginx/sites-available/app.example.com, потім увімкніть і завантажте конфігурацію.

sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI -H 'Host: app.example.com' http://127.0.0.1/

listen 80; прив’язує IPv4, а listen [::]:80; — IPv6. Якщо не додати другий рядок, відвідувач, для якого DNS (система доменних імен) повертає AAAA-запис вашого сервера, отримає відмову в підключенні, тоді як у всіх користувачів IPv4 сайт працюватиме. У звіті про помилку буде написано: «у мене працює».

server_name зіставляється із заголовком Host, який надсилає браузер. Можна вказати кілька імен, розділивши їх пробілами. Якщо жоден блок не збігається, nginx використовує блок, який є default_server, тому типовий сайт довелося видалити.

location / виконує префіксне зіставлення шляху запиту, а / відповідає будь-якому шляху. proxy_pass — це адреса, до якої nginx відкриває підключення. Залиште застосунок прив’язаним до 127.0.0.1, щоб єдиним шляхом доступу до нього був nginx. Якщо застосунок працює в контейнері, опублікуйте його як 127.0.0.1:3000:3000, а не як 3000:3000, оскільки Docker створює власні правила та публікує порти безпосередньо в обхід ufw, тому відкритий опублікований порт буде доступний з інтернету незалежно від налаштувань вашого firewall.

Рядок curl надсилає правильний заголовок Host із самого сервера, тому блок можна перевірити ще до того, як DNS почне вказувати на нього.

Що nginx надсилає на upstream, якщо нічого більше не налаштувати

proxy_pass сам по собі приховує від вашого застосунку чотири важливі речі.

За замовчуванням nginx використовує HTTP/1.0 для підключення до бекенду та надсилає Connection: close. Тому для кожного запиту відкривається нове upstream-з’єднання, а оновлення протоколу неможливе.

Заголовок Host переписується у значення proxy_pass, тобто 127.0.0.1:3000. Якщо застосунок формує абсолютні посилання на основі Host, він створює посилання, які ніхто за межами сервера не може відкрити.

З’єднання із застосунком надходить від nginx, тому застосунок бачить адресу клієнта 127.0.0.1. У кожному рядку журналу та в кожному обмеженні частоти запитів усередині застосунку замість відвідувача фіксується проксі.

Застосунок не може визначити, що браузер використовував HTTPS, оскільки отримане ним з’єднання є звичайним HTTP-з’єднанням через loopback-адресу.

Чотири рядки виправляють усе це.

Чотири заголовки та дані, які кожен із них передає бекенду

location / {
    proxy_pass http://127.0.0.1:3000;

    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;
}

Host передає ім’я, яке ввів відвідувач. $host — це ім’я з HTTP-запиту без порту, записане малими літерами. Установіть його, щоб застосунок формував правильні абсолютні URL-адреси: редирект після входу або посилання в електронному листі для скидання пароля. Якщо не передати цей заголовок, такі URL-адреси вказуватимуть на 127.0.0.1:3000, тому після входу браузер перейде на адресу, яка відхиляє з’єднання. Якщо застосунку потрібен також порт, наприклад коли сервіс працює на 8080, використовуйте $http_host — заголовок у точному вигляді, у якому його надіслав клієнт.

X-Real-IP передає одне значення: $remote_addr — адресу, з якої nginx прийняв з’єднання. Застосунки використовують її у власних журналах доступу та для власного обмеження частоти запитів.

X-Forwarded-For передає список. $proxy_add_x_forwarded_for додає $remote_addr до значення, яке клієнт уже вказав у цьому заголовку, тому значення розділене комами, а запис, доданий вашим nginx, є останнім. Це визначає, чи можна довіряти заголовку: клієнт може передати будь-який X-Forwarded-For, тому застосунок, який читає перший запис, можна змусити прийняти будь-яку адресу. Коли nginx є edge-сервером, натомість вказуйте $remote_addr і відкидайте версію клієнта. Якщо перед nginx працює CDN або інший проксі, використовуйте set_real_ip_from і real_ip_header з модуля realip, щоб $remote_addr сам став справжньою адресою клієнта.

X-Forwarded-Proto передає http або https. Фреймворки використовують це значення, щоб визначити, чи позначати cookies як Secure і чи примусово перенаправляти на HTTPS. Якщо не передати цей заголовок на TLS-сайті, застосунок, налаштований на примусовий HTTPS, бачить http, відповідає редиректом на HTTPS-адресу, отримує наступний запит через nginx, знову бачить http і знову виконує редирект. Браузер припиняє спроби та показує ERR_TOO_MANY_REDIRECTS.

Повторення цих чотирьох рядків у кожному location призводить до розбіжностей між ними. Розмістіть їх в одному файлі та підключіть його.

# /etc/nginx/snippets/proxy-headers.conf
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;
location / {
    include snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;
}

Успадкування тут має особливість. location успадковує директиви proxy_set_header із server-блоку, лише якщо в цьому location не визначено жодної власної директиви. Додайте один proxy_set_header усередині location, і всі заголовки, визначені на рівні server, буде скасовано для цього location. Тому зберігайте всі директиви на одному рівні або include фрагмент у кожному location, який проксіює запити.

Чому мій застосунок WebSocket підключається, а потім розриває з’єднання?

Тому що стандартні параметри забороняють upgrade, а стандартний тайм-аут читання закриває неактивний тунель через 60 секунд. WebSocket починається як HTTP-запит із заголовками Upgrade: websocket і Connection: Upgrade. Це hop-by-hop заголовки. Проксі має обробити їх і не передавати далі. Крім того, HTTP/1.0 взагалі не має механізму upgrade. Обидва заголовки потрібно додати вручну.

Директива map має бути в контексті http, в окремому файлі.

# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Потім додайте location.

location / {
    include snippets/proxy-headers.conf;
    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;
}

map потрібна, щоб один location обслуговував обидва типи трафіку. У звичайному запиті $http_upgrade порожня, тому $connection_upgrade стає close. У запиті на upgrade вона містить websocket, тому заголовок, надісланий upstream, має значення Connection: upgrade. Якщо жорстко вказати proxy_set_header Connection "upgrade";, цей заголовок надсилатиметься також у кожному звичайному запиті до сторінки, а деякі бекенди відповідають на такий запит кодом 400.

Саме proxy_read_timeout спричиняє повідомлення «сторінка завантажується, а потім перестає оновлюватися». Стандартне значення становить 60 секунд. Параметр вимірює інтервал між двома читаннями з бекенду, а не тривалість з’єднання. Якщо WebSocket не передає дані протягом 60 секунд, nginx закриває з’єднання, а консоль браузера показує закриття сокета з кодом 1006. Застосунки, які надсилають власний heartbeat частіше ніж раз на хвилину, цього не помічають. Застосунки без heartbeat припиняють роботу рівно через хвилину. Найчастіше це проявляється в інтерактивних редакторах і дашбордах; поширений приклад — self-hosted екземпляр n8n за HTTPS.

Чому кінцевий символ / у proxy_pass змінює URL?

Правило просте. Якщо proxy_pass закінчується URI (уніфікованим ідентифікатором ресурсу), навіть самим лише /, nginx видаляє частину шляху запиту, яка відповідає префіксу location, і підставляє замість неї цей URI. Якщо proxy_pass закінчується на імені хоста та порту, шлях запиту передається без змін.

location /app/ {
    proxy_pass http://127.0.0.1:3000/;
}

Запит до /app/status потрапляє на бекенд як /status.

location /app/ {
    proxy_pass http://127.0.0.1:3000;
}

Запит до /app/status потрапляє на бекенд як /app/status.

Потрібна форма залежить від застосунку. Застосунок із налаштуванням base-path або sub-folder потребує другої форми; у цьому налаштуванні потрібно вказати /app. Застосунку, який нічого не знає про префікси, потрібна перша форма. Її недолік видно одразу: HTML, який повертає застосунок, усе ще містить абсолютні шляхи, наприклад /static/main.css, тому браузер запитує їх у корені сайту, жоден location не відповідає цим запитам, і сторінка відображається без стилів. На вкладці мережевих запитів у браузері такі запити до ресурсів мають статус 404. Виправленням є власне налаштування base-path у застосунку або другий location /static/, що вказує на той самий бекенд.

Регулярний вираз у location не може містити URI у proxy_pass. sudo nginx -t відхиляє конфігурацію та вказує причину: "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block.

Цей клас проблем повністю зникає, якщо кожен застосунок отримує власне ім’я, app.example.com, і проксіюється з location /. Підшляхи виправдані лише тоді, коли неможливо додати записи DNS.

Як розмістити кілька бекендів за одним ім’ям?

За допомогою блока upstream. Він належить до контексту http, тому розмістіть його перед блоком server у тому самому файлі або в /etc/nginx/conf.d/.

upstream app_backend {
    least_conn;
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

Потім директива location посилається на нього: proxy_pass http://app_backend;.

Стандартний метод — round robin. least_conn надсилає кожен запит на бекенд із найменшою кількістю активних з’єднань. Це підходить для запитів різної тривалості. ip_hash прив’язує одну клієнтську адресу до одного бекенду. ip_hash потрібен, якщо застосунок зберігає сесії у власній пам’яті. Інакше round robin між двома такими бекендами випадково завершуватиме сесії, коли запити надходитимуть до екземпляра, який ще не обробляв цього клієнта. Краще зберігати сесії у спільному сховищі.

max_fails=3 fail_timeout=30s означає, що після трьох невдалих спроб протягом 30 секунд сервер вилучається з балансування на 30 секунд. Якщо всі сервери в блоці мають такий стан, клієнти отримують 502, а в журналі помилок з’являється no live upstreams while connecting to upstream.

keepalive 32 підтримує до 32 простоюючих з’єднань із бекендами для кожного worker process. Це усуває TCP handshake для більшості запитів. Директива працює лише з proxy_http_version 1.1 і коли до upstream не передається Connection: close. Якщо той самий location також використовує WebSocket map, змініть порожній випадок із close на порожній рядок. Тоді звичайні запити не міститимуть заголовка Connection, а pooled connection буде повторно використано.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

Імена всередині блока upstream розв’язуються під час запуску nginx. Якщо бекенд є контейнером, який після перезапуску отримує нову адресу, nginx продовжує використовувати стару адресу, доки ви не перезавантажите його конфігурацію. У Docker network пошук можна перенести на час обробки запиту за допомогою вбудованого resolver.

resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;

Якщо контейнери починають достатньо часто з’являтися та зникати, і ви постійно редагуєте nginx, щоб встигати за змінами, краще використати proxy, який читає labels контейнерів. Traefik перед кількома застосунками Docker Compose будує маршрути на основі самих контейнерів.

Чому завантаження завершуються помилкою 413 Request Entity Too Large?

client_max_body_size за замовчуванням має значення 1 megabyte. nginx відхиляє запит із більшим тілом до того, як застосунок отримує будь-які його дані, а в журналі помилок записує client intended to send too large body. Збільште це значення в блоці server або в location, де виконуються завантаження.

client_max_body_size 512m;

Значення 0 повністю вимикає перевірку. Застосунок також має власне обмеження, тому помилка 413, яка зберігається після цієї зміни, надходить від бекенду. Далі перевірте параметр завантаження самого застосунку.

За замовчуванням nginx зчитує все тіло запиту до встановлення upstream-з’єднання. Великі дані спочатку записуються в тимчасовий файл на диску. Це захищає застосунок від повільних клієнтів, оскільки бекенд отримує завантаження з повною локальною швидкістю. Для дуже великих завантажень можна використовувати потокову передачу.

proxy_request_buffering off;

Тоді бекенд отримує тіло в міру його надходження і має вміти його обробляти. nginx також втрачає можливість повторити запит до іншого upstream, оскільки тіло вже передано.

client_body_timeout, за замовчуванням 60 seconds, застосовується між двома послідовними операціями зчитування тіла, а не до всього завантаження. Повільне, але стабільне завантаження не переривається. Якщо передача зупинилася, з’єднання закривається.

Буферизація відповіді та параметр, який порушує виведення в реальному часі

proxy_buffering увімкнено за замовчуванням, і зазвичай це саме те, що потрібно. nginx читає відповідь від застосунку з максимально можливою для нього швидкістю, зберігає її та передає повільному клієнту з темпом, який відповідає цьому клієнту. Worker застосунку завершує роботу раніше, замість того щоб залишатися зайнятим протягом усього повільного завантаження.

Це порушує потокову передачу відповідей. Server-sent events і виведення журналу в реальному часі нічого не показують читачеві, доки буфер не заповниться. Вимикайте буферизацію лише в цій location.

proxy_buffering off;

Якщо ви керуєте застосунком, краще додавати заголовок X-Accel-Buffering: no лише до потокових відповідей. nginx читає цей заголовок для кожної відповіді та вимикає буферизацію тільки для неї, тому звичайні сторінки зберігають переваги буферизації.

Якщо в журналі помилок з’являється upstream sent too big header while reading response header from upstream, заголовки відповіді не вмістилися в один буфер. proxy_buffer_size за замовчуванням дорівнює одній сторінці пам’яті — 4 або 8 кілобайт залежно від платформи, — а довгі cookies або великі заголовки автентифікації можуть переповнити цей буфер. Збільште обидва значення.

proxy_buffer_size 16k;
proxy_buffers 8 16k;

Де в цій конфігурації має бути TLS?

У nginx, перед усіма описаними вище компонентами. TLS (захист транспортного рівня) завершується на проксі, а з’єднання від nginx до застосунку залишається звичайним HTTP через loopback-адресу. Інші пристрої в мережі не можуть його прочитати. Застосунок дізнається, що відвідувач використовував HTTPS, із X-Forwarded-Proto — четвертого з чотирьох заголовків.

Не вказуйте шляхи до сертифікатів вручну. Спрямуйте DNS-запис на сервер, відкрийте порт у firewall і дозвольте Certbot змінити цей самий server block. Він додасть рядок listen 443 ssl зі шляхами ssl_certificate, а також перенаправлення з порту 80. У матеріалі Випуск сертифіката Let's Encrypt для nginx за допомогою Certbot описано випуск сертифіката й таймер поновлення.

sudo ufw allow 'Nginx Full'
sudo ufw status

Nginx Full — це профіль застосунку, який встановлює пакет nginx. Він відкриває порти 80 і 443 одночасно. Порт 80 має залишатися відкритим для перевірки HTTP-01 під час поновлення, навіть якщо всіх відвідувачів перенаправлено на HTTPS.

Перевірте конфігурацію, а потім перезавантажте її

sudo nginx -t
sudo systemctl reload nginx

nginx -t аналізує кожен підключений файл і або повідомляє про успішну перевірку, або виводить файл і рядок, на якому зупинився аналіз. Прочитайте цей результат перед перезавантаженням. Перезавантаження з пошкодженою конфігурацією не застосовує зміни: nginx продовжує обслуговувати попередню конфігурацію, тому сайт залишається доступним, а ваша зміна непомітно не діє. systemctl restart працює інакше та гірше, оскільки під час перезапуску запущений сервер спочатку зупиняється. Через помилку конфігурації nginx може взагалі не запуститися. За замовчуванням використовуйте перезавантаження, а перезапуск залиште для рідкісних змін, які цього потребують.

sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'

Рядок ss показує, який процес утримує кожен порт. Так можна підтвердити, що застосунок справді прослуховує порт, на який вказує proxy_pass.

Типові проблеми, з якими ви реально зіткнетеся

502 Bad Gateway із connect() failed (111: Connection refused) while connecting to upstream у журналі помилок. За адресою, указаною в proxy_pass, ніхто не прослуховує з’єднання. Застосунок зупинений, прив’язаний до іншого порту або прив’язаний до внутрішньої адреси контейнера, недоступної з хоста.

502 із no live upstreams while connecting to upstream. Усі сервери в блоці upstream наразі позначені як несправні компонентом max_fails. Відновіть роботу бекендів. nginx повторно спробує підключитися до них після завершення fail_timeout.

504 Gateway Time-out із upstream timed out (110: Connection timed out) while reading response header from upstream. Бекенд прийняв з’єднання, але протягом proxy_read_timeout секунд нічого не надіслав. Збільшити тайм-аут правильно для справді повільного звіту, але неправильно для застосунку, який завис.

Кожен шлях повертає 404 із застосунку. Правило кінцевого слеша змінило шлях. Порівняйте шлях у журналі застосунку зі шляхом, який ви запитували.

Відповідає інший сайт. server_name не відповідає заголовку Host, тому запит перейшов до блоку default_server.

Сторінка завантажується, а приблизно через хвилину інтерфейс зависає. Це випадок із WebSocket: відсутнє налаштування Upgrade або proxy_read_timeout досі дорівнює 60 секундам.

FAQ

Чому nginx повертає 502 Bad Gateway після додавання proxy_pass?

nginx не зміг встановити з’єднання з адресою, указаною в proxy_pass. У журналі помилок за адресою /var/log/nginx/error.log зазначено причину: connect() failed (111: Connection refused) while connecting to upstream означає, що на цій адресі нічого не прослуховує порт, а no live upstreams — що всі сервери в блоці upstream позначено як недоступні. Виконайте sudo ss -lntp | grep 3000, щоб перевірити, який процес використовує порт і на якій адресі він прослуховує. Якщо застосунок прив’язаний до адреси, доступної лише всередині контейнера, або до іншого порту, ніж указано в конфігурації, ця помилка виникатиме щоразу.

Чому мій застосунок розриває з’єднання приблизно через хвилину за nginx?

Це WebSocket-з’єднання, а proxy_read_timeout досі має стандартне значення 60 секунд. Цей параметр вимірює інтервал між двома читаннями з бекенду. nginx закриває неактивний сокет, а консоль браузера повідомляє про код закриття 1006. Установіть proxy_http_version 1.1, передайте Upgrade і Connection через map у $http_upgrade та збільште proxy_read_timeout, наприклад, до 3600s. Без заголовка Upgrade оновлення з’єднання взагалі не відбувається, тому застосунок переходить на опитування або не показує оновлення в реальному часі.

Чи має значення завершальна коса риска в proxy_pass?

Так, вона змінює шлях, який отримує бекенд. За наявності location /app/ і proxy_pass http://127.0.0.1:3000/ запит до /app/status надходить на бекенд як /status, оскільки будь-який URI після хоста й порту замінює префікс відповідного location. Якщо прибрати завершальну косу риску, той самий запит надходить як /app/status. Видалення префікса часто порушує посилання на ресурси самого застосунку. Вони залишаються абсолютними, а потім повертають 404 у корені сайту. Тому для застосунку з параметром базового шляху краще використовувати форму, яка передає шлях без змін.

Чому мій застосунок записує 127.0.0.1 як IP-адресу кожного відвідувача?

Тому що з’єднання, яке отримує застосунок, справді надходить від nginx через loopback-адресу. Адреса відвідувача передається застосунку лише в заголовку, який ви встановлюєте: proxy_set_header X-Real-IP $remote_addr; для одного значення та proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; для ланцюжка доданих адрес. Після цього застосунок потрібно налаштувати так, щоб він довіряв цим заголовкам. Пам’ятайте, що клієнт може надіслати власний X-Forwarded-For. Тому, якщо nginx є edge-сервером, перезаписуйте цей заголовок значенням $remote_addr, а не додавайте його до наявного.

Чи потрібен TLS для з’єднання між nginx і моїм застосунком?

Ні, якщо застосунок працює на тому самому сервері та прив’язаний до 127.0.0.1, оскільки такий трафік не залишає межі машини. Завершуйте TLS на nginx, залишайте proxy_pass звичайним HTTP через loopback і передавайте X-Forwarded-Proto $scheme, щоб застосунок знав, що відвідувач використовував HTTPS. Якщо бекенд розміщено на іншому хості, а мережа між ними не перебуває під вашим контролем, цей канал потребує окремого захисту: HTTPS до бекенду або приватного тунелю між двома машинами.