Diskless replication: a few design notes.

Salvatore Sanfilippo

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

約1か月前、Redisの開発に関心を持つ多くのメンバーがロンドンに集まり、初の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転送を次のどちらの方式で行うかを決めることでした。

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

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

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

$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にも適用される予定です。Redis Clusterもまた、特にキャッシュのユースケースにおいてディスクレス運用の有力な候補です。レプリカがデータの冗長性確保に十分役立つ一方で、複数インスタンスのクラッシュ・再起動によってクラスタ内のハッシュスロットの一部のデータが失われても、それほど致命的ではない場合があるからです。

今後の予定
===

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

原文は Salvatore Sanfilippo により に公開されました。

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