WebSocket в FastAPI: масштабирование и разрывы соединений
При переходе от одного инстанса FastAPI к нескольким WebSocket-соединения начинают теряться, broadcast ломается, а клиенты молча отваливаются. Разбираем, почему это происходит, как устроена маршрутизация соединений и что делать с разрывами на каждом уровне — от балансировщика до кода приложения.

Один инстанс — всё просто, два — всё сломалось
Типичная ситуация: чат или дашборд на FastAPI работает на одном процессе, WebSocket-соединения живут в словаре в памяти, broadcast отправляет сообщения всем подключённым клиентам. Потом приложение выкатывают за Nginx с двумя воркерами Uvicorn — и половина пользователей перестаёт получать сообщения. Причина элементарна: каждый процесс держит свой набор соединений, и broadcast одного процесса не знает о клиентах другого. Но это только начало проблем. Под нагрузкой к ним добавляются молчаливые разрывы, zombie-соединения и потеря сообщений при переподключении.
Почему WebSocket и горизонтальное масштабирование конфликтуют
HTTP-запрос stateless: любой инстанс может его обработать. WebSocket — stateful: после handshake клиент привязан к конкретному процессу TCP-соединением. Это создаёт три проблемы одновременно.
- Сегментация аудитории. Каждый воркер видит только «своих» клиентов. Если пользователь A подключён к процессу 1, а пользователь B — к процессу 2, отправить сообщение от A к B через общий словарь в памяти невозможно.
- Потеря при деплое. При rolling update процесс останавливается — все его WebSocket-соединения рвутся. Клиенты переподключаются, но сообщения, отправленные в момент разрыва, теряются.
- Sticky sessions — не решение. Балансировщик может привязать клиента к конкретному бэкенду по cookie или IP, но это не помогает с broadcast и усложняет автоскейлинг: нагрузка распределяется неравномерно.
Pub/Sub как общая шина между процессами
Стандартный выход — вынести координацию соединений из памяти процесса во внешний брокер. Каждый инстанс подписывается на канал, публикует туда сообщения, и все инстансы ретранслируют их своим клиентам.
Redis Pub/Sub
Самый распространённый вариант для небольших и средних проектов. Redis уже часто есть в инфраструктуре, а pub/sub в нём работает без дополнительной настройки. Минус: если подписчик не успевает читать — сообщения теряются, буферизации нет. Для чата это терпимо, для финансовых данных — нет.
Redis Streams
В отличие от pub/sub, сообщения сохраняются в логе. Потребительские группы (consumer groups) позволяют нескольким инстансам читать поток, а при переподключении — дочитать пропущенное. Сложнее в реализации, но закрывает проблему потери сообщений при деплое.
Kafka, NATS, RabbitMQ
Для проектов, где WebSocket — лишь один из каналов доставки, а брокер уже используется для очередей задач или событийной архитектуры. Добавлять Kafka ради чата на 500 пользователей — оверинжиниринг; но если Kafka уже стоит, логичнее подключиться к ней, чем городить параллельный Redis.
| Брокер | Буферизация | Дочитывание после разрыва | Сложность внедрения | Когда уместен |
|---|---|---|---|---|
| Redis Pub/Sub | Нет | Нет | Минимальная | Чаты, уведомления без гарантий |
| Redis Streams | Да | Да (consumer groups) | Средняя | Нужна гарантия доставки |
| NATS JetStream | Да | Да | Средняя | Микросервисы, низкая латентность |
| Kafka | Да | Да (offset) | Высокая | Уже в инфраструктуре, большие объёмы |
Пример: broadcast через Redis Pub/Sub
Ниже — минимальный рабочий пример для FastAPI 0.110+ и Uvicorn. Используется redis.asyncio (пакет redis версии 5.x). Каждый инстанс при старте подписывается на канал ws:broadcast, а при получении сообщения от клиента публикует его туда же. Все инстансы получают сообщение и рассылают своим клиентам.
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import redis.asyncio as aioredis
REDIS_URL = "redis://localhost:6379"
CHANNEL = "ws:broadcast"
# Локальное хранилище соединений этого процесса
connected: set[WebSocket] = set()
async def redis_listener(pubsub: aioredis.client.PubSub):
"""Фоновая задача: читает сообщения из Redis и рассылает локальным клиентам."""
async for message in pubsub.listen():
if message["type"] != "message":
continue
payload = message["data"]
# Рассылаем всем соединениям этого воркера
for ws in list(connected):
try:
await ws.send_text(payload.decode() if isinstance(payload, bytes) else payload)
except Exception:
connected.discard(ws)
@asynccontextmanager
async def lifespan(app: FastAPI):
r = aioredis.from_url(REDIS_URL)
pubsub = r.pubsub()
await pubsub.subscribe(CHANNEL)
task = asyncio.create_task(redis_listener(pubsub))
app.state.redis = r
yield
task.cancel()
await pubsub.unsubscribe(CHANNEL)
await pubsub.close()
await r.close()
app = FastAPI(lifespan=lifespan)
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
connected.add(ws)
try:
while True:
data = await ws.receive_text()
# Публикуем в Redis — все инстансы получат
await app.state.redis.publish(CHANNEL, data)
except WebSocketDisconnect:
connected.discard(ws)
Запуск двух воркеров для проверки:
uvicorn app:app --workers 2 --host 0.0.0.0 --port 8000
Проверка: откройте два WebSocket-клиента (например, через websocat или вкладку в браузере). Если оба получают сообщения друг друга — broadcast работает через оба воркера.
Pub/Sub решает broadcast между процессами, но не решает проблему потери сообщений при разрыве. Если клиент отключился на две секунды, он не получит то, что было отправлено за это время. Для гарантированной доставки нужен другой механизм — очередь на клиента или Redis Streams с курсором.
Молчаливые разрывы: почему клиент «висит» без ошибки
WebSocket поверх TCP не гарантирует, что разрыв будет замечен мгновенно. Если сетевой кабель выдернут, мобильный клиент ушёл в туннель или промежуточный прокси закрыл idle-соединение — приложение может не узнать об этом минуты. Результат: сервер пытается отправить данные в мёртвый сокет, копит буфер, тратит память.
Ping/Pong на уровне протокола
WebSocket-протокол предусматривает контрольные фреймы ping и pong. Uvicorn и Starlette поддерживают их, но по умолчанию не отправляют ping с серверной стороны. Клиент (браузер) тоже не шлёт ping автоматически. Если ни одна сторона не пингует — zombie-соединение живёт, пока TCP-стек не сбросит его по таймауту ОС (на Linux по умолчанию это может занять десятки минут).
Решение: реализовать application-level heartbeat. Сервер периодически отправляет ping-фрейм или текстовое сообщение-маркер. Если ответ (pong или текстовый ack) не пришёл за N секунд — сервер закрывает соединение и убирает его из пула.
Nginx и proxy_read_timeout
Если FastAPI стоит за Nginx, директива proxy_read_timeout (по умолчанию 60 секунд) закроет WebSocket, по которому не было трафика дольше минуты. Это самая частая причина «внезапных» разрывов на проде. Увеличьте значение или — лучше — настройте heartbeat с интервалом короче таймаута.
Ключевые директивы в конфигурации Nginx для проксирования WebSocket (точный синтаксис уточняйте в документации вашей версии Nginx):
proxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";proxy_read_timeout 300s;— или другое значение, превышающее интервал heartbeat
Облачные балансировщики
AWS ALB по умолчанию закрывает idle WebSocket через 60 секунд. GCP — через 30 секунд. Эти значения настраиваются, но о них часто забывают, и на стейджинге всё работает (один инстанс, прямое подключение), а на проде — нет.
Переподключение на клиенте: не надейтесь на браузер
Браузерный WebSocket API не переподключается автоматически. Если соединение разорвалось — сработает onclose, и на этом всё. Клиентский код должен:
- Отловить событие закрытия.
- Подождать с экспоненциальной задержкой (1 с, 2 с, 4 с, …, до потолка в 30–60 с).
- Переподключиться.
- После восстановления запросить пропущенные сообщения (если сервер их хранит) или запросить полное состояние.
Библиотеки вроде reconnecting-websocket для JS делают первые три пункта из коробки, но четвёртый — логика приложения, и его нужно реализовать самостоятельно.
Сноска: Zombie-соединение (в контексте WebSocket) — соединение, которое на сервере числится открытым, но фактически данные по нему не доходят до клиента. Возникает при потере связи без корректного закрытия TCP-сессии.
Где подход ломается
- Тысячи соединений на процесс. Python (даже asyncio) на одном ядре обрабатывает порядка 5 000–15 000 одновременных WebSocket-соединений — дальше event loop начинает отставать. Точная граница зависит от объёма сообщений и логики обработки; замеряйте под свою нагрузку.
- Redis Pub/Sub под шквалом сообщений. Если каждый клиент отправляет по 10 сообщений в секунду и их 10 000 — это 100 000 msg/s через Redis. Pub/Sub справится, но каждый подписчик получает каждое сообщение. При 8 инстансах это 800 000 доставок в секунду. Фильтрация по каналам (один канал на комнату/топик) снижает нагрузку на порядки.
- Graceful shutdown. FastAPI через Uvicorn при получении SIGTERM закрывает HTTP-соединения, но WebSocket-соединения обрываются жёстко, если обработчик не ловит
asyncio.CancelledError. Без аккуратного shutdown клиенты не получат close frame и не поймут, что нужно переподключиться — вместо этого будут ждать ответа до таймаута.
Что делать дальше
Если вы только добавляете WebSocket в FastAPI-проект — начните с Redis Pub/Sub и application-level heartbeat. Этого хватит для большинства задач с сотнями одновременных соединений. Далее по приоритету:
- Настройте таймауты на каждом уровне: Nginx (или облачный балансировщик), Uvicorn, клиент. Убедитесь, что heartbeat-интервал короче минимального таймаута в цепочке.
- Добавьте мониторинг: количество активных соединений на процесс, задержка доставки сообщений, частота переподключений. Prometheus + Grafana или хотя бы логирование этих метрик.
- Когда pub/sub перестанет справляться (потеря сообщений при деплое, нужны гарантии) — переходите на Redis Streams или внешний брокер.
- Тема аутентификации и авторизации WebSocket-соединений — отдельный разговор; в документации FastAPI есть раздел про зависимости в WebSocket-эндпоинтах, начните оттуда.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: FastAPI — WebSockets (официальная документация), Starlette — WebSocket support (документация Starlette)
Частые вопросы
Можно ли масштабировать WebSocket в FastAPI без Redis или другого брокера?
Почему соединения рвутся через 60 секунд при работе через Nginx?
Что будет с WebSocket-соединениями при деплое новой версии?
Сколько одновременных WebSocket-соединений выдерживает один процесс FastAPI?
Как понять, что соединение «умерло», если клиент не отправил close frame?
Что если у меня другая версия FastAPI или Uvicorn — примеры будут работать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.