RE:NODE

Приложения14 мин чтения

WebSocket за reverse proxy: что ломается и почему

Заголовки Upgrade, блок map в nginx, таймауты, убивающие сокет на 60-й секунде, sticky-сессии для Socket.IO и как читать ошибки.

0 прочтений

WebSocket - это обычный HTTP-запрос, который просит перестать быть HTTP-запросом. Всё, что ломается, когда перед ним ставят прокси, следует из этой фразы: прокси созданы для того, чтобы прочитать запрос, переслать его, прочитать ответ и закрыть соединение. Если вашему прокси не сказать, что соединение нужно держать открытым и пропускать два конкретных заголовка, рукопожатие либо завершится ошибкой, либо - что хуже - пройдёт успешно, а через шестьдесят секунд молча оборвётся. Исправление - это четыре строки конфигурации, таймаут, который вы задаёте осознанно, и heartbeat. В этой статье разобраны все три, а также проблема sticky-сессий, которая появляется только тогда, когда вы запускаете второй процесс.

Как на самом деле устанавливается соединение WebSocket#

Клиент открывает обычное TCP-соединение, выполняет TLS, если адрес начинается с wss://, и отправляет GET-запрос с двумя заголовками, означающими «я хочу сменить протокол»:

code
GET /ws HTTP/1.1Host: app.example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13

Если сервер согласен, он отвечает статусом 101, а не 200, и с этого момента TCP-соединение несёт в обе стороны кадры WebSocket вместо HTTP:

code
HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Отсюда следуют три вывода, и это и есть вся статья.

  1. Заголовки Upgrade и Connection - hop-by-hop. По правилам HTTP посредник не должен пересылать их вслепую, поэтому прокси их отбрасывает, пока вы явно не вернёте их на место. Вот почему обычный proxy_pass даёт вам 400 или простой 200 вместо 101.
  2. Переключение протокола существует только в HTTP/1.1. Прокси, который общается с вашим приложением по HTTP/1.0 (а это протокол nginx по умолчанию для upstream), выполнить его не может.
  3. После 101 соединение превращается в туннель, который может минутами не нести вообще ничего. Теперь каждый таймаут простоя между браузером и вашим процессом применяется к соединению, которое абсолютно здорово.

Браузеры открывают WebSocket по HTTP/1.1, даже если сайт работает по HTTP/2, так что для этого ничего специально делать не нужно. Нужен прокси, настроенный пропускать переключение протокола.

заголовок UpgradeHTTP/1.1101кадрырассылкаБраузерwss:// на 443Ваше приложениеслушает 3000Reverse proxyдержит сертификатОбщее состояниекомнаты и сессии
Как upgrade проходит через прокси

Конфигурация nginx, которая работает#

Вот всё, что нужно. Блок map помещается в контекст http, а не внутрь server, и нужен он для того, чтобы обычный запрос (без заголовка Upgrade) получал Connection: close, а не случайный Connection: upgrade.

/etc/nginx/conf.d/app.conf
map $http_upgrade $connection_upgrade {    default upgrade;    ''      close;}server {    listen 443 ssl;    server_name app.example.com;    location / {        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_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_read_timeout 3600s;        proxy_send_timeout 3600s;    }}

Строка за строкой, те, что важны:

  • proxy_http_version 1.1 - без неё nginx говорит с upstream по HTTP/1.0, а в нём нет механизма переключения протокола. Эта единственная строка - самая частая причина рукопожатия, которое возвращает 400.
  • proxy_set_header Upgrade / Connection - возвращает на место hop-by-hop заголовки.
  • proxy_set_header Host $host - ваше приложение видит настоящее имя хоста. Без неё проверки origin и маршрутизация между арендаторами видят 127.0.0.1:3000.
  • proxy_read_timeout - по умолчанию 60 секунд. Это и есть загадочный обрыв на шестидесятой секунде. Задайте большое значение и шлите heartbeat, а не ставьте сутки и не надейтесь на лучшее.

