redis-cluster の環境を docker-compose/circle ci 上に構築する

17 min read読了の目安(約15300字

はじめに

redis-cluseter の環境を docker-composecircle ci の両方の環境で用意する必要があったが、参考になるコードが少なかったので記事にした。
docker-compose up をするだけで、ローカルに redis クラスターの環境ができて、circle ci 上でも同様の環境を扱えれるようにする。

コード

サンプルコードはこちら

https://github.com/pokotyan/redis-cluster-sample

docker-compose up で redis-cluster を利用する

いきなり、docker-compose.yamlの全体を貼り付け

docker-compose.yaml
version: "3.7"
services:
  app:
    build:
      context: .
      target: builder
    command: go run main.go
    environment:
      REDIS_CLUSTER_1_NODES: "redis_cluster1:7000,redis_cluster1:7001,redis_cluster1:7002,redis_cluster1:7003,redis_cluster1:7004,redis_cluster1:7005"
      REDIS_CLUSTER_2_NODES: "redis_cluster2:7010,redis_cluster2:7011,redis_cluster2:7012,redis_cluster2:7013,redis_cluster2:7014,redis_cluster2:7015"
    depends_on:
      - redis_cluster1
      - redis_cluster2
    container_name: app

  redis_cluster1:
    build:
      context: .
      dockerfile: DockerfileRedisCluster
    ports:
      - 7000-7005:7000-7005
    environment:
      CLUSTER_PORTS: "7000 7001 7002 7003 7004 7005"
      SLAVES_PER_MASTER: 1
    volumes:
      - ./tmp/redis/redis_cluster1:/data
    container_name: redis_cluster1

  redis_cluster2:
    build:
      context: .
      dockerfile: DockerfileRedisCluster
    ports:
      - 7010-7015:7010-7015
    environment:
      CLUSTER_PORTS: "7010 7011 7012 7013 7014 7015"
      SLAVES_PER_MASTER: 1
    volumes:
      - ./tmp/redis/redis_cluster2:/data
    container_name: redis_cluster2

redis_cluster1 と redis_cluster2 が参照している DockerfileRedisCluster はこんなやつ
リポジトリ上にある redis-cluster の設定ファイルと、クラスター作成スクリプトを redis のコンテナへコピーし、スクリプトを実行している。

DockerfileRedisCluster
FROM redis:5.0.6

RUN apt-get update && apt-get install gettext-base

COPY ./docker/redis/redis.conf.tmpl /usr/local/etc/redis/redis.conf.tmpl
COPY ./docker/redis/init.sh /init.sh
COPY ./DockerfileRedisCluster ./tmp/redis/redis_cluster1/dump-* /usr/local/etc/redis/tmp/redis_cluster1/

CMD ["/bin/bash", "/init.sh"]

redis-cluster の設定ファイルはこんな感じでテンプレート化して使いまわせるようにしている。

docker/redis/redis.conf.tmpl
port ${PORT}
pidfile "/var/run/redis_${PORT}.pid"
logfile "redis-${PORT}.log"
dbfilename "dump-${PORT}.rdb"
appendonly yes
appendfilename "appendonly-${PORT}.aof"
cluster-enabled yes
cluster-config-file nodes-${PORT}.conf
cluster-node-timeout 5000
daemonize yes

redisクラスターの作成スクリプトはこんな感じ。

docker/redis/init.sh
#!/bin/bash

set -euxo pipefail

for port in $CLUSTER_PORTS; do
    mkdir -p /usr/local/etc/redis/"${port}"
    PORT=${port} envsubst < /usr/local/etc/redis/redis.conf.tmpl > /usr/local/etc/redis/"${port}"/redis.conf
done

chown -R redis.redis /usr/local/etc/redis

create_nodes_command=""
for port in $CLUSTER_PORTS; do
  create_nodes_command="${create_nodes_command}redis-server /usr/local/etc/redis/${port}/redis.conf && "
done

nodes=""
for port in $CLUSTER_PORTS; do
  IP=$(hostname -i)
  nodes="$nodes ${IP}:${port}"
done

if ! ls /usr/local/etc/redis/tmp/redis_cluster1/dump-*.rdb >/dev/null 2>&1; then
  bash -c "${create_nodes_command}yes yes | redis-cli --cluster create${nodes} --cluster-replicas ${SLAVES_PER_MASTER} && tail -f /dev/null"
else
  bash -c "${create_nodes_command}tail -f /dev/null"
fi

環境変数の CLUSTER_PORTS に渡された数だけ、 redis クラスターに参加させるノードを作成し、そのノードでクラスターを作成(redis-cli --cluster create)している。

redis-cli --cluster create のコマンド実行には redis ノードの IP が必要なので、 hostname -i を実行して実行中のコンテナの IP を取得して動的にコマンド文字列を生成している。
クラスター作成後は tail -f /dev/null をすることで、プロセス終了からのコンテナ終了とならないようにしている。

環境変数 SLAVES_PER_MASTER で、マスターひとつに対して、スレーブをいくつ作るかを設定できる。
redis クラスターは最低マスターが三つ必要なので、 --cluster-replicas を1にしたいなら、ひとつのクラスターにノードは6つ必要になってくる。

※マスターが三つより少ない状態で redis-cli --cluster create するとこんなエラーでクラスタ作成に失敗する。

*** ERROR: Invalid configuration for cluster creation.
*** Redis Cluster requires at least 3 master nodes.
*** This is not possible with 4 nodes and 1 replicas per node.
*** At least 6 nodes are required.

クラスター作成済の状態での起動処理

上述した docker/redis/init.sh のここの分岐が何をしているかというと、redis クラスターを一度でも構築済みかどうかを判定し、一度でも構築済みの場合は redis-cli --cluster create は実行しないという制御をしている。

なぜ、こういう制御が必要かというと、 redis-cli --cluster create でクラスターを構築済の状態で、再度 redis-cli --cluster create を実行してしまうと Node xxx.xxx.xxx.xxx:xxxx is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0. のエラーが発生する。クラスターをすでに構築済の場合は redis-server コマンドで redis を立ち上げるだけでいいため。

docker/redis/init.sh抜粋
if ! ls /usr/local/etc/redis/tmp/redis_cluster1/dump-*.rdb >/dev/null 2>&1; then
  bash -c "${create_nodes_command}yes yes | redis-cli --cluster create${nodes} --cluster-replicas ${SLAVES_PER_MASTER} && tail -f /dev/null"
else
  bash -c "${create_nodes_command}tail -f /dev/null"
fi

上記の if 文に至るまでの流れを説明すると、 docker-compose.yml のなかで redis のデータをこのように永続化している。
一度でも docker-compose up をして、 redis を起動していると、ローカルの tmp 配下には redis の永続化のためのデータが保存されている。

docker-compose.yaml抜粋
    volumes:
      - ./tmp/redis/redis_cluster1:/data
    volumes:
      - ./tmp/redis/redis_cluster2:/data
tmp 配下に保存される redis のデータ永続化ファイル
tmp
└── redis
    ├── redis_cluster1
    │   ├── appendonly-7000.aof
    │   ├── appendonly-7001.aof
    │   ├── appendonly-7002.aof
    │   ├── appendonly-7003.aof
    │   ├── appendonly-7004.aof
    │   ├── appendonly-7005.aof
    │   ├── dump-7000.rdb
    │   ├── dump-7001.rdb
    │   ├── dump-7002.rdb
    │   ├── dump-7003.rdb
    │   ├── dump-7004.rdb
    │   ├── dump-7005.rdb
    │   ├── nodes-7000.conf
    │   ├── nodes-7001.conf
    │   ├── nodes-7002.conf
    │   ├── nodes-7003.conf
    │   ├── nodes-7004.conf
    │   ├── nodes-7005.conf
    │   ├── redis-7000.log
    │   ├── redis-7001.log
    │   ├── redis-7002.log
    │   ├── redis-7003.log
    │   ├── redis-7004.log
    │   └── redis-7005.log
    └── redis_cluster2
        ├── appendonly-7010.aof
        ├── appendonly-7011.aof
        ├── appendonly-7012.aof
        ├── appendonly-7013.aof
        ├── appendonly-7014.aof
        ├── appendonly-7015.aof
        ├── dump-7010.rdb
        ├── dump-7011.rdb
        ├── dump-7012.rdb
        ├── dump-7013.rdb
        ├── dump-7014.rdb
        ├── dump-7015.rdb
        ├── nodes-7010.conf
        ├── nodes-7011.conf
        ├── nodes-7012.conf
        ├── nodes-7013.conf
        ├── nodes-7014.conf
        ├── nodes-7015.conf
        ├── redis-7010.log
        ├── redis-7011.log
        ├── redis-7012.log
        ├── redis-7013.log
        ├── redis-7014.log
        └── redis-7015.log

なので、ローカルのtmp配下に 上記のファイルがある場合は Redis クラスターをすでに構築済という判定ができる。 tmp 配下のファイルであればなんでもいいので、今回は redis-cluster1 配下の rdb ファイルの存在有無をクラスター構築済みかどうかの判定に利用した。

ローカルのtmp配下に永続化された redis の rdb ファイルは 以下の記述で redis のコンテナ内にコピーしている。

DockerfileRedisCluster抜粋
COPY ./DockerfileRedisCluster ./tmp/redis/redis_cluster1/dump-* /usr/local/etc/redis/tmp/redis_cluster1/

COPY する際に注意するところが、 rdb ファイルはまだ一回も redis コンテナを起動していない場合は存在しないファイルなのでシンプルに、 COPY ./tmp/redis/redis_cluster1/dump-7000.rdb /usr/local/etc/redis/tmp/redis_cluster1/ という書き方をすると、ファイルが存在しない場合では COPY コマンドでエラーになってしまう。
なので以下のような、ちょっとハックっぽい書き方をしている。
COPY <必ず存在するファイル(ローカル)> <存在するかもしれないファイル(ローカル)> <コピー先(コンテナ内)>
こういう書き方をすることで、ファイルが存在する場合はコピーするし、存在しない場合はコピーしない、という COPY ができる。

https://stackoverflow.com/questions/31528384/conditional-copy-add-in-dockerfile
https://redgreenrepeat.com/2018/04/13/how-to-conditionally-copy-file-in-dockerfile/

こうして redis コンテナ内にコピーした rdb ファイルを冒頭の if 判定で利用している。

アプリケーション側

redis-cluster を利用するアプリケーション(golang)のコードはこんな感じ。
go-redis を使って接続している。

main.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	"github.com/go-redis/redis/v8"
)

