Docker上でRedis Clusterを構築する:シャーディング、高可用性、フェイルオーバー

Database tutorial - IT technology blog
Database tutorial - IT technology blog

背景と導入理由:単一ノードRedisの限界を超えるスケーリング

半年前、セールの急増時に当社の通知サービスでメッセージのドロップが発生し始めました。32 GBのスタンドアロンRedisインスタンスのメモリ使用率は28 GB(87%)を超え、書き込みスパイク時のp99レイテンシは1.2 msから140 msへと跳ね上がりました。PostgreSQLやMongoDBのクラスタはデータの永続化を問題なく処理できていましたが、単一のRedisプロセスに対して毎秒48,000回ものインメモリ書き込み操作を行う構成は、ハードウェアの限界に達していたのです。

Redis Sentinelを使えばフェイルオーバーを自動化できますが、すべての書き込みコマンドは依然として単一のプライマリノードで処理されます。1台のマシンのCPUやメモリの上限を超える場合は、水平シャーディングが必要不可欠です。Redis Clusterは、キースペースを16,384個の固定ハッシュスロットに分割することでこの課題を解決します。CRC16(key) mod 16384を用いてすべてのキーを正確なスロットにマッピングし、読み書き処理を複数の独立したマスターノードに分散させます。

高可用性は、各マスターに少なくとも1つの専用レプリカを組み合わせることで実現されます。あるマスターがpingへの応答を停止すると、残りのマスターがクラスタバス経由でハートビートの障害を検出します。マスター間でのGossipプロトコルによる合意形成を経て選挙を行い、人間の介入なしにスタンバイ状態のレプリカを昇格させます。この6ノード構成をDocker上で再現することで、ステージング環境に触れる前にフェイルオーバーの挙動、スロットのマイグレーション、クライアントのリダイレクトをローカルで検証できます。

インストール:コンテナ化されたクラスタの設計

耐障害性に優れたRedis Clusterを構成するには、最低3つのマスターノードが必要です。この奇数台の構成により、ネットワーク分断時にも過半数のクォーラムが保証され、スプリットブレイン状態を防止できます。各マスターに1つのレプリカを追加すると、合計6ノードのデプロイになります。本番のクラスタは通常複数の独立したホストに分散して稼働させますが、カスタムのDockerブリッジネットワークを使用することで、ローカル環境でも予測可能で分離されたIPアドレス割り当てが可能になります。

プロジェクトディレクトリを作成し、6つのノードすべてに対応する専用データボリュームを準備します:

mkdir -p redis-cluster && cd redis-cluster
mkdir -p data/node-{1..6}

以下の構成内容を docker-compose.yml として保存します:

version: '3.8'

networks:
  redis-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

