WebSocket - это обычный HTTP-запрос, который просит перестать быть HTTP-запросом. Всё, что ломается, когда перед ним ставят прокси, следует из этой фразы: прокси созданы для того, чтобы прочитать запрос, переслать его, прочитать ответ и закрыть соединение. Если вашему прокси не сказать, что соединение нужно держать открытым и пропускать два конкретных заголовка, рукопожатие либо завершится ошибкой, либо - что хуже - пройдёт успешно, а через шестьдесят секунд молча оборвётся. Исправление - это четыре строки конфигурации, таймаут, который вы задаёте осознанно, и heartbeat. В этой статье разобраны все три, а также проблема sticky-сессий, которая появляется только тогда, когда вы запускаете второй процесс.
Как на самом деле устанавливается соединение WebSocket#
Клиент открывает обычное TCP-соединение, выполняет TLS, если адрес начинается с wss://, и отправляет GET-запрос с двумя заголовками, означающими «я хочу сменить протокол»:
GET /ws HTTP/1.1Host: app.example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13Если сервер согласен, он отвечает статусом 101, а не 200, и с этого момента TCP-соединение несёт в обе стороны кадры WebSocket вместо HTTP:
HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Отсюда следуют три вывода, и это и есть вся статья.
- Заголовки
UpgradeиConnection- hop-by-hop. По правилам HTTP посредник не должен пересылать их вслепую, поэтому прокси их отбрасывает, пока вы явно не вернёте их на место. Вот почему обычныйproxy_passдаёт вам400или простой200вместо101. - Переключение протокола существует только в HTTP/1.1. Прокси, который общается с вашим приложением по HTTP/1.0 (а это протокол nginx по умолчанию для upstream), выполнить его не может.
- После
101соединение превращается в туннель, который может минутами не нести вообще ничего. Теперь каждый таймаут простоя между браузером и вашим процессом применяется к соединению, которое абсолютно здорово.
Браузеры открывают WebSocket по HTTP/1.1, даже если сайт работает по HTTP/2, так что для этого ничего специально делать не нужно. Нужен прокси, настроенный пропускать переключение протокола.
Конфигурация nginx, которая работает#
Вот всё, что нужно. Блок map помещается в контекст http, а не внутрь server, и нужен он для того, чтобы обычный запрос (без заголовка Upgrade) получал Connection: close, а не случайный Connection: upgrade.
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:
app.example.com { reverse_proxy 127.0.0.1:3000}Apache требует модуль, который по умолчанию не включён. Сначала a2enmod proxy_wstunnel, затем отдельно направьте путь WebSocket, потому что mod_proxy_http сам переключение не выполнит:
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 на него больше не действуют.
defaults timeout client 30s timeout server 30s timeout tunnel 1hНа хостинге с панелью всё это обычно писать не приходится. На RE:NODE тарифы для приложений и сайтов включают слот прокси: вы направляете запись A на адрес, показанный на вкладке, сертификат выпускается и продлевается автоматически внутри окна в 21 день, а исходный адрес клиента приходит в X-Forwarded-For. Чего не сделает за вас ни один прокси, так это ответ на upgrade - эта часть лежит на вашем приложении. А если вы предпочитаете обойтись вовсе без прокси, порт, выделенный вашему серверу, доступен напрямую, а дополнительные можно добавить на вкладке Network.
Таймауты, heartbeat и сокет, который умирает на шестидесятой секунде#
WebSocket, который открыт, но молчит, для всего на пути выглядит точно так же, как зависшее соединение. У каждого звена своё представление о том, сколько это терпеть:
| Звено | Настройка | Типичное значение по умолчанию |
|---|---|---|
| nginx | proxy_read_timeout | 60 секунд |
| Apache | ProxyTimeout | 60 секунд |
| HAProxy | timeout tunnel | наследует timeout server |
| Прокси Cloudflare | не настраивается на младших тарифах | около 100 секунд простоя |
| Облачные балансировщики | таймаут простоя | от 60 до 350 секунд |
| Корпоративный Wi-Fi, мобильный NAT | отслеживание соединений | от 30 до 300 секунд, не документировано |
Последнее настроить нельзя, поэтому ответ никогда не сводится к «увеличьте таймаут». Отправляйте трафик. В протоколе WebSocket для этого есть управляющие кадры ping и pong, и каждая серьёзная библиотека их поддерживает.
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, а затем и переключение протокола должны попасть в тот же процесс. С одним процессом это бесплатно. С двумя примерно половина запросов попадает не туда, и клиент бесконечно крутится с ошибкой:
{"code":1,"message":"Session ID unknown"}Есть три честных выхода:
- Запустите один процесс. На небольшом тарифе это обычно правильный ответ. Один процесс Node держит тысячи сокетов, и вы не управляете проблемой, а убираете её.
- Сделайте балансировщик sticky. В nginx это
ip_hashили консистентный хеш по адресу клиента. Такой вариант хрупок: все, кто сидит за одним офисным NAT, одним мобильным оператором или одним CDN, хешируются в один и тот же воркер. - Пропустите polling.
transports: ["websocket"]на клиенте отправляет upgrade сразу и никогда не создаёт сессию polling, так что привязка перестаёт иметь значение. Цена - отсутствие запасного варианта для небольшого числа сетей, которые блокируют WebSocket целиком.
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:
app.set("trust proxy", 1); // number of proxies in front of you, not `true`Указывайте число, а не true. true означает «доверять всей цепочке», а поскольку X-Forwarded-For - заголовок, который задаёт клиент, тогда любой сможет выдать себя за любой адрес, прислав собственный. С 1 Express берёт последнюю запись - ту, что записал ваш собственный прокси.
С «сырой» библиотекой ws настраивать нечего, нет никакого фреймворка, так что прочитайте заголовок сами из запроса на переключение протокола:
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:
$ 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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.