tech.guitarrapc.cóm

Technical updates

StackExchange.RedisでRedisフェイルオーバー時に追随する

C#のRedisクライアントライブラリといえばStackExchange.Redisが定番です。StackExchange.Redisを使っていて困るあるある筆頭は「デフォルト設定ではRedisのフェイルオーバーに追随しないこと」ではないでしょうか。今回は、ElastiCache for RedisやMemoryDB for Redisでフェイルオーバーが発生した時に、C#アプリケーションを追随させる方法を紹介します。1

ElastiCache for RedisやMemoryDB for Redisへの接続

最初にElastiCache for RedisやMemoryDB for Redisに接続する方法を紹介します。接続回りはクラスターモードを前提として進めます。

ElatiCache for Redis/Valkeyのクラスターモードと非クラスターモードについて

クラスターモードとは何か、となりそうなのでザクっと説明します。

ElastiCache for Redisにはクラスターモードと非クラスターモードがあります。MemoryDB for Redisはクラスターモードのみの提供です。クラスターモードを有効にするかどうかは、スケール耐性、アプリケーションからの透過性、管理の容易さで決めるでしょう。言い換えると運用負荷ですね。個人的には本番環境ではクラスターモードを用いたいです。

スケール耐性

クラスターモードでは、クラスターの中にN個のシャードがあり、シャードは1つのプライマリとM個のレプリカが存在します。非クラスターモードでは、クラスターの中に1つのプライマリとM個のレプリカが存在します。2 クラスターモードではシャードを追加することでデータがスロット分割されて書き込み・読み込みスケールできます。一方、非クラスターモードは1クラスターにレプリカは追加できますがプライマリは1つなので、書き込みスケールするにはクラスターを追加するしかありません。

なお、サーバーレスはクラスターモードのみの提供となっています。3

アプリケーションからの透過性

クラスターモードではシャードを増減しても常に単一エンドポイントですが、非クラスターモードではクラスターの数だけエンドポイントが複数存在します。 エンドポイントが増えるということは、アプリケーション的にはコネクションが別になります。コネクションが増えるということはアプリケーションの再デプロイが必要になったり、データをどのように分散させるか考慮する必要が出てくるでしょう。

管理の容易さ

クラスターモードではクラスター内のシャードが増えても単一スナップショットです。非クラスターモードではクラスターごとにスナップショットを持ちます。スナップショット1つで済むか、クラスターごとにスナップショットを選んで復元するか、どちらが楽かはいうまでもないでしょう。

基準 クラスターモード 非クラスターモード
書き込み・読み込み負荷をスケール 書込み×, 読み込み: 〇
エンドポイントの数 単一エンドポイント Redisクラスターの数
スナップショットの数 1つ Redisクラスターの数

AWSドキュメントReplication: Valkey and Redis OSS Cluster Mode Disabled vs. Enabled - Amazon ElastiCache User Guideの図を引用すると、両者はノード・データ配置が次のように違います。クラスターモードではデータの論理区分けであるスロットがシャードごとに分割しているのが特徴的です。

image

StackExchange.Redisで接続する

ElastiCache for Redis(クラスターモード)やMemoryDB for RedisにStackExchange.Redisで接続する際は、クラスターのエンドポイントを指定し、認証はACLモードでしょう。4ただ、今回はサンプルコードなのでlocalhost+ポート6379にAuth passwordで接続します。

まずは、docker composeで適当にRedisを起動しておきます。

services:
  redis:
    image: redis:7.4
    command: redis-server --save 60 1 --loglevel warning --requirepass password111
    ports:
      - 6379:6379
    volumes:
      - redis:/data

volumes:
  redis: {}

ローカルホストに接続するだけなら次のようなコードで接続できます。ConnectionMultiplexerはスレッドセーフかつ接続コストが安くないため、シングルトンとしてアプリケーション全体で使いまわすことが多いです。

