Diskless replication: a few design notes.

Salvatore Sanfilippo

ディスクレスレプリケーション:設計に関する覚え書き

原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する

1か月ほど前、Redisの開発に関心を持つ多くの人々がロンドンに集まり、第1回Redis開発者ミーティングが開催されました。私たちはそこで緊急に対応が必要な機能をいくつか特定し、現在はGitHubのissueにまとめられています(https://github.com/antirez/redis/issues/2045)。その中でも、一日を通して何度も言及された課題が一つありました。ディスクレスレプリケーションです。

この機能は決して新しいアイデアではありません。何度も提案されてきました。特にEC2のユーザーからは、マスターがスレーブの同期中に安定したパフォーマンスを維持するのが必ずしも容易ではないことが知られていたためです。しかし、物理サーバーで動かしている場合であっても、特にRedisをキャッシュとして利用している場合には、ディスクに触れたくないというユースケースは数多くあります。端的に言えば、Redisのレプリケーションは、ディスクへの永続化を必要としない、あるいは望まないユーザーに対してまで、ディスクの使用を強制していたのです。

帰国後、私はミーティングに参加してくれた開発者の方々にいち早くフィードバックを返したいと考え、まずは特定された課題の中でも最も重要で、自明ではないと思われた機能の実装に取りかかりました。今後数週間で、issueの扱い方やRedisプロジェクトへの新しいアイデアの提案方法など、Redisの開発プロセス自体にも目を向けていく予定です。こうした他の重要なテーマへの対応が遅れていることはお詫びします。今のところお届けできるのは、少なくともいくらかのコードです ;-) 

ディスクレスレプリケーションには、いくつか設計上の課題がありました。一見すると些細なことに思えますが、実際はそうではありません。もう少しブログを書きたいと思っていたので、この機能が内部でどのように動いているのかを記録しておこうと考えました。この記事が、新機能の理解や導入を少しでも簡単にしてくれれば幸いです。

これまでのレプリケーションの仕組み
===

比較的新しいバージョンのRedisでは、マスターとの接続が切れても再接続し、それまでに蓄積された差分だけを取得して増分的にレプリケーションを継続できるようになっています。しかし、スレーブが長時間切断されていたり、再起動されたり、新しいスレーブであったりする場合には、「フル再同期」と呼ばれる処理が必要になります。

これはごく単純な概念で、要するに「このスレーブをセットアップするために、マスターのデータセット全体をスレーブに転送しよう」ということです。スレーブは古いデータを破棄し、新しいデータをゼロから再読み込みすることで、マスターのデータの完全なコピーとして動作することを保証します。スレーブがマスターの完全なコピーになった後は、クライアントから送られてくる書き込みコマンドによってマスターのデータセットが変更されるのに合わせて、その後の変更は通常のRedisコマンドとして増分的にストリーム配信されます。

問題は、このフル再同期に必要な初期の「一括転送」がどのように行われていたかにありました。基本的に、マスターはRDBファイルを生成するために子プロセスを作成します。子プロセスがRDBファイルの生成を終えると、親プロセスがノンブロッキングI/Oを使ってそのファイルをスレーブに送信します。転送が完了すると、スレーブはRDBファイルを再読み込みしてオンラインになり、新しい書き込みの増分ストリームを受信するようになります。

しかし、これはマスターの視点から見ると、フル同期を行うために次のことが必要だったということです。

1) RDBをディスクに書き込む。
2) RDBをディスクから読み戻してスレーブに送信する。

「2」も理想的ではありませんが、「1」はさらに深刻です。例えば同時にAOFが有効になっている場合、子プロセスが可能な限り高速にディスクへの書き込みを行っている間、AOFのfsync()が大幅に遅延する可能性があります。特にリモートディスクを使った誤ったセットアップの場合や、カーネルパラメータのチューニングが万全でない場合でも、ディスクへの負荷がレイテンシのスパイクを引き起こし、対処が困難になることがありました。Redis 2.8で導入された部分再同期によってこの問題はある程度緩和されましたが、スレーブを再起動したり、長時間オフラインになったりすることは時々あるため、フル再同期を完全に避けることは不可能です。

同時に、このプロセスにはいくつかの利点もありました。RDBの保存コードがレプリケーションでも再利用されていたため、レプリケーションのコードがシンプルになっていました。さらに、子プロセスがRDBファイルを生成している間に、新しいスレーブが接続してきてキューに入ることができました。RDBの準備ができれば、複数のスレーブに同時にデータを供給できたのです。

