🪴 Daily gardening

Search

Search IconIcon to open search

[redis-py Cluster] Redis Cluster cannot be connected. Please provide at least one reachable node

Last updated Feb 18, 2023

memorydb 를 node 2개(master + replica)로, client는 redis-py cluster로 연결하고 사용하고 있었다. Memory DB는 Redis Cluster로 구성되어있기 때문에 하나의 node가 내려가더라도 대기중인 replica가 올라가기 때문에 내구성이 뛰어나고, 모든 노드에 똑같이 복사가 되기 때문에 데이터의 유실도 없다.

redis-py 는 아래와 같이 client 설정을 해주었다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster

...

REDIS_CONNECTION_PARAMS = {
	"password": app_settings.redis_password,
	"username": app_settings.redis_user,
	"host": app_settings.redis_endpoint,
	"port": app_settings.redis_port,
	"encoding": "utf-8",
	"ssl": True,
	"decode_responses": True,
	"require_full_coverage": False,
}

self._redis_cluster = AsyncRedisCluster(**REDIS_CONNECTION_PARAMS)
...

그러던 어느날, Client쪽에서 다음과 같은 에러가 떨어졌다.

1
Redis Cluster cannot be connected. Please provide at least one reachable node: None

즉, 붙을 수 있는 node가 없다는 에러다. 확인한 결과 분명 Momory DB는 떠 있는 상태였다. 다만 이벤트를 확인해 보았을 때, 어떠한 이유로 master node가 내려간 것으로 보인다.

unnamed__3_

이후, 위 에러가 발생하였다. 분명 node가 내려가더라도 서비스 장애 없이 잘 되어야 할 것 같은데 node를 찾지 못했다.

# MemoryDB Failover 테스트하기

우선 현상을 재현해보기 위해 Memory DB의 master node를 일부러 내리는 테스트를 해보았다. 두가지 방법으로 master node를 내려볼 수 있다.

# 1) AWS Console

# 2) AWS CLI

1
2
3
4
5
6
7
8
9
# Linux, macOS, Unix
aws memorydb failover-shard \  
   --cluster-name my-cluster \  
   --shard-name 0001

# Windows
aws memorydb failover-shard ^  
   --cluster-name my-cluster ^  
   --shard-name 0001

위와 같이 요청하면, 다음과 같은 응답값이 온다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

{  
     "Events": [  
        {  
             "SourceName": "my-cluster",  
             "SourceType": "cluster",  
             "Message": "Failover to replica node my-cluster-0001-002 completed",  
             "Date": "2021-08-22T12:39:37.568000-07:00"  
         },  
         {  
             "SourceName": "my-cluster",  
             "SourceType": "cluster",  
             "Message": "Starting failover for shard 0001",  
             "Date": "2021-08-22T12:39:10.173000-07:00"  
         }  
    ]  
 }

# 유의 사항

# redis-py 확인하기

위 테스트를 몇번 해 본 결과, 첫번째 장애 이후에는 문제없이 잘 동작한다. 하지만 두번째 장애가 난 후에는 계속하여 노드를 찾지 못한다.

# 1) reinitialize_steps 설정

Redis Client 설정값 중 reinitialize_steps 라는 녀석이 있다. ( redis-py/redis/cluster.py)

reinitialize_steps (int, default: 5) – Specifies the number of MOVED errors that need to occur before reinitializing the whole cluster topology. If a MOVED error occurs and the cluster does not need to be reinitialized on this current error handling, only the MOVED slot will be patched with the redirected node. To reinitialize the cluster on every MOVED error, set reinitialize_steps to 1. To avoid reinitializing the cluster on moved errors, set reinitialize_steps to 0.

즉, MOVED error가 난 slot에 대해서 redirected node로 패치를 해준다는 것인데. MOVEDError( redis-py/redis/exceptions.py)란 cluster에서 해당 키가 없는 노드에다가 요청하면 Cluster 에서 올바른 노드를 알려주는 Error다.

1
2
3
4
5
6
7
8
class MovedError(AskError):
    """
    Error indicated MOVED error received from cluster.
    A request sent to a node that doesn't serve this key will be replayed with
    a MOVED error that points to the correct node.
    """

    pass

Redis client를 통해 command를 실행하였을 때 MOVED error가 발생하게 되면 내부에서 _should_reinitialized 함수가 불리우게 된다. ( redis-py//redis/cluster.py)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
...
	except MovedError as e:
		# First, we will try to patch the slots/nodes cache with the
		# redirected node output and try again. If MovedError exceeds
		# 'reinitialize_steps' number of times, we will force
		# reinitializing the tables, and then try again.
		# 'reinitialize_steps' counter will increase faster when
		# the same client object is shared between multiple threads. To
		# reduce the frequency you can set this variable in the
		# RedisCluster constructor.
		self.reinitialize_counter += 1
		if self._should_reinitialized():
			self.nodes_manager.initialize()
			# Reset the counter
			self.reinitialize_counter = 0
		else:
			self.nodes_manager.update_moved_exception(e)

		moved = True
...

이 함수 내에서 Redis Client 설정시 넘겨주었던 reinitialized_steps 가 사용된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def _should_reinitialized(self):
	# To reinitialize the cluster on every MOVED error,
	# set reinitialize_steps to 1.
	# To avoid reinitializing the cluster on moved errors, set
	# reinitialize_steps to 0.

	if self.reinitialize_steps  0:
		return False
	else:
		return self.reinitialize_counter % self.reinitialize_steps  0

따라서, 넘겨준 n번의 MOVED error가 발생하였을 때, 그 때 reinitialize 해주는 로직이다. MOVED가 일어날 때 마다 reinitialize 해주려면 1로, 일어나지 않게 하려면 0으로 세팅하면 된다. (기본 값은 5이다)

위 상황을 미뤄 보았을 때, 변경된 node를 알아채지 못하여 reachable node를 못찾은 건가 싶어 1로 설정하고 다시 테스트를 해보았다. 하지만 여전히 해결되지 못했다.

# 2) NodeManager 이슈

정확하게 저 에러 메세지가 일어나는 부분부터 다시 확인을 하였다. 에러가 난 부분은 아래 코드이다. ( redis-py/redis/cluster.py)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class NodesManager:
...
	def initialize(self):
	...
	startup_nodes_reachable = False
	
	for startup_node in self.startup_nodes.values():
		startup_nodes_reachable = True
	
	...
	if not startup_nodes_reachable:
		raise RedisClusterException(
			f"Redis Cluster cannot be connected. Please provide at least "
			f"one reachable node: {str(exception)}"
		) from exception

NodeManager 에서 initialize 시에 reachable한 node가 없는 경우 생기는 이슈다. 여기서 startup_nodes_reachableNodeManager 생성시 넘어온 startup_nodes를 루프를 돌며 설정이 되는데 이때, 모든 node가 reachable 하지 못했던 것이였다.

여기서 사용한 self.startup_nodesRedisCluster 처음 생성될 때 url 혹은 host, port 정보를 통해 Cluster에 등록된 node들을 한번에 가져와 등록을 해준다. ( redis-py/redis/cluster.py)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class RedisCluster(AbstractRedisCluster, RedisClusterCommands):
...
def __init__(
	self,
	host: Optional[str] = None,
	port: int = 6379,
	startup_nodes: Optional[List["ClusterNode"]] = None,
	cluster_error_retry_attempts: int = 3,
	retry: Optional["Retry"] = None,
	require_full_coverage: bool = False,
	reinitialize_steps: int = 5,
	read_from_replicas: bool = False,
	dynamic_startup_nodes: bool = True,
	url: Optional[str] = None,
	**kwargs,
	):
	...
	if url is not None:
		url_options = parse_url(url)
		...
		kwargs.update(url_options)
		host = kwargs.get("host")
		port = kwargs.get("port", port)
		startup_nodes.append(ClusterNode(host, port))
	elif host is not None and port is not None:
		startup_nodes.append(ClusterNode(host, port))
	...
	self.nodes_manager = NodesManager(
		startup_nodes=startup_nodes, # 여기! 
		from_url=from_url,
		require_full_coverage=require_full_coverage,
		dynamic_startup_nodes=dynamic_startup_nodes,
		**kwargs,
	)

이 과정은 RedisClient가 처음 init 될때 생성한 startup_nodes__init__ 이후에는 변하지 않는다. 즉, 처음 RedisClient 객체가 생성할 때 가지고 온 node로 계속하여 initialize를 하는 것이다. NodeManager에서는 node의 IP addr를 보고 있기 때문에, node가 내려갔다 다시 뜨면서 IP가 변경되면서 NodeManager에서는 해당 node를 찾을 수 없다.

내 상황의 경우 cluster에 유효한 node가 처음 뜰 때 startup_node 로 등록되었고 2번 failover를 하게되면 더 이상 이 Cluster Client가 알 수 있는 node를 모두 사용했기 때문에 더 이상 reachable node가 없는 것이였다.찾아보니 이미 2022년 11월에 redis-py issue로 올라와있는 상태였고, 2023년 2월 현재 아직 fix가 되지 않은 상태이다. 이상적인 방법은 Client가 reinitialize 될때, 라이브러리를 업데이트를 기다리고 있을 수만은 없어 exception 발생시 다시 Client 자체를 새로 생성하여 __init__을 다시 하여 node를 찾을 수 있게 임시방편을 추가했다.