こんにちは。
インメモリデータストアであるRedisはRedis Sentinelと組み合わせることで自動フェイルオーバーが可能になります。
それなら「Kubernetesに冗長化構成を楽々作れる?」と思ったのですがひと手間必要だったので、やってみたことをまとめます。
(手元のシングルノード環境でお手軽に試しただけなので、本番運用は考慮していません)
注意事項
筆者の完全な勘違いで「Redisのslave-read-only
をno
に設定すれば、スレーブ側でも書き込み&レプリケーション全体で同期される」と思っていたのですが、実際にはスレーブ側で書き込んだ内容はそのスレーブ限定になるのでこの記事で紹介する構成はフェイルオーバー時にクライアント側でアレコレする必要があります……
ほとんど書いてしまった後に気づいたのですが、せっかくなので投稿します。RedisやSentinel設定の参考になれば幸いです。
RedisとRedis Sentinelについて
Redisはオープンソースのインメモリデータストアで、AWS、GCP、Azureなど各社でマネージドサービスも提供されています。
(そういう意味では自分で冗長構成組必要あるか?とも思いますが、とりあえずやってみたいのエンジニアですよね?)
Redisでは標準機能でマスターとスレーブのレプリケーション構成を組むことができます。Redisのレプリケーションはスレーブ側からも書き込みが可能です。
一方でマスター側に障害が発生した場合に「スレーブをマスターに昇格させる」というようなフェイルオーバー機能はないため、別途監視用のプロセスを用いて自動フェイルオーバーを実現するのがRedis Sentinelの機能です。
出来上がった構成
下記のような構成になりました。
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にアップロードしています。
Redis本体
Kubernetesマニュフェスト
Redis本体のPodイメージはredis公式イメージをベースに、後述する設定ファイルと起動スクリプトを内包させたものです。
マスター情報を保持するためのPVはローカルホストを使っているので、マルチノード対応は考慮が必要です。
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
としています。
スレーブの場合は後述の起動用スクリプト内で、このファイルに対して設定を追記します。
daemonize no
bind 0.0.0.0
port 6379
起動用スクリプト
Redis本体のエントリーポイントです。
最初の配置では共有ストレージのマスター情報が作られていないため、その場合には自分のIPを書き込みます。
それ以外は既に存在するマスター情報からIPを読み取り、スレーブとして参加します。
Redis Sentinelはデフォルト30秒のマスターダウンでフェイルオーバーの判断をします。
一方でStatefulSetはPodの異常を検知すると即新しいIPのPodを再配置するため、podの再配置がフェイルオーバー判定を先回りして、共有ストレージ上のマスター情報が更新される前に古いマスターIPに対してスレーブ参加してしまいます。
それを回避するためにエントリーポイント内で30秒の待機時間を設けています。
# !/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が立ち上がるまで=共有ストレージのマスター情報が書き込まれるまで待機させます。
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のマスター情報を起動用スクリプト内でこの設定ファイルに追記します。
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を起動します。
# !/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パラメータにイベントの詳細が渡されます。
具体的には、フェイルオーバーが発生しマスターが切り替わるイベントは以下のようになります。
# 第1パラメータ
+switch-master
# 第2パラメータ
mymaster 10.1.2.74 6379 10.1.2.76 6379
このログから正規表現で切り替え後のマスターIPを抽出して共有ストレージのマスター情報を書き換え、master以外のpodはこの情報を見てスレーブとして参加する、という流になります。
# !/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
実際に動かしてみる
それでは動かしてみましょう。
$ kubectl apply -f .\sample\k8s-sample.yml
Redisのpodが3つ、Sentinelのpodが3つ立ち上がりました。
$ 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)がマスターになりました。
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
ここでマスターを削除してみます。
$ kubectl delete pod session-store-0 --namespace=myapp
statefulset なので削除されたインデックス0のpodは復帰しました。
$ 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に切り替わった」**ことがわかります。
$ 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のスレーブとして参加しているのが分かります。
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の使い方など何かの参考になれば幸いです。