WebSocket — протокол, который начинается как обычный HTTP-запрос, а затем превращается в постоянный двусторонний канал. Именно из-за этого превращения Nginx требует нескольких неочевидных директив: без них соединение либо не устанавливается, либо обрывается через минуту простоя. Ниже — рабочая конфигурация обратного прокси, разбор тайм-аутов и способы понять, кто именно закрывает соединение.
Чем WebSocket отличается от обычного запроса
Клиент отправляет GET с заголовками Upgrade: websocket и Connection: Upgrade, а также случайный ключ Sec-WebSocket-Key. Сервер отвечает кодом 101 Switching Protocols и заголовком Sec-WebSocket-Accept. С этого момента по тому же TCP-соединению идут кадры WebSocket, а HTTP-парсер больше не участвует. Для прокси это означает, что соединение должно оставаться открытым часами, а не завершаться после первого ответа.
Отсюда два практических следствия. Во-первых, балансировщик обязан поддерживать HTTP/1.1 на участке до бэкенда. Во-вторых, все тайм-ауты, рассчитанные на короткие запросы, становятся опасными: они рвут канал в самый неподходящий момент.
Минимальная рабочая конфигурация
Сначала определим переменную для заголовка Connection. Без неё нельзя одновременно обслуживать обычные запросы и апгрейд: пустое значение должно превращаться в close, а не в upgrade.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream ws_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 443 ssl;
server_name example.com;
location /ws/ {
proxy_pass http://ws_backend;
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_http_version 1.1 обязательна: по умолчанию Nginx говорит с бэкендом по HTTP/1.0, где заголовок Upgrade не поддерживается, и приложение вернёт ошибку или просто закроет соединение. Заголовок Host тоже стоит передавать явно, иначе виртуальные хосты приложения могут выбрать не тот сайт.
Тайм-ауты: что и когда срабатывает
Главный параметр — proxy_read_timeout. Он отсчитывается не от начала сессии, а от последнего успешного чтения. Если приложение раз в 30 секунд отправляет ping или любое служебное сообщение, значения по умолчанию в 60 секунд обычно достаточно. Если трафик бывает только по действию пользователя, тайм-аут нужно увеличить, иначе Nginx разорвёт канал после паузы.
location /ws/ {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_socket_keepalive on;
proxy_buffering off;
}
proxy_send_timeout ограничивает паузу при записи в сокет бэкенда, а не общую длительность передачи. proxy_socket_keepalive включает TCP keepalive для соединения с бэкендом и помогает заметить мёртвый узел.
Отдельно проверьте keepalive_timeout. Он относится к клиентским HTTP-соединениям до апгрейда и после ответа 101 уже не действует, поэтому увеличивать его до часа ради WebSocket бессмысленно. Не путайте proxy_read_timeout с временем жизни сессии: приложение может держать канал сколько угодно, пока идут обмены.
Несколько бэкендов и липкие сессии
Если WebSocket-серверов больше одного и состояние хранится в памяти процесса, обычный round-robin приведёт к тому, что повторное подключение попадёт на другой узел и потеряет контекст. Для небольших установок помогает ip_hash, для более аккуратного распределения — hash по идентификатору пользователя из cookie или токена.
upstream ws_backend {
hash $cookie_session consistent;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
keepalive 64;
}
Параметр consistent включает кольцо с ограниченным влиянием: при выпадении узла перераспределяется только часть ключей. Keepalive в upstream экономит рукопожатия для сопутствующего HTTP-трафика; для самого WebSocket соединение всё равно занято на всю сессию.
Буферы, заголовки и размеры
Длинные cookie и токены авторизации иногда не помещаются в стандартные буферы заголовков. Тогда клиент получает 400 ещё до апгрейда, а в error.log появляется запись о слишком большом заголовке. Помогает увеличение large_client_header_buffers, но злоупотреблять им не стоит: это память на каждое соединение.
large_client_header_buffers 4 16k;
client_header_timeout 20s;
client_body_timeout 20s;
Эти директивы касаются только этапа рукопожатия. После ответа 101 они перестают влиять на трафик. Если приложение отдаёт большие бинарные сообщения, следите за памятью воркеров и не увеличивайте proxy_buffers наугад.
Диагностика: кто закрывает соединение
Начните с рукопожатия через curl. Код 101 и заголовок Upgrade подтверждают, что Nginx и бэкенд договорились. Если приходит 400, 426 или 502, проблема в конфигурации или в недоступном приложении, а не в тайм-аутах.
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/ws/
Дальше смотрите access.log: успешный апгрейд виден как статус 101, а время запроса в логе будет коротким, потому что фиксируется только рукопожатие. Для наблюдения за живыми сокетами используйте ss и фильтр по порту. В error.log ищите сообщения upstream timed out и client prematurely closed connection: первое указывает на бэкенд, второе — на клиента или промежуточное оборудование.
sudo tail -f /var/log/nginx/error.log
ss -tnp | grep ':443'
sudo nginx -t && sudo systemctl reload nginx
В браузере вкладка Network показывает статус 101 и дальнейшие кадры, а закрытие с кодом 1006 означает обрыв без корректного close-кадра.
Нагрузка, ограничения и безопасность
Каждое проксируемое соединение занимает два сокета в worker_connections: клиентский и upstream. При лимите 1024 и одном воркере это максимум около 512 одновременных WebSocket-клиентов, а не 1024. Для тысяч подключений поднимайте worker_connections, число воркеров и проверяйте лимиты файловых дескрипторов через ulimit и systemd.
Апгрейд — удобная точка для ограничений:
- limit_conn на зону по IP не даёт одному клиенту открыть сотни каналов;
- limit_req сдерживает шторм переподключений;
- TLS обязателен для внешнего трафика;
- проверку Origin и авторизацию делайте в приложении.
Не открывайте /ws/ в обход аутентификации и не логируйте токены целиком.
Итог
Для WebSocket в Nginx достаточно пяти вещей: HTTP/1.1 до бэкенда, корректные заголовки Upgrade и Connection, разумный proxy_read_timeout, понимание лимита worker_connections и проверка рукопожатия через curl. Тайм-ауты считайте от последнего обмена, а не от старта сессии, и тогда долгие каналы перестанут рваться «сами собой».