LabHub

ブログ

Redis Cluster 構築と運用の実践ガイド — シャーディング、レプリケーション、フェイルオーバー

한국어English日本語

Redis Cluster Setup

はじめに

単一の Redis インスタンスにはメモリとスループットに限界があります。Redis Cluster はデータを複数のノードに自動分散(シャーディング)し、ノード障害時に自動フェイルオーバーを提供するネイティブクラスタリングソリューションです。

この記事では、Redis Cluster のアーキテクチャを理解し、実際の構築から運用までをステップバイステップで見ていきます。

この記事が基準にしているバージョン

この記事のコマンドと設定のデフォルト値は、すべて Redis 7.4 のオープンソース版を基準にしています。下の docker compose の例が redis:7.4 イメージを固定して使っているのと同じバージョンです。

クラスター関連のコマンド名や設定のデフォルト値はメジャーバージョンごとに変わります。たとえばスロットマップを読む CLUSTER SLOTS は公式仕様ドキュメントですでに deprecated と明記されており、新しいクライアントは CLUSTER SHARDS を使うよう案内されています。別のメジャーバージョンを使っているなら、コマンド名とデフォルト値は必ずそのバージョンのドキュメントで確認してください。この記事で数値として断定したデフォルト値は、後述の「クラスター設定パラメータとデフォルト値」の節に出典つきでまとめてあります。

Redis Cluster アーキテクチャ

ハッシュスロット(Hash Slots)

Redis Cluster は 16,384個のハッシュスロット を使ってデータを分散します:

# キーのハッシュスロット計算
# HASH_SLOT = CRC16(key) % 16384

# 例: 3つのマスターノード
# Node A: スロット 0 ~ 5460
# Node B: スロット 5461 ~ 10922
# Node C: スロット 10923 ~ 16383

クラスタートポロジー

# 最小推奨構成: 3 Master + 3 Replica = 6 ノード
#
# Master A (スロット 0-5460)     ←→  Replica A'
# Master B (スロット 5461-10922)  ←→  Replica B'
# Master C (スロット 10923-16383) ←→  Replica C'
#
# 各 Master がダウンすると該当 Replica が自動昇格

6ノード Redis Cluster の構築

Docker Compose で構築

# docker-compose.yml
version: '3.8'

services:
  redis-node-1:
    image: redis:7.4
    container_name: redis-node-1
    ports:
      - '7001:7001'
      - '17001:17001'
    volumes:
      - ./redis-node-1:/data
    command: >
      redis-server
      --port 7001
      --cluster-enabled yes
      --cluster-config-file nodes.conf
      --cluster-node-timeout 5000
      --appendonly yes
      --protected-mode no
      --bind 0.0.0.0
    networks:
      redis-cluster:
        ipv4_address: 172.20.0.11

  redis-node-2:
    image: redis:7.4
    container_name: redis-node-2
    ports:
      - '7002:7002'
      - '17002:17002'
    volumes:
      - ./redis-node-2:/data
    command: >
      redis-server
      --port 7002
      --cluster-enabled yes
      --cluster-config-file nodes.conf
      --cluster-node-timeout 5000
      --appendonly yes
      --protected-mode no
      --bind 0.0.0.0
    networks:
      redis-cluster:
        ipv4_address: 172.20.0.12

  redis-node-3:
    image: redis:7.4
    container_name: redis-node-3
    ports:
      - '7003:7003'
      - '17003:17003'
    volumes:
      - ./redis-node-3:/data
    command: >
      redis-server
      --port 7003
      --cluster-enabled yes
      --cluster-config-file nodes.conf
      --cluster-node-timeout 5000
      --appendonly yes
      --protected-mode no
      --bind 0.0.0.0
    networks:
      redis-cluster:
        ipv4_address: 172.20.0.13

  redis-node-4:
    image: redis:7.4
    container_name: redis-node-4
    ports:
      - '7004:7004'
      - '17004:17004'
    volumes:
      - ./redis-node-4:/data
    command: >
      redis-server
      --port 7004
      --cluster-enabled yes
      --cluster-config-file nodes.conf
      --cluster-node-timeout 5000
      --appendonly yes
      --protected-mode no
      --bind 0.0.0.0
    networks:
      redis-cluster:
        ipv4_address: 172.20.0.14

  redis-node-5:
    image: redis:7.4
    container_name: redis-node-5
    ports:
      - '7005:7005'
      - '17005:17005'
    volumes:
      - ./redis-node-5:/data
    command: >
      redis-server
      --port 7005
      --cluster-enabled yes
      --cluster-config-file nodes.conf
      --cluster-node-timeout 5000
      --appendonly yes
      --protected-mode no
      --bind 0.0.0.0
    networks:
      redis-cluster:
        ipv4_address: 172.20.0.15

  redis-node-6:
    image: redis:7.4
    container_name: redis-node-6
    ports:
      - '7006:7006'
      - '17006:17006'
    volumes:
      - ./redis-node-6:/data
    command: >
      redis-server
      --port 7006
      --cluster-enabled yes
      --cluster-config-file nodes.conf
      --cluster-node-timeout 5000
      --appendonly yes
      --protected-mode no
      --bind 0.0.0.0
    networks:
      redis-cluster:
        ipv4_address: 172.20.0.16

