Что учесть если используешь SignalR и захочешь масштабировать .Net-приложение?

Дисклеймер: я не претендую на открытие в данной статье, а лишь хочу помочь избежать ошибок при создании приложений.

Все мы любим «магию», которую дает нам такой инструмент как SignalR и с удовольствием внедряем это в проекты.

Конечно, кто откажется от динамики, мгновенного отклика на действия и мигающих иконочек с подписью «что же делает система в данный момент и не стоит ли перезагрузить страницу чтобы клацнуть ее еще раз?» ?

Однако и тут есть пара подводных камней, с которым я с командой столкнулся на продакшне.

Такс, в чем проблема-то?

Не могу раскрывать некоторых моментов, но вкратце скажу так: мы используем SignalR для ряда вещей на фронтенде, одна из которых — отслеживание статуса асинхронной задаче, которая запускается по кнопке.


В моменте мы выдаем пользователю статусы задачи, чтобы он чувствовал себя комфортно (и не бежал в поддержку).

Что могло пойти не так?
Да все окей, на самом деле: статусы отдаются, соединение по websocket держится, задача выполняется идеально…
| «Не знаю, локально все работает» © Фонд цитат разработчика.

Проблема начинается ровно в тот момент, когда в роль вступает несколько экземпляров приложения.
Есть идеи?

Дело в том, что когда у нас есть 3 экземпляра приложения A1, A2, A3, то каждый инстанс знает только про свои подключения.
И если при открытии connection запрос поступает в A1, то при следующих запросах он может попасть уже в абсолютно другой (A1, A2 или A3).

29f12e3ae4157022423c787f9c8237f4.png

А это значит что?
Правильно, ошибка подключения в консоли и новое подключение, к другому инстансу.
И так до бесконечности…

Что там пишут в интернетах?

Конечно, я сразу же полез изучать проблему глубже и глобально предлагают 3 решения:

  1. Не использовать SignalR

  2. Использовать БД для хранения подключений

  3. Использовать общую шину, которая будет связывать все экземпляры приложения (прикрутить 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

  1. При помощи cli создаем простейшее Vue3 приложение и удаляем все лишнее

  2. Создаем компонент Chat.vue и там заносим всю логику (не уделяем вниманию красоте кода, ибо цель другая)

  3. Устанавливаем библиотеку 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 экземпляров приложения и поймали ошибку

Итак, мы подняли 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
И получаем результат, где все пользователи получают сообщения:

f110c70dc8c8df7850488344b1da77a9.png

Лог из Redis (Monitor) при открытии новым пользователем приложения:

5b141dcca7be94c65c97abe2edab4e3e.png

В итоге

Мы воспроизвели проблему и нашли оптимальное решение для нашего случая, однако это необязательно может быть идеальным решением в вашей ситуации.

Что стоит добавить:

  • Microsoft рекомендует держать Redis как можно «ближе» к приложениям, чтобы это избежать издержек при передачи данных (как мы видим, довольно большой объем взаимодействия с Redis между приложениями)

  • Старайтесь минимизировать объем передаваемых данных в теле сообщения через Redis

  • Стоит отдельное внимание уделить настройке nginx и оптимальному количеству экземпляров приложения

  • При большой нагрузке собрать Redis-кластер

Репозиторий — https://github.com/mushegovdima/chat
May be 4th with you.

Contacts

Social: @mushegovdima Email:  mushegovdima@yandex.ru

© Habrahabr.ru