RedisクラスタをKubernetes上に構築する方法のメモ

## 背景

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_Cluster.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42829/9e79aac4-d177-9001-0821-82bff95fd64a.png)


Redisクラスタにデータを読み書きするアプリケーションは、Redisクラスタに対応した機能を持ったソフトウェアでなければなりません。クライアントがRedisマスターの一つにアクセスした場合、キーのハッシュによって、クライアントに対して他のRedisマスターへのリダイレクトアクセスを要求します。Redisクライアントには、そのリダイレクト要求に応じる機能が必要とされます。以下に調べたRedisクラスタに対応したプログラム言語のライブラリ、および、コマンドを列挙します。

* Ruby [redis-rb-cluster](http://github.com/antirez/redis-rb-cluster)
* Python [redis-py-cluster](https://github.com/Grokzen/redis-py-cluster)
* PHP [Predis](https://github.com/nrk/predis)
* Java [Jedis](https://github.com/xetorthio/jedis)
* C# [StackExchange.Redis](https://github.com/StackExchange/StackExchange.Redis)
* Node.js [thunk-redis](https://github.com/thunks/thunk-redis)
* Go [redis-go-cluster](https://github.com/chasex/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_Cluster_on_K8s.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42829/9ac5f9b1-2e00-0b7e-fb89-840ef0374f1c.png)




## 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 のオペレータで軽くなると良いですね。期待したいと思います。


## 参考資料
1. Running Redis Cluster on Kubernetes, https://sanderp.nl/running-redis-cluster-on-kubernetes-e451bda76cad
1. GitHub Redis cluster, https://github.com/sanderploegsma/redis-cluster
1. GitHub helm/charts/redis, https://github.com/helm/charts/tree/master/stable/redis
1. Redisクラスターチュートリアル, https://redis.io/topics/cluster-tutorial