networks:
  redis-cluster:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24
# コンテナを起動
docker compose up -d

# クラスター作成(3 master + 3 replica)
docker exec -it redis-node-1 redis-cli --cluster create \
  172.20.0.11:7001 172.20.0.12:7002 172.20.0.13:7003 \
  172.20.0.14:7004 172.20.0.15:7005 172.20.0.16:7006 \
  --cluster-replicas 1 --cluster-yes

# クラスター状態確認
docker exec -it redis-node-1 redis-cli -p 7001 cluster info
docker exec -it redis-node-1 redis-cli -p 7001 cluster nodes

ベアメタル / VM での構築

# Redis インストール(Ubuntu)
sudo apt update && sudo apt install -y redis-server

# ノード別設定ファイルの作成
cat > /etc/redis/redis-7001.conf << 'EOF'
port 7001
cluster-enabled yes
cluster-config-file nodes-7001.conf
cluster-node-timeout 5000
appendonly yes
appendfilename "appendonly-7001.aof"
dbfilename dump-7001.rdb
dir /var/lib/redis/7001
logfile /var/log/redis/redis-7001.log
pidfile /var/run/redis/redis-7001.pid
protected-mode no
bind 0.0.0.0

# メモリ設定
maxmemory 4gb
maxmemory-policy allkeys-lru

# パフォーマンスチューニング
tcp-backlog 511
timeout 0
tcp-keepalive 300
EOF

# ディレクトリ作成
sudo mkdir -p /var/lib/redis/7001
sudo chown redis:redis /var/lib/redis/7001

# サービス起動
sudo redis-server /etc/redis/redis-7001.conf --daemonize yes

# 6ノードすべて起動後、クラスターを作成
redis-cli --cluster create \
  192.168.1.1:7001 192.168.1.2:7002 192.168.1.3:7003 \
  192.168.1.4:7004 192.168.1.5:7005 192.168.1.6:7006 \
  --cluster-replicas 1

クラスター設定パラメータとデフォルト値

クラスターの挙動を左右する設定はごくわずかです。前述の compose ファイルは cluster-node-timeout 5000 を明示していますが、これはディストリビューションのデフォルトではなく、例のためにわざと下げた値です。以下のデフォルト値は Redis 7.4 の redis.conf のコメントに書かれている値で、いずれもコメントアウトされた状態で配布されます。つまり設定ファイルに書かなければこの値が効きます。

ここに載っていない値をチューニングする必要があるなら、推測せずに使用中のバージョンの redis.conf のコメントを直接読んでください。このファイルはディストリビューションに必ず付属し、各オプションのデフォルト値がコメントにそのまま書かれています。

クラスター運用

データの読み書き

# クラスターモードで接続(-c フラグ)
redis-cli -c -h 172.20.0.11 -p 7001

# MOVED リダイレクションが自動処理される
172.20.0.11:7001> SET user:1000 "Kim Youngju"
-> Redirected to slot [3817] located at 172.20.0.11:7001
OK

172.20.0.11:7001> SET user:2000 "Park Minho"
-> Redirected to slot [8234] located at 172.20.0.12:7002
OK

Hash Tag で同じスロットに格納

# {user:1000} の部分のみハッシュ計算に使用される
SET {user:1000}.profile "Kim Youngju"
SET {user:1000}.email "youngju@example.com"
SET {user:1000}.settings "{\"theme\":\"dark\"}"

# 同じスロットに格納されるので MGET が可能
MGET {user:1000}.profile {user:1000}.email

Python クライアント

from redis.cluster import RedisCluster

# クラスター接続
rc = RedisCluster(
    startup_nodes=[
        {"host": "172.20.0.11", "port": 7001},
        {"host": "172.20.0.12", "port": 7002},
        {"host": "172.20.0.13", "port": 7003},
    ],
    decode_responses=True,
    skip_full_coverage_check=True
)

# 基本操作
rc.set("user:1000", "Kim Youngju")
print(rc.get("user:1000"))

# パイプライン(同じスロットのキーのみ)
pipe = rc.pipeline()
pipe.set("{user:1000}.name", "Kim Youngju")
pipe.set("{user:1000}.age", "30")
pipe.get("{user:1000}.name")
results = pipe.execute()
print(results)

# クラスター情報
print(rc.cluster_info())

ノードの追加 / 削除

# 新しいマスターノードを追加
redis-cli --cluster add-node 172.20.0.17:7007 172.20.0.11:7001

# スロットのリバランス
redis-cli --cluster rebalance 172.20.0.11:7001

