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-сервисов это значение стоит увеличить до типичного времени жизни соединения.

  1. Балансировщик получает сигнал вывести бэкенд (деплой / сбой)
  2. Health check начинает возвращать 503 - новые соединения не направляются
  3. Существующие WS-соединения продолжают работать
  4. По истечении draining timeout бэкенд закрывает соединения с кодом 1001
  5. Процесс завершается, клиенты переподключаются к другим бэкендам

При деплое новой версии сервера процесс убивается сигналом 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 при одновременном переподключении тысяч клиентов опасен и как его смягчить на уровне клиента?

Связанные уроки

  • net-37-load-balancing
  • net-38-lb-layers
Load Balancing WebSocket

0

1

Войти