LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

マッハバイトにRedis Sentinelを導入した話とClusterデータ移行ツールを自作した話

インフラグループの春日です。

従来のパッチワーク的なアップデートとは別に、完全新規にサーバーを構築し直すメンテナンスのことをインフラグループでは 式年遷宮 と呼んでいます。 我々は以下の目的でオンプレサーバーに対して定期的に式年遷宮を実施しています。

  • OSやミドルウェアのバージョンUPになるべく追従して脆弱性や不具合を回避する
  • アップグレードや設定変更の積み重ねで汚れたOSをクリーンな状態に戻す
  • より良い構成に変えて運用負荷を減らしていく

今回は マッハバイト で使っている Redis の式年遷宮で、

した話をさせていただきます。

Redis
Redis

Redis Sentinel を導入

Redis の負荷分散や冗長化には Keepalived を使用しています。 アプリケーション側からはVIPで参照しているため、参照先がフェイルオーバーしてもアプリケーション側の修正は必要ありません。

Sentinel 導入前の構成と課題

Sentinel 導入前は基本的に2パターンの構成をとっていました。しかし双方に課題がありました。

Active Standby 構成

Active Standby 構成
Active Standby 構成
この構成ではレプリケーションをしていません。 フェイルオーバーが発生するとデータが空の状態になります。 Keepalived を用いた冗長化はできていますが、障害発生時のデータロスが課題でした。

Replication 構成

Replication 構成
Replication 構成
この構成ではレプリケーションしていて Write / Read で負荷分散しています。 レプリカのどれかが落ちても Keepalived により故障ノードが排除されるためアプリケーションが落ちることはありません。 しかしプライマリが落ちた場合の残存ノードからの選定・昇格までは実現できていませんでした。 復旧に人手を介す必要があり、これも課題でした。

Sentinel 導入後の構成

Sentinel 構成
Sentinel 構成
Active Standby 構成をなくし、一律でレプリケーションさせることによりデータのロスを最小限にとどめられるようになりました。 またプライマリが落ちても Sentinel が自動でレプリカの選定・昇格をやってくれるため人手を介す必要がなくなりました。

マッハバイト は殆どが Rails で実装されていて Redis Gem を使っています。 ちゃんとしたフェイルオーバー対応は Sentinel だけでなくクライアントライブラリ側も対応している必要があります。 その点は Redis Gem の README に対応が明記されています。

The client is able to perform automatic failover by using Redis Sentinel.

Sentinel で発生したイベントを Slack に通知

マッハバイト では監視やモニタリングに Mackerel を利用しています。 公式の check-redis プラグインで基本的な疎通の監視ができます。 今回は Sentinel の初導入ということもあり、何かしらの状態変化イベントが発生したら別途 Slack へ通知するようにしています。

イベントの検知方法ですが Sentinel 側にフェイルオーバー時のフックスクリプトを仕込むことができます。 しかしそのやり方よりも fluentd を用いたログベース処理の方が可搬性が高いと判断しました。 Redis のログフォーマットには一貫性があるのでそのルールに従ってパースしています。 ただし古い Redis はログフォーマットが統一されていないためこのパーサーは使えません。