# 新しいノードにレプリカを追加
redis-cli --cluster add-node 172.20.0.18:7008 172.20.0.11:7001 \
  --cluster-slave --cluster-master-id <master-node-id>

# ノード削除(まずスロットを他のノードに移動)
redis-cli --cluster reshard 172.20.0.11:7001 \
  --cluster-from <removing-node-id> \
  --cluster-to <target-node-id> \
  --cluster-slots 5461 \
  --cluster-yes

redis-cli --cluster del-node 172.20.0.11:7001 <removing-node-id>

MOVED と ASK — リダイレクトの二つの意味

Redis Cluster で最も誤解されている箇所です。どちらの応答も「別のノードへ行け」という意味ですが、クライアントがすべきことは正反対です。

MOVED — スロットの持ち主が恒久的に変わった

ノードは受け取ったキーのハッシュスロットを自分が担当していない場合、内部のスロットとノードの対応表を見て MOVED エラーを返します。仕様ドキュメントの例はこうです。

GET x
-MOVED 3999 127.0.0.1:6381

エラーにはキーのハッシュスロット番号(3999)と、そのクエリを処理できるインスタンスのエンドポイントとポートが入っています。エンドポイントは IP アドレスでもホスト名でもよく、空の場合もあります。空なら「ポートだけ変えて、今と同じエンドポイントへ送り直せ」という意味です。

MOVED を受け取ったクライアントはスロットマップを更新すべきです。仕様はスロット 1 つだけを直すより CLUSTER SHARDS でマップ全体を読み直すことを勧めています。リダイレクトが起きたということは、たいていスロット 1 つではなく複数が同時に再構成されたという合図だからです。レプリカがマスターに昇格すると、そのマスターが担当していたスロットすべてが一度に再配置されます。

ASK — このキーだけ、この 1 リクエストだけ

スロットがマイグレーション中のときに出ます。移行元ノードでスロットが MIGRATING と印されると、そのノードはそのスロットへのクエリを受け付けますが、キーが実際に存在するときだけ 処理します。存在しなければ移行先ノードへ ASK リダイレクトを返します。移行先ノードはスロットが IMPORTING 状態なので、ASKING コマンドが先に来ていないクエリは本来の持ち主へ MOVED で送り返します。

仕様が整理しているクライアント側の ASK 処理ルールは 3 行です。

  1. リダイレクトされたそのクエリだけを指定ノードへ送り、以降のクエリは元のノードへ送り続ける。
  2. リダイレクトされたクエリは ASKING コマンドで始める。
  3. ローカルのスロットマップはまだ更新しない。

ASKING はクライアントに一度きりのフラグを立て、IMPORTING 状態のスロットへのクエリを強制的に処理させます。本当に一度きりです。

ASK を MOVED のように扱うと何が起きるか

スロットマップが事実とずれます。マイグレーションはまだ終わっていないのに、クライアントはそのスロットを移行先ノードの所有として記録してしまいます。その状態で次のリクエストを移行先へ直接送ると、ASKING を前置していないため、移行先は MOVED で元の持ち主を指し返します。

データは壊れません。仕様が明示しているとおり、ASKING の要求こそがその安全装置です。ただし代償は払います。そのスロットへの全リクエストが往復を 1 回余計にすることになり、クライアントのマップはマイグレーションが終わるまで 2 つのノードの間を行き来し続けます。トラフィックの多いスロットを移している最中なら、この追加の往復はレイテンシのグラフにそのまま現れます。マイグレーションが終われば移行元が MOVED を返すので、そのときに初めてマップを恒久的に更新すればよいのです。

redis-cli -c はこの 2 つを自動で処理します。自分でクライアントを書くのでなければライブラリを信じてよいですが、そのライブラリが 2 つの応答を区別しているかは確認する価値があります。仕様は ASK リダイレクトを扱えないクライアントを「完全な Redis Cluster クライアントではない」と言い切っています。

リシャーディングの実践ウォークスルー

スロットを移す作業は、ノード追加・ノード削除・リバランスがすべて同じ演算に抽象化されています。仕様の表現どおり「ハッシュスロットをあるノードから別のノードへ移すこと」ただ 1 つです。

ステップ 1 — 移す前に状態を記録する

redis-cli --cluster check 127.0.0.1:7000

# ノード ID はここで確認します
redis-cli -p 7000 cluster nodes | grep myself
# 出力例(仕様ドキュメントの形)
97a3a64667477371c4479320d683e4c8db5858b1 :0 myself,master - 0 0 0 connected 0-5460

[OK] All 16384 slots covered

[OK] All 16384 slots covered は、16384 個のスロットそれぞれを担当するマスターが最低 1 つずつあるという意味です。この行が出ないならリシャーディングを始めないでください。すでに壊れたクラスターでスロットを移すと、原因の切り分けが難しくなります。

ステップ 2 — リシャーディングの実行

