# теория · шаг 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 - листай до «Теперь сам».
Сначала ответь сам
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
HTTP/1.1 101 Switching Protocols
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 - слова «заголовок» там нет.
На что это похоже
Переговоры через переводчика. Ты просишь перейти на прямую линию - дальше без посредника. Переводчик - существо аккуратное: он передаёт содержание разговора, а служебные реплики про сам порядок разговора считает своими и дальше не несёт. Собеседник просьбы не слышит и продолжает говорить через переводчика.
Просьба относилась к участку «ты - переводчик», и он честно принял её на свой счёт. Чтобы она доехала до собеседника, её надо передать явно.
Механизм: апгрейд относится к участку пути
Правило
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
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 на
уровень http (в conf.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 не нужна.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий