RE:NODE

Приложения11 мин чтения

Хостинг SignalR: транспорты, прокси и масштабирование

ASP.NET Core SignalR в продакшене: транспорты и negotiate, WebSockets через прокси, таймауты, токены авторизации и backplane на Valkey для масштабирования.

0 прочтений

SignalR - часть ASP.NET Core, которая держит соединение между сервером и браузером открытым, чтобы сервер мог отправлять сообщения без запроса. Захостить его на одном сервере легко, а интересно становится, как только что-то оказывается посередине. Одному экземпляру за прокси нужны три вещи: прокси должен пропускать WebSocket upgrade, его таймаут простоя должен быть длиннее 15-секундного keep-alive SignalR, а аутентификация должна работать без собственных заголовков. Двум и более экземплярам нужны ещё две: sticky sessions или только WebSockets с пропуском negotiate, а также backplane вроде Valkey, чтобы сообщение, отправленное на одном сервере, доходило до клиентов на другом. Эта статья разбирает каждое из этого вместе со значениями по умолчанию, которые решают, когда соединения обрываются.

Что SignalR на самом деле делает в сети#

Соединение SignalR начинается как обычный HTTP-запрос. Клиент отправляет POST на /<hub>/negotiate, а сервер отвечает токеном соединения и списком поддерживаемых транспортов. Затем клиент выбирает лучший из тех, что принимают обе стороны:

ТранспортКак работаетПримечания
WebSocketsОдно полнодуплексное TCP-соединение после HTTP upgradeПредпочтительный. Минимальные накладные расходы и задержка
Server-Sent EventsДолгоживущий HTTP-ответ от сервера к клиенту, отдельные POST от клиента к серверуЗапасной вариант, когда WebSockets заблокированы
Long PollingПовторяющиеся запросы, удерживаемые открытыми, пока не появятся данныеКрайняя мера. Работает почти везде, стоит дороже всего

Поверх транспорта работает протокол хаба - по умолчанию JSON или MessagePack, если добавить Microsoft.AspNetCore.SignalR.Protocols.MessagePack на сервере и соответствующий клиентский пакет. Сообщения MessagePack меньше и дешевле в разборе; JSON проще читать во вкладке сети браузера.

Минимальный сервер - это две строки плюс класс хаба:

csharp
builder.Services.AddSignalR();var app = builder.Build();app.MapHub<ChatHub>("/hubs/chat");public class ChatHub : Hub{    public Task Send(string room, string text) =>        Clients.Group(room).SendAsync("message", Context.UserIdentifier, text);    public Task Join(string room) =>        Groups.AddToGroupAsync(Context.ConnectionId, room);}

И сторона браузера с пакетом @microsoft/signalr:

javascript
import * as signalR from "@microsoft/signalr";const connection = new signalR.HubConnectionBuilder()  .withUrl("/hubs/chat")  .withAutomaticReconnect()  .build();connection.on("message", (user, text) => render(user, text));await connection.start();await connection.invoke("Join", "general");

withAutomaticReconnect() без аргументов повторяет попытки через 0, 2, 10 и 30 секунд, после чего сдаётся и вызывает onclose. Группы после переподключения не восстанавливаются, потому что у переподключившегося клиента новый ID соединения; заново вступайте в них в обработчике onreconnected. Одна эта деталь объясняет большинство жалоб вида «сообщения перестали приходить после того, как моргнул Wi-Fi».

Keep-alive и таймауты#

Соединение SignalR, по которому не идут сообщения, поддерживается пингами. Значения по умолчанию на обоих концах подогнаны друг под друга:

НастройкаСторонаПо умолчаниюЧто означает
KeepAliveIntervalСервер15 секундКак часто сервер пингует простаивающего клиента
ClientTimeoutIntervalСервер30 секундСервер отключает клиента, от которого ничего не было столько времени
HandshakeTimeoutСервер15 секундВремя, отведённое на начальное рукопожатие
keepAliveIntervalInMillisecondsJS-клиент15,000Как часто клиент пингует сервер
serverTimeoutInMillisecondsJS-клиент30,000Клиент сдаётся, если сервер молчит столько времени

Правило такое: таймаут каждой стороны должен быть примерно вдвое больше интервала keep-alive другой стороны. Если поднимаете KeepAliveInterval на сервере, поднимите соответственно и serverTimeoutInMilliseconds на клиенте, иначе клиенты будут отключаться сами без причины.

csharp
builder.Services.AddSignalR(options =>{    options.KeepAliveInterval = TimeSpan.FromSeconds(15);    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);    options.MaximumReceiveMessageSize = 64 * 1024;});