redis-cli --cluster reshard 127.0.0.1:7000

ノードを 1 つ指定すれば残りは redis-cli が自分で見つけます。対話形式で 3 つのことを聞かれます。

How many slots do you want to move (from 1 to 16384)?

次にスロットを受け取る対象ノードのノード ID を聞かれ、最後にどのノードから取るかを聞かれます。ここで all と答えると、他のすべてのマスターから少しずつ取ります。確定すると移すスロットごとにメッセージが 1 行ずつ出て、実際に移動するキー 1 つごとにドットが 1 つ打たれます。

自動化するなら非対話形式を使います。

redis-cli --cluster reshard <host>:<port> \
  --cluster-from <node-id> \
  --cluster-to <node-id> \
  --cluster-slots <number of slots> \
  --cluster-yes

--cluster-yes はプロンプトに自動で yes と答えさせます。環境変数 REDISCLI_CLUSTER_YES でも有効にできます。

ステップ 3 — 移動中に内部で起きていること

スロット 8 を A から B へ移す場合、redis-cli はこの順序で動きます。

# B へ: このスロットを A から受け取っている最中だ
CLUSTER SETSLOT 8 IMPORTING A

# A へ: このスロットを B へ渡している最中だ
CLUSTER SETSLOT 8 MIGRATING B

# A のスロット 8 にあるキーを count 個ずつ取り出して
CLUSTER GETKEYSINSLOT slot count

# アトミックに移す
MIGRATE target_host target_port "" target_database id timeout KEYS key1 key2 ...

# 終わったら両側(そして通常は他の全ノード)に正常状態を知らせる
CLUSTER SETSLOT <slot> NODE <node-id>

MIGRATE は移行先インスタンスに接続してシリアライズしたキーを送り、OK を受け取ったら自分側のキーを削除します。移動のごく短い間だけ両インスタンスがロックされるので、競合状態は起きません。外部クライアントから見ると、キーは常に A か B のちょうど一方にだけ存在します。

読み書きが止まらない理由がここにあります。他のノードは依然としてスロット 8 について A を案内し、A にまだ残っているキーは A が処理し、A にないキーは ASK で B へ渡されます。だから新しいキーが A に増えないまま、リクエストは途切れません。

ただし複数キーのコマンドは例外です。マイグレーション中でも、対象キーがすべて存在し、すべて同じスロットの片側(移行元か移行先のどちらか)に揃っていれば正常に動きます。しかしキーの一部が存在しなかったり、移行元と移行先に分かれていたりすると -TRYAGAIN エラーになります。クライアントは少し待って再試行するか、エラーをそのまま返すべきです。そのスロットのマイグレーションが終われば、複数キー演算はまた正常に戻ります。

ステップ 4 — 終わったら再確認

redis-cli --cluster check 127.0.0.1:7000

スロットは引き続き全部カバーされているはずで、対象ノードのスロット数だけが増えているはずです。ドキュメントの例では 1000 個のスロットを移したあと 127.0.0.1:7000 が 6461 個前後を担当するようになります。この確認を飛ばすと、途中で中断したマイグレーションのせいでスロットが MIGRATING/IMPORTING のまま残り、後で CLUSTERDOWN として返ってきます。

自動フェイルオーバー

フェイルオーバーの動作過程

# 1. Master A のダウンを検知(cluster-node-timeout 秒後)
# 2. Replica A' が他の Master に投票を要求
# 3. 過半数の Master が承認すれば Replica A' が Master に昇格
# 4. 新しい Master A' が既存のスロットを担当

# フェイルオーバーテスト
docker stop redis-node-1

# 状態確認(Replica が Master に昇格済み)
docker exec -it redis-node-2 redis-cli -p 7002 cluster nodes

手動フェイルオーバー

# Replica で実行(graceful failover)
redis-cli -h 172.20.0.14 -p 7004 CLUSTER FAILOVER

# 強制フェイルオーバー(Master がダウンしている場合)
redis-cli -h 172.20.0.14 -p 7004 CLUSTER FAILOVER FORCE

フェイルオーバーのタイムライン — 実際に何が起きているのか

上の 4 ステップの要約は正しいものの、粗すぎます。障害時間を見積もるには、各段階が何にどれだけ縛られているかを知る必要があります。以下はクラスター仕様ドキュメントの障害検知とレプリカ選出の手順を順番に写したものです。読みやすさのため、以下では cluster-node-timeout の値をノードタイムアウトと呼びます。

1. マスターが応答を止める。他のノードは、すでに送った ping への応答を待っている状態になります。ノードタイムアウトの半分が過ぎると、ノードは相手と接続を張り直そうとします。切れた TCP 接続が誤検知につながるのを防ぐための仕組みです。