Если WebSocket говорит только часть вашего приложения, ограничьте настройку блоком location /ws и остальное не трогайте. Заголовки upgrade на обычном маршруте не вредят, но более узкую конфигурацию проще анализировать, когда что-то сломалось. Обычный путь HTTP в том порядке, в каком приходят пакеты, разобран в статье что на самом деле делает reverse proxy; полная настройка nginx с TLS есть в руководстве по nginx reverse proxy.

Caddy, Apache и HAProxy#

Caddy 2 ничего не требует. reverse_proxy 127.0.0.1:3000 уже обрабатывает переключение протокола, а matcher websocket, который копируют из старых статей, был особенностью Caddy 1:

Caddyfile
app.example.com {    reverse_proxy 127.0.0.1:3000}

Apache требует модуль, который по умолчанию не включён. Сначала a2enmod proxy_wstunnel, затем отдельно направьте путь WebSocket, потому что mod_proxy_http сам переключение не выполнит:

config
ProxyPass        /ws  ws://127.0.0.1:3000/wsProxyPassReverse /ws  ws://127.0.0.1:3000/wsProxyPass        /    http://127.0.0.1:3000/ProxyPassReverse /    http://127.0.0.1:3000/

Порядок важен: сначала самый длинный путь, иначе / проглотит /ws. ProxyTimeout в Apache по умолчанию равен Timeout, обычно 60 секунд, и здесь он тоже действует.

HAProxy туннелирует переключение протокола штатно в режиме HTTP. Настройка, которую пропускают, - timeout tunnel: она управляет соединением после переключения, а timeout client и timeout server на него больше не действуют.

config
defaults    timeout client  30s    timeout server  30s    timeout tunnel  1h

На хостинге с панелью всё это обычно писать не приходится. На RE:NODE тарифы для приложений и сайтов включают слот прокси: вы направляете запись A на адрес, показанный на вкладке, сертификат выпускается и продлевается автоматически внутри окна в 21 день, а исходный адрес клиента приходит в X-Forwarded-For. Чего не сделает за вас ни один прокси, так это ответ на upgrade - эта часть лежит на вашем приложении. А если вы предпочитаете обойтись вовсе без прокси, порт, выделенный вашему серверу, доступен напрямую, а дополнительные можно добавить на вкладке Network.

Таймауты, heartbeat и сокет, который умирает на шестидесятой секунде#

WebSocket, который открыт, но молчит, для всего на пути выглядит точно так же, как зависшее соединение. У каждого звена своё представление о том, сколько это терпеть:

ЗвеноНастройкаТипичное значение по умолчанию
nginxproxy_read_timeout60 секунд
ApacheProxyTimeout60 секунд
HAProxytimeout tunnelнаследует timeout server
Прокси Cloudflareне настраивается на младших тарифахоколо 100 секунд простоя
Облачные балансировщикитаймаут простояот 60 до 350 секунд
Корпоративный Wi-Fi, мобильный NATотслеживание соединенийот 30 до 300 секунд, не документировано

Последнее настроить нельзя, поэтому ответ никогда не сводится к «увеличьте таймаут». Отправляйте трафик. В протоколе WebSocket для этого есть управляющие кадры ping и pong, и каждая серьёзная библиотека их поддерживает.

server.js
import { WebSocketServer } from "ws";const wss = new WebSocketServer({ port: 3000 });wss.on("connection", (socket) => {  socket.isAlive = true;  socket.on("pong", () => { socket.isAlive = true; });});// Каждые 30 секунд: отбросить тех, кто не ответил в прошлый раз, остальным отправить ping.setInterval(() => {  for (const socket of wss.clients) {    if (!socket.isAlive) { socket.terminate(); continue; }    socket.isAlive = false;    socket.ping();  }}, 30_000);

