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の図を引用すると、両者はノード・データ配置が次のように違います。クラスターモードではデータの論理区分けであるスロットがシャードごとに分割しているのが特徴的です。
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つです。
ConfigurationOptions.SyncTimeout
で接続リトライ時のpingタイムアウトを設定する- 現在のコネクション再利用による接続復旧を上限まで試み、上限を超えたら
IConnectionMultiplexer
を再生成する - 自動復旧中にASP.NET Coreのサーバー終了なら自動復旧は止めたいので
IHostApplicationLifetime
を使ってApplicationStopping
のキャンセルトークンをチェックしている - 復旧処理は逐次処理が望ましいので
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でフェイルオーバー時に接続が自動復旧できない場合、参考にどうぞ。