2. PFAIL を立てる。ノードタイムアウトを超えても応答がなければ、そのノードを PFAIL(possible failure)と印します。マスターでもレプリカでも、相手の種類に関係なく印せます。重要なのは PFAIL が 1 つのノードのローカルな判断にすぎない ということです。これだけでは何も起きません。

3. gossip が PFAIL を FAIL へ昇格させる。すべてのノードはハートビートに、自分が知っているいくつかのノードの状態を載せて送ります。ノード A が B を PFAIL と見ていて、過半数のマスターも同じ B を PFAIL か FAIL と報告している事実を A が gossip で集めると、昇格の条件が成立します。この報告は、ノードタイムアウトに有効性の係数を掛けた時間内に届いたものだけが数えられ、現在の実装ではその係数は 2 です。つまりノードタイムアウトの 2 倍の窓に集まった報告だけを数えます。条件が揃うと A は B を FAIL と印し、到達可能なすべてのノードへ FAIL メッセージを直接送ります。

4. レプリカは少し待つ。マスターが FAIL になっても、レプリカはすぐには選挙を始めません。仕様に書かれている遅延の計算式はこれです。

DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds +
        REPLICA_RANK * 1000 milliseconds.

固定の 500ms は FAIL 状態がクラスター全体へ伝播する時間を稼ぎます。まだ FAIL を知らないマスターは投票を拒むからです。ランダム遅延はレプリカ同士が同時に選挙を始めないよう散らします。REPLICA_RANK はレプリケーションオフセットが最も進んだレプリカが 0、次が 1 という順で付くので、データが最新のレプリカが先に動きます。順位は強制されないので、上位が当選に失敗すれば下位がすぐ後に試みます。

5. 投票の要求。レプリカは自分の currentEpoch を 1 つ上げ、すべてのマスターへ FAILOVER_AUTH_REQUEST をブロードキャストします。応答はノードタイムアウトの 2 倍、ただし最低 2 秒待ちます。

6. 過半数の承認。マスターが FAILOVER_AUTH_ACK で答え、過半数が集まれば当選です。マスターは 1 つのエポックにつき 1 回しか投票せず、同じマスターの別のレプリカにはノードタイムアウトの 2 倍の間は投票し直しません。過半数が集まらなければ選挙は中断され、ノードタイムアウトの 4 倍(最低 4 秒)後に再試行します。

7. 昇格と伝播。当選したレプリカは既存のどのマスターより大きい新しい configEpoch を得て、自分をマスターとして広告し、担当スロットの集合を一緒に載せます。再構成を速めるため pong パケットをクラスター全体へブロードキャストします。他のノードは同じスロットをより大きい configEpoch で主張する新しいマスターを見て、設定を更新します。

クライアントが各段階で見るもの

結局その窓はどれくらいか

ドキュメントは、多数派側のクラスターがノードタイムアウトの時間に加えて、レプリカが選出されてフェイルオーバーを終えるのに必要な数秒後に再び利用可能になると書き、フェイルオーバー自体は通常 1〜2 秒で終わると添えています。つまり実質的な下限が cluster-node-timeout プラス数秒です。デフォルトの 15000ms をそのまま使えば 15 秒以上になり、例の compose が 5000ms に下げているのはまさにこのためです。

とはいえ、いくらでも下げられるわけではありません。仕様は、この仕組みが働くにはノードタイムアウトがネットワークの往復時間に比べて十分大きくなければならないと明記しています。値が小さいと一瞬の遅延も PFAIL と読まれ、障害がないのにフェイルオーバーが起きます。フェイルオーバーはタダではなく、後述の書き込み損失の窓を伴うので、不要なフェイルオーバーはそれ自体が事故です。

一貫性の境界 — Redis Cluster が失いうる書き込み

この節がなければこの記事は誠実ではありません。Redis Cluster はノード間のレプリケーションを 非同期 で行い、衝突解決のルールは仕様の表現どおり「last failover wins」です。つまり最後に選出されたマスターのデータセットが最終的に他を上書きします。そのため、ack された書き込みが消えうる窓が 2 つ存在します。

窓 1 — 非同期レプリケーションの窓

マスターはクライアントへ OK を返すことと、レプリカへ書き込みを伝播することをほぼ同時に行います。マスターが伝播の前に死に、レプリカが昇格するほど長く到達不能なら、その書き込みは永久に失われます。仕様はこれを観測しにくいとしつつ「実世界の障害モード」だと明記しています。

この窓は設定で消せません。非同期レプリケーションの定義そのものだからです。

窓 2 — 分断されたマスターの窓

マスターがネットワーク分断で多数派から切れているのに、クライアントはまだそのマスターにつながっている状況です。多数派側でレプリカが昇格すると、少数派マスターがその間に受けた書き込みはすべて捨てられます。