func main() {
	ctx := context.Background()

	if err := connectToRedis(ctx); err != nil {
		log.Fatalf("main process: %v", err)
	}
}

func connectToRedis(ctx context.Context) error {
	cluster1Nodes := strings.Split(os.Getenv("REDIS_CLUSTER_1_NODES"), ",")
	cluster2Nodes := strings.Split(os.Getenv("REDIS_CLUSTER_2_NODES"), ",")

	cluster1Client := redis.NewClusterClient(&redis.ClusterOptions{
		Addrs: cluster1Nodes,
	})

	cluster2Client := redis.NewClusterClient(&redis.ClusterOptions{
		Addrs: cluster2Nodes,
	})

	pong, err := cluster1Client.Ping(ctx).Result()
	if err != nil {
		return fmt.Errorf("Could not connect to redis cluster1!: %w ", err)
	} else {
		fmt.Printf("redis cluster 1: %s\n", pong)
	}

	pong, err = cluster2Client.Ping(ctx).Result()
	if err != nil {
		return fmt.Errorf("Could not connect to redis cluster2!: %w ", err)
	} else {
		fmt.Printf("redis cluster 2: %s\n", pong)
	}
	return nil
}

redis.ClusterOptions.Addrs には、 host:port の配列を渡してあげる必要がある。元となるデータは環境変数で渡すようにしている

