背景
Redis+Sentinel は、Sentinelが監視して、Redisマスタの停止時にスレーブを昇格してマスターにするという便利な仕組みでした。しかし、実際に動かしてみると、一つのマスターに集中してしまうこと、Sentinelに問い合わせするクライアントライブラリが必要なこと、繰り返しマスターを停止させた場合、スレーブの昇格が止まってしまうこと、など思うこともあり、Redisクラスタなら、もっと気持ちが良い構成が組めるんじゃないかと... 思ったので、試してみることにしました。
今回も、IBM Cloud Kubernetes Service (IKS) https://cloud.ibm.com/ で実行するのですが、K8sクラスタがkubectlコマンドで操作できる前提で進めていきます。今回利用したマニフェストは、どのプロバイダーのK8sサービスでも動作すると思います。
Redisクラスタの基本
Redisクラスタは、複数のマスターとスレーブからなります。 キーのハッシュによって、マスター#1〜#3の格納先が決まります。そして、スレーブはマスターのデータを同期して保持しています。このような構造のため、例えば、マスター#1が停止したら、スレーブ#1がマスター#1に昇格して、稼働を継続します。 もしも、昇格したマスター#1が止まれば、Redisクラスタ全体が停止することになります。
Redisクラスタにデータを読み書きするアプリケーションは、Redisクラスタに対応した機能を持ったソフトウェアでなければなりません。クライアントがRedisマスターの一つにアクセスした場合、キーのハッシュによって、クライアントに対して他のRedisマスターへのリダイレクトアクセスを要求します。Redisクライアントには、そのリダイレクト要求に応じる機能が必要とされます。以下に調べたRedisクラスタに対応したプログラム言語のライブラリ、および、コマンドを列挙します。
- Ruby redis-rb-cluster
- Python redis-py-cluster
- PHP Predis
- Java Jedis
- C# StackExchange.Redis
- Node.js thunk-redis
- Go redis-go-cluster
- クライアントコマンド redis-cli
クラウドのRedisサービス vs Redis Cluster on K8s のどちらを選ぶべき?
クラウドのサービスがあるのに、ワザワザ Redisクラスタを作って、自ら運用しなくても良いんじゃない?という意見はもっともだと思います。 水平分散でスケールする方式のアプリケーションに対しては、キャッシュは無くてはならない機能です。しかも、応答性能は、サービスのスループットなどパフォーマンスと密接に関係します。 もちろんクラウドのサービスでも、その点を重視したサービス品質になっていると思うのですが、開発環境のような環境であればシンプルなコンテナのRedisサーバーで十分でしょう。また、本番環境でもキャッシュのチューニングが手の内で可能というのは安心材料と思います。 また、標準のRedisのプロトコル自体がSSL/TSLに対応しないこともあり、トラフィックはK8sクラスタ内に留めておきたいとの希望もあります。
K8s上のRedisクラスタの構成
次に、RedisクラスタをKubernetesで稼働させる構成について考えていきます。
Redisクラスタのクライアントは、Redisマスターへアクセスする場合、いずれかのRedisマスターにアクセスして、リダイレクトを要求されると、応じて別のマスターへアクセスしなければなりません。この理由から、VIP(代表IPアドレス)を持ち、ランダムに振り分け先を決定するより、VIPを持たず、RedisマスターのIPアドレスを返すヘッドレスが適しています。さらに、Redisはオンメモリのキャッシュですが、データを永続ストレージに保存する機能があります。 Redisのマスターやスレーブのポッドが削除された場合でも、永続ストレージにデータを保持しておくことができます。ステートフルセットを利用すれば、ポッド番号と永続ストレージの関係を固定して、利用することができ、Redisクラスタを再スタートすることがあっても、データを失うことがありません。
これまで述べたRedisクラスタの特徴と、K8sのリソースの特徴から、VIPを持たないヘッドレスのサービス、ステートフルセット・コントローラー、永続ボリュームを利用して構築したいと思います。
RedisクラスタをK8s上で構築
前述の条件で、インターネットのサイトを検索して探したところ、希望通りのマニフェストを探すことができました(参考資料1,2)。 マニフェストを眺めたところ、そのまま、IKS (IBM Cloud Kubernetes Service)で動きそうだったので、そのまま利用することにしました。 利用するマニフェストのGitHub https://github.com/sanderploegsma/redis-cluster
このGitHubのREADME.mdの冒頭で、HelmやOperatorを利用するようにとのコメントがあります。 実業務においては、その方が良いと思うのですが、やはり、中身を知らないで、適用することは避けるべきとの考えて、このGitHubのマニフェストを検証していきます。
今回利用したK8sクラスタのノードは2つです。
$ kubectl get node
NAME STATUS ROLES AGE VERSION
10.193.10.14 Ready <none> 4d21h v1.13.8+IKS
10.193.10.58 Ready <none> 4d21h v1.13.8+IKS
前述のGitHubをフォークして、ローカル環境へクローンしました。そして、そのディレクトリにあるマニフェスト redis-cluster.yml をアプライするだけです。
$ git clone https://github.com/takara9/redis-cluster
Cloning into 'redis-cluster'...
remote: Enumerating objects: 71, done.
remote: Total 71 (delta 0), reused 0 (delta 0), pack-reused 71
Unpacking objects: 100% (71/71), done.
$ kubectl apply -f redis-cluster.yml
configmap/redis-cluster created
service/redis-cluster created
statefulset.apps/redis-cluster created
このマニフェストでは、Redisマスター x3、Redisスレーブ x3 を起動します。それぞれ、永続ストレージをダイナミックプロビジョニングしますから、全てのメンバーが立ち上がるのに少し時間がかかります。以下は全てのインスタンスが起動した状態です。
imac:redis-cluster maho$ kubectl get all
NAME READY STATUS RESTARTS AGE
pod/redis-cli 1/1 Running 0 11h
pod/redis-cluster-0 1/1 Running 0 16m
pod/redis-cluster-1 1/1 Running 0 14m
pod/redis-cluster-2 1/1 Running 0 12m
pod/redis-cluster-3 1/1 Running 0 10m
pod/redis-cluster-4 1/1 Running 0 8m5s
pod/redis-cluster-5 1/1 Running 0 5m59s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 172.21.0.1 <none> 443/TCP 4d21h
service/redis-cluster ClusterIP None <none> 6379/TCP,16379/TCP 16m
NAME READY AGE
statefulset.apps/redis-cluster 6/6 16m
永続ボリューム要求(PVC)と永続ボリューム(PV)もリストしておきます。
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-redis-cluster-0 Bound pvc-017f5b56 20Gi RWO ibmc-file-bronze 16m
data-redis-cluster-1 Bound pvc-50bf3d62 20Gi RWO ibmc-file-bronze 14m
data-redis-cluster-2 Bound pvc-9ce8035f 20Gi RWO ibmc-file-bronze 12m
data-redis-cluster-3 Bound pvc-e673a183 20Gi RWO ibmc-file-bronze 10m
data-redis-cluster-4 Bound pvc-3d0d53a3 20Gi RWO ibmc-file-bronze 8m10s
data-redis-cluster-5 Bound pvc-8807937d 20Gi RWO ibmc-file-bronze 6m4s
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
pvc-017f5b56-b2c9 20Gi RWO Delete Bound default/data-redis-cluster-0
pvc-3d0d53a3-b2ca 20Gi RWO Delete Bound default/data-redis-cluster-4
pvc-50bf3d62-b2c9 20Gi RWO Delete Bound default/data-redis-cluster-1
pvc-8807937d-b2ca 20Gi RWO Delete Bound default/data-redis-cluster-5
pvc-9ce8035f-b2c9 20Gi RWO Delete Bound default/data-redis-cluster-2
pvc-e673a183-b2c9 20Gi RWO Delete Bound default/data-redis-cluster-3
Redisクラスタを初期化します。マスター#1〜#3 にハッシュスロットが割り当てられていることに注目してください。
$ kubectl exec -it redis-cluster-0 -- redis-cli --cluster create --cluster-replicas 1 \
> $(kubectl get pods -l app=redis-cluster -o jsonpath='{range.items[*]}{.status.podIP}:6379 ')
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 172.30.222.165:6379 to 172.30.94.159:6379
Adding replica 172.30.94.161:6379 to 172.30.222.164:6379
Adding replica 172.30.222.166:6379 to 172.30.94.160:6379
M: 5845f7c1e9b95b2096c314a06ee2ec3fe8a3c5ae 172.30.94.159:6379
slots:[0-5460] (5461 slots) master
M: 3705e6cd8202e177ddf36097c5c635f1b31e464e 172.30.222.164:6379
slots:[5461-10922] (5462 slots) master
M: 1ff6f27b8d3727f03333f03fb16ac3e36490f20e 172.30.94.160:6379
slots:[10923-16383] (5461 slots) master
S: a6752ad3cf9d9c1fe3ed4b94673b0e5ae5945dcb 172.30.222.165:6379
replicates 5845f7c1e9b95b2096c314a06ee2ec3fe8a3c5ae
S: e32a5d433257be4efd1515a519bb67fbba6b5c8c 172.30.94.161:6379
replicates 3705e6cd8202e177ddf36097c5c635f1b31e464e
S: f4151900caeefef18c75e7f9a1a50d1df6516f4b 172.30.222.166:6379
replicates 1ff6f27b8d3727f03333f03fb16ac3e36490f20e
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
...
>>> Performing Cluster Check (using node 172.30.94.159:6379)
M: 5845f7c1e9b95b2096c314a06ee2ec3fe8a3c5ae 172.30.94.159:6379
slots:[0-5460] (5461 slots) master
1 additional replica(s)
S: f4151900caeefef18c75e7f9a1a50d1df6516f4b 172.30.222.166:6379
slots: (0 slots) slave
replicates 1ff6f27b8d3727f03333f03fb16ac3e36490f20e
S: a6752ad3cf9d9c1fe3ed4b94673b0e5ae5945dcb 172.30.222.165:6379
slots: (0 slots) slave
replicates 5845f7c1e9b95b2096c314a06ee2ec3fe8a3c5ae
M: 1ff6f27b8d3727f03333f03fb16ac3e36490f20e 172.30.94.160:6379
slots:[10923-16383] (5461 slots) master
1 additional replica(s)
M: 3705e6cd8202e177ddf36097c5c635f1b31e464e 172.30.222.164:6379
slots:[5461-10922] (5462 slots) master
1 additional replica(s)
S: e32a5d433257be4efd1515a519bb67fbba6b5c8c 172.30.94.161:6379
slots: (0 slots) slave
replicates 3705e6cd8202e177ddf36097c5c635f1b31e464e
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
これで準備が完了したのですが、Redisクラスタのメンバーをリストします。Redisマスターとスレーブをリストして、スレーブがどのマスターに付いているかが解ります。
oot@redis-cli:/data# redis-cli
Could not connect to Redis at 127.0.0.1:6379: Connection refused
not connected> connect 172.30.94.160 6379
172.30.94.160:6379> cluster nodes
3705... 172.30.222.164:6379@16379 master - 0 1564492915000 2 connected 5461-10922
a675... 172.30.222.165:6379@16379 slave 5845... 0 1564492915334 4 connected
f415... 172.30.222.166:6379@16379 slave 1ff6... 0 1564492913000 6 connected
1ff6... 172.30.94.160:6379@16379 myself,master - 0 1564492914000 3 connected 10923-16383
e32a... 172.30.94.161:6379@16379 slave 3705e... 0 1564492913000 5 connected
5845... 172.30.94.159:6379@16379 master - 0 1564492914321 1 connected 0-5460
次はポッドのリストで、IPのカラムが、K8sクラスタネットワーク上のポッドのIPアドレスです。そしてNODEのカラムが、プライベートIPアドレスが表示されていますが、実行してるノードの名前です。 redis-cliはテスト用のクライアントで、それ以外が、Redisクラスタのメンバーになります。
$ kubectl get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE
redis-cli 1/1 Running 0 11h 172.30.222.163 10.193.10.58
redis-cluster-0 1/1 Running 0 27m 172.30.94.159 10.193.10.14
redis-cluster-1 1/1 Running 0 25m 172.30.222.164 10.193.10.58
redis-cluster-2 1/1 Running 0 23m 172.30.94.160 10.193.10.14
redis-cluster-3 1/1 Running 0 21m 172.30.222.165 10.193.10.58
redis-cluster-4 1/1 Running 0 19m 172.30.94.161 10.193.10.14
redis-cluster-5 1/1 Running 0 16m 172.30.222.166 10.193.10.58
ステートフルセットと連携するサービスについて、確認しておきます。redis-cluster の ClusterIPにIPアドレスがセットされていません。
$ kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 172.21.0.1 <none> 443/TCP 4d23h
redis-cluster ClusterIP None <none> 6379/TCP,16379/TCP 108m
サービス名 redis-cluster を内部DNSで解決した場合、エンドポイントのIPアドレスを返却します。そこで、エンドポイントの詳細をリストしてみます。以下のように、Redisクラスタのメンバーの全てのIPアドレスが表示されます。 つまり、多少無駄なトラフィックが増えますがスレーブが昇格してマスターになった時に対応できるようにするためです。
$ kubectl get ep redis-cluster
NAME ENDPOINTS AGE
redis-cluster 172.30.222.164:6379,172.30.222.165:6379,172.30.222.166:6379 + 9 more... 142m
$ kubectl describe ep redis-cluster
Name: redis-cluster
Namespace: default
Labels: app=redis-cluster
Annotations: <none>
Subsets:
Addresses: 172.30.222.164,172.30.222.165,172.30.222.166,172.30.94.159,172.30.94.160,172.30.94.161
NotReadyAddresses: <none>
Ports:
Name Port Protocol
---- ---- --------
client 6379 TCP
gossip 16379 TCP
Events: <none>
redis-cliからアクセスして動作確認
新たにクライアントを起動する場合は、以下のコマンドを利用します。
$ kubectl run -it redis-cli --rm --image redis --restart=Never -- bash
既に起動している場合は、ポッドのコンテナで対話型のシェルを起動します。
$ kubectl exec -it redis-cli -- bash
そして、Redisクラスタのサービス名を指定して、クライアントのコマンドを、クラスタ対応オプション(-c)をつけて起動します。
「set a 737」では、Key=a、Value=737 になり、aに737を格納するということになります。ここで、set a 737 の次の行に注目してください。Redirect to slot が表示されて接続先が、redis-cluster から 172.30.94.160 へ切り替わっています。
root@redis-cli:/data# redis-cli -c -h redis-cluster -p 6379
redis-cluster:6379> set a 737
-> Redirected to slot [15495] located at 172.30.94.160:6379
OK
172.30.94.160:6379> set b 767
-> Redirected to slot [3300] located at 172.30.94.159:6379
OK
172.30.94.159:6379> set c 777
-> Redirected to slot [7365] located at 172.30.222.164:6379
OK
172.30.222.164:6379> set d 787
-> Redirected to slot [11298] located at 172.30.94.160:6379
OK
今度は、保存した値を取り出す側です。 ハッシュスロットによってリダイレクトが発生して、キーに対応した値が取り出されているのが解ります。
172.30.94.160:6379> get a
"737"
172.30.94.160:6379> get b
-> Redirected to slot [3300] located at 172.30.94.159:6379
"767"
172.30.94.159:6379> get c
-> Redirected to slot [7365] located at 172.30.222.164:6379
"777"
172.30.222.164:6379> get d
-> Redirected to slot [11298] located at 172.30.94.160:6379
"787"
こんな便利なコマンドもあるんですね。 カウンタなど1を足すだけの機能もあります。
172.30.94.160:6379> incr a
(integer) 738
172.30.94.160:6379> incr a
(integer) 739
172.30.94.160:6379> incr b
-> Redirected to slot [3300] located at 172.30.94.159:6379
(integer) 768
172.30.94.159:6379> incr b
(integer) 769
障害回復テスト
マスターの一つを停止します。 ポッドが停止すると、ステートフルセットコントローラーが、ただちに再起動するので、「kubectl delete po redis-cluster-1」を繰り返し実行して、fail状態になるまで繰り返します。
スレーブがマスターに昇格したところで、キー c に格納されたデータを取得しています。スレーブからマスターへ昇格した 172.30.94.161 から応答が確認されました。
## 初期状態
172.30.94.159:6379> cluster nodes
f415... 172.30.222.166:6379@16379 slave 1ff6... 0 1564537372057 6 connected
a675... 172.30.222.165:6379@16379 slave 5845... 0 1564537371000 4 connected
1ff6... 172.30.94.160:6379@16379 master - 0 1564537369000 3 connected 10923-16383
3705... 172.30.222.164:6379@16379 master - 0 1564537373064 2 connected 5461-10922 <---- マスターを削除
5845... 172.30.94.159:6379@16379 myself,master - 0 1564537369000 1 connected 0-5460
e32a... 172.30.94.161:6379@16379 slave 3705 0 1564537371000 5 connected <---- 昇格してマスターへなるはず
172.30.94.159:6379> get c
-> Redirected to slot [7365] located at 172.30.222.164:6379
"777"
### スレーブがマスターへ昇格して、再開したところ
172.30.94.159:6379> cluster nodes
f415... 172.30.222.166:6379@16379 slave 1ff6... 0 1564537559676 6 connected
a675... 172.30.222.165:6379@16379 slave 5845... 0 1564537558663 4 connected
1ff6... 172.30.94.160:6379@16379 master - 0 1564537557000 3 connected 10923-16383
3705... 172.30.222.168:6379@16379 master,fail - 1564537505901 1564537503496 2 connected <--- 停止判断
5845... 172.30.94.159:6379@16379 myself,master - 0 1564537557000 1 connected 0-5460
e32a... 172.30.94.161:6379@16379 master - 0 1564537557659 7 connected 5461-10922 <---- 昇格
172.30.94.160:6379> get c
-> Redirected to slot [7365] located at 172.30.94.161:6379
"777"
本当は、障害ポッドの切り離しと、新たなスレーブの追加の方法も書くべきなんでしょうけど、ステートフルセットの動作で、ポッドの回復と永続ボリュームとの接続は、ただちに復旧するのですが、Redisクラスタの操作は、どうしても残ってしまいます。
この後、スレーブを追加して、壊れたマスターを削除が必要になります。 ポッド上に作られるクラスタの管理は、かなり面倒なので、Operatorが、この辺りを簡単にしてくれることを期待したいと思います。
クリーンナップ
次のコマンドで削除完了です。
$ kubectl delete statefulset,svc,configmap,pvc -l app=redis-cluster
statefulset.apps "redis-cluster" deleted
service "redis-cluster" deleted
configmap "redis-cluster" deleted
persistentvolumeclaim "data-redis-cluster-0" deleted
persistentvolumeclaim "data-redis-cluster-1" deleted
persistentvolumeclaim "data-redis-cluster-2" deleted
persistentvolumeclaim "data-redis-cluster-3" deleted
persistentvolumeclaim "data-redis-cluster-4" deleted
persistentvolumeclaim "data-redis-cluster-5" deleted
まとめ
Redisクラスタは、クライアントの対応を要求するものの、複数のマスターで、負荷集中を軽減して運用でき、永続ボリュームにデータを保存することができる優れたソフトウェアであること、ステートフルセットと永続ボリュームでK8s上に構築できることがわかりました。
しかし、Redisクラスタのファイルオーバー後のスレーブノードの追加、削除など、手動で実行しなければならないため、本番前に十分な準備が必要と思われます。 Redisクラスタの運用が、RedHat のオペレータで軽くなると良いですね。期待したいと思います。
参考資料
- Running Redis Cluster on Kubernetes, https://sanderp.nl/running-redis-cluster-on-kubernetes-e451bda76cad
- GitHub Redis cluster, https://github.com/sanderploegsma/redis-cluster
- GitHub helm/charts/redis, https://github.com/helm/charts/tree/master/stable/redis
- Redisクラスターチュートリアル, https://redis.io/topics/cluster-tutorial