ASP.NET Core SignalR × Redis でHubを冗長化


# 初めに
ASP.NET Core SignalR の冗長化について調査する。

[参考]ASP.NET Core SignalR 概要
https://learn.microsoft.com/ja-jp/aspnet/core/signalr/introduction

# 課題
ネットワーク構成例は下図の通り。(HubServer = Hubの機能を有するサーバー)
HubServerが停止したらシステムは成り立たなくなる。HubServerを冗長化したい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/384230fe-59aa-26bd-6bb3-edb6e7e3c16d.png)

# 答え：SignalRのバックプレーンに Redis を使用する
調査したところ、SignalRのバックプレーンに Redis を使用することでHubServerを冗長化ができそうなことが分かった。

[参考]ASP.NET Core SignalR のホスティングとスケーリング
https://learn.microsoft.com/ja-jp/aspnet/core/signalr/scale

# 見直し後のネットワーク構成
見直し後のネットワークは下図の通りとなる。
HubServerを2台構成にし、バックプレーンにRedisを接続する。
HubServer - Redis間はRESP通信が行われるっぽい？
（なお、Client - HubServer間で処理分散化を行いたいのであればロードバランサー等を導入する必要があるが、ここでは割愛）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/620bb300-9dd6-a3dc-6f9b-989364fd3ae8.png)

この構成にすれば、どちらかのHubServerが停止してもHubは機能し続ける。らしい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/9f228dd0-613f-3803-31f5-463c30a08a84.png)



# 試してみる
ということでテスト環境を作って試してみる。

テスト環境におけるネットワーク構成は下図の通り。
HubServerのOSは最小インストールしたAlmaLinux 9.2を使用。
Clientは自PCのWindows 11を使用。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/09bed10b-0fee-220f-98c0-b007d5191918.png)

## テストコード
Clientは1秒毎に"Hello."と自分のプロセスIDをHubServerにメッセージ送信する。
HubServerは受信したメッセージをClientにオウム返しする。
だけの単純なコード。

### Client用テストコード
プロジェクトテンプレート：コンソール アプリ (.NET 6)
プロジェクト名：SignalRClientApplication
nuget追加：Microsoft.AspNetCore.SignalR.Client (6.0.22)
``` csharp:Program.cs
using Microsoft.AspNetCore.SignalR.Client;

namespace SignalRClientApplication
{
    internal class Program
    {
        static async Task Main(string[] args)
        {
            while (true)
            {
                try
                {
                    // 接続先URL入力
                    WriteLine("Input HubServer URL : ", ConsoleColor.Yellow);
                    var url = Console.ReadLine();
                    if (string.IsNullOrEmpty(url)) continue;

                    // 接続情報作成
                    WriteLine($"HubServer Connecting...");
                    var connection = new HubConnectionBuilder()
                        .WithUrl(url)
                        .Build();

                    // 接続開始
                    await connection.StartAsync();
                    WriteLine($"HubServer Connected!");

                    // Clientのメソッド登録
                    connection.On<string, string>("ClientMethod", (user, message) =>
                    {
                        WriteLine($"Recv ClientMethod : {message} from {user}{(user == Environment.ProcessId.ToString() ? "(my)" : "(other)")}", ConsoleColor.Blue);
                    });

                    // 接続中
                    while (true)
                    {
                        if (connection.State != HubConnectionState.Connected)
                        {
                            WriteLine($"HubServer DisConnected {url}");
                            break; // 再接続へ
                        }

                        // Hubのメソッド呼び出し
                        await connection.InvokeAsync("HubMethod", Environment.ProcessId.ToString(), "Hello.");

                        await Task.Delay(1000);
                    }
                }
                catch (Exception ex)
                {
                    WriteLine(ex.Message, ConsoleColor.Red);
                }
            };
        }

        // コンソール画面に色分け表示して表示
        private static readonly object lockobj = new();
        private static void WriteLine(string value, ConsoleColor ForegroundColor = ConsoleColor.Gray)
        {
            lock (lockobj)
            {
                Console.ForegroundColor = ForegroundColor;
                Console.WriteLine(value);
                Console.ResetColor();
            }
        }
    }
}
```

### HubServer用テストコード
プロジェクトテンプレート：ASP.NET Core Web アプリ (.NET 6)
プロジェクト名：SignalRHubServerWebApplication
nuget追加：Microsoft.AspNetCore.SignalR.StackExchangeRedis (6.0.22)
``` csharp:Program.cs
using StackExchange.Redis;

namespace SignalRHubServerWebApplication
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddRazorPages();
            builder.Services.AddSignalR().AddStackExchangeRedis(configure =>
            {
                configure.Configuration = new ConfigurationOptions
                {
                    ChannelPrefix = "MyApp",
                    EndPoints =
                    {
                        { "192.168.56.106", 6379 } // Redis
                    },
                    AbortOnConnectFail = false,
                };
            });

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            //app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapRazorPages();
            app.MapHub<HubServer>("/hub");

            app.Run("http://*:5000");
        }
    }
}
```
``` csharp:HubServer.cs
using Microsoft.AspNetCore.SignalR;

namespace SignalRHubServerWebApplication
{
    public class HubServer : Hub
    {
        public async Task HubMethod(string user, string message)
        {
            // Clientのメソッド呼び出し
            await Clients.All.SendAsync("ClientMethod", user, message);
        }
    }
}
```

## Redis構築
さらっとメモ書き程度。
``` shell
# テストには邪魔なファイアウォール停止
> sudo systemctl stop firewalld
> sudo systemctl disable firewalld

# Redisインストール
> sudo dnf install redis
全てy

# localhostのみ接続許可になっているので bind から始まる行を見つけて変更する
> sudo vi /etc/redis/redis.conf
# bind 127.0.0.1 -::1
bind * -::*

> sudo systemctl start redis
```

## HubServer構築
2台用意する。
``` shell
# テストには邪魔なファイアウォール停止
> sudo systemctl stop firewalld
> sudo systemctl disable firewalld

# .NET 6ランタイム
> sudo dnf install aspnetcore-runtime-6.0
全てy

# HostServer用テストコードをpublishしてリモートにコピー、実行
> dotnet SignalRHubServerWebApplication.dll
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /root/publish/
```

### Client構築
Clientは自PCを使う。

## テスト
下図の通り、ClientAをHubServerA、ClientBをHubServerBに接続する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/6ac37658-8555-541d-76fc-9574258698a8.png)

``` shell:Client A
> dotnet SignalRClientApplication.dll
Input HubServer URL [e.g. http://localhost:5005/hub] :
http://192.168.56.107:5000/hub
HubServer Connecting...
HubServer Connected!
```

``` shell:Client B
> dotnet SignalRClientApplication.dll
Input HubServer URL [e.g. http://localhost:5005/hub] :
http://192.168.56.108:5000/hub
HubServer Connecting...
HubServer Connected!
```

接続できた。

``` shell:Client A
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 23380(other)
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 23380(other)
:
```

``` shell:Client B
Recv ClientMethod : Hello. from 23380(my)
Recv ClientMethod : Hello. from 1816(other)
Recv ClientMethod : Hello. from 23380(my)
Recv ClientMethod : Hello. from 1816(other)
:
```

全クライアントに"Hello."が伝播しているのが分かる。

HubServer Bを停止してみる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/0ed1027c-51fa-4bff-a9d4-eb79453b6fc6.png)

Client BはHubServer Bの停止を検知して切断した。
``` shell:Client B
HubServer DisConnected http://192.168.56.108:5000/hub
Input HubServer URL :
```

Client Aは問題なく稼働しているが、Client Bからのメッセージは受信しなくなった。
``` shell:Client A
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 1816(my)
:
```

Client BをHubServer Aに接続する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/fee798dc-25a8-13ce-c409-408935154808.png)

Client Aは再びClient Bからのメッセージを受信する。

``` shell:Client A
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 23380(other)
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 23380(other)
:
```

Client BもClient Aからのメッセージを受信する。
``` shell:Client B
Input HubServer URL :
http://192.168.56.107:5000/hub
HubServer Connecting...
HubServer Connected!
Recv ClientMethod : Hello. from 23380(my)
Recv ClientMethod : Hello. from 1816(other)
:
```

HubServer Bを復活させ、Client CをHubServer Bに接続する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/27fd9a12-b1c4-374a-6a40-2826bd84c919.png)

Client AはClient Cからのメッセージも受信する。
``` shell:Client A
Recv ClientMethod : Hello. from 1816(my)
Recv ClientMethod : Hello. from 23380(other)
Recv ClientMethod : Hello. from 24432(other)
:
```
Client BはClient Cからのメッセージも受信する。
``` shell:Client B
Recv ClientMethod : Hello. from 23380(my)
Recv ClientMethod : Hello. from 24432(other)
Recv ClientMethod : Hello. from 1816(other)
:
```

Client CはClient A/Bからのメッセージを受信する。
``` shell:Client C
Input HubServer URL :
http://192.168.56.108:5000/hub
HubServer Connecting...
HubServer Connected!
Recv ClientMethod : Hello. from 24432(my)
Recv ClientMethod : Hello. from 1816(other)
Recv ClientMethod : Hello. from 23380(other)
:
```

HubServerの冗長化が上手く動作していることが確認できた。

# Redisの冗長化
HubServerの冗長化は出来たが、Redisの冗長化は出来ていないのでRedisが停止したらシステムは停止してしまう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76153/b004f94c-0b21-efef-6100-240a6428b46d.png)

が、しかし本記事ではRedisの冗長化については扱わない。
Google検索すれば有益な情報が見つかるので…。

Redisの冗長化
https://www.sraoss.co.jp/tech-blog/redis/redis-ha/

Redisを冗長化構成にする（フェールオーバー環境を構築するーその２）
https://qiita.com/KurosawaTsuyoshi/items/936c32bb8947fb907ef5

AWSを使用しているならば、AWS ElastiCache for RedisやAmazon MemoryDB for Redis
Azureを使用しているならば、Azure SignalR Service
を使うほうが簡単に冗長化できる。



以上。