1. Анатомия рукопожатия WebSocket и почему Nginx сбрасывает соединение
Протокол WebSocket (RFC 6455) обеспечивает полнодуплексный двунаправленный обмен сообщениями между клиентом и сервером поверх единого постоянного TCP-соединения. Он лежит в основе современных веб-чатов, дашбордов с живыми котировками и графиками, систем мгновенных push-уведомлений, совместных редакторов и ботов.
Установление WebSocket-сессии всегда начинается с обычного HTTP-запроса, называемого рукопожатием (WebSocket Handshake). Клиент отправляет стандартный запрос методом GET со специальными заголовками согласования:
GET /ws/ HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Если бэкенд готов перейти на протокол веб-сокетов, он возвращает ответ со статусом HTTP 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
С этой секунды TCP-сокет перестает быть HTTP-каналом: клиент и сервер начинают обмениваться легковесными бинарными или текстовыми фреймами с минимальным оверхедом (всего 2–10 байт на сообщение вместо сотен байт HTTP-заголовков).
⚠️ Почему Nginx «из коробки» ломает веб-сокеты
В конфигурации по умолчанию модуль ngx_http_proxy_module в Nginx работает с вышестоящими серверами (апстримами) по протоколу HTTP/1.0. Кроме того, согласно стандартам RFC 2616 и RFC 7230, Nginx автоматически вырезает так называемые hop-by-hop заголовки, включая Upgrade и Connection. В результате бэкенд получает обычный урезанный HTTP/1.0 запрос, не понимает, что от него требуется рукопожатие сокета, и возвращает ошибку 400 Bad Request либо статус 200 OK с отдачей статической страницы.
Чтобы проверить, какие заголовки фактически возвращает ваш сервер при попытке подключения, воспользуйтесь онлайн-инструментом Анализ HTTP-заголовков онлайн.
2. Директива map $http_upgrade: безопасное переключение протоколов
Самая распространенная ошибка начинающих администраторов — жестко прописать в конфигурации проксирующего блока:
# Ошибочный подход: жесткая фиксация заголовка Connection
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Такая запись приводит к скрытым багам: если к тому же эндпоинту или виртуальному хосту обратится обычный REST API или браузер со стандартным HTTP-запросом (где заголовок Upgrade отсутствует), Nginx все равно принудительно отправит бэкенду заголовок Connection: upgrade. Многие бэкенд-фреймворки (FastAPI, Node.js, Go) расценивают такой запрос как некорректный и аварийно завершают обработку с ошибкой 400.
Официальный стандарт Nginx требует использования динамической карты map в контексте http:
# Файл /etc/nginx/nginx.conf (внутри блока http { ... })
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
💡 Как работает карта переменных $connection_upgrade
Директива map анализирует значение входящей переменной $http_upgrade:
1. Если клиент прислал заголовок Upgrade: websocket, переменная $connection_upgrade принимает значение upgrade.
2. Если клиент отправил обычный HTTP-запрос (заголовок пуст: ''), переменная принимает значение close (или стандартное поддержание сессии).
Это гарантирует, что один и тот же location может абсолютно безопасно обрабатывать как сокеты, так и регулярные HTTP/API запросы.
3. Конфигурация виртуального хоста Nginx для проксирования сокетов
Объединим директиву map и параметры обратного проксирования в боевой конфигурации виртуального хоста Nginx.
Откройте файл конфигурации сайта (например, /etc/nginx/sites-available/app.conf):
# 1. Задаем пул upstream-серверов или локальный порт приложения
upstream websocket_backend {
# Приложение на Node.js, Python, Go или Laravel Reverb
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name app.example.com;
# Основной эндпоинт приложения или выделенный путь сокетов /ws/
location /ws/ {
proxy_pass http://websocket_backend;
# Обязательный перевод проксирования на HTTP/1.1
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_buffering off;
# Увеличение таймаутов для предотвращения разрыва молчащих сессий
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Регулярные веб-страницы и статика
location / {
proxy_pass http://websocket_backend;
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_buffering off критична для сокетов
По умолчанию Nginx пытается буферизовать входящие и исходящие пакеты проксируемого сервера. Для интерактивных чатов, торговых сигналов или онлайн-игр буферизация недопустима: директива proxy_buffering off отключает задержку и заставляет Nginx мгновенно транслировать фреймы сокета напрямую клиенту в режиме реального времени.
После редактирования проверьте синтаксис и мягко перезагрузите Nginx:
# Тест синтаксиса конфигурации Nginx
sudo nginx -t
# Применение новых правил без сброса активных сессий
sudo systemctl reload nginx
Готовые проверенные шаблоны конфигураций для популярных стеков доступны в наших pSEO-инструментах: Конфигурация Nginx для Laravel с WebSocket (Reverb / Echo) и Nginx Reverse Proxy для Node.js и Next.js с WebSocket.
4. Устранение ошибки 1006 (Abnormal Closure) и настройка таймаутов
Классическая ситуация: рукопожатие успешно завершилось, сокет открылся, данные начали передаваться. Но если пользователь отвлекся и перестал отправлять сообщения, ровно через 60 секунд соединение самопроизвольно обрывается.
В консоли браузера при этом регистрируется событие WebSocket.onclose со следующим кодом:
// Вывод в консоли разработчика браузера (F12)
WebSocket connection to 'wss://app.example.com/ws/' failed:
WebSocket is closed before the connection is established.
// CloseEvent: code = 1006 (Abnormal Closure), reason = ""
Код 1006 (Abnormal Closure) — это псевдокод браузера, зарезервированный спецификацией RFC 6455. Он указывает, что соединение было физически прервано на транспортном TCP-уровне (получен пакет TCP RST или TCP FIN) без предварительного обмена контрольным фреймом закрытия (Close Frame).
⚠️ Виновник сброса через 60 секунд: proxy_read_timeout
В Nginx параметр proxy_read_timeout по умолчанию составляет ровно 60s. Если между Nginx и вышестоящим приложением в течение 60 секунд не было передано ни одного байта, Nginx считает соединение зависшим и принудительно разрывает TCP-сокет.
Для устранения этой проблемы применяются два взаимодополняющих решения:
-
Увеличение таймаута на стороне Nginx:
Установитеproxy_read_timeout 86400s;(24 часа) или3600s(1 час) внутри блокаlocation. Это предотвратит закрытие сокета самим веб-сервером. -
Внедрение Heartbeat (Ping/Pong фреймов) на стороне бэкенда:
Даже если в Nginx таймаут равен суткам, сокет могут разорвать промежуточные узлы: NAT-роутеры домашних провайдеров, мобильные вышки операторов связи или облачные балансировщики. Протокол WebSocket имеет встроенные фреймы опроса:Ping (0x9)иPong (0xA).
Пример реализации автоматического пинга в Node.js (библиотека ws):
// heartbeat.js на стороне Node.js сервера
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3000 });
function heartbeat() {
this.isAlive = true;
}
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
// Отправляем Ping клиентам каждые 30 секунд
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping(); // Отправляет легковесный Ping-фрейм
});
}, 30000);
wss.on('close', () => clearInterval(interval));
5. Безопасность WSS, SSL-сертификаты и специфика работы за Cloudflare
В современных веб-стандартах запуск WebSocket по открытому протоколу ws:// на страницах с HTTPS категорически заблокирован политикой смешанного содержимого (Mixed Content). Все браузеры при попытке установить открытое WebSocket-соединение с защищенного сайта выбросят ошибку безопасности:
SecurityError: Failed to construct 'WebSocket': An insecure WebSocket connection may not be initiated from a page loaded over HTTPS.
Поэтому на продакшене используется исключительно защищенный протокол wss:// (WebSocket Secure), представляющий собой сокет поверх TLS-туннеля. Проверить корректность настройки TLS и дату истечения сертификата на вашем домене всегда можно с помощью инструмента SSL-чекер SysKit.
Благодаря архитектуре Nginx нам не требуется реализовывать SSL/TLS внутри самого бэкенд-сервера (Node.js, Go или Python). Nginx выполняет функцию SSL Termination: терминирует входящее шифрованное WSS-соединение на 443 порту и проксирует расшифрованный трафик по быстрому локальному http://127.0.0.1:3000.
Для автоматического выпуска и продления бесплатного доверенного сертификата Let's Encrypt на VDS под управлением Ubuntu/Debian используется официальный Certbot:
# Установка Certbot и плагина Nginx через snap
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
# Автоматический выпуск сертификата с настройкой редиректа на HTTPS
sudo certbot --nginx -d ws.example.com --redirect
Специфика проксирования WebSocket через Cloudflare
Если ваш VDS находится под защитой Cloudflare (включен оранжевый прокси-облако в DNS), необходимо учитывать важные особенности платформы:
- Поддержка WebSocket по умолчанию: В бесплатных и платных тарифах Cloudflare проксирование WebSocket включено из коробки на стандартных HTTPS-портах (443, 2053, 2083, 2087, 2096, 8443).
- Лимит неактивности (Idle Timeout): На всех тарифах Cloudflare жестко зафиксирован тайм-аут неактивного соединения, равный 100 секундам. Если в течение 100 секунд по сокету не прошло ни одного фрейма (данных или пинга), Cloudflare принудительно разрывает TCP-сессию.
- Требование к Ping/Pong: Чтобы предотвратить обрыв сокетов через Cloudflare, интервал heartbeat-пингов в вашем бэкенде или фронтенде должен составлять не более 50–60 секунд.
6. Коды завершения WebSocket (RFC 6455): диагностика проблем
При закрытии WebSocket-соединения клиентское событие socket.onclose = (event) => { ... } возвращает числовой код (event.code) и текстовую причину (event.reason). Понимание этих кодов позволяет точно локализовать сбой — на стороне браузера, обратного прокси Nginx или бэкенда.
| Код | Статус RFC | Причина сбоя | Решение |
|---|---|---|---|
1000 |
Normal Closure | Штатное корректное закрытие сокета клиентом или сервером. | Ошибки нет. Действий не требуется. |
1001 |
Going Away | Клиент закрыл вкладку браузера либо сервер перезагружается. | Штатная обработка отключения подписчика. |
1002 |
Protocol Error | Нарушение протокола кадрирования RFC 6455 одной из сторон. | Проверить совместимость WebSocket-библиотек бэкенда. |
1006 |
Abnormal Closure | TCP-сокет разорван без Close Frame (тайм-аут Nginx/NAT, сброс TCP RST). | Настроить proxy_read_timeout 86400s и внедрить Ping/Pong пинги. |
1008 |
Policy Violation | Отказ сервера из-за неверной авторизации или Origin. | Проверить JWT-токен или политику CORS/Origin на бэкенде. |
1011 |
Internal Error | Необработанное исключение или крах процесса на сервере приложений. | Изучить логи приложения (PM2, Docker, systemd journalctl). |
Не пишите конфигурации вручную с риском допустить синтаксическую ошибку. Воспользуйтесь специализированным генератором Nginx Reverse Proxy SysKit, чтобы за секунды сгенерировать готовый production-блок со всеми директивами Upgrade, Connection и защитными заголовками.
Рекомендуемые VDS-провайдеры
Отказоустойчивые сервера с быстрыми NVMe и каналом до 1 Гбит/с
Timeweb Cloud — Быстрые NVMe VDS
Надежные облачные серверы с быстрыми NVMe-дисками, выделенным статическим IP и открытыми портами 80/443 для безотказной работы SSL и Nginx.
Selectel — Премиальные облачные серверы
Инфраструктура корпоративного уровня в дата-центрах Tier III: почасовая тарификация, приватные VLAN и прямое подключение к ведущим точкам обмена трафиком.
Beget — Простота и стабильность VDS
Удобная интуитивная панель управления, автоматические ежедневные бэкапы и мгновенное развертывание LEMP-стека с предустановленным Nginx.