Тридцать секунд - хорошее значение по умолчанию: оно с запасом меньше любого таймаута из таблицы и достаточно дёшево, чтобы несколько тысяч соединений ничего не стоили. Браузеры отвечают на ping на уровне протокола автоматически, так что клиентского кода для этого при работе с «сырым» API нет вообще.

Socket.IO делает своё и делает это хорошо: сервер отправляет ping engine.io каждые pingInterval (по умолчанию 25 секунд) и закрывает соединение, если pong не пришёл за pingTimeout (по умолчанию 20 секунд). Эти значения уже поддерживают соединение живым, так что не трогайте их, если у вас нет измеренной причины.

Socket.IO, polling и sticky-сессии#

Socket.IO - это не библиотека WebSocket, а протокол поверх engine.io, который начинает с HTTP long-polling, а потом переключается на WebSocket. Именно то, что первый запрос идёт как polling, порождает самый запутанный сбой во всей этой области.

Рукопожатие создаёт сессию (sid), которая живёт в памяти одного процесса. Последующие запросы polling, а затем и переключение протокола должны попасть в тот же процесс. С одним процессом это бесплатно. С двумя примерно половина запросов попадает не туда, и клиент бесконечно крутится с ошибкой:

json
{"code":1,"message":"Session ID unknown"}

Есть три честных выхода:

  1. Запустите один процесс. На небольшом тарифе это обычно правильный ответ. Один процесс Node держит тысячи сокетов, и вы не управляете проблемой, а убираете её.
  2. Сделайте балансировщик sticky. В nginx это ip_hash или консистентный хеш по адресу клиента. Такой вариант хрупок: все, кто сидит за одним офисным NAT, одним мобильным оператором или одним CDN, хешируются в один и тот же воркер.
  3. Пропустите polling. transports: ["websocket"] на клиенте отправляет upgrade сразу и никогда не создаёт сессию polling, так что привязка перестаёт иметь значение. Цена - отсутствие запасного варианта для небольшого числа сетей, которые блокируют WebSocket целиком.
nginx
upstream app {    ip_hash;    server 127.0.0.1:3000;    server 127.0.0.1:3001;}

Привязка решает маршрутизацию, а не состояние. У двух процессов по-прежнему два отдельных набора комнат, так что сообщение, отправленное на воркере A, никогда не дойдёт до клиента на воркере B. Для этого нужен адаптер - общая шина, на которую подписан каждый процесс. @socket.io/cluster-adapter для Node (вместе с @socket.io/sticky) делает это через канал IPC кластера без лишнего сервиса, и на одной машине это правильный первый выбор. Адаптер Redis - обычный ответ для более крупных масштабов, но Redis - это ещё одна вещь, которую надо запускать и оплачивать; прежде чем добавлять его, стоит прочитать Redis: когда он вам действительно нужен, а фоновые задачи на небольшом сервере рассказывают, что делать, если его у вас нет.

Ещё две особенности Socket.IO, о которые обжигаются:

  • Путь по умолчанию - /socket.io/. Если вы проксируете только location /socket.io/, а потом добавляете второй namespace, он всё равно идёт через этот путь: namespace - это не URL.
  • maxHttpBufferSize по умолчанию равен 1 МБ. Более крупные сообщения закрывают соединение без явной ошибки. Поднимайте его осознанно или, лучше, не проталкивайте мегабайты через сокет.
  • Сжатие отдельных сообщений (perMessageDeflate) в Socket.IO версии 3 и выше выключено по умолчанию. Выключено оно не зря: оно заметно расходует память на каждое соединение. Включайте его только для больших повторяющихся текстовых данных.

Как получить настоящий адрес клиента#

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

Прокси записывает X-Forwarded-For, а вашему приложению нужно сказать, чтобы оно ему верило. В Express:

javascript
app.set("trust proxy", 1); // number of proxies in front of you, not `true`

Указывайте число, а не true. true означает «доверять всей цепочке», а поскольку X-Forwarded-For - заголовок, который задаёт клиент, тогда любой сможет выдать себя за любой адрес, прислав собственный. С 1 Express берёт последнюю запись - ту, что записал ваш собственный прокси.

