Что учесть если используешь SignalR и захочешь масштабировать .Net-приложение?
Дисклеймер: я не претендую на открытие в данной статье, а лишь хочу помочь избежать ошибок при создании приложений.
Все мы любим «магию», которую дает нам такой инструмент как SignalR и с удовольствием внедряем это в проекты.
Конечно, кто откажется от динамики, мгновенного отклика на действия и мигающих иконочек с подписью «что же делает система в данный момент и не стоит ли перезагрузить страницу чтобы клацнуть ее еще раз?» ?
Однако и тут есть пара подводных камней, с которым я с командой столкнулся на продакшне.
Такс, в чем проблема-то?
Не могу раскрывать некоторых моментов, но вкратце скажу так: мы используем SignalR для ряда вещей на фронтенде, одна из которых — отслеживание статуса асинхронной задаче, которая запускается по кнопке.
В моменте мы выдаем пользователю статусы задачи, чтобы он чувствовал себя комфортно (и не бежал в поддержку).
Что могло пойти не так?
Да все окей, на самом деле: статусы отдаются, соединение по websocket держится, задача выполняется идеально…
| «Не знаю, локально все работает» © Фонд цитат разработчика.
Проблема начинается ровно в тот момент, когда в роль вступает несколько экземпляров приложения.
Есть идеи?
Дело в том, что когда у нас есть 3 экземпляра приложения A1, A2, A3, то каждый инстанс знает только про свои подключения.
И если при открытии connection запрос поступает в A1, то при следующих запросах он может попасть уже в абсолютно другой (A1, A2 или A3).
А это значит что?
Правильно, ошибка подключения в консоли и новое подключение, к другому инстансу.
И так до бесконечности…
Что там пишут в интернетах?
Конечно, я сразу же полез изучать проблему глубже и глобально предлагают 3 решения:
Не использовать SignalR
Использовать БД для хранения подключений
Использовать общую шину, которая будет связывать все экземпляры приложения (прикрутить Redis, например)
Сразу отметем вариант с неиспользованием SignalR и использование БД ждя хранений подключений (слишком дорого по обслуживанию и времени).
Остановимся на варианте 3 — общая шина с Redis, к тому же уже есть готовые библиотеки для этих целей.
Предлагают рассмотреть это все на примере простейшего приложения с чатом и прикрутим туда общую шину, чтобы приложение было масштабируемым.
Создадим базовое приложение с чатом в real-time и воспроизведем проблему:
Backend
Для этого создадим .Net Core web-приложение без контроллеров и назовем его Chat.api
Подключим библиотеку SignalR
Опишем класс с полями обычного сообщения
public class Message
{
public required string UserName { get; set; }
public required string Description { get; set; }
public required long Timestamp { get; set; }
}
Реализуем класс Hub с одним методом SendMessage
public class ChatHub: Hub
{
public async Task SendMessage(Message message)
{
await Clients.All.SendAsync("ReceiveMessage", message);
}
}
Frontend
При помощи cli создаем простейшее Vue3 приложение и удаляем все лишнее
Создаем компонент Chat.vue и там заносим всю логику (не уделяем вниманию красоте кода, ибо цель другая)
Устанавливаем библиотеку
npm i @microsoft/signalr
и в нужном месте кода создаем подключение:
new HubConnectionBuilder()
.withUrl(`http://localhost:4000/hubs/chat`,
{
headers: { "access-control-allow-origin" : "*"},
})
.configureLogging(LogLevel.Information)
.build();
И используем это подключение для взаимодействия с сервером.
Упаковываем приложение в Docker-контейнер и настраиваем Nginx для того, чтобы можно было наслаждаться работой N-экемпляров приложения.
P.S. Не вижу смысла тут подробно описывать все, для этого можно посмотреть репозитория по ссылке https://github.com/mushegovdima/chat
Запускаем
Vue3 приложение запускаем напрямую с консоли, а Chat.Api поднимаем
при помощи команды docker-compose up --build --scale chat.api=5
.
Итак, мы подняли 5 экземпляров приложения и поймали ошибку
Тут мы видим, что эти 2 клиента подцепились к разным экземплярам и ничего не знают друг о друге.
Решение
В качестве решения мы решили использовать Redis, который будет хранить в себе состояния подключений всего кластера.
Для этого подключим библиотеку:
И занесем настройки в Program.cs с указанием префикса приложения (это важно)
builder.Services
.AddSignalR()
.AddStackExchangeRedis("host.docker.internal:6379", o => {
o.Configuration.AllowAdmin = true;
o.Configuration.ChannelPrefix = "Chat.Api";
});
*можно вынести это в config-файл параметры подключения к Redis
В настройки nginx.conf вносим нужные параметры для корректного взаимодействия:
server {
listen 4000;
location / {
proxy_pass http://chat.api:3001;
proxy_intercept_errors on;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
При формировании подключения на фронте вносим некоторые правки:
new HubConnectionBuilder()
.withUrl(`http://localhost:4000/hubs/chat`,
{
headers: { "access-control-allow-origin" : "*"},
skipNegotiation: true, <--- new!
transport: HttpTransportType.WebSockets, <--- new!
})
.configureLogging(LogLevel.Information)
.build();
Запускаем систему повторно: docker-compose up --build --scale chat.api=5
И получаем результат, где все пользователи получают сообщения:
Лог из Redis (Monitor) при открытии новым пользователем приложения:
В итоге
Мы воспроизвели проблему и нашли оптимальное решение для нашего случая, однако это необязательно может быть идеальным решением в вашей ситуации.
Что стоит добавить:
Microsoft рекомендует держать Redis как можно «ближе» к приложениям, чтобы это избежать издержек при передачи данных (как мы видим, довольно большой объем взаимодействия с Redis между приложениями)
Старайтесь минимизировать объем передаваемых данных в теле сообщения через Redis
Стоит отдельное внимание уделить настройке nginx и оптимальному количеству экземпляров приложения
При большой нагрузке собрать Redis-кластер
Репозиторий — https://github.com/mushegovdima/chat
May be 4th with you.
Contacts
Social: @mushegovdima Email: mushegovdima@yandex.ru