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

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

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

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

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

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


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

Что могло пойти не так?

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

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

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

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

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

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

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

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

  3. Использовать общую шину, которая будет связывать все экземпляры приложения (прикрутить Redis, например)

Сразу отметем вариант с неиспользованием SignalR и использование БД ждя хранений подключений (слишком дорого по обслуживанию и времени).
Остановимся на варианте 3 - общая шина с Redis, к тому же уже есть готовые библиотеки для этих целей.

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

Создадим базовое приложение с чатом в real-time и воспроизведем проблему:

Backend

Для этого создадим .Net Core web-приложение без контроллеров и назовем его Chat.api

Подключим библиотеку SignalR

<PackageReference Include="Microsoft.AspNet.SignalR.Core" Version="2.4.3" />

Опишем класс с полями обычного сообщения

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

Для этого подключим библиотеку:

<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="7.0.18" />

И занесем настройки в 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

Комментарии (7)


  1. mikegordan
    04.05.2024 13:32
    +1

    Самое загадочное в .net это масштабирование SignalR, какие лимиты, что будет если одинаковые хабы будут на разных машинах, нужно ли масштабировать хабы, а не группы внутри одного хаба, какие лимиты и как паралелить если допустим есть 1 супер большая группа. Как при этом работать с fallover допустим если образ SignalR развернут в docker compose в N сервисов с внутренней случайном балансировкой от докер композа.

    Ни разу нигде в интернатах не встречал такую статью. Хорошо бы чтобы ктото на хабаре такую сделал на конкретном примере.


    1. viordash
      04.05.2024 13:32

      Самое загадочное в .net это масштабирование SignalR, какие лимиты, что будет если одинаковые хабы будут на разных машинах, нужно ли масштабировать хабы, а не

      ...

      а это какой-то прикол? уже третий раз встречаю этот комментарий, прям слово в слово


      1. mushegovdima Автор
        04.05.2024 13:32

        интересно, может быть кто-то "ИИ" уже и в гитхаб подвез)


        1. Vitimbo
          04.05.2024 13:32
          +1

          Я даже нашел именно этот коммент https://habr.com/ru/articles/709002/comments/#comment_25072574

          Подозрительно, теперь, надо будет вместе с сообщением прикладывать капчу, чтобы убедиться в том, что у тебя мясной собеседник


          1. mushegovdima Автор
            04.05.2024 13:32

            Туда и катимся :D


  1. majorius
    04.05.2024 13:32

    А sticky sessions в nginx не пробовали?


    1. mushegovdima Автор
      04.05.2024 13:32

      Это решило бы вопрос, если на продакшне использовали nginx, но нет :)
      Поэтому тут разобрал более универсальный подход)