幸いこの窓には上限があります。マスターがフェイルオーバーされるには、過半数のマスターから最低でもノードタイムアウトの間到達不能でなければなりません。その前に分断が回復すれば失う書き込みはありません。そして少数派側のマスターは、多数派と連絡が取れないままノードタイムアウトが過ぎると 自分から書き込みを拒否し始めます。これで失いうる書き込みの最大の窓がノードタイムアウトで抑えられます。その時間を過ぎれば少数派はそもそも利用可能でないので、それ以上受けることも失うこともありません。

理論上はもう 1 つあります。フェイルオーバーされた古いマスターが分断から戻ってきたのに、クライアントのルーティングテーブルがまだ古くて古いマスターへ書いてしまう場合です。仕様はこのモードが起きにくいと見ています。多数派と十分長く通信できなかったマスターはすでに書き込みを拒否しており、分断が回復した後も設定変更が伝播するまでしばらく拒否し続けるからです。

可用性もまた確率の問題です

マスター N 台にそれぞれレプリカ 1 台という構成では、1 ノードが切り離されている間は多数派が利用可能なままです。2 ノードが切り離されると、利用可能なままである確率は 1-(1/(N*2-1)) になります。マスター 5 台のクラスターなら、2 ノードが切り離されたときにクラスターが利用不能になる確率は約 11.11% です。ノードを増やしてもこの確率がゼロになることはありません。

WAIT はどこまでやってくれるのか

WAIT numreplicas timeout は、現在のコネクションで先に送ったすべての書き込みが最低 numreplicas 個のレプリカへ転送され確認されるまでブロックします。タイムアウト(ミリ秒)に達すればその数に届かなくても返ります。戻り値は成功でもタイムアウトでも実際に確認したレプリカ数なので、その値が要求した数以上かどうかはクライアント自身が確かめる必要があります。MULTI の中や、スクリプトのようにブロックが許されない文脈ではブロックせず、現在の数をすぐに返します。タイムアウト 0 は無限待ちです。

保証しないことのほうが重要です。ドキュメントは WAIT が Redis を強一貫なストアにするわけではないとはっきり書いています。書き込みが 1 つ以上のレプリカへ転送されていれば、フェイルオーバーの際にその書き込みを持つレプリカが昇格する 可能性が高くなるだけで保証はされません。Sentinel も Cluster も最善努力で最良のレプリカを選ぼうとするだけで、ドキュメントの表現どおり、複数のレプリカへ同期的に複製された書き込みでも依然として失われることがあります。

まとめると WAIT は窓を狭める道具であって、なくす道具ではありません。失ってはいけないデータがあるなら、そのデータの原本は Redis であるべきではありません。

モニタリング

主要メトリクス

# クラスター状態確認
redis-cli -p 7001 cluster info
# cluster_state:ok
# cluster_slots_assigned:16384
# cluster_slots_ok:16384
# cluster_known_nodes:6

# ノード別メモリ使用量
redis-cli -p 7001 info memory
# used_memory_human:1.5G
# maxmemory_human:4.0G

# スロット分配の確認
redis-cli --cluster check 172.20.0.11:7001

Prometheus + Grafana によるモニタリング

# docker-compose.monitoring.yml
services:
  redis-exporter:
    image: oliver006/redis_exporter:latest
    environment:
      - REDIS_ADDR=redis://172.20.0.11:7001
      - REDIS_CLUSTER=true
    ports:
      - '9121:9121'
# prometheus.yml
scrape_configs:
  - job_name: 'redis-cluster'
    static_configs:
      - targets: ['redis-exporter:9121']
# 主要 Grafana ダッシュボードクエリ
# 1秒あたりのコマンド数
rate(redis_commands_processed_total[5m])

# メモリ使用率
redis_memory_used_bytes / redis_memory_max_bytes * 100

# キー数
redis_db_keys

# 接続中のクライアント数
redis_connected_clients

# レプリケーション遅延
redis_replication_offset

トラブルシューティング

CROSSSLOT エラー

# エラー: CROSSSLOT Keys in request don't hash to the same slot
# 原因: MGET、MSET などで異なるスロットのキーを使用

# 解決: Hash Tag を使用
MGET {order:1}.items {order:1}.total  # OK(同じスロット)
MGET order:1 order:2                  # ERROR(異なるスロットの可能性)

クラスター状態の復旧

# クラスター状態が fail の場合
redis-cli --cluster fix 172.20.0.11:7001

# スロットが欠落している場合
redis-cli --cluster fix 172.20.0.11:7001 --cluster-fix-with-unreachable-masters

失敗事例と落とし穴

症状から始めて診断の順序をたどる形で整理します。深夜にアラートを受けたときに必要なのは、概念の説明ではなく順序です。

1. CLUSTERDOWN Hash slot not served

症状: 特定のキーへのコマンドが CLUSTERDOWN Hash slot not served で失敗します。あるキーは通り、あるキーは通らないように見えることもあれば、全部が失敗することもあります。

