Real-Time Backend
Load Balancing WebSocket
Slack в 2015 году столкнулся с проблемой: при деплое новой версии все WebSocket-соединения рвались одновременно - тысячи клиентов переподключались в ту же секунду, роняя только что поднятые серверы. Проблема не в коде, а в том как устроен load balancing для long-lived соединений.
- Discord обслуживает более 7 миллионов одновременных WebSocket-соединений - они используют несколько уровней балансировки: L4 на уровне anycast IP и L7 с least_conn внутри datacenter
- Twitch чат переключился с round-robin на ip_hash после инцидента: при пике зрителей популярного стрима переподключения создавали thundering herd эффект на одном бэкенде
- AWS ALB добавил поддержку WebSocket в 2016 году специально под запросы таких сервисов как Slack и Zendesk - до этого им приходилось использовать Classic LB или HAProxy напрямую
- Команда Figma описывала как неправильный proxy_read_timeout в nginx разрывал соединения коллаборации каждые N минут у пользователей за корпоративными прокси с длинными idle-таймаутами
L4 vs L7 для WebSocket
Обычный HTTP-запрос живет миллисекунды - балансировщик выбирает бэкенд на каждый запрос независимо. WebSocket-соединение держится часами: клиент делает HTTP Upgrade один раз, и дальше весь трафик идет по одному TCP-каналу. Это меняет требования к балансировщику кардинально.
**L4-балансировщик** работает на уровне TCP: видит только IP-адреса и порты, не читает HTTP-заголовки. Он передает TCP-соединение на бэкенд и после этого не вмешивается. Для WebSocket это идеально - upgrade проходит прозрачно, длинное соединение не ломается. Минус: нет маршрутизации по URL, заголовкам или cookie.
**L7-балансировщик** (nginx, HAProxy, AWS ALB) читает HTTP: может маршрутизировать по пути `/ws` vs `/api`, добавлять заголовки, терминировать TLS. Для WebSocket требует явной настройки - нужно пробросить заголовки `Upgrade` и `Connection`, иначе балансировщик не даст соединению перейти в режим raw TCP.
Без `proxy_http_version 1.1` nginx по умолчанию шлет HTTP/1.0, который не поддерживает Upgrade. Соединение молча деградирует до обычного HTTP или рвется на handshake.
L7-балансировщик получил WebSocket Upgrade-запрос, но заголовки `Upgrade` и `Connection` не проксируются. Что произойдет?
Sticky Sessions и алгоритмы балансировки
Round-robin распределяет соединения по-очереди: клиент 1 - бэкенд A, клиент 2 - бэкенд B, клиент 3 - бэкенд A. Для stateless HTTP это идеально. Для WebSocket - проблема: если клиент переподключится, новый бэкенд не знает о его состоянии (комнаты, подписки, буфер сообщений).
**ip_hash** в nginx решает это просто: берет IP-адрес клиента, хеширует, делит на количество бэкендов - клиент всегда попадает на один и тот же сервер. Проблема: за NAT сидят тысячи клиентов с одним IP - все уходят на один бэкенд.
**least_conn** для WebSocket часто лучше ip_hash: новое соединение идет на бэкенд с наименьшей нагрузкой. Клиент, сделавший 100 переподключений, не гарантированно попадает на один сервер, но нагрузка распределяется честно. Используется совместно с внешним хранилищем состояния (Redis pub/sub) - тогда sticky sessions не обязательны.
- **ip_hash** - простой sticky, плохо работает за NAT/CDN
- **least_conn** - лучший выбор при наличии Redis для state sharing
- **round_robin** (default) - подходит только если бэкенды stateless
- **HAProxy `balance source`** - аналог ip_hash, поддерживает `hash-type consistent` (consistent hashing, меньше ремаппинга при изменении пула)
Чат-сервис хранит список онлайн-пользователей комнаты в памяти каждого Node.js-процесса. Балансировщик использует round-robin. Пользователь Alice переподключается и попадает на другой бэкенд. Что произойдет?
Health Checks и Connection Draining
Балансировщик должен знать, какие бэкенды живы. Для HTTP это просто: пинговать `/health` каждые 5 секунд, если ответ не 200 - вывести из пула. Для WebSocket есть нюанс: бэкенд может отвечать на HTTP-health-check, но перестать принимать новые WS-соединения из-за memory pressure или исчерпания file descriptors.
**Connection draining** (или graceful shutdown) - критичная практика при деплое. Когда бэкенд выводится из пула, нельзя просто убить процесс: у него могут быть тысячи активных WebSocket-соединений. Правильный сценарий: бэкенд сигнализирует, что не принимает новые соединения, ждет пока текущие закроются (или принудительно закрывает через timeout), затем завершается.
AWS ALB поддерживает WebSocket нативно и имеет `deregistration_delay` (по умолчанию 300 секунд) - именно столько ALB ждет, прежде чем убить бэкенд после его удаления из target group. Для WS-сервисов это значение стоит увеличить до типичного времени жизни соединения.
- Балансировщик получает сигнал вывести бэкенд (деплой / сбой)
- Health check начинает возвращать 503 - новые соединения не направляются
- Существующие WS-соединения продолжают работать
- По истечении draining timeout бэкенд закрывает соединения с кодом 1001
- Процесс завершается, клиенты переподключаются к другим бэкендам
При деплое новой версии сервера процесс убивается сигналом SIGKILL сразу. У 500 клиентов обрывается соединение без кода закрытия. Что произойдет на клиентской стороне?
Production-конфигурация: nginx + AWS ALB
В реальных деплоях WebSocket-сервисов комбинируют несколько уровней. AWS ALB терминирует TLS и направляет `/ws` на target group из EC2-инстансов или ECS-контейнеров. ALB поддерживает WebSocket нативно с 2016 года - нужно только убедиться, что listener правило пропускает Upgrade-заголовки.
Для on-premise или когда нужен контроль на уровне nginx: upstream с `least_conn` + `keepalive` (переиспользование TCP-соединений между nginx и бэкендами). `proxy_read_timeout` должен быть больше heartbeat-интервала клиента, иначе nginx закроет idle WS-соединение.
`proxy_buffering off` важно для WebSocket: nginx по умолчанию буферизирует ответы бэкенда в памяти. Для long-lived соединений это пустая трата RAM и задержки.
WebSocket нельзя балансировать через L7-балансировщик, нужен только L4 (TCP-уровень)
L7-балансировщики (nginx, HAProxy, AWS ALB) отлично работают с WebSocket при правильной настройке: нужно проксировать заголовки Upgrade/Connection и выставить адекватный proxy_read_timeout
WebSocket начинается как HTTP-запрос (Upgrade handshake), поэтому L7-балансировщик может его обработать. После upgrade соединение переходит в режим двунаправленного TCP, который L7 просто проксирует прозрачно. L4 проще конфигурировать, но теряет возможности L7: маршрутизацию по URL, TLS-терминацию, заголовки.
nginx проксирует WebSocket. Клиент отправляет ping каждые 30 секунд. `proxy_read_timeout` установлен в 60s. Что произойдет при 35-секундной паузе в трафике?
Итоги
- L4 балансирует TCP-соединения прозрачно, L7 читает HTTP и требует явного проксирования заголовков `Upgrade` и `Connection` для WebSocket
- ip_hash дает простую липкость по IP, least_conn лучше при наличии внешнего state store (Redis) - клиент может попасть на любой бэкенд
- Connection draining при деплое - обязательная практика: health check возвращает 503, бэкенд ждет закрытия соединений, только потом завершается
- `proxy_read_timeout` в nginx должен превышать heartbeat-интервал клиента, `proxy_buffering off` экономит RAM на long-lived соединениях
Связанные темы
Load balancing для WebSocket пересекается с несколькими фундаментальными темами масштабирования:
- WebSocket Horizontal Scaling — Sticky sessions нужны именно потому что горизонтальное масштабирование создает multiple isolated state stores
- Redis Pub/Sub для State Sharing — Альтернатива sticky sessions - вынести state в Redis, тогда балансировщик может слать клиента на любой бэкенд
- Consistent Hashing — HAProxy hash-type consistent использует consistent hashing чтобы при изменении пула бэкендов минимально менять маршруты
Вопросы для размышления
- Если сервис использует Redis для хранения состояния комнат, нужны ли sticky sessions? Какие компромиссы?
- Как организовать zero-downtime деплой WebSocket-сервиса, если типичная сессия пользователя длится 4 часа?
- Почему thundering herd при одновременном переподключении тысяч клиентов опасен и как его смягчить на уровне клиента?