// Localhostにauth passwordで接続する場合
IConnectionMultiplexer connection = ConnectionMultiplexer.Connect("localhost:6379,ssl=false,keepAlive=60,password=password111");

// ElastiCache for RedisやMemoryDB for Redisに接続する場合は、エンドポイントを指定 + TLSを有効にする
IConnectionMultiplexer connection = ConnectionMultiplexer.Connect("clustercfg.クラスター名.アカウント毎のID.REGION_CODE.cache.amazonaws.com:6379,ssl=true,sslHost=クラスターのエンドポイント,user=ACLのユーザー名,password=ACLのパスワード");

// データベースを取得して、コマンド実行。
connection.GetDatabase(0);

以上が基本的な接続方法です。次に、コネクション切断やフェイルオーバー時の自動復旧について説明します。

フェイルオーバーに追随しないとどうなるのか

仮にフェイルオーバーが起こった時にStackExchange.Redis追随しない場合、既存のコネクションが腐った状態になるためC#アプリケーションの再起動が必要になります。例えば、100サーバーあったら、100サーバーのアプリケーションを再起動する必要があります。これは、フェイルオーバーが発生するたびにアプリケーションを再起動する必要があるため、運用コストが高くなります。コンテナアプリケーションなら、タスクやPodの再起動なのでインパクト大きいですね。

フェイルオーバーしたら、ちゃんとアプリケーションも追随させたい動機としては十分です。

コネクション切断やフェイルオーバーに対応する

Azure Managed RedisのMicrosoft Learnには、接続の回復力に関するベスト プラクティス - Azure Cache for Redis | Microsoft Learnという記事があります。Azure Managed Redisを前提にしていますが、StackExchange.Redisを使った接続なので汎用的な情報になっています。フェイルオーバー対策をする際はこれを読んでおくといいでしょう。

また、この記事で紹介されている通り、StackExchange.Redisを直接使うのではなく、Microsoft.Extensions.Caching.StackExchangeRedisを使うとUseForceReconnectプロパティをtrueに設定するとコネクション再接続をしてくれるのを確認しています。ただし、このライブラリでフェイルオーバーさせたことはないので、本記事では触れません。

StackExchange.Redisでコネクション切断時の自動復旧

ElastiCache for RedisやMemoryDB for Redisのクラスターモードでは、フェイルオーバーが発生すると、リードレプリカがプロモート(昇格)して新しいプライマリになります。また、ネットワーク接続は脆弱なため、AWS内部通信だとしてもネットワーク一時切断はあります。

StackExchange.Redisはコネクション切断だけなら、abortConnect=falseを指定することで再接続をあきらめなくなります。また、HeartbeatConsistencyChecks=trueを指定すると接続状態をハードビートチェックし、HeartbeatInterval=TimeSpanでハートビート間隔を指定できます。設定はConfiguration - StackExchange.Redisを参照してください。

abortConnectオプションはコネクション文字列で指定してもいいですし、ConfigurationOptionsで設定してもいいでしょう。以下はConfigurationOptionsで設定する例です。

var configurationOptions = CreateConfigurationOptions(connectionStrings);
configurationOptions.AbortOnConnectFail = false; // 再接続をあきらめるかどうか
configurationOptions.HeartbeatConsistencyChecks = true; // ハートビートで接続状態を確認
configurationOptions.HeartbeatInterval = TimeSpan.FromSeconds(3); // ハートビートの間隔
IConnectionMultiplexer connection = ConnectionMultiplexer.Connect(configurationOptions);
// 以降でコネクション切断が起こっても再接続を試みる

問題はフェイルオーバーです。フェイルオーバー時は、リードレプリカがプライマリインスタンスになるので、クラスターエンドポイントのDNS解決結果が変わります。先のネットワーク切断の自動復帰コードはコネクションを使いまわす = DNS解決結果をキャッシュしており適切な接続先ではなくなっているため、フェイルオーバー時に自動復旧できません。5DNS解決はConnectionMultiplexer.Connect()時に行われるので、再接続を試みるにはConnectionMultiplexer.Connect()を再度呼び出す = コネクションインスタンスを再生成する必要があります。幸い、StackExchange.Redisにはコネクション切断時に再接続を試みるイベントがあるのでそこにひっかけるといいでしょう。