診断の順序:

  1. redis-cli -p 7001 cluster info を見ます。cluster_state:fail ならクラスターが意図的にクエリを拒否している状態です。cluster_slots_assigned が 16384 より小さければ、持ち主のないスロットがあるということです。
  2. redis-cli --cluster check でどのスロットが空いているかを確認します。[OK] All 16384 slots covered が出なければ、ここで原因が見えます。
  3. 原因を 2 つに分けます。マスターが死んだのにレプリカの昇格が起きなかったのか、リシャーディングが途中で中断してスロットが MIGRATING/IMPORTING のまま残っているのか。cluster nodes の出力でスロットの横にマイグレーションの印が残っていれば後者です。
  4. 後者なら redis-cli --cluster fix で整理します。前者ならなぜ昇格しなかったかを見ます。残ったマスターが過半数でないか、レプリカが cluster-replica-validity-factor の条件に引っかかって昇格を諦めたか、cluster-replica-no-failover が有効になっているかのいずれかです。

注意: このエラーの根本原因は cluster-require-full-coverage がデフォルト yes であることです。キャッシュ用途で部分的な可用性のほうがよいなら no に変えられますが、それは「欠けたスロットを諦めて残りを提供する」という選択であって、データが戻るという意味ではありません。設定で症状だけ消して原因を残す、よくある間違いです。

2. クライアントライブラリを上げた途端に CROSSSLOT が噴き出す

症状: コードは変えていないのに、ライブラリのアップグレード後に CROSSSLOT Keys in request don't hash to the same slot が出始めます。たいていは、古いライブラリが複数キーのコマンドをこっそり分割して送ってくれていたものを、新しいバージョンが仕様どおりそのままサーバーへ渡すようになって表面化したものです。

診断の順序: まず失敗しているコマンドを分類します。複数キーのコマンド(MGET、MSET、SUNION など)なのか、複数のキーを触る Lua スクリプトなのか、MULTI トランザクションなのか。3 つとも同じ制約を受けます。

設計上の帰結こそが本当の要点です。仕様はハッシュタグを使えば複数キー演算が可能だと述べていますが、裏返せばハッシュタグなしでは不可能だという意味です。複数キーのコマンド、Lua スクリプト、トランザクションのすべてが 1 つのスロットの中に制限されます。だからハッシュタグはエラーが出たときに持ち出す回避策ではなく、どのキーが必ず同じスロットに住むべきかを決める スキーマの決定 です。キー命名規則の設計時に決めるべきもので、後から変えるならキーを全部書き直すマイグレーションになります。

リシャーディング中は同じスロットでも -TRYAGAIN が出うることも覚えておいてください。キーの一部がまだ移行元に、一部が移行先にある瞬間が存在します。

3. 1 ノードだけ熱い — ホットスロット

症状: リバランスをしたのに 1 ノードだけ CPU と秒間コマンド数が跳ね上がり、残りは暇です。ノードを追加しても変わりません。

診断の順序:

  1. ノードごとの秒間コマンド数を比べます。前述の Grafana クエリをインスタンスのラベルで分けて見れば、1 インスタンスだけ突出しているのがすぐ分かります。
  2. そのノードが担当しているスロット範囲を cluster nodes で確認します。
  3. キー命名規則でハッシュタグをどう取ったかを見ます。大きなテナント 1 つを丸ごと 1 つのタグにまとめていれば、そのテナントのトラフィック全部が 1 スロットへ行きます。

なぜリシャーディングで直らないのか: スロットは分割できないからです。リシャーディングはスロット単位で移す作業であって、スロットの中を分ける作業ではありません。熱いスロットを別のノードへ移せば、熱いノードが変わるだけです。直すにはタグをより細かく分ける必要があり、それはキー名を変えることなのでデータマイグレーションになります。ハッシュタグの設計を最初に慎重にやるべき理由がここにあります。

4. Python 例の skip_full_coverage_check は今の引数ではありません

上の Python クライアント例にある skip_full_coverage_check は、古い redis-py-cluster パッケージで使われていた引数です。現在の redis-pyRedisCluster にはその名前の引数はありません。対応する引数は require_full_coverage で、意味も方向が逆です。

つまりクライアントのオプションでサーバーの設定を上書きすることはできません。上の例を現在のバージョンに合わせるとこうなります。

from redis.cluster import ClusterNode, RedisCluster

rc = RedisCluster(
    startup_nodes=[
        ClusterNode("172.20.0.11", 7001),
        ClusterNode("172.20.0.12", 7002),
        ClusterNode("172.20.0.13", 7003),
    ],
    decode_responses=True,
    # デフォルトは True。スロットが完全にカバーされていなくても接続するなら False。
    # ただしサーバーが cluster-require-full-coverage yes ならキーコマンドは失敗します。
    require_full_coverage=True,
)

rc.set("user:1000", "Kim Youngju")
print(rc.get("user:1000"))

ライブラリの引数名はこうして静かに変わります。ブログの例をそのまま貼り付ける前に、使用中のバージョンのドキュメントで引数名を一度確認する習慣が時間を節約します。