MaximumReceiveMessageSize по умолчанию равен 32 KB на входящее сообщение. Клиенты, отправляющие данные побольше, отключаются с ошибкой, которую легко принять за сетевую проблему. Поднимите его, если иначе нельзя, но большим загрузкам место на обычном HTTP-эндпоинте, а не в методе хаба. MaximumParallelInvocationsPerClient по умолчанию равен 1, то есть вызовы хаба от одного клиента обрабатываются по одному; это разумное значение, потому что не даёт одному клиенту монополизировать сервер, и частая неожиданность, когда долгий метод хаба заставляет ждать следующий вызов от того же клиента.

У прокси есть собственный таймаут. proxy_read_timeout в nginx по умолчанию равен 60 секундам, что с запасом длиннее 15-секундного keep-alive, так что приложение SignalR с настройками по умолчанию переживает nginx с настройками по умолчанию. Проблемы появляются, когда кто-то поднимает интервал keep-alive, чтобы «уменьшить трафик», выше таймаута простоя прокси, или когда балансировщик где-то на пути закрывает простаивающие соединения через 30 секунд. Держите keep-alive заметно ниже самого короткого таймаута простоя на пути.

WebSockets через обратный прокси#

WebSocket начинается как HTTP-запрос с заголовками Upgrade: websocket и Connection: Upgrade. Прокси, который их не пропускает или говорит с бэкендом по HTTP/1.0, превращает каждое соединение в неудачный upgrade. SignalR после этого тихо откатывается на Server-Sent Events или long polling, приложение продолжает работать, и никто не замечает, что каждый клиент теперь держит открытыми лишние запросы, а сервер делает в несколько раз больше работы. Проверьте вкладку сети в браузере: когда WebSockets работают, за запросом negotiate следует запрос к URL хаба со статусом 101 Switching Protocols.

Для nginx это четыре строки:

nginx
location /hubs/ {    proxy_pass http://127.0.0.1:5000;    proxy_http_version 1.1;    proxy_set_header Upgrade $http_upgrade;    proxy_set_header Connection "upgrade";    proxy_set_header Host $host;    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;    proxy_set_header X-Forwarded-Proto $scheme;    proxy_read_timeout 120s;}

WebSockets за обратным прокси разбирает блок map, который обрабатывает и upgrade, и обычные запросы в одном location, а также ошибки, которые вы видите, когда он настроен неправильно. На стороне приложения включите forwarded headers, чтобы приложение знало исходную схему и адрес клиента; точные ForwardedHeadersOptions есть в статье ASP.NET Core за обратным прокси.

В RE:NODE тарифы C# / .NET включают слот прокси: направьте A-запись на показанный адрес, и сертификат будет выпущен и продлеваться автоматически, а адрес клиента придёт в X-Forwarded-For. После деплоя откройте вкладку сети и убедитесь, что там 101, прежде чем верить, что ваши пользователи получают именно WebSockets. Если вы предпочитаете подключаться к приложению напрямую, выделенный тарифу порт доступен напрямую, а дополнительные порты можно добавить на вкладке Network - в этом случае соединение терминирует сам Kestrel.

Клиентам с другого origin нужен CORS с credentials, потому что SignalR отправляет cookies при negotiate:

csharp
builder.Services.AddCors(o => o.AddPolicy("app", p => p    .WithOrigins("https://app.example.com")    .AllowAnyHeader()    .AllowAnyMethod()    .AllowCredentials()));

AllowCredentials() нельзя сочетать с AllowAnyOrigin(); перечислите origins явно.

Аутентификация на постоянном соединении#

Аутентификация через cookie работает с SignalR без всякой особой обработки: браузер отправляет cookie при запросе negotiate и при WebSocket upgrade. С bearer-токенами сложнее, потому что браузерный WebSocket API не умеет выставлять заголовок Authorization. JavaScript-клиент обходит это, передавая токен в строке запроса как access_token для WebSockets и Server-Sent Events, а серверу нужно явно сказать читать его оттуда:

csharp
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)    .AddJwtBearer(options =>    {        // Authority, Audience or TokenValidationParameters as usual ...        options.Events = new JwtBearerEvents        {            OnMessageReceived = context =>            {                var token = context.Request.Query["access_token"];                if (!string.IsNullOrEmpty(token) &&                    context.HttpContext.Request.Path.StartsWithSegments("/hubs"))                {                    context.Token = token;                }                return Task.CompletedTask;            }        };    });

На клиенте - .withUrl("/hubs/chat", { accessTokenFactory: () => getToken() }). Фабрика вызывается при каждом подключении и переподключении, так что возвращайте свежий токен, а не захваченную строку.

Из этого следуют два вывода. Строки запроса попадают в журналы доступа, поэтому убедитесь, что ни ваш прокси, ни логирование запросов не записывают полный URL для путей хабов. И токен проверяется при установке соединения, а не на каждом сообщении; соединение, аутентифицированное токеном, который истекает через час, остаётся аутентифицированным, пока не отключится. Если нужно, чтобы отзыв действительно срабатывал, закрывайте соединения с сервера, когда пользователь выходит, или задайте максимальное время жизни соединения, отслеживая соединения и обрывая их через Context.Abort().

Clients.User(userId) отправляет сообщение на каждое соединение, открытое пользователем, - несколько вкладок, телефон и ноутбук. ID пользователя берётся из IUserIdProvider, который по умолчанию читает claim ClaimTypes.NameIdentifier. Если ваши токены кладут ID в sub, а сопоставление claims выключено, Context.UserIdentifier равен null, и сообщения, адресованные пользователю, уходят в никуда; зарегистрируйте собственный провайдер, который читает тот claim, что вы на самом деле выдаёте.

Отправка извне хаба#

Большинство настоящих сообщений рождаются не в методе хаба. Заказ оплачивается в обработчике webhook, завершается фоновая задача, меняется цена. Внедрите IHubContext<THub> в любом месте приложения:

csharp
public class OrderNotifier(IHubContext<ChatHub> hub){    public Task OrderPaid(string userId, int orderId) =>        hub.Clients.User(userId).SendAsync("orderPaid", orderId);}

Экземпляры хаба transient: на каждый вызов метода создаётся новый и затем уничтожается, так что состояние в поле хаба теряется сразу. Храните состояние соединения с ключом Context.ConnectionId в singleton-сервисе или в Context.Items, который живёт столько же, сколько соединение. Долгую работу, запущенную из хаба, стоит передавать фоновому сервису, а не ожидать внутри метода хаба - из-за упомянутого выше ограничения в один вызов за раз. Паттерн с очередью описан в статье Worker services и фоновые задачи в .NET.

Память и расчёт на одно соединение#

Каждое открытое соединение держит буферы, контекст соединения и всё, что к нему прикрепляет ваше приложение, - членство в группах, состояние пользователя, кэшированные данные. Базовые затраты на простаивающее WebSocket-соединение невелики, но они растут с размером сообщений, с числом групп и прежде всего с состоянием приложения, которое вы храните на соединение. Единственная надёжная цифра - ваша собственная: откройте известное число тестовых соединений скриптом на .NET-клиенте (Microsoft.AspNetCore.SignalR.Client) или инструментом нагрузочного тестирования, который умеет WebSockets, и сравните график памяти до и после.

Практические советы для небольшого сервера:

  • Следите за запасными транспортами. Тысяча клиентов на long polling стоит гораздо дороже тысячи клиентов на WebSocket, потому что каждый опрос - это полный цикл запроса. Если WebSockets не проходят через ваш прокси, исправить это ценнее любого железа.
  • Ограничьте upgraded-соединения. MaxConcurrentUpgradedConnections в Kestrel по умолчанию не ограничен. Лимит превращает неожиданный наплыв в отказы в соединении, а не в остановку по нехватке памяти. В RE:NODE сервер, достигший лимита памяти, останавливается и перезапускается начисто, что обрывает все соединения разом и вызывает шторм переподключений; оставляйте запас.
  • Разносите переподключения во времени. После перезапуска каждый клиент переподключается в течение нескольких секунд. Задержки повторов по умолчанию уже немного их разносят; собственный IRetryPolicy с jitter разносит сильнее.
  • Blazor Server - это SignalR. Каждый пользователь Blazor Server - это соединение SignalR с circuit, который держит состояние UI в памяти сервера, а это тяжелее типичного соединения с хабом. Расчёт для этого случая разобран в статье Blazor Server или WebAssembly: хостинг.

Масштабирование с backplane на Valkey#

При двух и более экземплярах приложения клиент, подключённый к экземпляру A, не получит сообщение, отправленное через IHubContext на экземпляре B, потому что каждый экземпляр знает только свои соединения. Backplane решает это, публикуя каждое сообщение в общий канал pub/sub, на который подписаны все экземпляры.

bash
$ dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
csharp
builder.Services.AddSignalR()    .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Valkey")!, options =>    {        options.Configuration.ChannelPrefix = RedisChannel.Literal("myapp");    });

Строка подключения - это строка конфигурации StackExchange.Redis, например valkey.example.net:6380,password=...,abortConnect=false. Valkey говорит на протоколе Redis, так что пакет backplane для Redis работает с ним без изменений. Задайте ChannelPrefix, когда один сервер Valkey делят несколько приложений, иначе их сообщения перемешаются.

IHubContextpublishsubscribeОбработчик webhookна экземпляре AЭкземпляр Aхаб SignalRValkeybackplane pub/subЭкземпляр Bхаб SignalRБраузерыWebSockets
Сообщение, отправленное на одном экземпляре, доходит до клиентов на всех

Знайте, чего backplane не делает:

  • Он не отменяет необходимость в sticky sessions. Запрос negotiate и последующее соединение должны попасть на один и тот же экземпляр. Либо настройте балансировщик на привязку сессий, либо пусть каждый клиент использует только WebSockets с skipNegotiation: true и transport: signalR.HttpTransportType.WebSockets, что убирает отдельный шаг negotiate. Второй вариант лишает запасных транспортов клиентов в сетях, которые блокируют WebSockets.
  • Он не хранит сообщения. Если Valkey недоступен, сообщения, опубликованные во время сбоя, теряются, и SignalR их не воспроизводит. Сообщениям, которые обязаны дойти, место в базе данных или очереди, а SignalR служит лишь уведомлением о том, что появилось что-то новое.
  • Каждое сообщение уходит на каждый экземпляр. Пропускная способность backplane - потолок скорости сообщений всего кластера. Для большинства приложений этот потолок далеко; для нагрузки с очень большим fan-out именно его и нужно измерять.

Модель pub/sub в Valkey и её отличия от streams описаны в статье Pub/sub и streams в Valkey, а одного небольшого сервера Valkey для backplane более чем достаточно - он не хранит данные, только сообщения в пути.

Решение проблем#

`Error: Failed to start the transport 'WebSockets'`, а затем откат. Upgrade не доходит до приложения. Проверьте заголовки Upgrade и Connection на прокси и HTTP/1.1 к бэкенду.

`No Connection with that ID` после масштабирования до двух экземпляров. Negotiate и подключение попали на разные экземпляры. Включите sticky sessions или пропускайте negotiate, используя только WebSockets.

Соединения обрываются ровно каждые 60 или 100 секунд. Таймаут простоя где-то на пути короче промежутка между сообщениями, а keep-alive отключён или слишком редкий. Верните 15-секундный keep-alive по умолчанию.

`Server timeout elapsed without receiving a message from the server.` serverTimeoutInMilliseconds у клиента короче удвоенного keep-alive сервера, или сервер слишком занят, чтобы отправлять пинги - проверьте CPU.

401 на WebSocket, хотя negotiate работает. Для сокета токен передаётся в строке запроса, а сервер его оттуда не читает. Добавьте обработчик OnMessageReceived.

После смены сети сообщения приходят в одну вкладку, но не в другую. Переподключившееся соединение потеряло свои группы. Вступайте в них заново в onreconnected.

FAQ#

Нужен ли backplane для одного сервера?

Нет. Один экземпляр знает все соединения, так что Clients.All и Clients.Group и так доходят до всех. Backplane нужен только для двух и более экземпляров и добавляет зависимость, которой иначе у вас бы не было.

Можно ли использовать Valkey вместо Redis для backplane SignalR?

Да. Пакет backplane говорит на протоколе Redis через StackExchange.Redis, а Valkey этот протокол реализует. Направьте строку подключения на сервер Valkey и задайте префикс канала, если им пользуется что-то ещё.

Сколько соединений SignalR выдержит один небольшой сервер?

Тысячи простаивающих WebSocket-соединений умещаются в скромный объём памяти; ограничивает вас состояние, которое приложение хранит на соединение, и скорость сообщений. Измерьте тестовым клиентом на собственном хабе, а не доверяйте опубликованным цифрам.

Может, лучше использовать Azure SignalR Service?

Он снимает обработку соединений с ваших серверов и является самым простым путём к очень большому числу соединений, но это внешняя зависимость со своей ценой, и ваши сообщения покидают вашу инфраструктуру. Для большинства приложений на нескольких экземплярах достаточно собственного backplane.

Почему в продакшене SignalR использует long polling, хотя локально работал на WebSockets?

Что-то между браузером и приложением не пропускает WebSocket upgrade - обычно обратный прокси, иногда корпоративная сеть. Приложение продолжает работать, поэтому это и остаётся незамеченным. Проверьте, есть ли ответ 101 во вкладке сети.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000