私がフェイルオーバー復帰に利用しているコードは次のような処理です。

public class RedisConnectionContext
{
    private readonly string _connectionString;
    private Lazy<IConnectionMultiplexer> _lazyConnection; // Lazy initialization for singleton
    private Lazy<IDatabase> _lazyDatabase; // Lazy initialization for singleton

    private readonly ILogger<RedisConnectionContext> _logger;
    private readonly IHostApplicationLifetime _lifetime;
    private readonly object _lock = new object();
    private int _failedConnectionAttempts = 0;
    private const int MaxFailedConnectionAttempts = 10; // Threshold to regenerate the connection

    public string Name { get; }

    public RedisConnectionContext(string name, string connectionString, ILogger<RedisConnectionContext> logger, IHostApplicationLifetime lifetime)
    {
        Name = name;
        _connectionString = connectionString;
        _logger = logger;
        _lifetime = lifetime;
        _lazyConnection = new Lazy<IConnectionMultiplexer>(() => CreateConnection());
        _lazyDatabase = new Lazy<IDatabase>(() => _lazyConnection.Value.GetDatabase());
    }

    /// <summary>
    /// Method to get the Redis database instance
    /// </summary>
    /// <returns></returns>
    public IDatabase GetDatabase() => _lazyDatabase.Value;

    /// <summary>
    /// Create new Redis connection
    /// </summary>
    /// <returns></returns>
    private IConnectionMultiplexer CreateConnection()
    {
        var configurationOptions = CreateConfigurationOptions(_connectionString);
        var connection = ConnectionMultiplexer.Connect(configurationOptions);
        connection.ConnectionFailed += (_, args) => OnConnectionFailed(args);
        connection.ConnectionRestored += (_, args) => OnConnectionRestored(args);
        return connection;
    }

    /// <summary>
    /// Create configuration options for Redis connection with failover handling
    /// </summary>
    /// <remarks>
    /// https://stackexchange.github.io/StackExchange.Redis/Configuration
    /// </remarks>
    /// <param name="connectionString"></param>
    /// <returns></returns>
    private ConfigurationOptions CreateConfigurationOptions(string connectionString)
    {
        var configurationOptions = ConfigurationOptions.Parse(_connectionString);

        // MUST BE FALSE. If true, Connect will not create a connection while no servers are available (default true)
        configurationOptions.AbortOnConnectFail = false;
        // SHOULD ADJUST. Time (seconds) at which to send a message to help keep sockets alive (60 sec default)
        configurationOptions.KeepAlive = 60;
        // SHOULD ADJUST. Used for ping on connection recovery. Timeout for synchronous operations. (default 5000)
        configurationOptions.SyncTimeout = 3000;
        // SHOULD ADJUST. Reconnect retry policy. Exponential retry every 5sec
        configurationOptions.ReconnectRetryPolicy = new ExponentialRetry(5000);
        // MUST BE TRUE. follow to Redis Cluster topology change by failover
        configurationOptions.AllowAdmin = true;
        // MUST BE TRUE. Set heartbeat to detect connection failure
        configurationOptions.HeartbeatConsistencyChecks = true;
        // SHOULD ADJUST. Server shutdown delay duration this interval
        configurationOptions.HeartbeatInterval = TimeSpan.FromSeconds(3);
        _logger.LogInformation($"Connecting to redis: {Name}/{string.Join(",", configurationOptions.EndPoints)}");
        return configurationOptions;
    }