総じて、多くの環境ではうまく機能し、複数のスレーブを同時に同期させることができます。また、多くのユーザーはマスター側でRDBの永続化を有効にしつつAOFは無効にして運用しているため、いずれにせよ時々ディスクへの永続化は発生していました。ベアメタルのユーザーの多くは、Redisが永続化している間もレイテンシをまったく感じませんし、特にローカルディスクはパフォーマンスが予測しやすく、子プロセスが保存を開始すれば、タイムアウトを気にしたり時間がかかりすぎていないかをチェックしたりする必要もなく、最終的には、通常は妥当な時間内に完了します。

こうした理由から、ディスクベースのレプリケーションは今でもデフォルトのレプリケーション方式であり、今のところ廃止する予定はありません。ただ、これまでうまくいかなかったユースケースのために、代替手段が用意されたということです。

では、ディスクレスレプリケーションとは何でしょうか。それは、子プロセスからソケットを介して、中間ステップを挟まずに直接スレーブへ書き込むというアイデアです。

ソケットはディスクではない
===

ディスクレスレプリケーションの明らかな問題は、ディスクへの書き込みとソケットへの書き込みは別物だということです。まずAPIが異なります。RDBのコードはこれまでCのFILEポインタに書き込んでいましたが、ソケットへの書き込みはファイルディスクリプタへの書き込みになります。さらに、ディスクへの書き込みはハードなI/Oエラー(例えばディスクフル)でもない限り失敗しないため、書き込みが失敗すればプロセス全体が中断したとみなせます。しかしソケットの場合は事情が異なり、受信側の処理が遅く、ローカルのカーネルバッファがいっぱいになれば書き込みは遅延します。もう一つの興味深い問題は、タイムアウトへの対処です。受信側で障害が発生して読み込みが止まったらどうなるでしょうか。あるいはTCP接続が切れているのにリセットが届かないといった場合はどうでしょうか。RDBファイルをスレーブに送信している子プロセスを永遠に動かし続けるわけにはいかないので、タイムアウトを検出する方法が必要です。

幸いなことに、RDBのコードをファイルディスクリプタに書き込むように修正するのは簡単でした。というのも、まったく別の問題(Redis Cluster向けのMIGRATE/RESTORE)のために、コードはすでに「rio」(Redis I/O)と呼ばれる抽象化を使っていたからです。これはRDB形式でのRedisの値のシリアライズとデシリアライズを抽象化するもので、値をディスクに書き込むことも、メモリ上のバッファに書き込むこともできます。私がやったのは、新しい「rio」のターゲットとして「fdset」、すなわちファイルディスクリプタの集合をサポートすることでした。後述するように、これは複数のファイルディスクリプタに同時に書き込む必要があるためです。

しかし、それだけでは十分ではありませんでした。主な設計上のトレードオフの一つは、メモリ上でのRDB転送を次の2つのうちどちらの方法で行うかを決めることでした。

1) 方法#1:バッファ内のメモリ上に完全なRDBファイルを生成してから転送する。
2) 方法#2:RDBが作成されるのに合わせて、増分的にスレーブのソケットへ直接書き込む。

方法#1は、ある種のRAMディスクに書き込むようなもので、ディスクへの書き込みとほぼ同じなので、はるかにシンプルです。しかし、明らかなリスクはメモリを使いすぎてしまうことです。方法#2は、RDBファイルを生成する子プロセスが動作している間に転送しなければならないため、少しリスキーです。しかし、この機能の本質は、ディスクが遅いかもしれないが*ネットワークは高速*な環境を対象とし、あまり追加のメモリを必要としないことにありました。そうでなければ、この機能は役に立たないものになってしまう恐れがあります。そこで方法#2が選ばれました。

しかし、このようにRDBファイルをストリーム配信すると、新たに解決すべき問題が出てきます。スレーブはどうやってEOFに到達したことを知るのでしょうか。転送を開始した時点では、転送がどれくらいの大きさになるのか分かりません。ディスク上のレプリケーションではサイズが分かっていたため、転送は長さが前に付いたRedisプロトコルの「バルク」文字列を使って行われていました。例えば次のようなものです。

$92384923423\r\n
… data follows …

私は増分的なブロックサイズを通知するための複雑なチャンクプロトコルを実装するのが面倒だったので、より力任せなアプローチを取ることにしました。マスターは推測不可能で衝突する可能性が極めて低い160ビットのランダムな文字列を生成し、スレーブに次のようなものを送信します。

$EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n
… data follows …
796f255829a040e80168f94c9fe7eda16b35e5df

つまり、この文字列は、ファイル内のいかなるデータとも(衝突確率が限りなく小さいという理由だけで)決して衝突しないことが保証されているため、ファイル終端のマークとして使われるのです。単純ですが、非常にうまく機能し、シンプルです。

