Redis Sentinelの特性と障害シナリオについての考察
昨日、分散システムの専門家であるAphyrが、ある匿名希望の企業で起きたRedis Sentinelの問題についてツイートしました。
「ああ、Redis Sentinelの件で『kill -9でマスターを落としたらスプリットブレインが起きて……』」
「『その後、古いマスターがデータを空のまま復帰して、その“空”を他の全ノードにレプリケートしてしまった。文字通りバックアップから復元するしかなかった』」
なんとひどいバグかと思いました。しかしKyleに詳しい状況を聞いてみたところ、ユーザーはなんとマスタープロセスでディスクへの永続化を完全に無効にしていたとのことでした。そうです、マスターは再起動時にデータセットが空になるように、あえてそう設定されていたのです。
どうなったと思いますか。すぐにTwitterで騒ぎが始まりました。みんなRedisユーザーのことを本気で心配しています。かわいそうなRedisユーザー!いつも危険にさらされている、と。
もちろん、深く心配するというのは賢明な態度ではありますが、ここでは別の道を選びたいと思います。もう少し情報を補足することです。しかもこの記事を書くのは興味深いことでもあります。というのもKyleは、最初はほとんど文脈なしに問題を報告したものの、数ツイート後には、私見ではありますが、Redis Sentinelで本当に改善すべき点を的確に指摘してくれたからです。それは今回の件とは直接関係がなく、ずっと前から私のTODOリストに載っている事柄でした。
ただ、その前に今回の騒動となった事案について、Redis/Sentinelの挙動をもう少し詳しく見ていきましょう。
クラッシュリカバリというシステムモデルへようこそ
現実世界の分散システムの多くは、プロセスがランダムに再起動しうるという前提に対して耐性を持つように設計されなければなりません。これは、他のプロセスとメッセージを交換できなくなる、いわゆる分断(パーティション)とはまったく別の問題であることに注意してください。ここで問題なのは、状態を失うことなのです。
より正確に言えば、分散アルゴリズムが「プロセスは再起動後も状態を保持することを保証しなければならない」という前提で設計されているのに、それが守られない場合、技術的にはビザンチン障害が起きていることになります。状態は破損し、そのプロセスはもはや信頼できません。
さて、RedisインスタンスとRedis Sentinelインスタンスからなる分散システムでは、再起動したインスタンスが以前のデータセットを保持したまま復帰できることが根本的に重要です。空のデータセットで起動してしまうことはビザンチン障害であり、Redis Sentinelはこの種の問題からは回復できません。
ここで一歩引いて考えてみましょう。実際、この種の事案にRedis Sentinelは直接関与していない場合もあります。典型的な例は、設定を誤ったマスターがあまりに速く再起動するため、Sentinelがまったく障害を検知できないケースです。
- ノードAがマスターです。
- ノードAが永続化を無効にした状態で再起動されます。
- SentinelはノードAに到達できないことを(もしかすると)検知しますが、設定されたタイムアウトに達するほどではありません。
- ノードAは再び利用可能になりますが、完全に空のデータセットで再起動しています。
- スレーブノードB、C、D……は、喜んでその空のデータセットを同期してしまいます。
結局、マスターからは設定どおりにすべてが消え、そしてスレーブからもすべてが消えます。スレーブはデータセットの正しいコピー元だと信じている相手からレプリケートしているのですから。
ここでSentinelを式から取り除いてみましょう。つまり上記タイムラインの「3」です。このシナリオではSentinelは何もしていなかったのですから。
結果はこうなります。N台のスレーブにレプリケートしているRedisマスターがあります。マスターが再起動され、空のデータセットで新たに開始するように設定されています。スレーブは再びそこから(空のデータセットを)レプリケートします。
これはRedisユーザーにとって目新しい話ではないと思います。これがRedisのレプリケーションの仕組みです。スレーブは常にマスターの完全なコピーであろうとします。ただ、別のモデルも考えてみましょう。
例えば、RedisインスタンスがRDB/AOFファイルに永続化されるNode IDを持つようにできます。再起動するたびにNode IDを読み込みます。もしNode IDが一致しなければ、スレーブはマスターから一切レプリケートしません。ずっと安全ですよね? 実際には、ほんのわずかに安全になるだけです。マスターには別の設定ミスがあるかもしれません。再起動後に、何らかの理由でスナップショットが失敗していたために、何週間も前のデータセットを読み込んでしまうといった具合です。
つまり不正な再起動の後も、正しいNode IDは保持しているものの、データセットが古すぎて、実質的には削除されたのと大差なく、ただ検知がより難しいだけということになります。
しかし、わずかに安全性を高める代償として、運用がより複雑なシステムになり、永続化を無効にするのと似たような運用ミス――ただし、はるかに気づきにくいミス――によって、IDが一致しないためにスレーブがマスターからレプリケートできなくなる危険も生じます。
では話題を変えて、Sentinelが実際に関与する障害モードのうち、改善可能なものを見ていきましょう。
すべてのレプリカが同じわけではない
技術的に言えば、Redis Sentinelが提供する保証は、理解しやすいごく限られたものだけです。
- すべてのSentinelは、互いに通信できるようになればすぐに設定について合意します。実際、各サブパーティション内では常に合意が取れます。
- Sentinelは、Sentinelプロセスの過半数からの承認なしにフェイルオーバーを開始できません。
- フェイルオーバーには厳密な順序があります。時間的に後に発生したフェイルオーバーほど大きな設定「番号」(Sentinel用語でconfig epoch)を持ち、常に古い設定より優先されます。
- 最終的に、Redisインスタンスは勝利した論理的な設定(より大きなconfig epochを持つもの)に合致するように設定されます。
つまりデータセットの意味論は「最後のフェイルオーバーが勝つ」ということです。しかしここで欠けている情報があります。フェイルオーバー時に、どのスレーブがマスターの後継として選ばれるのか、という点です。これは結局のところ、根本的に重要な特性です。例えば、Redis Sentinelが(誤った設定で再起動したばかりの)空のスレーブを選んでしまうとしたら、それこそがSentinelの問題です。Sentinelは、Redisが非同期レプリケーションのシステムであるという制約の中でも、利用可能な中で最良のスレーブを選ぶことでユーザーの利益を最大化し、到達可能な適切なスレーブがなければそもそもフェイルオーバーを拒否するようにしなければなりません。
ここは改善の余地がある部分です。現在、マスター障害時にスレーブを選ぶ際には次のように動作しています。
- スレーブが再起動され、再起動後にマスターと一度も接続して正常な同期(データ転送)に成功していない場合、そのスレーブはスキップされます。
- スレーブがマスターから切断されている時間が、設定されたタイムアウト(Sentinel群がマスターを障害と判断するまでに到達不能であるべき時間)の10倍を超えている場合、そのスレーブは不適格とみなされます。
- 残ったスレーブの中から、Sentinelは最も良い「レプリケーションオフセット」を持つものを選びます。
レプリケーションオフセットは、Redisのマスター・スレーブレプリケーションが、レプリケーションチャネル経由で送信されたバイト数を数えるために使う数値です。フェイルオーバーだけでなく、さまざまな場面で役立ちます。例えばネットワーク分断後の部分的再同期では、スレーブはマスターに対して「最後に受信したバイトであるオフセットXからデータをください」と要求します。
しかしこのレプリケーション番号には、昇格させるのに最適なスレーブを選ぶという文脈では2つの問題があります。
- 再起動後にリセットされることです。一見すると無害に思えます。より大きな数値を持つスレーブを選びたいわけですし、再起動後に接続できなければスキップされるのですから。しかし実際にはまったく無害ではありません。詳しくは後述します。
- ただの数値に過ぎないことです。つまり、Redisスレーブがある特定のマスターからレプリケートしたことを意味しません。
なお、スレーブがマスターに昇格する際には、マスターのレプリケーションオフセットを引き継ぐことにも注意してください。ですから再起動を除けば、この数値は増加し続けます。
なぜ「1」や「2」が最適でない選択であり、改善できるのでしょうか。
次のような構成を想像してください。ノードA、B、C、D、Eがあります。Dが現在のマスターで、Eとともに少数派パーティションに分断されています。Eは依然としてDからレプリケートしており、彼らの視点からはすべて正常です。
一方、多数派パーティションではA、B、Cが互いに通信でき、Aがマスターに選出されます。
その後Aが再起動し、オフセットがリセットされます。BとCはAからレプリケートし、低いオフセットから再び開始します。
しばらくしてAが障害を起こし、同時にEが多数派パーティションに再合流します。
EのデータセットはBやCのデータセットに比べて更新が遅れていますが、レプリケーションオフセットはより大きくなっています。それだけでなく、Eは最近までマスターに接続していたと主張することもできます。
これを改善するのは簡単です。各Redisインスタンスは「runid」という、Redisが起動するたびに変わる一意のIDを持っています。これは部分的再同期の際に、誤ったマスターから増分ストリームを取得してしまうのを防ぐのに役立ちます。スレーブは最後に正常にレプリケートしたマスターのrunidを公開すべきであり、Sentinelのフェイルオーバーでは、実際にフェイルオーバー対象となっているマスターからレプリケートしていたスレーブだけを選ぶようにすべきです。
レプリケーションオフセットを特定のrunidに紐づければ、スレーブがどれだけ最新であるかを測る絶対的な物差しが得られます。2台のスレーブが利用可能で、どちらも旧マスターとの連続性を主張できる場合、レプリケーションオフセットが大きい方が最良の選択であることが保証されます。
ただしこれは、データ自体はそれほど重要ではなく可用性が求められるあらゆるケースで、可用性への懸念も生みます。例えばAがクラッシュしたときにEだけが利用可能になった場合、たとえEがDからレプリケートしていたとしても、何もないよりはましです。可用性の高いキャッシュが必要で一貫性がそれほど問題にならないのであれば、Redisクラスターをmemcachedのように使う(N台のマスター間でクライアントサイドのコンシステントハッシュを行う)のが良い方法だと私は考えています。
なお、runidのチェックなしでも、再起動後もレプリケーションオフセットを永続化するだけで、挙動はかなり改善されることに注意してください。上記の例では、Eが選ばれるのは、スレーブとともに少数派パーティションに隔離されている間に、多数派側の他のスレーブよりも多くの書き込みを受け取っていた場合に限られます。
TL;DR:これは修正しなければなりません。データセットなしで再起動するマスターの問題とは関係ありませんが、より正確な実装を持つことは有用です。ただし、これが抑止できるのは、非常に起こりにくい部類の問題だけです。
これは以前から私のTODOリストに載っていました。数回のツイートのやり取りで実際的な実装上の問題を特定したAphyrに拍手を送りたいと思います。Aphyrが報告した匿名企業の障害については、深刻な設定ミスから保護しようとすることは現時点では現実的ではないと考えています。しかしこれは、より良いSentinelドキュメントが必要であることの明確なサインです。現在の、システムがどう動くかを説明しようとするドキュメントに比べ、より段階的に理解できるものが求められています。より賢明なアプローチは、一般的な健全な設定から始め、「やってはいけないこと」リスト――例えば、空のインスタンスになっても構わない場合を除き、永続化を無効にしてはいけない、といったリスト――を提示することかもしれません。
記事をランダムに読む