    /// <summary>
    /// Conneciton failure callback. Recreate IConnectionMultiplexer when it's internal state may broken because of Failover or any reason
    /// </summary>
    /// <param name="args"></param>
    private void OnConnectionFailed(ConnectionFailedEventArgs e)
    {
        _logger.LogError(e.Exception, $"Redis disconnection detected, restoring connection. Endpoint={e.EndPoint}, FailureType={e.FailureType}, ExceptionType={e.Exception?.Message}");
        WaitForReconnect();

        // Wait for the connection to be restored
        void WaitForReconnect()
        {
            var ct = _lifetime.ApplicationStopping;

            // reconnect every 5sec, ping 3sec + 2sec wait, until application stopping. It may be some connection problem.
            while (!ct.IsCancellationRequested)
            {
                var instanceHash = _lazyConnection.Value.GetHashCode();
                try
                {
                    // Timeout by SyncTimeout value
                    _lazyDatabase.Value.Ping();
                    _failedConnectionAttempts = 0;
                    return;
                }
                catch (RedisConnectionException ex)
                {
                    Interlocked.Increment(ref _failedConnectionAttempts);
                    _logger.LogError(ex, $"Redis reconnect ping failed. ({_failedConnectionAttempts}/{MaxFailedConnectionAttempts}). Endpoint={e.EndPoint}, Hash={instanceHash}");
                }

                // failed connection attempts is less than threshold, wait 2sec and retry
                if (_failedConnectionAttempts < MaxFailedConnectionAttempts)
                {
                    Thread.Sleep(TimeSpan.FromSeconds(2));
                    continue;
                }

                // connection failed count is over threshold, recreate connection instance. It may be failover.
                if (_failedConnectionAttempts >= MaxFailedConnectionAttempts)
                {
                    lock (_lock)
                    {
                        // Recreate ConnectionMultiplexer instance to handle failover.
                        if (_lazyConnection.IsValueCreated)
                        {
                            _logger.LogWarning($"Redis reconnect reached max commands to retry, creating new multiplexer instance. ({_failedConnectionAttempts}/{MaxFailedConnectionAttempts}). Endpoint={e.EndPoint}");
                            _lazyConnection.Value.Dispose();
                            _lazyConnection = new Lazy<IConnectionMultiplexer>(() => CreateConnection());
                        }
                        if (_lazyDatabase.IsValueCreated)
                        {
                            _lazyDatabase = new(() => _lazyConnection.Value.GetDatabase());
                        }
                        _failedConnectionAttempts = 0;
                    }

                    // ConnectionMultiplexer instance is connected, exit the loop
                    if (_lazyConnection.Value.IsConnected)
                        return;

                    // ConnectionMultiplexer instance is not connected, continue reconnection loop. It may be fail over isn't completed.
                    _logger.LogWarning($"Redis reconnect failed event recreated multiplexer, continue reconnection loop.");
                }
            }
        }
    }

    /// <summary>
    /// Connection restore callback while connection established. Reset failed attemp count because it already restored.
    /// </summary>
    /// <param name="args"></param>
    private void OnConnectionRestored(ConnectionFailedEventArgs args)
    {
        _logger.LogWarning($"Redis connection restored {Name}: {args.EndPoint}.");
        _failedConnectionAttempts = 0; // Reset the counter on successful reconnection
    }
}

接続復旧処理はConnectionFailedイベントに登録したOnConnectionFailedで行っています。フェイルオーバー時の自動復旧コードのポイントは4つです。

  1. ConfigurationOptions.SyncTimeoutで接続リトライ時のpingタイムアウトを設定する
  2. 現在のコネクション再利用による接続復旧を上限まで試み、上限を超えたらIConnectionMultiplexerを再生成する
  3. 自動復旧中にASP.NET Coreのサーバー終了なら自動復旧は止めたいのでIHostApplicationLifetimeを使ってApplicationStoppingのキャンセルトークンをチェックしている
  4. 復旧処理は逐次処理が望ましいのでOnConnectionFailedは同期コードにしている

次のように利用します。ASP.NET CoreなどDIを想定しているので、コンソールアプリケーションで使うときはいい感じに変更してください。DI部分を外しても適当に使えます。6

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(sp =>
  {
      var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
      var connectionString = sp.GetRequiredService<IConfiguration>().GetConnectionString("Local");
      var lifetime = sp.GetRequiredService<IHostApplicationLifetime>();
      return new RedisConnectionContext("local", connectionString, logger, lifetime);
  });

簡易動作確認として、ローカルでRedisを起動、アプリケーションから接続してRedisを落としてみましょう。

$ docker compose up
# アプリケーションでRedis接続後にCtrl + CでRedis終了
$ Ctrl +C

C#アプリケーションログでRedis接続が切断されたことを確認します。再接続上限までいくと、新しいIConnectionMultiplexerが生成されています。

info: RedisFailover.Direct[0]
      Cache Redis operation success. 28/01/2025 04:04:56 +09:00
fail: RedisFailover.Direct.Infrastructures.RedisConnectionContext[0]
      Redis disconnection detected, restoring connection. Endpoint=Unspecified/localhost:6379, FailureType=SocketClosed, ExceptionType=SocketClosed (ReadEndOfStream, last-recv: 0) on localhost:6379/Interactive, Idle/MarkProcessed, last: GET, origin: ReadFromPipe, outstanding: 0, last-read: 0s ago, last-write: 0s ago, keep-alive: 60s, state: ConnectedEstablished, mgr: 8 of 10 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: 1s ago, last-mbeat: 1s ago, global: 1s ago, v: 2.8.24.3255
      StackExchange.Redis.RedisConnectionException: SocketClosed (ReadEndOfStream, last-recv: 0) on localhost:6379/Interactive, Idle/MarkProcessed, last: GET, origin: ReadFromPipe, outstanding: 0, last-read: 0s ago, last-write: 0s ago, keep-alive: 60s, state: ConnectedEstablished, mgr: 8 of 10 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: 1s ago, last-mbeat: 1s ago, global: 1s ago, v: 2.8.24.3255