タイムアウトについては、ブロッキング書き込みプロセスであるため(保存用の子プロセスのコンテキストで実行されているため)、SO_SNDTIMEOソケットオプションを使っただけです。これにより、確実に処理が前進しなければレプリケーションプロセスが中断されることが保証されます。したがって、現時点では子プロセスの寿命に厳密な上限を設ける方法はなく、理論上は、スレーブがタイムアウト時間より1秒短い間隔で1バイトだけを受け入れ続けることで、非常に遅い転送状態を作り出すという病的なケースも考えられます。おそらく将来的には、子プロセスが転送レートを監視し、妥当な水準を下回った場合にはエラーで終了するようにするでしょう。

複数のスレーブに同時に配信する
===

この実装のもう一つの目標は、複数のスレーブに同時に配信できるようにすることでした。一見すると、RDB転送が始まってしまえば新しいスレーブはアタッチできず、現在の子プロセスが停止して新しい子プロセスが開始するまで待たなければならないため、不可能に思えます。

しかし、多くのユースケースをカバーできる非常にシンプルなトリックがあります。それは、最初のスレーブがレプリケーションを要求してきたら、他のスレーブも到着するのを数秒間待つというものです。これにより、例えば複数のスレーブが一斉に再同期するといった明白なケースをカバーできます。

そのため、I/Oコードは複数のファイルディスクリプタに同時に書き込めるように設計されています。さらに、ブロッキングI/Oが使われていても転送を並列化するために、コードはループ内で各fdに少量ずつデータを書き込もうとするため、カーネルが複数のスレーブに対してバックグラウンドでパケットを送信することになります。

おそらく、コード自体は非常に分かりやすいでしょう。

    while(len) {
        size_t count = len < 1024 ? len : 1024;
        int broken = 0;
        for (j = 0; j < r->io.fdset.numfds; j++) {
            … error checking removed …

            /* Make sure to write 'count' bytes to the socket regardless
             * of short writes. */
            size_t nwritten = 0;
            while(nwritten != count) {
                retval = write(r->io.fdset.fds[j],p+nwritten,count-nwritten);
                if (retval <= 0) {
                     … error checkign removed …
                }
                nwritten += retval;
            }
        }
        p += count;
        len -= count;
        r->io.fdset.pos += count;
        … more error checking removed …
    }

なお、書き込みはrio.cの書き込みターゲットによってバッファリングされています。というのも、一定量のデータが利用可能になったときだけ書き込みたいからです。そうしなければ、5バイトのデータしか入っていないTCPパケットを送ってしまう恐れがあるためです。

部分的な障害への対処
===

複数のスレーブを扱うということは、単に複数のFDに書き込むという単純な話ではありません。話の大きな部分は、実際には一部のスレーブが失敗しても、他のすべてのスレーブの処理をブロックする必要がないようにすることにあります。
エラーになったファイルディスクリプタは関連するエラーコードとともにマークされ、再び書き込みが試みられることはありません。また、すべてのFDがエラーになったことを検出し、プロセス全体を中断するコードもあります。

しかし、RDBの書き込みが終了したとき、子プロセスはどのスレーブがRDBを受信し、レプリケーションプロセスを継続できるかを報告する必要があります。このタスクのために、プロセス間でUNIXパイプが使われます。子プロセスはスレーブIDとそれに対応するエラー状態の配列を返すため、親プロセスはエラーを適切にログに記録することができます。

この変更がRedisをより深いレベルでどう変えるか、私が考えたこと
===

ディスクレスレプリケーションによって、ついにRedisのマスター・スレーブ構成で完全にディスクを使わない運用が可能になりました。
これは、このユースケースをより適切にサポートする必要があることを意味します。現在のレプリケーションは永続化を無効にした状態で実行すると危険です。というのも、いずれにせよレプリケーションが永続化をトリガーするのであれば、永続化をオフにするケースはないだろうと考えていたからです。しかし今や状況は変わりました。その結果、ディスクを使わない環境でのレプリケーションをより適切にサポートする計画がすでにあります。これはRedis Clusterにも同様に適用されます。Clusterもまた、特にキャッシュのユースケースではディスクレス運用の有力な候補です。レプリカがデータの冗長性を提供する上で良い働きをしますが、複数のインスタンスがクラッシュして再起動した際に、クラスター内のハッシュスロットの一部のデータが失われても、それほど致命的ではないかもしれないからです。

今後の予定
===

コードはすでにベータ版としてこちらで公開されています:https://github.com/antirez/redis/commits/memsync
数日以内にunstableブランチにマージされる予定ですが、まずはフィードバックやバグ報告を少し待ってから、3.0と2.8にもマージする計画です。この機能は非常に有用であり、無効になっているときはRedisコアの他の部分との相互作用もほとんどありません。単純にあらゆるバージョンにバックポートし、しばらくの間は「実験的」な機能としてリリースする予定です。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント