KubernetesとRedis Sentinelで自動フェイルオーバー&レプリケーション再構成するセッションストアの構築

こんにちは。

インメモリデータストアである**Redis**は**Redis Sentinel**と組み合わせることで自動フェイルオーバーが可能になります。

それなら「Kubernetesに冗長化構成を楽々作れる？」と思ったのですがひと手間必要だったので、やってみたことをまとめます。
（手元のシングルノード環境でお手軽に試しただけなので、本番運用は考慮していません）

## 注意事項
筆者の完全な勘違いで「Redisの`slave-read-only`を`no`に設定すれば、スレーブ側でも書き込み&レプリケーション全体で同期される」と思っていたのですが、**実際にはスレーブ側で書き込んだ内容はそのスレーブ限定になるのでこの記事で紹介する構成はフェイルオーバー時にクライアント側でアレコレする必要があります……**

ほとんど書いてしまった後に気づいたのですが、せっかくなので投稿します。RedisやSentinel設定の参考になれば幸いです。


## RedisとRedis Sentinelについて
[https://redis.io/](https://redis.io/)

Redisはオープンソースのインメモリデータストアで、AWS、GCP、Azureなど各社でマネージドサービスも提供されています。  
（そういう意味では自分で冗長構成組必要あるか？とも思いますが、とりあえずやってみたいのエンジニアですよね？）

Redisでは標準機能でマスターとスレーブのレプリケーション構成を組むことができます。Redisのレプリケーションはスレーブ側からも書き込みが可能です。

一方でマスター側に障害が発生した場合に「スレーブをマスターに昇格させる」というようなフェイルオーバー機能はないため、別途監視用のプロセスを用いて自動フェイルオーバーを実現するのがRedis Sentinelの機能です。

## 出来上がった構成
下記のような構成になりました。

![k8s構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/5e815522-5558-8bb3-2501-aed1bda747ee.png)



RedisレプリケーションはStatefulSetの順序性の保証により、最初に起動したpodをmasterとして構成します。

Redis Sentinelは1台のサーバー内にプロセス3つ起動でも問題ありませんがDeploymentで配置するほうが簡単そうなのでこのようにしました。

**「Redis Sentinelのイベントトリガーを使って、フェイルオーバー時に共有ストレージに現在のマスターIPを更新していく」**という部分がポイントです。


## 環境
- Docker Desktop v20.10.7
- Kubernetes on Docker Desktop v1.21.2
- Redis 6.2.6

## 自動フェイルオーバーのための設定
ソースは[GitHub](https://github.com/quotto/redis-on-kubernetes)にアップロードしています。

### Redis本体
#### Kubernetesマニュフェスト
Redis本体のPodイメージは[redis公式イメージ](https://hub.docker.com/_/redis)をベースに、後述する設定ファイルと起動スクリプトを内包させたものです。

マスター情報を保持するためのPVはローカルホストを使っているので、マルチノード対応は考慮が必要です。

```yml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: session-store-shared-pv
  namespace: myapp
  labels:
    app: myapp
    type: storage
    subtype: shared
spec:
  storageClassName: slow
  capacity:
    storage: 1Gi
  accessModes:
  - ReadWriteMany
  hostPath:
    path: /run/desktop/mnt/host/c/Users/takah/project/session-store
    type: Directory
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: session-store-shared-pvc
  namespace: myapp
spec:
  resources:
    requests:
      storage: 1Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteMany
  storageClassName: slow
  selector:
    matchLabels:
      app: myapp
      type: storage
      subtype: shared
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: session-store
  namespace: myapp
spec:
  selector:
    matchLabels:
      app: myapp
      type: session-store
  serviceName: session-store-svc
  replicas: 3
  template:
    metadata:
      labels:
        app: myapp
        type: session-store
        kube-svc: session-store-svc
      namespace: myapp
    spec:
      containers:
      - name: redis
        image: session-store
        imagePullPolicy: Never
        ports:
          - containerPort: 6379
            name:  redis
        command:
        - "/scripts/entrypoint.sh"
        volumeMounts:
          - name:  shared-volume
            mountPath: /redis/share
      volumes:
        - name:  shared-volume
          persistentVolumeClaim:
            claimName: session-store-shared-pvc
---
kind: Service
apiVersion: v1
metadata:
  name:  session-store-svc
  namespace: myapp
spec:
  selector:
    app:  myapp
    type: session-store
  type:  ClusterIP
  clusterIP: None
  ports:
  - name:  redis
    port:  6379
    targetPort:  6379
```

#### 設定ファイル
Redis用の設定ファイルのテンプレートです。外からの接続を受け付けるために`bind 0.0.0.0` としています。

スレーブの場合は後述の起動用スクリプト内で、このファイルに対して設定を追記します。

```conf:redis.conf
daemonize no
bind 0.0.0.0
port 6379
```

#### 起動用スクリプト
Redis本体のエントリーポイントです。
最初の配置では共有ストレージのマスター情報が作られていないため、その場合には自分のIPを書き込みます。

それ以外は既に存在するマスター情報からIPを読み取り、スレーブとして参加します。

**Redis Sentinelはデフォルト30秒のマスターダウンでフェイルオーバーの判断をします。**
一方で**StatefulSetはPodの異常を検知すると即新しいIPのPodを再配置する**ため、podの再配置がフェイルオーバー判定を先回りして、**共有ストレージ上のマスター情報が更新される前に古いマスターIPに対してスレーブ参加してしまいます。**

それを回避するためにエントリーポイント内で30秒の待機時間を設けています。

```bash:entrypoint.sh

#!/bin/bash
#!/bin/bash
set -e

if [[ -z ${SHARED_MASTER_INFO_FILE} ]]; then
    export SHARED_MASTER_INFO_FILE=/redis/share/master
fi

# masterで再起動された場合,Sentinelのフェイルオーバーを追い越してしまう可能性があるため
# 起動まで30秒待機（Sentinelのフェイルオーバー判断するまでの待機時間がデフォルト30秒）
echo "waiting for start in 30 seconds..."
sleep 30

if [[ $(hostname) =~ (.+)-([0-9]+)$ ]]; then
    podname=${BASH_REMATCH[1]}
    ordinal=${BASH_REMATCH[2]}
    if [[ ${ordinal} -eq 0 ]] && [[ ! -f ${SHARED_MASTER_INFO_FILE} ]]; then
        # マスターの場合は共有ストレージのファイルにIPアドレスを書き込む
        master_ip=$(hostname -i)
        echo "This is master, set own ip: ${master_ip}"
        echo ${master_ip} >> ${SHARED_MASTER_INFO_FILE}
    else
        # スレーブの場合はマスターのIPアドレスを指定してslaveofを設定
        master_ip=$(cat ${SHARED_MASTER_INFO_FILE})
        echo "This is slave, use master info file: ${master_ip}"
        echo "slaveof ${master_ip} 6379" >> ${REDIS_CONF}
    fi
    redis-server ${REDIS_CONF}
fi
```

### Redis Sentinel
続いてRedis Sentinelの各種設定です。
                                                                                        
#### Kubernetesマニュフェスト
Redis Sentinelもredis公式をベースにエントリーポイントとイベント通知用スクリプト、設定ファイルをもたせたカスタムイメージです。
                                                                                        DeploymentのPodで使っているPVCは前段に定義したものです。

`initContainers`で**最初のRedisが立ち上がるまで＝共有ストレージのマスター情報が書き込まれるまで待機させます。**

```yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name:  session-store-redis-sentinel
  namespace: myapp
  labels:
    app: myapp
    type:  session-store-sidecar
spec:
  replicas: 3
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate
  selector:
    matchLabels:
        app: myapp
        type:  session-store-sidecar
  template:
    metadata:
      labels:
        app: myapp
        type:  session-store-sidecar
    spec:
      initContainers:
      - name: wait-container
        image: redis-sentinel
        imagePullPolicy: Never
        command: ['sh','-c','until [ -f /redis/share/master ]; do echo waiting for redis server... >> /redis/share/sentinel.log; cat /redis/share/master >> /redis/share/sentinel.log; sleep 10; done']
        volumeMounts:
          - name:  shared-volume
            mountPath: /redis/share
      containers:
      - name: redis-sentinel
        image: redis-sentinel
        imagePullPolicy: Never
        command:
        - "/scripts/entrypoint-sentinel.sh"
        env:
        - name: SHARED_MASTER_INFO_FILE
          value: /redis/share/master
        ports:
          - containerPort: 23679
            name:  sentinel
        volumeMounts:
          - name:  shared-volume
            mountPath: /redis/share
      volumes:
        - name:  shared-volume
          persistentVolumeClaim:
            claimName: session-store-shared-pvc
```

#### 設定ファイル

Sentinelの起動時に指定する設定ファイルです。`sentinel notification-script`を設定することで、Sentinelのイベント発生時にスクリプトを起動します。

監視するRedisのマスター情報を起動用スクリプト内でこの設定ファイルに追記します。

```conf:redis-sentinel.conf
daemonize no
protected-mode no
port 26379
pidfile /redis/sentinel.pid
logfile /redis/sentinel.log
sentinel notification-script mymaster /scripts/notify.sh
```

#### 起動用スクリプト
エントリーポイントは単純で、共有ストレージのマスターIPを指定してRedis Sentinelを起動します。

```bash:entrypoint-sentinel.sh
#!/bin/bash
set -e

if [[ -z ${SHARED_MASTER_INFO_FILE} ]]; then
    export SHARED_MASTER_INFO_FILE=/redis/share/master
fi

master_ip=""
if [[ ! -f ${SHARED_MASTER_INFO_FILE} ]]; then
    echo "master info file not found"
    exit 1
else
    master_ip=$(cat ${SHARED_MASTER_INFO_FILE})
    echo "sentinel monitor mymaster ${master_ip} 6379 2" >> ${REDIS_SENTINEL_CONF}
    redis-sentinel ${REDIS_SENTINEL_CONF}
fi
```

#### フェイルオーバー通知用スクリプト
ここがポイントです。

Sentinel設定ファイルに`notify−script`を設定するとSentinelのイベント発生時、**第1パラメータにイベント種別、第2パラメータにイベントの詳細が渡されます。**

具体的には、フェイルオーバーが発生しマスターが切り替わるイベントは以下のようになります。

```bash
# 第1パラメータ
+switch-master

# 第2パラメータ
mymaster 10.1.2.74 6379 10.1.2.76 6379
```

このログから正規表現で切り替え後のマスターIPを抽出して共有ストレージのマスター情報を書き換え、master以外のpodはこの情報を見てスレーブとして参加する、という流になります。

```bash:notify.sh
#!/bin/bash

echo $1 >> /redis/event.log
echo $2 >> /redis/event.log
# +switch-masterが発生したら詳細情報から切替先のマスターIPを抽出して共有ストレージのファイルを上書きする
if [[ $1 == "+switch-master" ]]; then
    if [[ $2 =~ .+[[:space:]][0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[[:space:]][0-9]+[[:space:]]([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) ]]; then
        master_ip=${BASH_REMATCH[1]}
        echo "Over write master ip on shared file: ${master_ip}" >> /redis/event.log
        echo  ${master_ip} > ${SHARED_MASTER_INFO_FILE}
    fi
fi
```

## 実際に動かしてみる
それでは動かしてみましょう。

```console
$ kubectl apply -f .\sample\k8s-sample.yml
```

Redisのpodが3つ、Sentinelのpodが3つ立ち上がりました。

```console
$ kubectl get all --namespace=myapp
NAME                                                READY   STATUS    RESTARTS   AGE
pod/session-store-0                                 1/1     Running   0          49s
pod/session-store-1                                 1/1     Running   0          47s
pod/session-store-2                                 1/1     Running   0          41s
pod/session-store-redis-sentinel-66c49d5955-8pzcw   1/1     Running   0          49s
pod/session-store-redis-sentinel-66c49d5955-f77qv   1/1     Running   0          49s
pod/session-store-redis-sentinel-66c49d5955-mfx4g   1/1     Running   0          49s

NAME                        TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
service/session-store-svc   ClusterIP   None         <none>        6379/TCP   49s

NAME                                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/session-store-redis-sentinel   3/3     3            3           49s

NAME                                                      DESIRED   CURRENT   READY   AGE
replicaset.apps/session-store-redis-sentinel-66c49d5955   3         3         3       49s

NAME                             READY   AGE
statefulset.apps/session-store   3/3     49s
```

初回起動なので予定どおりstatefulset の1つめ（インデックス0）がマスターになりました。

```sh
root@session-store-0:/redis# redis-cli
127.0.0.1:6379> info replication
# Replication
role:master
connected_slaves:2
slave0:ip=10.1.2.150,port=6379,state=online,offset=17182,lag=1
slave1:ip=10.1.2.151,port=6379,state=online,offset=16912,lag=1
master_failover_state:no-failover
master_replid:ebf677a9f4611c50ac75aee1dd1b9380491980cc
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:17317
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:17317
```

ここでマスターを削除してみます。

```console
$ kubectl delete pod session-store-0 --namespace=myapp
```

statefulset なので削除されたインデックス0のpodは復帰しました。

```console
$ kubectl get all --namespace=myapp
NAME                                                READY   STATUS    RESTARTS   AGE
pod/session-store-0                                 1/1     Running   0          26s
pod/session-store-1                                 1/1     Running   0          4m27s
pod/session-store-2                                 1/1     Running   0          4m21s
pod/session-store-redis-sentinel-66c49d5955-8pzcw   1/1     Running   0          4m29s
pod/session-store-redis-sentinel-66c49d5955-f77qv   1/1     Running   0          4m29s
pod/session-store-redis-sentinel-66c49d5955-mfx4g   1/1     Running   0          4m29s
```

ここでsentinelのイベントログを見てみると**「redisのマスターが10.1.2.146から10.1.2.150に切り替わった」**ことがわかります。

```console
$ root@session-store-redis-sentinel-66c49d5955-8pzcw:/redis# cat /redis/sentinel.log
...
9:X 14 Nov 2021 15:15:53.290 # +sdown master mymaster 10.1.2.146 6379
9:X 14 Nov 2021 15:15:53.400 # +new-epoch 1
9:X 14 Nov 2021 15:15:53.414 # +vote-for-leader 70918ed35e8a13c470ac2eb16067d893750691fd 1
9:X 14 Nov 2021 15:15:53.852 # +config-update-from sentinel 70918ed35e8a13c470ac2eb16067d893750691fd 10.1.2.147 26379 @ mymaster 10.1.2.146 6379
9:X 14 Nov 2021 15:15:53.852 # +switch-master mymaster 10.1.2.146 6379 10.1.2.150 6379
9:X 14 Nov 2021 15:15:53.852 * +slave slave 10.1.2.151:6379 10.1.2.151 6379 @ mymaster 10.1.2.150 6379
9:X 14 Nov 2021 15:15:53.852 * +slave slave 10.1.2.146:6379 10.1.2.146 6379 @ mymaster 10.1.2.150 6379
9:X 14 Nov 2021 15:16:03.965 * +slave slave 10.1.2.152:6379 10.1.2.152 6379 @ mymaster 10.1.2.150 6379
9:X 14 Nov 2021 15:16:23.891 # +sdown slave 10.1.2.146:6379 10.1.2.146 6379 @ mymaster 10.1.2.150 6379
```

復帰したインデックス0のpodに入ってレプリケーション情報を見てみると、10.1.2.150のスレーブとして参加しているのが分かります。

```console
root@session-store-0:/redis# redis-cli
127.0.0.1:6379> info replication
# Replication
role:slave
master_host:10.1.2.150
master_port:6379
master_link_status:up
master_last_io_seconds_ago:1
master_sync_in_progress:0
slave_read_repl_offset:112386
slave_repl_offset:112386
slave_priority:100
slave_read_only:1
replica_announced:1
connected_slaves:0
master_failover_state:no-failover
master_replid:905df3a7cd6654a6cd525593e7d4a0920b789e2a
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:112386
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:41809
repl_backlog_histlen:70578
```

## 動作確認
「Kubernetesのサービスにアクセスすればフェイルオーバー後も後ろを意識せずに読み書きできるぞ！」とやりたかったのですが、**冒頭に書いたとおりRedis ResplicationはSlave側で書き込んでも同期されないためダメでした。**

中途半端になってしまいましたが、Sentinelの使い方など何かの参考になれば幸いです。