# expression 部分が Redis のログフォーマットを表現しています。
<source>
  @type tail

  path /var/log/redis/redis.log
  pos_file /var/log/td-agent/tmp/redis.log.pos
  tag redis.generic.raw
  <parse>
    @type regexp
    expression /^(?<pid>[0-9]+):(?<role>[A-Z]) (?<time>[0-9]{2} [a-zA-Z]{3} [0-9]{4} [0-9:.]{12}) (?<loglevel>[*#.-]) (?<message>.*)$/
    time_format %d %b %Y %H:%M:%S.%L
  </parse>
</source>

# @see https://github.com/redis/redis/blob/9b7f8b1c9b379ab842d40df4636dfbbeb6376fcb/src/redis.c#L323-L329 role
# role で何担当のプロセスのログなのかがわかります。
# child は非同期な内部処理などで fork が発生した際の子プロセスです。
<match redis.generic.raw>
  @type rewrite_tag_filter

  <rule>
    key role
    pattern /^X$/
    invert false
    tag ${tag_parts[0]}.sentinel.${tag_parts[2]}
  </rule>
  <rule>
    key role
    pattern /^M$/
    invert false
    tag ${tag_parts[0]}.master.${tag_parts[2]}
  </rule>
  <rule>
    key role
    pattern /^S$/
    invert false
    tag ${tag_parts[0]}.replica.${tag_parts[2]}
  </rule>
  <rule>
    key role
    pattern /^C$/
    invert false
    tag ${tag_parts[0]}.child.${tag_parts[2]}
  </rule>
</match>

# @see https://github.com/redis/redis/blob/9b7f8b1c9b379ab842d40df4636dfbbeb6376fcb/src/redis.c#L300 loglevel
# ログレベルは記号で表現されています。
<match redis.*.raw>
  @type rewrite_tag_filter

  <rule>
    key loglevel
    pattern /^[.]$/
    invert false
    tag ${tag_parts[0]}.${tag_parts[1]}.debug
  </rule>
  <rule>
    key loglevel
    pattern /^[-]$/
    invert false
    tag ${tag_parts[0]}.${tag_parts[1]}.info
  </rule>
  <rule>
    key loglevel
    pattern /^[*]$/
    invert false
    tag ${tag_parts[0]}.${tag_parts[1]}.notice
  </rule>
  <rule>
    key loglevel
    pattern /^[#]$/
    invert false
    tag ${tag_parts[0]}.${tag_parts[1]}.warn
  </rule>
</match>

# 最終的に Sentinel として起動しているプロセスが吐いた、
# NOTICE と WARN レベルのログだけを収集して Slack へ飛ばします。
# メッセージ部分は情報量によっては Slack 上で表示が崩れる課題が残っています。
# fluentd 内部でバッファリングしているのでそこらへんの調整が難しいです。
<match redis.sentinel.{notice,warn}>
  @type record_reformer

  enable_ruby true
  renew_record true
  tag Slack通知用の fluentd が拾ってくれるタグ
  <record>
    room    Slack部屋名
    from    Redis-Sentinel
    color   yellow
    message "```\n${record['message']}\n```"
  </record>
</match>

fluentd を通じて以下のような通知が来ます。

SentinelイベントのSlack通知
SentinelイベントのSlack通知
ノードの故障自体は Mackerel 監視の通知で気付けるようにしています。 その際に Sentinel で何のイベントが発生したかを Slack 上で確認できるようにしています。

Redis Cluster のデータ移行ツールを自作

セッションストアとしての Redis Cluster

マッハバイト ではシャーディングができる Redis Cluster をセッションストアとして使用しています。(こちらは Sentinel とは無関係)

Cluster 構成
Cluster 構成
セッションストアにはユーザーのログイン状態やCSRFトークンなどのデータが格納されています。 Web画面からのアクセスで更新が発生する性質があるため、マイグレーションには工夫が必要です。

できる限りサイトを止める時間を短くマイグレーションしたい

セッションストアのマイグレーション方法は以下が考えられます。

  1. データを移行せずに切り替える
  2. メンテナンス画面を設けてデータ移行してから切り替える
  3. サイトを止めずにデータ移行して切り替える

1番のやり方はログインしてたのにログアウトしたり、入力したフォームをsubmitするタイミング次第でエラーが返ることが想定されます。 すべてのユーザーに影響が出てしまいます。

2番は一番安全なやり方ですが、なるべくユーザー影響の少ない時間帯に作業する必要があります。 また、少なからず売上にも影響が出てしまいます。

3番はサービスの運営に影響を与えない理想的なやり方です。 バッチ処理で更新されるデータなど、あらかじめ貯めておける場合はアプリケーション側で参照先を切り替えるだけで済みます。 しかしセッションストアの場合は直前にデータ移行をしなければなりません。 そして移行時間はなるべく短い方が良く、長引けば長引くほどデータ移行の取りこぼしによるユーザーへの悪影響が増えていきます。

速度を求めたデータ移行ツール

Redis のデータ移行ツールは調べるとたくさん出てきました。Cluster 対応されている物も多いです。 RDB ファイルのバイナリフォーマットは後方互換性があるとのことで、RDB ベースでの移行も考えられます。 あとは移行元と移行先の Redis 同士でレプリケーションさせるやり方もあります。 しかし検討の結果、メンテナンスウィンドウをなるべく短くした Cluster マイグレーションは既存ツールでは難しいことがわかりました。

移行手段 懸念点
RDB ファイルベース 移行元と移行先とで Cluster 構成を揃える必要がある
レプリケーション Cluster を跨げない、メジャーバージョンが違うと組めない
redis-cli の import 機能 移行元データが消えてしまう

そこで以下の要件を満たすデータ移行ツールをC言語で自作しました。

  • Cluster から Cluster への移行ができること
  • 移行元と移行先とで Cluster 構成が違っていても移行できること
  • 移行しても元データは消えないこと (コピー)
  • 今のデータ量で遅くとも5分以内に処理が完了すること

redis-cluster-data-transfer

結果的に約1100万件のデータを3分でコピーできました。 あくまで マッハバイト の環境、データ量での比較になりますが処理時間の検証結果は以下です。

ツール 移行時間 備考
redis-cli の import 機能 2時間 過去の深夜作業でそれくらいかかった
自作ツール 1st バージョン 推定6時間 素朴な実装
自作ツール 2nd バージョン 50分 マルチスレッド化
自作ツール 3rd バージョン 40分 pipelining 対応
自作ツール 4th バージョン 3分 完成

データ移行ツールの実装で苦労した点

実践では初めてのC言語だったので以下の本には大変お世話になりました。

自作ツール 1st バージョン (素朴な実装)

最初の実装はシングルスレッドでシンプルなものでした。 MIGRATE コマンドという Cluster のリシャーディング操作でも使われているコマンドを使いました。 これは移行元の Redis に対して発行するコマンドで、これを受信した移行元 Redis が移行先 Redis に payload を送信します。 payload を受け取った移行先 Redis がキーデータを格納して終了です。

素朴な実装
素朴な実装

オプション次第で移行元データを消去するか残すかを選べます。残す場合はコピーしたことになります。 ツールは移行元 Redis とだけやり取りすれば良く、送信するコマンドや返信内容はテキストベースでした。 RESP2 のパース処理は fgets(3) を使った行ベースのものでした。 標準ライブラリの文字列関数も大いに使えました。 しかし検証環境で動かしてみると約1100万件のデータを移行するのに推定6時間かかることがわかりました。

自作ツール 2nd バージョン (マルチスレッド化)

単純に処理を分割して並列で動かせば速くなると思い、マルチスレッド化を試みました。 マルチスレッドプログラミングはリソースの競合さえ発生させなければ罠は少ないだろうと思い、 サーバーへの接続ソケットはスレッドごとに保持するよう心掛けました。 また、グローバル変数を使わないことはもちろんですが、スレッドセーフでない関数も使わないよう気を付けました。

肝心の処理の分担方法ですが Redis Cluster のスロット範囲で分けることにしました。 Redis Cluster のスロットについては以下の記事で言及しているのでご参照いただければと思います。

RubyのRedis Client LibraryをCluster Modeに対応させた話

Redis における全データ処理は SCAN コマンドでイテレーションするやり方が一般的です。 KEYS コマンドの使用は計算量が非常に多いのでアンチパターンとして知られています。 Cluster の場合はシャーディングしている都合上、どのキーがどのノードに格納されるかを意識しないといけません。 なので SCAN コマンドを利用する場合は移行元と移行先とでノード数やスロット設定を揃える制約が発生してしまいます。 もしスロット設定を揃えない場合は キーをスロット値に変換する処理 が必要になります。 よって SCAN コマンドを使ったイテレーションではなく、スロット番号ベースで処理を分担することにしました。

  1. CLUSTER SLOTS コマンドの結果からスロット番号とノードとの対応表をつくる
  2. 0 から 16383 番までのスロット番号をスレッドごとに均等に分配する
  3. スロット番号ごとに CLUSTER GETKEYSINSLOT コマンドでキーリストを得て処理する

という流れです。Redis Cluster のシャーディングに偏りがなければ並列度は上がります。 このマルチスレッド化により推定6時間かかってた処理を50分まで短縮できました。

自作ツール 3rd バージョン (pipelining対応)

一度に複数コマンドを送信して返信を一括で得る pipelining に対応しました。 クライアントとサーバー間の Round Trip Time コストを下げる効果があります。 返信のパース処理における複雑度がやや増しましたが、送るコマンドもサーバーからの返信も依然テキストベースです。 この対応により50分を40分程度にまで縮められました。

自作ツール 4th バージョン (完成)

ここからが地獄でした。バイナリデータを扱う必要が出てきて不慣れで苦戦しました。

元々使っていた MIGRATE コマンドは内部でブロッキングが発生します。 そのせいか思うような処理スピードが出ていませんでした。 移行元 Redis に対してマルチスレッドで攻めても、移行元 Redis と移行先 Redis の通信部分が頭打ちになっていました。

MIGRATEコマンド
MIGRATEコマンド

そこで MIGRATE コマンドの内部処理と等しい DUMP コマンドと RESTORE コマンドを直接使うことにしました。 そうすれば移行元と移行先それぞれの Redis に対してマルチスレッドの恩恵が効くと判断したからです。

DUMPコマンドとRESTOREコマンド
DUMPコマンドとRESTOREコマンド

DUMP コマンドの返信や RESTORE コマンドに渡す引数にはバイナリデータが含まれます。 テキストベースの処理では基本的に \0 で NULL 終端された文字列を扱います。 扱うデータがテキストであれば標準ライブラリの文字列関数や fgets(3) などの行ベースの処理が可能でした。 しかしバイナリデータにはその決まりはなく、NULL 終端されていなかったり途中に改行コードと同じバイトコードが含まれていたりします。 バイナリとテキストが混在しているデータに対して標準ライブラリの文字列関数を使うと意図しない動作、未定義動作が多発しました。 落ちるべき箇所で落ちるのではなく、後々の処理に悪影響が出て結果的に想定外のステップで謎のコケ方をするパターンが多かったです。 GDB デバッグに非常に苦労しました。

バイナリデータの登場により標準ライブラリの文字列関数は封印されました。ループでバイトごとに地道に処理することにしました。 また、送受信まわりの実装では fputs(3)fgets(3) の利用を諦めて send(2)recv(2) を使うことにしました。 しかしそこでも私の勘違いによるバグに悩まされました。

recv(2) は1回呼べば送ったコマンドの返信を全て得られるものと勘違いしてました。例えば

お疲れ様です。\r\n
春日です。\r\n

のような返信を期待して recv(2) を1回だけ呼んで

お疲れ様です。\r\n
春

が返ってきてそのままパースしようとして変になる、という具合です。 期待する返信内容が全部得られるまで何度も呼ぶ必要があります。そもそも recv(2) 関数はそういうものです。

このような初歩的な紆余曲折があって何とかツールは完成しました。 最終的に約1100万件のデータを3分でコピーでき、Cluster のマイグレーション自体も特に障害なく終えることができました。

ツールを自作して感じたこと

世の中のシステムプログラマーには頭が上がりません。 RubyRedis もC言語で実装されていますが、あんなに高機能なプログラムで segmentation fault に滅多に遭遇しないのは本当に凄いと思います。

最後に

現在インフラグループでは将来的なクラウド移行を見据えた様々な準備を進めています。 今回の Redis 式年遷宮はオンプレ環境に対して行いましたが、次回はマネージドサービスの利用やコンテナ化を目指します。