С «сырой» библиотекой ws настраивать нечего, нет никакого фреймворка, так что прочитайте заголовок сами из запроса на переключение протокола:

javascript
wss.on("connection", (socket, request) => {  const forwarded = request.headers["x-forwarded-for"];  const ip = forwarded ? forwarded.split(",")[0].trim() : request.socket.remoteAddress;});

Если вы ещё и завершаете TLS на прокси, то X-Forwarded-Proto - это способ, которым приложение узнаёт, что исходный запрос был защищённым. Это важно для cookie с флагом Secure и для любых редиректов, которые приложение строит само.

Cloudflare и другие прокси перед вашим#

Cloudflare проксирует WebSocket на всех тарифах, включая бесплатный, и включать для этого ничего не нужно. Когда включено оранжевое облачко, меняются три вещи:

  • Соединения в простое закрываются примерно через 100 секунд. Ваш heartbeat в 30 секунд с этим уже справляется.
  • Проксируются только те порты HTTP и HTTPS, которые поддерживает Cloudflare. WebSocket на порту 8443 работает; на порту 3000 - нет, потому что запись придётся сделать серой, а это всё равно раскрывает адрес origin.
  • Bot Fight Mode и агрессивные правила WAF могут выдать проверку на запрос переключения. Браузер проверку переживает; мобильное приложение или клиент «сервер - сервер» - нет, и вы получаете 403, невидимый в ваших собственных логах. В статье Cloudflare для сайтов и игровых серверов разобрано, какой трафик прокси пропустит, а какой нет.

Если вы выстраиваете цепочку прокси - Cloudflare, потом ваш nginx, потом приложение, - то X-Forwarded-For накапливает список. Крайняя левая запись - исходный клиент, крайняя правая - ближайший прокси, а заслуживающая доверия часть ровно настолько длинна, насколько длинна цепочка, которую вы контролируете.

Сколько соединений действительно помещается#

По отдельности WebSocket дёшев, а в совокупности дорог. Ориентировочный бюджет:

РесурсСтоимость одного простаивающего соединенияГде заканчивается
Файловые дескрипторы1 в приложении, 2 в проксиulimit -n, часто 1024 по умолчанию
Память приложения20-60 КБ плюс всё, что вы храните на пользователяЛимит памяти тарифа
Память проксиНесколько КБ в nginxРедко заканчивается первой
CPUОколо нуля в простое; весь - при рассылкеОдновременная рассылка каждому клиенту

Число, которое удивляет людей, - в последней строке. Десять тысяч простаивающих сокетов почти ничего не стоят; десять тысяч сокетов, каждый из которых получает обновление в 2 КБ раз в секунду, - это 20 МБ/с сериализации и системных вызовов, и это загрузит одно ядро целиком. Исправление почти всегда в том, чтобы отправлять меньше: объединяйте обновления в тики, отправляйте дельты, а не объекты целиком, и рассылайте только той комнате, которой это нужно.

Два практических лимита, которые стоит поднять, прежде чем искать экзотические. ulimit -n для процесса, потому что значение по умолчанию 1024 ограничивает вас примерно тысячей пользователей. И worker_connections в nginx (по умолчанию 512 или 1024 в зависимости от сборки), помня, что проксируемое соединение занимает два из них. На хостинге на основе контейнеров оба обычно уже выставлены разумно; на вашем собственном VDS - нет.

Реальный потолок на небольшом тарифе - память. Если ваше приложение на тарифе с 1 ГБ, именно состояние на соединение нужно измерять, а сбой при этом резкий: на RE:NODE при достижении лимита памяти контейнер останавливается и запускается заново с чистого листа, а не уходит в swap, что для приложения на WebSocket означает, что все клиенты переподключаются в один и тот же миг. Для такого «стада» стоит заранее предусмотреть случайную задержку переподключения на клиенте. Про сторону кучи рассказывает статья лимиты памяти Node, а в материале корректное завершение и health checks описано, как закрывать сокеты вежливо, чтобы переподключения растянулись во времени.

Диагностика#

`400 Bad Request` на рукопожатии. Прокси съел заголовки. Сначала проверьте proxy_http_version 1.1, затем две строки proxy_set_header. Убедитесь с помощью curl: рабочий endpoint отвечает 101:

bash
$ curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \    -H "Sec-WebSocket-Version: 13" \    -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \    https://app.example.com/ws

`426 Upgrade Required`. Вы обратились к чему-то, что говорит только на WebSocket, обычным HTTP-запросом. Как правило, браузер или проверка здоровья бьёт прямо в путь сокета.

`502 Bad Gateway`. Приложение не слушает порт, на который указывает прокси, либо оно привязано к 127.0.0.1, а прокси находится в другом контейнере. Привязывайтесь к 0.0.0.0, когда прокси не на том же хосте, и проверьте, что приложение вообще запустилось.

Соединение устанавливается, а затем закрывается ровно через 60 секунд. proxy_read_timeout. Ровные круглые числа в журнале отключений - это всегда таймаут, и число подсказывает, на каком звене.

`WebSocket is closed before the connection is established`. Формулировка Chrome для «рукопожатие так и не завершилось». Поищите 301/302 на вкладке сети: редирект с http на https или редирект из-за косой черты в конце подойдёт, потому что клиенты WebSocket редиректам не следуют.

Заблокировано как смешанное содержимое. Страница, отданная по https://, не может открыть ws://. Стройте адрес из самой страницы: ` ${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws `.

Локально работает, в production не работает, и нигде нет ошибки. Подозревайте что-то между вами и приложением, чего вы туда не ставили: CDN, офисный прокси, корпоративный перехватчик TLS. Проверьте с телефона по мобильному интернету. Если там работает, проблема в сети, а не в вашей конфигурации.

Соединения копятся и никогда не сбрасываются. Нет heartbeat, либо сервер никогда не вызывает terminate() для сокета, не ответившего pong. Сверьте число открытых сокетов с числом людей, которые действительно пользуются приложением.

FAQ#

Нужен ли отдельный порт или поддомен для WebSocket?

Нет. Переключение протокола - обычный запрос к обычному пути на том же хосте и порту, и тот же сертификат его покрывает. Отдельный поддомен полезен, только если вы хотите направлять трафик WebSocket в другой процесс или применять другие таймауты прокси.

Почему мой сокет обрывается ровно через 60 секунд?

Потому что у какого-то звена цепочки таймаут простоя в 60 секунд: proxy_read_timeout в nginx и ProxyTimeout в Apache оба по умолчанию равны ему. Увеличьте его на том звене, которым вы управляете, и отправляйте ping каждые 30 секунд, чтобы соединение вообще никогда не простаивало.

Нужны ли sticky-сессии для WebSocket?

Только если вы запускаете больше одного процесса и используете библиотеку, которая начинает с HTTP polling, например Socket.IO в конфигурации по умолчанию. Одному процессу ничего не нужно. Принудительный выбор транспорта WebSocket тоже снимает это требование ценой потери запасного варианта с polling.

Можно ли использовать WebSocket через Cloudflare?

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

Сколько соединений WebSocket выдержит один небольшой сервер?

Тысячи, если они в основном простаивают: закладывайте 20-60 КБ памяти на каждое плюс собственное состояние на пользователя и поднимите лимит файловых дескрипторов. Раньше всего по CPU упирается скорость рассылки, а не число соединений.

Не использовать ли вместо этого Server-Sent Events?

Если данные идут только от сервера к клиенту - живые логи, прогресс, уведомления - SSE проще: это обычный HTTP-ответ, он сам переподключается, и ему нужен proxy_buffering off, а не заголовки upgrade. Выбирайте WebSocket, когда клиенту тоже нужно непрерывно отправлять сообщения.


Комментарии

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

0/2000