1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 1 из 5

WebSocket через прокси

Коротко

WebSocket - постоянный двусторонний канал между браузером и сервером: открыв его, обе стороны шлют сообщения когда угодно, без нового запроса на каждое. Открывается он обычным HTTP-запросом с двумя заголовками, и задача прокси - не съесть их и не закрыть канал по таймауту.

  • Upgrade и Connection - хоп-хоп-заголовки, nginx сам их не передаёт. Нужны две строки proxy_set_header.
  • Нужны именно две: с одним Connection бэкенд отвечает 200 вместо 101.
  • Наследование всё или ничего бьёт и здесь: строка Host в location уносит заголовки апгрейда, объявленные в server.
  • Простаивающий канал nginx рвёт через proxy_read_timeout. Замер: 60,1 секунды.

Если знаешь про пару Upgrade плюс Connection и про то, что канал живёт ровно proxy_read_timeout - листай до «Теперь сам».

Сначала ответь сам

/etc/nginx/conf.d/shop.local.confhttp server
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection $connection_upgrade;

location /a/ { proxy_pass http://ws:9001/; }
location /b/ { proxy_pass http://ws:9001/; proxy_set_header Host $host; }

Два блока, разница в одной безобидной строке. Что ответит каждый на запрос с Upgrade: websocket?

$  команда
curl -si -H 'Upgrade: websocket' -H 'Connection: Upgrade' http://shop.local/a/x | head -1
↳  выводcurl -si -H 'Upgrade: websocket' -H 'Connection: Upgrade' http://shop.local/a/x | head -1
HTTP/1.1 101 Switching Protocols
$  команда
curl -si -H 'Upgrade: websocket' -H 'Connection: Upgrade' http://shop.local/b/x | head -1
↳  выводcurl -si -H 'Upgrade: websocket' -H 'Connection: Upgrade' http://shop.local/b/x | head -1
HTTP/1.1 200 OK

Код 101 значит «переключаю протокол»: дальше по этому соединению пойдёт уже не HTTP, а кадры WebSocket. head -1 оставляет только строку статуса - она тут и важна. Строка про Host в блоке /b/ отменила оба заголовка апгрейда. Бэкенд не увидел просьбы переключить протокол и ответил обычной страницей, а фронтенд написал WebSocket connection failed - слова «заголовок» там нет.

На что это похоже

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

Просьба относилась к участку «ты - переводчик», и он честно принял её на свой счёт. Чтобы она доехала до собеседника, её надо передать явно.

Механизм: апгрейд относится к участку пути

браузер nginx приложение Upgrade: websocket Connection: Upgrade по умолчанию не передаётся хоп-хоп-заголовок описывает один участок соединения, поэтому посредник обязан его снять, а не пересылать после 101 nginx перестаёт разбирать HTTP и просто перекладывает байты
Апгрейд надо попросить заново на каждом участке. Это не недоработка nginx, а правило протокола.

Правило

/etc/nginx/nginx.confhttp
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
/etc/nginx/conf.d/shop.local.confhttp server location /ws/
proxy_pass http://app:8000;

proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host       $host;

proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

Карта читается так: входная переменная - $http_upgrade (заголовок Upgrade от клиента), выходная - $connection_upgrade; default upgrade - значение для непустого Upgrade, '' close - для пустого. То есть map ставит Connection: upgrade только тем запросам, где клиент действительно просил апгрейд, и Connection: close всем прочим. Кладут map в nginx.conf на уровень httpconf.d/*.conf он тоже сработает - там уже http), внутрь server его писать нельзя. Написать вместо этого proxy_set_header Connection "upgrade"; можно, но тогда обычные запросы в тот же блок тоже поедут с этим заголовком.

Обрати внимание: Host стоит в том же блоке. Если он нужен, он должен быть здесь, рядом, а не этажом выше.

Разбор: почему заголовка нужно два

Стенд, три блока с разным набором строк, запрос с обоими заголовками от клиента:

в блоке написано что увидел бэкенд ответ клиенту
ничего upgrade=None connection=None 200
Upgrade и Connection upgrade='websocket' connection='upgrade' 101
только Connection upgrade=None connection='upgrade' 200

Третья строка объясняет, почему конфиг «вроде из статьи» не работает: одного Connection мало, переключением командует Upgrade. Первая строка - ответ на вопрос, почему без настройки не работает вовсе.

Разбор: канал живёт ровно таймаут

Открыли соединение и молчим. Скрипт печатает момент разрыва:

↳  выводнебольшой скрипт: открыть канал и ждать молча
ответ: HTTP/1.1 101 Switching Protocols
СОЕДИНЕНИЕ ЗАКРЫТО через 60.1 c

Шестьдесят секунд - умолчание proxy_read_timeout. Для WebSocket пауза между сообщениями это норма, поэтому таймаут поднимают до часа, а приложение шлёт ping/pong: любой кадр в канале сбрасывает отсчёт, и до таймаута дело не доходит вовсе.

Поднять таймаут без ping тоже можно, но тогда мёртвые соединения будут висеть час и занимать память. Пара «большой таймаут плюс регулярный ping» решает обе задачи сразу.

Что ломается без этого

Строка proxy_http_version 1.1 из чужих руководств. Раньше она была обязательной: nginx ходил к бэкенду по HTTP/1.0, где апгрейда не существует. С версии 1.29.7 умолчание - 1.1, и стенд это подтверждает: бэкенд видит HTTP/1.1 без всяких строк. Вредной она не стала, просто перестала быть обязательной. Поддерживаешь старую сборку - оставь.

Долгие соединения и reload. nginx -s reload не рвёт установленные соединения: старые воркеры доживают их до конца. С часовым таймаутом это значит, что после каждой перезагрузки в списке процессов (ps aux | grep nginx) висят воркеры с пометкой «is shutting down», и висят они часами. Верхнюю границу ставит worker_shutdown_timeout; без неё nginx ждёт бесконечно (пятнадцатый раздел).

Буферизация тут ни при чём. proxy_buffering часто советуют выключать «для WebSocket» - после 101 nginx просто перекладывает байты, и директива на это не влияет. Для потоковых ответов вроде SSE (server-sent events, поток событий от сервера) разговор другой, и он в следующем уроке.

Зачем это в работе

Разбор «WebSocket не подключается» занимает один запрос. Ответ 101 значит, что до бэкенда доехало всё нужное:

$  команда
curl -si -o /dev/null -w '%{http_code}\n' \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Version: 13' \
  http://shop.local/ws/chat

Обратная косая в конце строки переносит команду на следующую строку, можно набрать и в одну. Четыре заголовка - то, что шлёт настоящий браузер, открывая WebSocket; Sec-WebSocket-Key - случайная строка рукопожатия, значение годится любое. После 101 соединение остаётся открытым, и curl с -o /dev/null сам закроет его - терминал не повиснет.

101 - конфиг верен, дальше ищи в приложении. 200 или 400 - заголовки не доехали: смотри, нет ли в этом блоке своего proxy_set_header, и в тот ли блок вообще попал запрос. 502 - апгрейд доехал, но бэкенд его не принял.

Теперь сам

Конфиг проверили запросом выше, пришло 101, чат работает. Через неделю пользователи жалуются: раз в минуту соединение обрывается и переподключается. В конфиге стоит proxy_read_timeout 3600s;. Где искать?

Раз в минуту - это шестьдесят секунд, то есть чьё-то умолчание. У nginx оно поднято, значит рвёт другой участник: внешний балансировщик (распределяет запросы между серверами) или облачный терминатор TLS (принимает HTTPS снаружи) перед nginx, у которого свой таймаут простоя. Проверяется запросом напрямую к nginx, минуя этот узел, - с машины внутри сети по его внутреннему адресу. Лечится либо настройкой на том узле, либо ping'ом со стороны приложения чаще, чем самый короткий таймаут в цепочке.

Главное

Upgrade и Connection - хоп-хоп-заголовки, и nginx их не передаёт: нужны две строки proxy_set_header, причём именно две - с одним Connection бэкенд отвечает 200 вместо 101. Значение Connection берут из map по $http_upgrade, чтобы обычные запросы в тот же блок не получали лишнего заголовка. Наследование всё или ничего действует и здесь: любой свой proxy_set_header в блоке уносит заголовки апгрейда. Простаивающий канал рвётся через proxy_read_timeout - замерено 60,1 секунды, поэтому таймаут поднимают и добавляют ping. Строка proxy_http_version 1.1 с версии 1.29.7 не нужна.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.