docker-compose.yaml抜粋
      REDIS_CLUSTER_1_NODES: "redis_cluster1:7000,redis_cluster1:7001,redis_cluster1:7002,redis_cluster1:7003,redis_cluster1:7004,redis_cluster1:7005"
      REDIS_CLUSTER_2_NODES: "redis_cluster2:7010,redis_cluster2:7011,redis_cluster2:7012,redis_cluster2:7013,redis_cluster2:7014,redis_cluster2:7015"

名前解決後のホスト名を渡すようにしているので、 docker-compose.yaml の redis のコンテナの container_name のところでクライアント側から接続する際のホスト名を設定している。

で、 redis クラスターへの接続後は Ping を使って接続確認をしている

main.go抜粋
	pong, err := cluster1Client.Ping(ctx).Result()
	if err != nil {
		return fmt.Errorf("Could not connect to redis cluster1!: %w ", err)
	} else {
		fmt.Printf("redis cluster 1: %s\n", pong)
	}

	pong, err = cluster2Client.Ping(ctx).Result()
	if err != nil {
		return fmt.Errorf("Could not connect to redis cluster2!: %w ", err)
	} else {
		fmt.Printf("redis cluster 2: %s\n", pong)
	}

docker-compose up で起動後、 main.go を実行すれば、アプリケーションのコードから redisクラスターに接続できていることがわかる。

$ docker-compose run app go run main.go
Starting redis_cluster1 ... done
Starting redis_cluster2 ... done
Creating redis-cluster-sample_app_run ... done
redis cluster 1: PONG
redis cluster 2: PONG

こんな感じで cluster nodes cluster info コマンドを実行すれば、ちゃんとクラスターが作成されていることも確認できる。

$ docker-compose exec redis_cluster1 redis-cli -p 7000 -c cluster nodes
e3b2b3445800f58daddc6b83f4b5a7ee17243bd1 172.28.0.3:7003@17003 slave 5865a3c0ff8dc2e74b1051736e8d416b637faaed 0 1615115528937 4 connected
4b0da518d2f221162da6a110e76b89ba60a8006f 172.28.0.3:7002@17002 master - 0 1615115528000 3 connected 10923-16383
3857ca35c96a6b36cb31868b1601a105ac0c16ff 172.28.0.3:7005@17005 slave 078792f30dd6fd46cdcb1aacee7255d99388c1e1 0 1615115529943 6 connected
fe317e6850f2bb19bcf1a2ec4fb3cf7928745013 172.28.0.3:7004@17004 slave 4b0da518d2f221162da6a110e76b89ba60a8006f 0 1615115529000 5 connected
5865a3c0ff8dc2e74b1051736e8d416b637faaed 172.28.0.3:7001@17001 master - 0 1615115529000 2 connected 5461-10922
078792f30dd6fd46cdcb1aacee7255d99388c1e1 172.28.0.3:7000@17000 myself,master - 0 1615115529000 1 connected 0-5460

$ docker-compose exec redis_cluster1 redis-cli -p 7000 -c cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:11238
cluster_stats_messages_pong_sent:11182
cluster_stats_messages_sent:22420
cluster_stats_messages_ping_received:11177
cluster_stats_messages_pong_received:11238
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:22420

$ docker-compose exec redis_cluster2 redis-cli -p 7010 -c cluster nodes
61f9b35aa9623fa991c4c0e455d034673f9beeea 172.28.0.2:7014@17014 slave acfe3198808b320f2ce7124eb9e53440c7a04331 0 1615115597000 5 connected
acfe3198808b320f2ce7124eb9e53440c7a04331 172.28.0.2:7012@17012 master - 0 1615115597000 3 connected 10923-16383
f2fd8a857168791d532a404ce1065b11301a7c7b 172.28.0.2:7010@17010 myself,master - 0 1615115596000 1 connected 0-5460
c9c587fc5118d1fed58fdb9e26d53c56920609ba 172.28.0.2:7013@17013 slave 411de9950f84e6ad7398e909df3eaac60dffd50d 0 1615115597530 4 connected
411de9950f84e6ad7398e909df3eaac60dffd50d 172.28.0.2:7011@17011 master - 0 1615115598538 2 connected 5461-10922
130cf369f6fe5f57bc6a4031f84c9f02125c5880 172.28.0.2:7015@17015 slave f2fd8a857168791d532a404ce1065b11301a7c7b 0 1615115598034 6 connected

$ docker-compose exec redis_cluster2 redis-cli -p 7010 -c cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:11412
cluster_stats_messages_pong_sent:11364
cluster_stats_messages_sent:22776
cluster_stats_messages_ping_received:11359
cluster_stats_messages_pong_received:11412
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:22776

circle ci で redis-cluster を利用する

いきなり、 config.yaml の全体を貼り付け

.circleci/config.yml
version: 2.1
references:
  working_directory: &working_directory /go/src/redis-cluster-sample
executors:
  build-excutor:
    working_directory: *working_directory
    docker:
      - image: circleci/golang:1.15.3
      - image: pokotyan/redis-cluster-sample
        name: "redis_cluster1"
        environment:
          CLUSTER_PORTS: "7000 7001 7002 7003 7004 7005"
          SLAVES_PER_MASTER: 1
      - image: pokotyan/redis-cluster-sample
        name: "redis_cluster2"
        environment:
          CLUSTER_PORTS: "7010 7011 7012 7013 7014 7015"
          SLAVES_PER_MASTER: 1
commands:
  build_and_test:
    steps:
      - checkout
      - restore_cache:
          name: Restore go modules cache
          keys:
            - go-modules-cache-v1-{{ checksum "go.sum" }}

      - run:
          name: Set up dependent module
          command: go mod download

      - run:
          name: Connection to redis-cluster
          command: go run main.go

      - save_cache:
          name: Save go modules cache
          key: go-modules-cache-v1-{{ checksum "go.sum" }}
          paths:
            - "/go/pkg/mod"
jobs:
  build_and_test:
    executor:
      name: build-excutor
    environment:
      - REDIS_CLUSTER_1_NODES: "redis_cluster1:7000,redis_cluster1:7001,redis_cluster1:7002,redis_cluster1:7003,redis_cluster1:7004,redis_cluster1:7005"
      - REDIS_CLUSTER_2_NODES: "redis_cluster2:7010,redis_cluster2:7011,redis_cluster2:7012,redis_cluster2:7013,redis_cluster2:7014,redis_cluster2:7015"
    steps:
      - build_and_test
workflows:
  version: 2
  build_and_test:
    jobs:
      - build_and_test

circle ci上で利用する redis クラスターのイメージは docker hub に事前にあげておいたものを利用している。
こんな感じで、事前にイメージをレジストリにあげておく。

docker build -f DockerfileRedisCluster -t pokotyan/redis-cluster-sample .
docker push pokotyan/redis-cluster-sample:latest

circle ci の設定で特筆するところとしては、アプリケーション側は redis クラスターに対して

.circleci/config.yml抜粋
      - REDIS_CLUSTER_1_NODES: "redis_cluster1:7000,redis_cluster1:7001,redis_cluster1:7002,redis_cluster1:7003,redis_cluster1:7004,redis_cluster1:7005"
      - REDIS_CLUSTER_2_NODES: "redis_cluster2:7010,redis_cluster2:7011,redis_cluster2:7012,redis_cluster2:7013,redis_cluster2:7014,redis_cluster2:7015"

このホスト名で接続できるようにする必要があるため、 docker.name のところでコンテナ名を明示してあげている。

これで、circle ci 上で立ち上げた reids クラスターにアプリケーション側から接続できるのが確認できる。