LIVESENSE ENGINEER BLOG

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

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

こんにちは、アルバイト事業部の春日です。アルバイト求人サイトである マッハバイト のサーバーサイドを担当しております。 Redis GemCluster Mode に対応させる Pull Request を出す機会に恵まれたため、本日は振り返りも兼ねてそれについてお話させていただければと思います。

背景

Ruby on Railsで実装しているマッハバイトでは、将来的なユーザー増加を見越して2015年03月頃にRedis ClusterをSession Storeとして使い始めました。 当時はまだRedis3.0が出たばかりだった記憶があり技術選定時に twemproxy なども検討されていました。 Reshardingなどもできる運用ツール もあってRedis本体がNativeでサポートしている、という理由でRedis Clusterを採用しました。

しかしRedis3.0で初めてCluster Modeが登場したというのもあり、Cluster Modeに対応した定番Gemがまだありませんでした。 RailsのSession Store機構が定番のGemセットでうまく扱えずにログイン周りなどを自前実装することになりました。

そこで定番とは言えないものの Redis本体の作者がつくったCluster Client Library を本番環境で使用することにしました。 Gem化されていないので vendor/* 配下に直接コピーして使ってました。それでも問題なく動作していたのですが、徐々に自前実装しているセッションロジックまわりのメンテがキツくなってきました。

その後、この Redis本体の作者がつくったライブラリをGem化する試み や別のGemが出てきたみたいですが、どれも活発に使用されている気配はありませんでした。 できれば我々はビジネスロジックに集中したいため、そうすると定番Gemセットを使うには Redis Gem に頼らざるを得ません。

Redis Gem のメンテナの方々は Redis本体 もメンテしている方が多く、本体のメンテが忙しくてClientソフトの方までなかなか手が回っていない印象を受けました。 Cluster Mode対応のissueは何個かあがっていて需要はあったみたいなので、チャンスと捉えてPull Requestを出すことにしました。

時系列

出来事
2017-09 Pull Request を出すも活動的なメンテナがいなくてしばらく放置される
2018-05 Sidekiqオーナーが出した救いの手issue によりメンテナが増える、このタイミングでRailsをメンテしているようなShopifyのRubyistたちが加わったのが大きい
2018-06 RailsもメンテしてるShopifyのRubyistの方に丁寧に的確にレビューしてもらう
2018-07 マージされる
2018-10 Ruby Prize 2018 に並ぶも他が強過ぎて普通に選考から外れる

最初は Redis本体の作者がつくったライブラリ のリファクタリング程度のものでしたが、レビューを通してテストコードを拡充していくとリファクタだけでは全然考慮が足りていなかったことに気付き、あわてて全面実装し直しました。 RailsもメンテしているShopifyのRubyistの方にはこの場を借りて感謝を申し上げます。本当にありがとうございます。頭が上がりません。

RedisのCluster Modeについて

Slot

Redis ClusterのShardnigはCommandのKeyから 算出 されるSlot値をもとに行われます。

CLUSTER KEYSLOT コマンドを使うとKeyを指定してSlot値を得ることができます。

(以降の例はローカルに7000-7005ポートで1レプリカずつの計6インスタンスを立てているケースを想定) 下記の例だと key19189 のSlotを持つNodeに格納されることがわかります。

$ telnet localhost 7000
cluster keyslot key1
:9189

Slot値は0番から16383番までの16384個を取ります。Cluster構築時にSlot RangeをどのNodeに割り当てるかを明示的に指定します。 Cluster操作の運用ツールとして redis-trib.rb を使うとそこらへんの分割計算をしてくれます。 なお最新のRedisだと redis-trib.rbredis-cli に統合されました。Ruby以外でRedis Clusterを使用している環境では運用し易くなったと思います。

マッハバイトの本番環境ではレプリカ1つずつの計6台を運用しており、Master Nodeそれぞれに以下のSlotを割り当ててます。

  • Master1 0-5460
  • Master2 5461-10922
  • Master3 10923-16383

HashTag

Redisでは複数のKeyを指定できるCommandがあります。Cluster Modeだとそれに気を使う必要があります。Nodeを跨いだ複数Key指定はエラーが返ります。 下記の例だと key1key2 が異なるNodeに格納されていて CROSSSLOT エラーが返ってます。

$ telnet localhost 7000
mget key1 key2
-CROSSSLOT Keys in request don't hash to the same slot

これには複数Key指定コマンドの使用を諦めるか HashTag を使って意図的に同一ノードにKeyをまとめる必要があります。 下記の例だと {key}1 {key}2 は同一Nodeに格納されるようになります。MGET の結果が読みにくいですが [a b] の配列が返ってます。 余談ですが Redis Protocol はシンプルなので配列以外は読み易いです。

$ telnet localhost 7002
mset {key}1 a {key}2 b
+OK
mget {key}1 {key}2
*2
$1
a
$1
b

HashTagを使用すると中括弧内の文字列でSlot値を 算出 するようになります。 しかしこのHashTagを多用するとせっかくのShardingが偏るので注意が必要です。

Redirection

Redis ClusterのNodeはRequestをProxyしてくれません。指定したKeyが送信したNodeとは別のNodeに格納されている場合はリダイレクトを要求してきます。 SRE本 にもカスケード障害の章に以下の記述があります。

通常は、通信経路上に循環が生じることがありうるので、ユーザーのリクエスト処理の経路内でレイヤー内通信をするのは避けるようにした方が良いでしょう。 その代わりに、クライアントに通信をしてもらうようにしましょう。 例えば、フロントエンドがバックエンドに通信したものの、そのバックエンドの選択が適切ではなかった場合、そのバックエンドは適切なバックエンドへのプロキシとして振る舞うべきではありません。 その代わりに、問題のバックエンドがフロントエンドに対し、適切なバックエンドへリクエストのリトライを行うよう伝えさせてください。

下記の例だと key17001 番ポートで動いているNodeにあると教えてくれます。

$ telnet localhost 7000
get key1
-MOVED 9189 127.0.0.1:7001
quit
+OK

7001 番に繋ぎ直すと今度はリダイレクトを要求されなくなりました。(セットしていないため値は空)

$ telnet localhost 7001
get key1
$-1

Resharding

Cluster稼動中にshardingに偏りが目立ってきたり、新しいNodeを追加してさらに分散させたいときとかに Resharding ができます。 手動で CLUSTER コマンドを打ってもできますが、煩雑なので基本的には redis-trib.rb などの運用ツールを使います。

Resharding中は特殊なResponseが返る場合があります。Reshardingの流れは以下です。

  1. 移動先Nodeに対して CLUSTER SETSLOT コマンドで CLUSTER SETSLOT Slot値 IMPORTING 移動元NodeID を打って開始宣言する
  2. 移動元Nodeに対して CLUSTER SETSLOT コマンドで CLUSTER SETSLOT Slot値 MIGRATING 移動先NodeID を打って開始宣言する
  3. 移動元Nodeに対して CLUSTER GETKEYSINSLOT コマンドを打ってKeyリストを得る
  4. 移動元Nodeに対して MIGRATE コマンドを打ってKeyたちを移動させていく
  5. 任意のNodeに対して CLUSTER SETSLOT コマンドで CLUSTER SETSLOT Slot値 NODE 移動先NodeID を打って移動したSlotの所在を確定させる

この1, 2番の宣言から5番の確定までの間にRedisは -ASK というResponseを返す場合があります。RequestしたKeyが今どっちのNodeにあるか ASKING で尋ねる必要があるためです。

RedisのCluster Mode Clientの責務

Commandを正確に対象Nodeに送信する

Redis Cluster Clientは最悪Redirectionのみ対応できていれば機能はします。ですが無駄な通信は発生させない方が良いのでCommand送信先Nodeを正確に把握している必要があります。 基本的には以下の工程が必要になります。

  1. 送信するCommandからKeyを抽出する
  2. KeyからSlot値を算出する
  3. Slot値からNodeを特定する
  4. Scale Readしている場合は更新系コマンドはMaster Nodeに、参照系コマンドはSlave Nodeに送信する

Commandの細かな情報、Keyの位置や更新/参照系などは COMMAND コマンドが教えてくれます。 4番のScale Readは READONLY コマンドをSlave Nodeに打つと使えるようになります。

Redirection対応はあくまで上記のNode特定から漏れたときのセーフティネットと考えておいた方が良いかと思います。 また、Redirection対応に加えてResharding中のResponseの対応も必要です。 仕様ドキュメント にも以下の記述があります。

A client must be also able to handle -ASK redirections that are described later in this document, otherwise it is not a complete Redis Cluster client.

Commandごとの性質の違いを考慮する

RedisのCommandSET GET のようなものからPub/Sub、Luaスクリプトなど多岐にわたります。

単純にKeyを指定する系のCommandは普通にKeyからSlot値を得てNodeを特定して送信できます。

KEYS などのCommandはすべてのNodeに対して送信して結果をマージする必要があります。 しかしそもそも計算量の大きいCommandなので本番環境で使う機会はないでしょう。

Luaスクリプト は各Nodeに登録してあげないと不便です。単一Nodeに登録しても他のNodeでは使えません。

Pub/Sub コマンドだけは例外で、Redis ClusterではどのNodeに対して送信しても 伝搬 して動作してくれます。

Transaction対応

Transaction は2パターンの使い方があります。

  1. MULTI などのコマンドを個別に送信する
  2. Pipelining で一度に送る

Redis Clusterだと1番のやり方に問題が発生します。 MULTI を打つときにどのNodeに対して打てば良いのかわからないためです。 Redis ClusterのShardingはCommandのKeyに対して分散させるため、KeyのないCommandは送信先Nodeを特定できません。

よって2番の Pipelining を使ってTransactionをまとめて単一Nodeに送信することになります。

また2番でもTransaction内のCommandのKeyが複数Nodeに対するものであった場合に一貫性が保てません。 TransactionをNodeを跨いで担保することはできないため、ユーザーはTransaction内で単一Nodeにしかアクセスしないことを意識しないといけません。

Pipelining対応

Pipelining は悩ましい部分があります。 Transaction とは違い、複数Nodeに対するCommandが含まれていても、Clientソフト側でPipeliningを分割してあげて複数Nodeに送信してあげても良い気はします。 ですがたいていのClientソフトはTransactionでPipeliningを使用しているので、あんまり複雑にするよりはTransactionに合わせて単一Nodeのみに限定した方がシンプルになる気もします。 Redis Gem では後者のシンプルな方に倒しましたが議論の余地はあります。

Node変更対応

Clusterに対してNodeの追加やFailoverなどの変更が発生した場合にClientソフトは検知できないといけません。 CLUSTER NODES コマンドを使用すると最新のCluster情報を得ることができます。 Master/Slaveロールや割り当てられているSlot範囲などがわかります。

$ telnet localhost 7000
cluster nodes
$697
b22b58c1b9c711920a8e4e0d680ad19e842a54aa 127.0.0.1:7004 slave 27769953b7b22889a1d61c9aaf29d72b6931b0ff 0 1539232690326 5 connected
ebd83490643a0c129334dd47f6bfa761a0e72ef6 127.0.0.1:7002 master - 0 1539232690326 3 connected 10923-16383
50d8a34b0c9e10477587e9ed21e743c7852f05e4 127.0.0.1:7005 slave ebd83490643a0c129334dd47f6bfa761a0e72ef6 0 1539232691832 3 connected
5447d2b5ab8a0efff66d55a392099f142d30296c 127.0.0.1:7000 master - 0 1539232691330 1 connected 0-5460
27769953b7b22889a1d61c9aaf29d72b6931b0ff 127.0.0.1:7001 myself,master - 0 0 2 connected 5461-10922
b01855140ec22f9da217f3c379bcc4e0e33b6c78 127.0.0.1:7003 slave 5447d2b5ab8a0efff66d55a392099f142d30296c 0 1539232689826 4 connected

b22b58c1b9c711920a8e4e0d680ad19e842a54aa などの文字列はRedis Clusterが内部で管理しているNode IDです。 これとは別に 127.0.0.1:7000 などのIPとPORTの組み合わせ文字列で特定するケースもあるので、Node特定時にはコンテキストに応じて使い分けが必要です。

Redis Gemについて

Redis Gem はスター数も少なくはなく、Ruby界隈で主流のRedis Clientソフトです。 Ezra Zygmuntowicz さんが最初に実装したらしいです。

Cluster Modeの使い方

2018年10月現在、GitHubのエッジ版、または 4.1.0.beta1 のバージョンでCluster Modeを使用できます。

# Nodeごとの接続情報を配列でclusterオプションに渡します。
nodes = (7000..7005).map { |port| "redis://127.0.0.1:#{port}" }
redis = Redis.new(cluster: nodes)

# Nodeの接続情報は1つだけでも構いません。
# 内部で引数で指定されたNodeに対してCLUSTERコマンドを打ってNode情報を取り直しているからです。
redis = Redis.new(cluster: %w[redis://127.0.0.1:7000])

# Scale Readさせたい場合はreplicaオプションをtrueに指定します。
# デフォルトはfalseでMaster Nodeしか接続しません。
Redis.new(cluster: nodes, replica: true)

redis-rails Gemを使ってRailsアプリケーションでSession StoreやCache Storeとして使う場合は以下の設定で動きます。 なおRails5.2ではこのGemを使わなくてもActiveSupport側でCache Storeが使えるようになっているみたいです。 リリースノート

Rails.application.configure do
  # Redis Session Store (redis-rails Gem)
  nodes = (7000..7005).map { |port| "redis://localhost:#{port}/0/session" }
  config.session_store :redis_store, servers: { cluster: nodes }, expires_in: 1.month

  # Redis Cache Store (redis-rails Gem)
  nodes = (7000..7005).map { |port| "redis://localhost:#{port}/0" }
  config.cache_store = :redis_store, { cluster: nodes }

  # Redis Cache Store (ActiveSupport)
  nodes = (7000..7005).map { |port| "redis://localhost:#{port}/0" }
  config.cache_store = :redis_cache_store, { cluster: nodes }
end

Redis::Distributedについて

これはClient側でキー分散をサポートしている機能で こちら で言及されているやつです。

Clients supporting consistent hashing

Redis Cluster Modeとは一切関係がありません。こちらのRedisはノーマルモードで起動したものに対して使うみたいです。

なので Redis Gem は3つのモードをサポートしていることになります。

  1. 単一ノーマルRedisのClient
  2. Client側でサポートしている分散モードのClient Redis::Distributed
  3. Redis Cluster ModeのClient (今回私が実装したやつ)

Redis::Distributed は一部のCommandに対応していなかったり可用性も別に担保しないといけなかったりします。 一度削除されかけましたが周辺Gemがこれに依存している部分もあって反発に合い、Redis側にCluster Modeが誕生した現在でもまだ残っています。

構成

Redis クラスに公開インターフェースが揃ってます。ここにないCommandは method_missing に拾われます。 Redis クラスではCommandを配列として加工して後述の Client クラスに渡し、サーバーからのResponseをRuby用に最終加工したりしてます。 Redisからの生Responseは各種接続Driverが最低限のRuby用加工だけやってくれます。そこから先のBoolean化やHash化などのリッチデータ化の加工は Redis クラスでやってます。

接続周りの煩雑な処理は Client クラスに委譲されてます。 Cluster Modeでは Cluster クラスに委譲し Cluster クラスは内部で Client クラスに委譲しています。

Redis::Distributed クラスはその設計から外れていて独自に公開インターフェースを定義しています。 なので Redis クラスと Redis::Distributed の二重メンテが発生しています。

テスト

CIの設定ファイル を見るとRubyとRedisの各バージョンと各種接続DriverとのMatrixでテストされていることがわかります。

ローカルでテストする場合もCIと同じく make を使うと楽な気がします。私は最初は手元でDockerでコンテナを利用していましたが最終的には make を利用しました。

$ bundle install --path=.bundle
$ make start          # ノーマルの単一Redisが起動
$ make start_cluster  # Cluster Mode用のRedis6台が起動
$ make create_cluster # redis-trib.rb や redis-cli でClusterを組む
$ make test           # テストを通しで実行
$ make stop           # ノーマルの単一Redisの停止
$ make stop_cluster   # Cluster Mode用のRedis6台の停止

./tmp/* 配下に資源をダウンロードしてビルドして動かします。初回は redis-server 実行ファイルの生成に時間がかかりますが、以降はRedis本体側に更新がなければその工程はスキップされます。 テストを1ファイル単発で実行したいときは以下のように実行できます。

$ bundle exec ruby -w -Itest test/hogehoge_test.rb

テストツールは test-unit を使ってます。 minitest ではありません。

テストにおける共通処理やsetup/teardown系の処理は ヘルパー に記述されています。

Redisの基本データ型であるHash, List, Set, SortedSet, Stringなどのテストケースは Lint に定義されて共通化されています。 Single Mode Client Redis::Distributed Cluster Mode Client ではこれらを include してテストしてます。

モック はスレッドを立ててRedisプロトコルをしゃべらせて使ってます。 引数でコマンドとその生Responseを指定する形で使います。

def test_hogehoge
  redis_mock(save: ->(*_) { '+OK' }) do |redis|
    assert_equal 'OK', redis.save
  end
end

Redisのバージョンごとに異なるテストケースではバージョン指定ヘルパーを使って限定します。引数で指定したバージョン以降のRedisでないとテストが実行されなくなります。

def test_fugafuga
  target_version '3.2.0' do
    # test code
  end
end

反省 x お願い x これから

反省: 英語力の必要性

お恥ずかしながら私は致命的に英語ができません。今回のPull Requestでは英語ができないとスタートラインにすら立てないことを再認識させられました。 レビュアーの方にも私のカタコト英語にお付き合いいただき申し訳ない気持ちでいっぱいです。勉強します。

お願い: リアルワールドの高負荷環境での動作確認

現在マッハバイトでは Redis Gem のCluster Mode対応版である 4.1.0.beta1 を本番環境で使用しております。Scale ReadはせずにMaster Nodeのみを使用しております。

マッハバイトの本番環境は基本的にオンプレで、Redis Clusterもデータセンターで稼動しています。 Pull Requestでいただいたコメント ではCluster ModeをONにしたAWSのElastiCacheでも動作報告があがってます。

マッハバイトでは動作しても、より大規模で高負荷なサービスでこの 4.1.0.beta1 を使用したときに、ちゃんと長期的に動作し続けられるか見えない部分があります。 もし良かったらRubyで組まれてる他のサービスでも試験的に導入いただき、フィードバックをもらえたらうれしいです。

これから: TODO

  • マッハバイトのセッションまわりの独自実装を定番ライブラリを使うように修正(Must)
  • Redis Gem にてREADMEにCluster Modeについて追記(Should)
  • Redis Gem にてRedis5から使えるようになるらしい Streams の対応(Want)

合わせて読みたい