... 省略 ...
fail: RedisFailover.Direct.Infrastructures.RedisConnectionContext[0]
      Redis reconnect ping failed. (10/10). Endpoint=Unspecified/localhost:6379, Hash=28068188
      StackExchange.Redis.RedisConnectionException: The message timed out in the backlog attempting to send because no connection became available (3000ms) - Last Connection Exception: SocketClosed (ReadEndOfStream, last-recv: 0) on localhost:6379/Interactive, Idle/MarkProcessed, last: GET, origin: ReadFromPipe, outstanding: 0, last-read: 0s ago, last-write: 0s ago, keep-alive: 60s, state: ConnectedEstablished, mgr: 8 of 10 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: 1s ago, last-mbeat: 1s ago, global: 1s ago, v: 2.8.24.3255, command=PING, timeout: 3000, inst: 0, qu: 4, qs: 0, aw: False, bw: SpinningDown, last-in: 0, cur-in: 0, sync-ops: 12, async-ops: 19, serverEndpoint: localhost:6379, conn-sec: n/a, aoc: 0, mc: 1/1/0, mgr: 10 of 10 available, clientName: HOGWARTS(SE.Redis-v2.8.24.3255), IOCP: (Busy=0,Free=1000,Min=1,Max=1000), WORKER: (Busy=3,Free=32764,Min=32,Max=32767), POOL: (Threads=6,QueuedItems=0,CompletedItems=252,Timers=3), v: 2.8.24.3255 (Please take a look at this article for some common client-side issues that can cause timeouts: https://stackexchange.github.io/StackExchange.Redis/Timeouts)
       ---> StackExchange.Redis.RedisConnectionException: SocketClosed (ReadEndOfStream, last-recv: 0) on localhost:6379/Interactive, Idle/MarkProcessed, last: GET, origin: ReadFromPipe, outstanding: 0, last-read: 0s ago, last-write: 0s ago, keep-alive: 60s, state: ConnectedEstablished, mgr: 8 of 10 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: 1s ago, last-mbeat: 1s ago, global: 1s ago, v: 2.8.24.3255
         --- End of inner exception stack trace ---
         at StackExchange.Redis.ConnectionMultiplexer.ExecuteSyncImpl[T](Message message, ResultProcessor`1 processor, ServerEndPoint server, T defaultValue) in /_/src/StackExchange.Redis/ConnectionMultiplexer.cs:line 2099
         at StackExchange.Redis.RedisBase.ExecuteSync[T](Message message, ResultProcessor`1 processor, ServerEndPoint server, T defaultValue) in /_/src/StackExchange.Redis/RedisBase.cs:line 62
         at StackExchange.Redis.RedisBase.Ping(CommandFlags flags) in /_/src/StackExchange.Redis/RedisBase.cs:line 24
         at RedisFailover.Direct.Infrastructures.RedisConnectionContext.<>c__DisplayClass15_0.<OnConnectionFailed>g__WaitForReconnect|0() in D:\github\guitarrapc\csharp-lab\src\Redis\RedisFailover.Direct\Infrastructures\RedisConnectionContext.cs:line 119
warn: RedisFailover.Direct.Infrastructures.RedisConnectionContext[0]
      Redis reconnect reached max commands to retry, creating new multiplexer instance. (12/10). Endpoint=Unspecified/localhost:6379
info: RedisFailover.Direct.Infrastructures.RedisConnectionContext[0]
      Connecting to redis: ElastiCache/Unspecified/localhost:6379

Redisを復帰します。

# Redisを起動しなおし
$ docker compoose up

C#アプリケーションログでRedis接続が復旧しています。

Redis connection restored ElastiCache: Unspecified/localhost:6379.
Redis connection restored ElastiCache: Unspecified/localhost:6379.

なお、切断時に実行していたコマンドは、フェイルオーバーなどでConnectionMultiplexerがDisposeされるとObjectDisposedExceptionが生じます。意図通りですが、普段出ないエラーが生じるので注意してください。

ElastiCache for RedisやMemoryDB for Redisでフェイルオーバーして挙動を確認

1つ以上のリードレプリカを持つElastiCache for RedisやMemoryDB for Redisのクラスターモードでフェイルオーバーを発生させて、アプリケーションが自動復旧するか確認しましょう。どちらもAWSコンソールから手動でフェイルオーバーを行えます。1つ以上のシャードで1つ以上のリードレプリカを持つクラスターでフェイルオーバーを行うと、リードレプリカがプライマリに昇格し、新しいプライマリに接続できます。

次の結果が期待できます。

  • フェイルオーバーに対応できていないと、フェイルオーバー完了後にアプリケーションはRedisと接続できずコマンドをいくら実行してもエラーになる
  • フェイルオーバーに対応できていると、フェイルオーバー完了後にアプリケーションはRedisと再接続して、以降のコマンドが成功する

なお、ElatiCache for RedisとMemoryDB for Redisのフェイルオーバーは、それぞれAWSアカウントごとに1日5回の上限があるので障害試験時などは注意してください。

まとめ

StackExchange.Redisでフェイルオーバー時に接続が自動復旧できない場合、参考にどうぞ。


  1. Valkey 8.0でも同様です。
  2. クラスターモードのシャードに対して、非クラスターモードではレプリケーションと呼ばれます。
  3. サーバーレスモードはお高すぎるので価格1/2ぐらいになりませんか
  4. ElastiCache for RedisならConfiguration endpoint、MemoryDB for RedisならCluster Endpointがクラスターのエンドポイントです。
  5. 再接続時にDNSを強制的に解決させたいというIssueはありますが完全に虫されています。
  6. lifeTimeを省略するなり、Loggerの渡し方を変えるなりしてください