Redis Cluster を使わないほうがよい場合

Redis Cluster の本当のコストはノード数でも運用の手間でもなく、アプリケーションコードに永久に残る制約 です。以下に当てはまるなら、導入を考え直す価値があります。

逆に、データが 1 台に収まらない、単一ノードのスループットが限界、マスター障害時に人が介入せず数秒で復旧しなければならない、といった場合はクラスターが正しい答えです。そのときは上の制約を知ったうえで選ぶことになります。

参考資料

まとめ

Redis Cluster 運用の要点:

  1. 最低6ノード: 3 Master + 3 Replica で高可用性を確保
  2. Hash Tag の活用: 関連キーを同じスロットに配置
  3. 自動フェイルオーバー: cluster-node-timeout の設定に基づく自動復旧
  4. リシャーディング: ノードの追加/削除時にスロットを再分配
  5. モニタリング: Prometheus + redis_exporter で常時監視

クイズ(7問)

Q1. Redis Cluster のハッシュスロット数は? 16,384個

Q2. キーのハッシュスロットを計算する式は? CRC16(key) % 16384

Q3. Hash Tag の役割は? 中括弧内の文字列のみをハッシュ計算に使用し、関連キーを同じスロットに配置する

Q4. 自動フェイルオーバー時に Replica が Master に昇格するために必要なものは? 過半数の Master の投票(承認)が必要

Q5. CROSSSLOT エラーの原因と解決法は? 異なるスロットのキーを1つのコマンドで使用した際に発生。Hash Tag で同じスロットに配置して解決

Q6. cluster-node-timeout の役割は? ノード障害を検知する時間。この時間内に応答がなければ障害と判断

Q7. ノード削除時にまず行うべき作業は? 該当ノードのスロットを他のノードにリシャーディング(reshard)

クイズ

Q1: 「Redis Cluster 構築と運用の実践ガイド — シャーディング、レプリケーション、フェイルオーバー」の主なトピックは何ですか?

Redis Cluster のアーキテクチャから6ノードクラスター構築、ハッシュスロット、自動フェイルオーバー、リシャーディングまで、運用に必要なすべてを実践コードで解説します。

Q2: Redis Cluster アーキテクチャについて説明してください。 ハッシュスロット(Hash Slots) Redis Cluster は 16,384個のハッシュスロット を使ってデータを分散します: クラスタートポロジー

Q3: 6ノード Redis Cluster の構築の主な手順は何ですか? Docker Compose で構築 ベアメタル / VM での構築

Q4: クラスター運用の主な特徴は何ですか? データの読み書き Hash Tag で同じスロットに格納 Python クライアント ノードの追加 / 削除

Q5: 自動フェイルオーバーはどのように機能しますか? フェイルオーバーの動作過程 手動フェイルオーバー

Q6: MOVED と ASK の違いは何で、クライアントはそれぞれどう反応すべきですか?

MOVED はスロットの持ち主が恒久的に変わったという意味なので、クライアントはスロットマップを更新すべきです。ASK はスロットがマイグレーション中でそのキーがすでに移行先へ移ったという意味なので、ASKING を前置してその 1 リクエストだけを移行先へ送り、スロットマップは更新しません。

Q7: cluster-node-timeout のデフォルト値はいくつで、フェイルオーバー時間にどう反映されますか?

Redis 7.4 の redis.conf 基準でデフォルトは 15000ms です。クラスター内部の他の時間制限のほとんどがこの値の倍数で計算されるため、多数派が再び利用可能になるまでの時間は、およそこの値にレプリカの選出と昇格に必要な数秒を足した値が下限になります。

Q8: 非同期レプリケーションによって生じる書き込み損失の窓を 2 つ説明してください。 1 つ目は、マスターがクライアントへ応答を返したあと、レプリカへ伝播する前に死ぬ場合です。2 つ目は、分断された少数派マスターが受け続けた書き込みが、多数派でのフェイルオーバーによって捨てられる場合で、少数派はノードタイムアウトが過ぎると自ら書き込みを拒否するため、この窓の上限はノードタイムアウトです。

Q9: WAIT コマンドは何を保証し、何を保証しませんか? 先に送った書き込みが指定した数のレプリカへ転送され確認されるまでブロックし、実際に確認したレプリカ数を返します。ただしドキュメントは WAIT が Redis を強一貫なストアにするわけではなく、複数のレプリカへ複製された書き込みでも依然として失われうると明記しています。

Q10: ハッシュタグを粗く取りすぎて生じたホットスロットが、なぜリシャーディングで解決しないのですか?

スロットは分割できないからです。リシャーディングはスロット単位で移す作業なので、熱いスロットを移しても熱いノードが変わるだけです。タグをより細かく分けるにはキー名を変える必要があり、データマイグレーションになります。

コメント

まだコメントはありません。

ログインするとコメントできます