services:
  redis-node-1:
    image: redis:7.2-alpine
    container_name: redis-node-1
    command: ["redis-server", "--port", "7001", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--protected-mode", "no"]
    volumes:
      - ./data/node-1:/data
    networks:
      redis-net:
        ipv4_address: 172.28.0.11
    ports:
      - "7001:7001"
      - "17001:17001"

  redis-node-2:
    image: redis:7.2-alpine
    container_name: redis-node-2
    command: ["redis-server", "--port", "7002", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--protected-mode", "no"]
    volumes:
      - ./data/node-2:/data
    networks:
      redis-net:
        ipv4_address: 172.28.0.12
    ports:
      - "7002:7002"
      - "17002:17002"

  redis-node-3:
    image: redis:7.2-alpine
    container_name: redis-node-3
    command: ["redis-server", "--port", "7003", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--protected-mode", "no"]
    volumes:
      - ./data/node-3:/data
    networks:
      redis-net:
        ipv4_address: 172.28.0.13
    ports:
      - "7003:7003"
      - "17003:17003"

  redis-node-4:
    image: redis:7.2-alpine
    container_name: redis-node-4
    command: ["redis-server", "--port", "7004", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--protected-mode", "no"]
    volumes:
      - ./data/node-4:/data
    networks:
      redis-net:
        ipv4_address: 172.28.0.14
    ports:
      - "7004:7004"
      - "17004:17004"

  redis-node-5:
    image: redis:7.2-alpine
    container_name: redis-node-5
    command: ["redis-server", "--port", "7005", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--protected-mode", "no"]
    volumes:
      - ./data/node-5:/data
    networks:
      redis-net:
        ipv4_address: 172.28.0.15
    ports:
      - "7005:7005"
      - "17005:17005"

  redis-node-6:
    image: redis:7.2-alpine
    container_name: redis-node-6
    command: ["redis-server", "--port", "7006", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--protected-mode", "no"]
    volumes:
      - ./data/node-6:/data
    networks:
      redis-net:
        ipv4_address: 172.28.0.16
    ports:
      - "7006:7006"
      - "17006:17006"

各コンテナに2つのポートがマッピングされている点に注目してください。Redisはポート 7001-7006 でアプリケーションのトラフィックをリッスンしますが、同時に 17001-17006(クライアントポート + 10,000)をクラスタバス通信用として割り当てます。クラスタバスは、バイナリ形式のGossip通信、ハートビートping、フェイルオーバーの承認パケットの送受信を担います。外部ファイアウォールやコンテナネットワークでこれらの高位ポートがブロックされると、ノード間でクォーラムを形成できなくなります。

6つのコンテナをすべてバックグラウンドで起動します:

docker compose up -d

設定:スロットとネットワークトポロジの初期化

コンテナを起動した時点では、クラスタモードで動作する6つのスタンドアロンRedisインスタンスが立ち上がった状態にすぎません。これらは自動的には相互検出を行いません。redis-cli --cluster create を実行してこれらをアクティブなトポロジへと結合し、ハッシュスロットを割り当てる必要があります:

docker exec -it redis-node-1 redis-cli --cluster create \
  172.28.0.11:7001 172.28.0.12:7002 172.28.0.13:7003 \
  172.28.0.14:7004 172.28.0.15:7005 172.28.0.16:7006 \
  --cluster-replicas 1

--cluster-replicas 1 引数により、最初の3つのIPアドレスがプライマリマスターとして指定され、それぞれに1つのレプリカが割り当てられます。CLIは利用可能な16,384個のスロットをバランスよく分配します:

  • Node 1 (172.28.0.11:7001): スロット 0 – 5460(5,461スロット)
  • Node 2 (172.28.0.12:7002): スロット 5461 – 10922(5,462スロット)
  • Node 3 (172.28.0.13:7003): スロット 10923 – 16383(5,461スロット)

確認を求められたら yes と入力します。Redisがエポックを設定し、レプリカをバインドして、[OK] All 16384 slots covered と表示されます。

インフラのネットワークと構成の検証

開発者のワークステーションで複数のカスタムDockerネットワークを運用していると、CIDRの競合が頻繁に発生します。172.28.0.0/16 をバインドする前に、企業のVPNルートやDockerのデフォルトブリッジプールと競合していないか確認してください。利用可能なアドレス範囲やネットマスクの計算には、ToolCraftのSubnet Calculatorが役立ちます。ブラウザのクライアントサイドで処理されるため、社内のインフラ設計情報が外部サーバーに送信される心配はありません。

また、Docker Composeの定義をKubernetesのConfigMapやHelmの値に移植する際は、デプロイマニフェストに構文エラーが混入しないよう注意が必要です。データ構造の変換には YAML to JSON Converter を、複雑なパラメータの妥当性確認には JSON Formatter & Validator を活用できます。

検証とモニタリング:スロットルーティングとフェイルオーバーテスト

redis-cli を使用していずれかのノードにクエリを実行し、トポロジが正常に収束したか確認します。クラスタルーティングを有効にするため、必ず -c フラグを付けて実行してください:

docker exec -it redis-node-1 redis-cli -p 7001 -c cluster nodes

コンソールには、40文字の16進数ノードID、現在のロール(master または slave)、ping/pongの状態、各マスターが担当するスロット範囲が出力されます。

自動スロットリダイレクトのテスト

標準的なRedisクライアントは、別のノードに格納されているキーを対象にコマンドを実行すると MOVED エラーを返します。-c フラグを付けると、クライアントはそのシグナルを検知してリダイレクト先へ自動追従します。Node 1でセッションを開き、2つの異なるキーを書き込んでみます:

docker exec -it redis-node-1 redis-cli -p 7001 -c

127.0.0.1:7001> SET user:alpha "session_payload_1"
-> Redirected to slot [14674] located at 172.28.0.13:7003
OK
172.28.0.13:7003> SET user:beta "session_payload_2"
-> Redirected to slot [9382] located at 172.28.0.12:7002
OK
172.28.0.12:7002> GET user:alpha
-> Redirected to slot [14674] located at 172.28.0.13:7003
"session_payload_1"

クライアントの出力を確認してください。user:alpha の書き込みはNode 3のスロット14674にマッピングされ、user:beta はNode 2のスロット9382に割り振られています。クライアントは1ミリ秒未満でマスターノード間を自動で切り替えるため、呼び出し元は分散配置の構造を意識する必要がありません。

マスター障害のシミュレーションとノードのフェイルオーバー

高可用性をうたっていても、実際に稼働中のプライマリを停止させて検証しなければ意味がありません。現在スロット0〜5,460を保持している redis-node-1 を停止してみましょう:

docker stop redis-node-1

割り当てられたレプリカ(クラスタ作成時に割り当てられた、通常は redis-node-4 または redis-node-5)のログで選挙プロセスを追跡します:

docker logs -f redis-node-4

5秒以内(設定した cluster-node-timeout の値)に、レプリカがマスターの欠落を検知し、スロット範囲を引き継ぎます:

* Marking node 172.28.0.11:7001 as failed (FAIL).
* Starting a failover election for epoch 2.
* Failover election won: I am the new master.
* Configuration epoch set to 2.
* Clearing my fail state and serving slots 0 - 5460.

稼働を継続しているNode 2からクラスタの状態をクエリし、新しいトポロジを確認します:

docker exec -it redis-node-2 redis-cli -p 7002 -c cluster nodes

Node 4に master フラグが立ち、データ損失なしにスロット0〜5460を提供していることが確認できます。docker start redis-node-1 で停止したコンテナを再起動してください。オンラインに戻ると、Node 1はNode 4が自身のハッシュスロットを引き継いだことを検知し、自らをレプリカへと適切に降格させます。

本番環境での半年間の運用から得られた知見

負荷環境下でシャーディングクラスタを運用する中で、いくつかの重要な運用上のポイントが明らかになりました:

  • 複数キーを扱うコマンドではハッシュスロットのアフィニティを強制する: MGET、パイプライントランザクション、Luaスクリプトなどの複数キー操作は、対象のキーが異なるマスターに配置されていると CROSSSLOT エラーで失敗します。共通のプレフィックスを中括弧で囲むことで、スロットの局所性を保ちます。例えば {tenant:101}:profile と {tenant:101}:orders は {...} の内部の文字列のみをハッシュ化するため、同一のマスターノードにバインドされます。
  • cluster-node-timeout を現実的な値に調整する: デフォルトのタイムアウトである15,000 msでは、フェイルオーバーが完了するまでにアプリケーションが15秒間待たされることになります。ステージング環境ではこれを5,000 msに短縮しました。ただし、本番環境では2,000 ms未満に設定するのは避けてください。標準的なJavaやNode.jsのガベージコレクションによる一時停止によって一瞬の誤検知が発生し、不要なフェイルオーバーが引き起こされる恐れがあります。
  • クラスタ対応ドライバを使用する: Lettuce(Java)、RedisCluster を備えた redis-py(Python)、ioredis(Node.js)など、インメモリでスロットマップを保持するライブラリを必ず採用してください。クラスタトポロジのキャッシュを持たないドライバを使用すると、スロットの不一致が発生するたびに余分な MOVED のネットワークラウンドトリップが発生し、往復レイテンシが即座に倍増してしまいます。
Share: