A few arguments about Redis Sentinel properties and fail scenarios.

Salvatore Sanfilippo

Redis Sentinel 속성과 장애 시나리오에 대한 몇 가지 이야기

어제 분산 시스템 전문가인 Aphyr가 익명을 요청한 어느 회사가 겪은 Redis Sentinel 이슈에 대해 트윗을 올렸습니다.

“아, Redis Sentinel 이야기인데요, “마스터에 kill -9를 날렸더니 스플릿 브레인이 발생했어요…”

“그러더니 예전 마스터가 데이터 없이 다시 떠서 데이터가 없다는 사실 자체를 다른 모든 노드에 복제해 버렸어요. 말 그대로 백업에서 복구해야 했죠.””

세상에, 뭔가 아주 심각한 버그가 있나 보다 생각했습니다. 하지만 Kyle에게 좀 더 자세한 정보를 물어보니, 사용자들은 사실 마스터 프로세스에서 디스크 영속성을 아예 비활성화해 두었다고 답하더군요. 네, 마스터가 재시작하면 데이터셋이 완전히 비워진 상태로 뜨도록 일부러 설정되어 있었던 겁니다.

그 다음은 예상하신 대로 트위터에서 바로 논쟁이 시작됐습니다. 사람들은 Redis 사용자들을 몹시 걱정했습니다. 불쌍한 Redis 사용자들! 언제나 위험에 처해 있다면서요.

물론 깊이 걱정하는 것이야말로 현명함의 증거이긴 하지만, 저는 다른 길을 택하려 합니다. 좀 더 많은 정보를 제공하는 쪽이죠. 게다가 이 글은 쓸 만한 가치가 있습니다. Kyle이 당시 맥락을 많이 생략한 채 문제를 제보하긴 했지만, 몇 차례 트윗을 더 주고받는 과정에서 제가 보기에 Redis Sentinel에서 실제로 개선할 수 있는 진짜 지점을 정확히 짚어냈기 때문입니다. 그건 이번에 보고된 장애와는 직접 관련이 없는 문제로, 제 TODO 목록에도 오랫동안 올라와 있던 것이기도 합니다.

하지만 그 전에, 이번 논쟁이 된 장애에서 Redis와 Sentinel이 어떻게 동작하는지 좀 더 자세히 살펴보겠습니다.

크래시 복구 시스템 모델에 오신 것을 환영합니다

현실 세계의 대부분의 분산 시스템은 프로세스가 언제든 재시작될 수 있다는 사실을 견딜 수 있도록 설계되어야 합니다. 이는 다른 프로세스와 메시지를 주고받을 수 없게 되는 파티션 문제와는 전혀 다른 문제라는 점에 유의해야 합니다. 오히려 상태 손실의 문제입니다.

이 문제를 좀 더 정확히 말하자면, 분산 알고리즘이 재시작 후에도 상태를 반드시 보존해야 한다고 설계되어 있는데 프로세스가 이를 지키지 못한다면, 이는 기술적으로 비잔틴 장애를 겪는 것입니다. 상태가 손상됐고, 그 프로세스는 더 이상 신뢰할 수 없게 된 것이죠.

이제 Redis 인스턴스와 Redis Sentinel 인스턴스로 구성된 분산 시스템에서는, 재부팅된 인스턴스가 기존 데이터셋을 그대로 가지고 재시작할 수 있어야 한다는 것이 매우 중요합니다. 데이터셋이 비워진 채로 시작하는 것은 비잔틴 장애이며, Redis Sentinel은 이러한 문제를 복구할 수 없습니다.

한 걸음 물러서서 생각해 봅시다. 사실 이런 종류의 장애에서는 Redis Sentinel이 직접 관여하지 않았을 수도 있습니다. 대표적인 예는 잘못 설정된 마스터가 Sentinel이 장애를 감지하기도 전에 너무 빨리 재시작되는 경우입니다.

  1. 노드 A가 마스터입니다.
  2. 노드 A가 영속성이 비활성화된 상태로 재시작됩니다.
  3. Sentinel은 노드 A에 도달할 수 없다는 것을 (어쩌면) 감지하지만, 설정된 타임아웃에 도달할 만큼 오래 지속되지는 않습니다.
  4. 노드 A는 다시 사용 가능해지지만, 완전히 비어 있는 데이터셋으로 재시작된 상태입니다.
  5. 모든 슬레이브 노드 B, C, D, …는 기꺼이 그 빈 데이터셋을 동기화합니다.

결국 설정대로 마스터에서는 모든 것이 지워지고, 데이터셋의 현재 진실 원천이라고 믿고 있는 마스터를 복제하던 슬레이브들에서도 모든 것이 지워지는 것입니다.

위 타임라인에서 3번, 즉 Sentinel 부분을 아예 빼 보겠습니다. 어차피 예시 시나리오에서는 Sentinel이 전혀 동작하지 않았으니까요.

그러면 이렇게 됩니다. N개의 슬레이브에 복제 중인 Redis 마스터가 있습니다. 마스터가 새로(비어 있는) 데이터셋으로 시작하도록 설정된 채 재시작됩니다. 슬레이브들은 다시 그 마스터(비어 있는 데이터셋)로부터 복제합니다.

Redis 사용자라면 이게 그리 놀라운 소식은 아니라고 생각합니다. Redis 복제가 원래 그렇게 동작하기 때문입니다. 슬레이브는 항상 마스터의 정확한 복사본이 되려고 합니다. 하지만 다른 모델도 한번 생각해 보겠습니다.

예를 들어 Redis 인스턴스가 RDB/AOF 파일에 영속화되는 Node ID를 갖도록 할 수도 있습니다. 노드가 재시작될 때마다 Node ID를 로드하고, Node ID가 맞지 않으면 슬레이브는 마스터로부터 전혀 복제하지 않는 방식입니다. 훨씬 더 안전해 보이지 않나요? 사실은 그렇지도 않습니다. 마스터에 다른 잘못된 설정이 있을 수도 있어서, 재시작 후에 스냅샷이 어떤 이유로 실패하는 바람에 수주일이나 된 오래된 데이터셋을 다시 로드할 수도 있기 때문입니다.

그러면 잘못된 재시작 후에도 Node ID는 올바르지만, 데이터셋이 너무 오래되어 사실상 비워진 것이나 다름없게 되고, 다만 탐지하기가 더 교묘해질 뿐입니다.

그런데 보안을 아주 조금 높이는 대가로, 운영이 더 복잡한 시스템이 되고, 영속성을 비활성화하는 것과 유사하지만 훨씬 덜 눈에 띄는 운영상의 실수 때문에 ID가 일치하지 않아 슬레이브가 마스터로부터 복제하지 못할 위험이 생깁니다.

이제 주제를 바꿔 Sentinel이 실제로 관여하고 개선할 여지가 있는 장애 모드를 살펴보겠습니다.

모든 레플리카가 같지는 않습니다

기술적으로 Redis Sentinel은 이해하기 쉬운 매우 제한적인 보장을 제공합니다.

  1. 모든 Sentinel은 서로 통신할 수 있게 되는 즉시 설정에 대해 합의합니다. 사실 각 서브 파티션 안에서는 항상 합의가 이루어집니다.
  2. Sentinel은 과반수 Sentinel 프로세스의 승인을 받지 않고는 페일오버를 시작할 수 없습니다.
  3. 페일오버는 엄격하게 순서가 정해집니다. 시간상 나중에 발생한 페일오버는 더 큰 설정 “번호”(Sentinel 용어로는 config epoch)를 가지며, 이는 항상 이전 설정보다 우선합니다.
  4. 결국 Redis 인스턴스들은 승리한 논리적 설정(더 큰 config epoch을 가진 설정)에 맞게 구성됩니다.

이는 데이터셋의 의미가 “마지막 페일오버가 승리한다”는 것을 뜻합니다. 하지만 여기서 빠진 정보는, 페일오버 과정에서 어떤 슬레이브가 마스터를 대체하도록 선택되는가 하는 점입니다. 이는 결국 매우 근본적인 속성입니다. 예를 들어 Redis Sentinel이 (잘못된 설정으로 재시작되어 데이터가 비워진) 슬레이브를 선택함으로써 실패한다면, 그것이야말로 Sentinel의 문제입니다. Sentinel은 Redis가 비동기 복제 시스템이라는 한계 안에서도 주변에서 가장 좋은 슬레이브를 선택하고, 도달 가능한 유효한 슬레이브가 없으면 아예 페일오버를 거부함으로써 사용자의 이익을 최대한 지키려 해야 합니다.

개선이 가능한 부분이 바로 여기이며, 현재 마스터에 장애가 발생했을 때 슬레이브를 선택하는 방식은 다음과 같습니다.

  1. 슬레이브가 재시작된 뒤 마스터와 한 번도 연결되어 성공적인 동기화(데이터 전송)를 수행한 적이 없으면 해당 슬레이브는 제외됩니다.
  2. 슬레이브가 설정된 타임아웃(Sentinel이 마스터를 장애로 판단하기 위해 마스터에 도달할 수 없어야 하는 시간)의 10배보다 더 오래 마스터와 연결이 끊겨 있었다면, 선택 대상에서 제외됩니다.
  3. 남은 슬레이브 중에서 Sentinel은 “replication offset”이 가장 높은 슬레이브를 선택합니다.

replication offset은 Redis 마스터-슬레이브 복제에서 복제 채널을 통해 전송된 바이트 수를 세는 데 사용하는 숫자입니다. 페일오버뿐만 아니라 여러모로 유용합니다. 예를 들어 네트워크 분할 후 부분 재동기화를 할 때 슬레이브는 마스터에게 “내가 마지막으로 받은 바이트인 오프셋 X부터 데이터를 달라”고 요청합니다.

하지만 이 복제 번호는 승격할 최적의 슬레이브를 고르는 맥락에서 두 가지 문제가 있습니다.

  1. 재시작하면 초기화됩니다. 더 높은 숫자를 가진 슬레이브를 고르고 싶고, 어차피 재시작 후 연결하지 못한 슬레이브는 제외되니 처음에는 무해해 보입니다. 하지만 전혀 무해하지 않습니다. 뒤에서 더 설명하겠습니다.
  2. 그저 숫자일 뿐입니다. Redis 슬레이브가 특정 마스터로부터 복제했다는 것을 의미하지는 않습니다.

또한 슬레이브가 마스터로 승격될 때 마스터의 replication offset을 상속한다는 점도 기억해 두어야 합니다. 따라서 재시작을 제외하면 이 숫자는 계속 증가합니다.

왜 1번 그리고/혹은 2번이 최적이 아닌 선택이며 개선될 수 있을까요?

이런 구성을 상상해 보겠습니다. 노드 A, B, C, D, E가 있습니다. D가 현재 마스터이며, E와 함께 마이너리티 파티션에 고립되어 있습니다. E는 여전히 D로부터 복제 중이며, 그들의 관점에서는 모든 것이 정상입니다.

하지만 메이저리티 파티션에서는 A, B, C가 서로 메시지를 교환할 수 있고, A가 마스터로 선출됩니다.

이후 A가 재시작하면서 오프셋이 초기화됩니다. B와 C는 A로부터 복제하면서 다시 낮은 오프셋부터 시작합니다.

얼마 후 A에 장애가 발생하고, 동시에 E가 메이저리티 파티션에 다시 합류합니다.

E가 가진 데이터셋은 B와 C의 데이터셋보다 덜 최신이지만, replication offset은 더 높습니다. 그뿐만 아니라 E는 최근까지 마스터와 연결되어 있었다고 주장할 수도 있습니다.

이를 개선하는 방법은 간단합니다. 각 Redis 인스턴스는 Redis가 실행될 때마다 바뀌는 고유 ID인 “runid”를 가지고 있습니다. 이는 잘못된 마스터로부터 증분 스트림을 받는 것을 피하기 위해 부분 재동기화에서 유용합니다. 슬레이브는 자신이 성공적으로 복제했던 마지막 마스터 runid가 무엇인지 공개해야 하며, Sentinel 페일오버는 실제로 페일오버하려는 그 마스터로부터 복제했던 슬레이브만 선택하도록 해야 합니다.

replication offset을 특정 runid에 묶어 두면, 슬레이브가 얼마나 최신 상태인지를 재는 절대적인 척도를 얻게 됩니다. 두 슬레이브가 모두 예전 마스터와의 연속성을 주장할 수 있다면, replication offset이 더 높은 쪽이 확실히 더 나은 선택임이 보장됩니다.

하지만 이는 데이터보다 가용성이 더 중요한 모든 경우에 가용성 문제를 만들기도 합니다. 예를 들어 A가 다운됐을 때 E만 사용 가능한 상황이라면, E가 예전에 D로부터 복제했더라도 없는 것보다는 낫습니다. 데이터 일관성이 크게 중요하지 않고 높은 가용성의 캐시가 필요할 때는, N개의 마스터 간에 클라이언트 측 일관된 해싱을 사용하는 Redis 클러스터 방식을, 즉 memcached처럼 쓰는 것이 정답이라고 말씀드리고 싶습니다.

runid를 확인하지 않더라도, 재시작 후에도 replication offset이 유지되도록 하는 것만으로도 동작이 상당히 개선됩니다. 위 예시에서 E는 마이너리티 파티션에 슬레이브와 함께 고립되어 있는 동안 메이저리티 쪽의 다른 슬레이브들보다 더 많은 쓰기를 받았을 경우에만 선택될 것입니다.

TLDR: 이 문제는 고쳐야 합니다. 데이터셋 없이 재시작된 마스터 문제와는 관련이 없지만, 더 정확한 구현을 갖추는 것은 유용합니다. 다만 이는 매우 발생하기 어려운 부류의 문제만을 줄여 줄 뿐입니다.

이건 제 TODO 목록에 꽤 오래 있었던 항목인데, 몇 차례 트윗을 주고받는 사이에 진짜 구현 문제를 찾아낸 Aphyr에게 축하를 보냅니다. Aphyr가 익명의 회사 사례로 보고한 장애에 대해서는, 심각한 잘못된 설정으로부터 보호하려는 시도가 현재로서는 현실적이지 않다고 생각합니다. 다만 이는 우리가 지금처럼 시스템 동작 방식을 설명하려는 문서보다 더 단계별로 안내하는, 더 나은 Sentinel 문서가 필요하다는 분명한 신호입니다. 더 현명한 접근은 상식적인 공통 설정에서 시작해, “하지 말아야 할 것” 목록을 제공하는 것일 수 있습니다. 예를 들어, 비워진 인스턴스를 감수할 수 있는 경우가 아니라면 영속성을 끄지 말라는 식으로요.

원문은 Salvatore Sanfilippo님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.