A few arguments about Redis Sentinel properties and fail scenarios.

Salvatore Sanfilippo

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

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

어제 분산 시스템 전문가인 Aphyr가 익명을 요청한 어느 기업의 Redis Sentinel 이슈에 대해 트윗을 올렸다:

“어? Redis Sentinel 관련인데 ‘그들이 마스터에 kill -9를 날렸더니 스플릿 브레인이 발생했어...’”

“‘그러더니 예전 마스터가 데이터 하나 없이 다시 떠서, 그 빈 상태를 다른 모든 노드에 복제해 버렸어. 말 그대로 백업에서 복구해야 했지.’”

OMG 심각한 버그가 있나 보다 생각했다. 하지만 Kyle에게 좀 더 자세한 정보를 요청해 보니, 사용자들은 실제로 마스터 프로세스에서 디스크 영속성을 완전히 비활성화해 두었다는 답이 돌아왔다. 그렇다. 마스터는 재시작 시 데이터셋이 전부 날아가도록 의도적으로 설정되어 있었던 것이다.

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

물론 깊이 걱정하는 것이야말로 현명한 태도라지만, 나는 다른 길을 택하고 싶다. 좀 더 많은 정보를 제공하는 길이다. 게다가 이 글은 쓰기에 흥미로운 주제이기도 하다. Kyle이 처음에는 맥락 없이 문제를 제보했지만, 몇 개의 트윗이 더 오가는 사이에 내가 보기에 Redis Sentinel에서 실제로 개선할 수 있는 — 이번 사건과는 관련이 없으며 오랫동안 내 TODO 목록에 있던 — 진짜 지점을 정확히 짚어냈기 때문이다. 물론 이는 어디까지나 내 개인적인 의견이다.

하지만 그전에, 이번 논란이 된 사건에서 Redis / Sentinel이 어떻게 동작하는지 좀 더 자세히 살펴보자.

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

현실 세계의 대부분의 분산 시스템은 프로세스가 언제든 재시작될 수 있다는 사실을 견딜 수 있도록 설계되어야 한다. 이는 다른 프로세스와 메시지를 교환하지 못하는 상태인 네트워크 파티션 문제와는 전혀 다른 문제라는 점을 기억하자. 이 문제는 오히려 상태를 잃어버리는 것에 관한 것이다.

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

이제 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을 가진 설정)에 맞게 구성된다.

이는 데이터셋의 의미론이 ‘last failover wins’라는 것임을 의미한다. 하지만 여기서 빠진 정보는, 페일오버 중에 어떤 슬레이브가 마스터를 대체하도록 선택되는가 하는 점이다. 이는 결국 근본적인 속성이다. 예를 들어 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 인스턴스는 ‘runid’라는, Redis가 새로 실행될 때마다 바뀌는 고유 ID를 가지고 있다. 이는 부분 재동기화 시 잘못된 마스터로부터 증분 스트림을 받는 것을 피하기 위해 유용하다. 슬레이브는 자신이 성공적으로 복제했던 마지막 마스터 run id가 무엇인지 알려야 하며, Sentinel 페일오버는 실제로 장애 조치하려는 그 마스터로부터 복제했던 슬레이브만 선택하도록 해야 한다.

replication offset을 특정 runid에 묶게 되면, 슬레이브가 얼마나 최신 상태인지를 재는 절대적인 척도가 생긴다. 두 슬레이브가 모두 가용하고 둘 다 예전 마스터와의 연속성을 주장할 수 있다면, replication offset이 더 높은 쪽이 반드시 더 좋은 선택임이 보장된다.

하지만 이는 데이터보다 가용성이 더 중요한 모든 경우에 가용성 문제를 야기하기도 한다. 예를 들어 A가 다운되었을 때 E만 가용해지더라도, E가 이전에 D로부터 복제하던 슬레이브였다 해도 없는 것보다는 낫기 때문이다. 데이터의 일관성이 크게 중요하지 않고 고가용성 캐시가 필요할 때는 N개의 마스터 간에 클라이언트 측 일관된 해싱을 사용하는 Redis 클러스터 ala-memcached 방식이 정답이라고 말하고 싶다.

runid를 확인하지 않더라도, 재시작 후에도 replication offset이 유지되도록 만드는 것만으로 이미 동작이 상당히 개선된다. 위 예시에서 E는 소수 파티션에 슬레이브와 함께 격리되어 있는 동안 다수 측의 다른 슬레이브들보다 더 많은 쓰기를 받았을 경우에만 선택될 것이다.

TLDR: 우리는 이 문제를 고쳐야 한다. 이는 데이터셋 없이 마스터를 재시작하는 것과는 관련이 없지만, 더 정확한 구현을 갖는 것이 유용하다. 다만 이는 매우 발생시키기 어려운 부류의 이슈만을 제한하게 될 것이다.

이 문제는 꽤 오래전부터 내 TODO 목록에 있었다. 몇 번의 트윗 교환만으로 실제 구현상의 문제를 짚어낸 Aphyr에게 축하를 보낸다. Aphyr가 제보한 익명 기업의 장애에 대해서는, 심각한 잘못된 설정으로부터 보호하려는 시도가 현재로서는 현실적이지 않다고 생각한다. 하지만 이는 우리가 현재 시스템의 동작 방식을 설명하려는 문서보다 더 단계적이고 점진적인, 더 나은 Sentinel 문서가 필요하다는 명확한 신호다. 더 현명한 접근은 상식적인 공통 설정과 함께 ‘하지 말아야 할 것’ 목록에서 시작하는 것일 수 있다. 예를 들면, 데이터가 지워진 인스턴스를 감수할 수 있지 않다면 영속성을 끄지 말라는 식이다.

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

댓글