HA Redisデプロイメントで信頼性の高いPUBSUBとブロッキングリスト操作を実現する方法

標準的な冗長化Redisソリューションは、マスター/スレーブレプリケーションを構成し、Sentinelがフェイルオーバーを管理する形が一般的です。この構成では、次のいずれかの方式が後続の処理として採用されます。a) クライアントがSentinelを利用して現在のマスターを検出して接続する方式、または b) Sentinelによって管理され、マスターを指し示すTCPプロキシをRedisポッドの前面に配置する方式です。前者はRedis Sentinel本来の設計思想に基づくものであり、後者は近年増えているトレンドであり、ObjectRocketのRedisもこのプロキシ方式で構成されています。
フェイルオーバーがRedisコマンドに与える影響
大多数のRedis操作において、この仕組みは期待どおりに機能します。フェイルオーバー発生時、クライアント側のコードは接続断を検知し、a) 現在のマスターを検索して再接続するか、b) 前述のシナリオに基づいて再接続を試みることが想定されます。プロキシ方式の場合はRedisサーバーの再起動と同じ挙動になるため、単一インスタンスのRedisを再起動するケースにも同じ考え方が当てはまります。
しかし、ごく一部のRedisコマンドには、長時間ブロックするものやアトミック(不可分)ではないものが存在します。PUBSUBのSUBSCRIBEおよびPSUBSCRIBEコマンドは非アトミックです。これらのコマンドは「指定されたチャネル宛てのメッセージを自分にも送ってほしい」という登録要求を行うだけで、実際のデータはかなり後――つまりメッセージがパブリッシュされた後に到達します。一方、BL*系コマンドは長時間・二段階型のコマンドであり、SUBSCRIBE系と同様にブロックし、リストから取り除ける要素が利用可能になるまで戻りません。
これらのコマンドに共通する重要な特徴は、「コマンドが発行されるとサーバー上で何らかの登録が行われるが、データ自体は後からやってくる」という点です。コマンド発行からサーバーが返信すべきデータを持つまでの間に、フェイルオーバーや再起動が発生しえます。再起動やフェイルオーバーが起きると、サーバー上の「登録」は失われてしまいます。ここで問題となるのは、こうしたシナリオにおいてこれらのコマンドに何が起こるのかという点です。
詳細に入る前に、テスト結果や障害シナリオ向けのコーディングに大きな影響を与えるもう一つの重要な観点について触れておきます。実はフェイルオーバーには2種類あります。一つは多くの人が思い浮かべる「マスター喪失」によるフェイルオーバーです。このタイプではマスターが応答しなくなります。本稿ではこれを「トリガードフェイルオーバー(triggered failover)」と呼びます。もう一つは「イニシエーテッドフェイルオーバー(initiated failover)」です。
イニシエーテッドフェイルオーバーは、管理者がsentinel failoverコマンドをSentinelに送信したときに発生し、Sentinelがポッドを再構成して新しいマスターを昇格させ、元のマスターを降格させます。表面上、この2つのシナリオは同じように見えます。大多数のRedisコマンドについては同一視して扱えます。しかし、長時間ブロックするコマンドに関しては、それぞれ固有の文脈で理解する必要があります。
この2種類のフェイルオーバーを機能的に分ける鍵はイベントの順序です。トリガードフェイルオーバーが発生するのは、マスターが応答しなくなったときです。この場合、データの追加・変更は行われず、マスター上でメッセージのパブリッシュも行われません。一方、イニシエーテッドフェイルオーバーでは、ごく短時間ながら「2つのマスターが存在する瞬間」があります。
本稿で扱う手法やシナリオは一般的な形で説明し、PythonのRedisライブラリ「redis-py」を使って実演しますが、どのクライアントライブラリにも適用できる内容です。それでは前提を踏まえ、長時間ブロック系コマンドへの影響を見ていきましょう。まずはPUBSUBからです。
PUBSUB
RedisのPUBSUBは3つのコマンドで構成されます:PUBLISH、SUBSCRIBE、PSUBSCRIBEです。最初のコマンドはメッセージ送信に使われ、残りの2つはPUBLISHコマンドで配信されるメッセージの受信登録に使われます。まず、PUBLISHコマンドの挙動を確認しましょう。
PUBLISHを実行すると、メッセージは指定チャネルへ即座に配信され、受信したクライアント数が戻り値として返ります。では、障害シナリオはどこで問題になるのでしょうか? フェイルオーバーの場合を考えてみます。TCP接続が切れるため、クライアントは再接続が必要になります。プロキシ経由なら直接再接続して別のサーバーにつながり、Sentinelによる検出方式なら少し時間はかかるものの、やはり別のサーバーに接続します。問題はタイミングにあります。もしパブリッシャーがサブスクライバーより先に新サーバーへ接続すると、そのメッセージは消えてしまいます――失われるのです。しかし、それは本当に起こりうるのでしょうか? それを理解するためにSUBSCRIBEコマンドを見てみましょう。
コードがsubscribeコマンドを発行すると、サーバーはメモリ内のデータ構造に対して「この特定の接続は、購読対象チャネルのメッセージを受け取るべきだ」という情報を登録します。この情報はスレーブには伝播しません。したがって、障害発生後にクライアントが再接続した際は、subscribeコマンドを再発行しなければなりません。さもないと、新しいサーバーはその接続が特定のメッセージを受信する必要があることを認識できません。こうして「サブスクライバーがパブリッシャーより後に新マスターへ再接続する可能性」が明確に存在することになり、メッセージ喪失の条件が整います。では、これを最小化あるいは防止するにはどうすればよいのでしょうか? いくつかの選択肢があります。
第一の選択肢は、PUBLISHが「受信したクライアント数」を返すという事実を活用する方法です。受信クライアント数がゼロであれば、パブリッシャーはリトライできます。サブスクライバー数が固定であるシステムや、「少なくとも1人のサブスクライバーが受け取れば十分」というシステムであれば、これによりメッセージ喪失を防げます。この条件に当てはまらないシステム向けには、もう一つ、やや堅牢性は劣るものの有効な選択肢があります。
第二の選択肢は、再接続のタイミング(ウィンドウ)を制御することです。経験則として、「パブリッシャーの遅延時間を、サブスクライバーの遅延時間の少なくとも3倍にする」のが目安になります。これにより、メッセージ配信前にサブスクライバーが再接続を完了する機会が最低3回確保されます。サブスクライバーが先にオンラインになってさえいれば、メッセージが宙に消えることはありません。ただし、何も制御しない場合より堅牢とはいえ、依然としてメッセージ喪失の可能性は残ります。
第三の選択肢は、この競合状態(race condition)を緩和するためにロック機構を組み込むことです。これは間違いなく最も複雑な道筋で、クライアントが接続完了するまでパブリッシャーがパブリッシュできないようにする仕組みを構築または導入する必要があります。基本的な流れとしては、Zookeeper、データベース、Consulなど何らかの共有ストレージを用意し、サブスクライバーが「受信準備完了」を登録し、パブリッシャーはメッセージ送信前に有効で準備済みのサブスクライバーがいるかを確認します。さらに複雑になる点として、サブスクライバーはTCP接続を失った際に、この機構から自分自身の登録を解除しなければなりません。
ここで強調しておきたいのは、ここまでの話はすべてトリガードフェイルオーバーに関するものだということです。トリガードフェイルオーバーではマスターが応答しないためメッセージが届かず、上記の手法が機能します。ところがイニシエーテッドフェイルオーバーのシナリオでは、マスターは降格されるか切断されるまでメッセージを受け付け続けます。上述のいずれの手法も、このシナリオを完全かつ適切にカバーすることはできません。
イニシエーテッドフェイルオーバー(たとえばシステム保守時に発生しうるもの)の場合、最善の防御策は第一と第二の選択肢を併用し、「メッセージ喪失がありうる」という事実を受け入れることです。どれだけのメッセージが失われるかは、完全にパブリッシュ頻度次第です。平均して数分に1回程度のパブリッシュであれば、喪失ウィンドウに遭遇する可能性は極めて小さくなります。パブリッシュ間隔が長いほどリスクは下がります。逆に、毎秒数百〜数千件のメッセージを配信しているなら、確実に一部は失われます。管理型プロキシ方式の場合は、プロキシでフェイルオーバーが完了する速さに依存します。ObjectRocketのRedisプラットフォームでは、このウィンドウは最大1.5秒です。
そこで、限界条件下では失敗するものの一つのアプローチとして、「サブスクライバーの再接続間隔をパブリッシュ間隔の1/3にする」方法があります。たとえば1分に1回メッセージを配信するなら、サブスクライバーは少なくとも20秒ごとに再接続するようコード化・設定し、パブリッシャーは1〜2分ごとに動作させます。ただし、パブリッシュ間隔が3秒近くに迫ると、これは達成が難しくなります。フェイルオーバー後の再接続プロセス(名前解決、TCPハンドシェイク、AUTH、SUBSCRIBEなど)だけで容易に数秒かかりうるためです。
BLPOPとその仲間たち
次に、これらの条件下でブロッキングリストコマンドがどう動作するのかという疑問に移りましょう。こちらのニュースはパブリッシュの場合より良好です。このケースではレプリケーション対象のデータ構造を変更しています。さらに、データ構造への変更であるため、再接続の順序によるメッセージ喪失のリスクは同じレベルではありません。プロデューサーがコンシューマーより先に再接続しても、期待される動作に変わりはありません。プロデューサーはアイテムをリストにPUSHし(またはPOPしてPUSHし)、コンシューマーが接続すればデータはそこにあります。ただし、例外が一つあります。
イニシエーテッドフェイルオーバーの場合、前述の「短時間の複数マスター問題」を考慮する必要があります。ほんの一瞬――ミリ秒オーダーですが――イニシエーテッドフェイルオーバー中の元マスターは依然としてデータ変更を受け付け続け、すでにスレーブがマスターへ昇格しているため、これらの変更はレプリケートされません。このウィンドウは非常に狭いことに注意してください。テストでは一桁ミリ秒のオーダーでしたが、確かに存在します。パブリッシュの場合と同様、これは本質的にコーナーケースであり、たとえば古いマスターに対してPOPコマンドを発行する確率は低いものの、プロデューサーとコンシューマーが毎秒数千回のリスト変更コマンドを発行するような状況になれば、現実的なリスクになりえます。
たとえばBRPOPLPUSHを実行しているワーカーの場合、結果として移動中だったアイテムはフェイルオーバー後に「元の位置に戻った」状態になります。BLPOPの場合も結果はほぼ同じです。フェイルオーバー完了後、アイテムは再キューイングされたように見えます。アイテムが冪等(idempotent)なジョブであれば、これは問題になりません。非冪等なジョブの場合にどれだけ防御的なコーディングをするべきかは、「ジョブが二重に実行される、またはアイテムが二重に処理されることの影響」と「変更コマンドの発行頻度、ひいてはこの状況に遭遇する可能性」を天秤にかけて判断するものです。また、この現象はイニシエーテッドフェイルオーバー時にのみ発生するため、運用管理下にある事象でもあります。可能な限り、この可能性を最小化ないし排除するために、システム使用率が最も低い時間帯に保守作業を行うことをお勧めします。
トリガードフェイルオーバーの場合、データ喪失は想定されません。トリガードフェイルオーバーはマスターが応答しなくなったときに発生するため、マスターへの変更は行われていないからです。ただし、他のシナリオと同様、あらゆるフェイルオーバー場面で必須となるTCP再接続のハンドリングという課題は残ります。
クライアントの再接続
トリガードかイニシエーテッドかを問わず、どちらのフェイルオーバーシナリオでも、クライアントは接続断を検知して再接続する必要があります――Sentinelによる検索後か、同じアドレスへの短い待機時間の後です。管理型プロキシ層を採用するObjectRocket Redisの場合、クライアントは単純に再接続するだけですみます。フェイルオーバープロセス自体の完了には最大2秒程度かかることがあるため、クライアントコードはこれを織り込んでおく必要があります。理想的には、経路上のどこかで接続が切れるだけの一過性のネットワーク不調に対応するため、即時リトライを行うべきです。しかしそれと同時に、スタンドアロンのRedis再起動やプロキシ再起動といったケースに備え、リトライ付きのバックオフアルゴリズムを組み合わせるべきです。
再接続後、すべてのサブスクライバーは再購読(re-subscribe)が必要です。リクエストチャネルと新しいTCP接続との関連付けを確立し直さなければならないからです。なお、RedisのPUBSUBチャネルはサブスクライバーまたはパブリッシャーがアクセスしようとした時点で作成されるため、チャネルを「再作成」する必要はありません。
では、具体的にどう実装するのでしょうか? 答えは、使用するクライアントライブラリと、そのライブラリが切断をどう扱うかに大きく依存します。理想のシナリオでは、接続は自動リトライ可能に設定でき、上限に達した場合にのみ最終的な失敗が返されることです。筆者はこれまでにいくつかのライブラリでこの挙動をテストしましたが、その差は歴然です。ここでは、よく使われるライブラリの一つ、redis-pyについて取り上げます。
まず朗報なのは、redis-pyは接続が切れたときにリトライするよう見えることです。残念ながら即座にリトライするため、フェイルオーバー中に確実に接続を回復するには速すぎます。さらに、この挙動を設定で変更する方法も見当たりません。その結果、コード側で再接続の失敗を捕捉・検知し、再接続を自前で管理する必要があります。
まず、標準的なredis-pyのパブリッシュとサブスクライブのコード例を見てみましょう。
### パブリッシャーのコード例
r.publish(channel,message)
### サブスクライバーのコード例
p = r.pubsub()
p.subscribe(channel)
for item in p.listen():
# メッセージ(item)に対する処理これは非常にシンプルです。しかし、サブスクライバーのforループ中に即時リトライが失敗すると、redis.ConnectionError例外がスローされます。厄介なのは、この例外が「for item in p.listen() の行の内部」で発生することです。したがって、適切に捕捉するにはfor文全体をtry/exceptブロックで囲む必要があります。これはせいぜい扱いにくく、不要なコードの複雑化を招きます。
代わりのアプローチとして、次のように書く方法があります。
### サブスクライバーのコード例
p = r.pubsub()
p.subscribe(channel)
while True:
try:
message = p.get_message()
except redis.ConnectionError:
# スリープとリトライなど、再接続処理をここに書く
p = r.pubsub()
p.subscribe(channel)
if message:
# メッセージに対する処理
time.sleep(0.001) # システムに優しく :)この方法ではget_message()を直接呼び出すため、その時点で例外を捕捉し、'p'オブジェクトの接続を再確立できます。スリープ時間を設けるかどうか、どの程度にするかは、コードの要件次第です。もちろん、サブスクライバーが一定数のメッセージを処理することを前提とし、forループの方が都合が良い場合でも、この考え方は応用できます。パブリッシャー側のコードは一般にイテレータ上で動かないため、よりシンプルです。
### パブリッシャーのコード例
while True:
try:
rcvd = r.publish(channel,message)
if rcvd >0:
break
except redis.ConnectionError:
# 再接続処理をここに書くこの方法なら、リトライするかどうか、いつ、どの頻度で行うかを完全に制御できます。障害イベントを透過的かつ適切にハンドリングするうえで、これは極めて重要です。なお、この対応はコードがSentinelを直接使っている場合や、対象のRedisサーバーが再起動した場合にも同様に必要になります。したがって、実質的にすべてのパブリッシュコードはこの基本メカニズムに従うべきです。
BLPOPなどのブロッキングリストコマンドについては、データは永続化されているため、クライアントの再接続順序は厳密には重要ではありません。コマンド実行時に「redis.ConnectionError」例外がスローされた場合に接続を再確立するために、前述のtry/except方式が必要になります。
特に、redis-pyをObjectRocket Redisプラットフォームに対して使う場合、3秒程度のリトライウィンドウを設けてこれらの手法を適用すれば、トリガードフェイルオーバーではデータ喪失なし、イニシエーテッドフェイルオーバー(インスタンスの垂直スケールなど)時には停止しないパブリッシャーについて約1.5秒分の潜在的なメッセージ喪失という期待値を保証できます。
コード例はredis-pyに特化したものでしたが、基本となるテクニックは、サーバーへの再接続をハンドリングする必要があり、PUBSUBコマンドまたはブロッキングリスト操作を利用するあらゆるクライアントコードに適用できるものです。この知識を武器に、高可用性のRedisポッドを活用しつつ、深夜に運用チームを叩き起こすことなくアプリケーションがフェイルオーバーを乗り越えられるよう、これらのテクニックを実装してみてください。
-
Discord コマンド一覧|覚えておきたいチャット&ボットコマンド徹底解説
オンラインゲームをプレイするゲーマーの間では、Mumble、Steam、TeamSpeakなど、さまざまなチャットアプリを使ってゲーム中にコミュニケーションを取るのが一般的です。最近特に人気が高く、多くのユーザーに利用されているのが「Discord」です。Discordを使えば、プライベートサーバーを通じて他のプレイヤーと音声通話やビデオ通話、テキストチャットが可能になります。Discordには複数のコマンドが用意されており、サーバー内で入力することで作業効率の向上、チャンネルの管理、そしてコミュニケーションをより楽しくすることができます。これらのコマンドは「Discord ボットコマンド」と
-
Windows 11 実行コマンドの完全なリスト
実行ダイアログ ボックスは、熱心な Windows ユーザーにとってお気に入りのユーティリティの 1 つです。これは Windows 95 から存在し、長年にわたって Windows ユーザー エクスペリエンスの重要な部分となっています。その唯一の義務はアプリやその他のツールをすばやく開くことですが、TechCult の私たちのような多くのパワー ユーザーは、[実行] ダイアログ ボックスの便利な性質を気に入っています。コマンドを知っている限り、どのツール、設定、またはアプリにもアクセスできるため、プロのように Windows を簡単に操作できるチート シートを提供することにしました。ただし、