Redis PSYNC2 bug post mortem

Salvatore Sanfilippo

Redis PSYNC2 버그 포스트모템

나흘 전 한 사용자가 Redis Github 저장소에 심각한 이슈를 올렸습니다. 문제는 새로 도입된 Redis 4.0의 PSYNC2 복제 프로토콜과 관련된 것이었고, 매우 치명적이었습니다. PSYNC2는 Redis 복제에 여러 개선을 가져왔습니다. 페일오버 이후는 물론, 슬레이브를 제어된 방식으로 재시작한 뒤에도 전체 데이터セット이 아니라 차이분만 교환해 재동기화할 수 있게 된 것이 대표적입니다. 문제는 바로 이 후자의 기능에 있었습니다. PSYNC2에서는 RDB 파일에 복제 정보가 추가로 저장됩니다. 슬레이브가 재시작되면 이 복제 메타데이터가 다시 로드되고, 슬레이브는 PSYNC를 시도해 마스터와 핸드셰이크를 수행하고 마지막 연결 끊김 이후의 차이분만 받아올 수 있습니다.

Redis 운영 관점에서는 모두 반가운 소식이지만, PSYNC2 자체는 Redis 4.0.0 정식 버전부터 꽤 안정적이었던 반면, 슬레이브 재시작과 관련된 기능은 확실히 신뢰성이 부족했습니다. 이 기능에는 두 가지 문제가 있었습니다. 첫째, PSYNC2에 막판에 추가된 기능이라 원래 설계 문서에 포함되어 있지 않았다는 점입니다. PSYNC2 작업의 당연한 연장선처럼 보였지만, 나머지 명세만큼 잠재적인 버그와 이슈에 대해 면밀히 검토되지는 않았습니다. 둘째, 이 기능이 처음 보기보다 훨씬 복잡하다는 점입니다. 재시작 후 슬레이브 측 복제 상태를 *완전히* 복원하는 것이 까다롭기 때문입니다. 게다가 상태의 일부를 제대로 복원하지 못하더라도 대부분의 경우 눈에 띄는 버그로 이어지지 않아 통합 테스트로 발견하기 어렵습니다. 특정 조건이 충족되어야만 비로소 일부 상태가 누락된 것이 문제로 드러납니다. 예컨대 슬레이브 복제 상태에서 현재 선택된 DB를 올바르게 복원하지 못하면, 서로 다른 Redis DB에 쓰기가 발생할 때만 문제가 생기며, 그런 버그는 특수한 조건에서만 현재 선택된 DB를 올바르게 저장하지 못하는 식입니다.

많은 Redis 기여자들, 특히 Alibaba의 Redis 개발자인 Github 사용자 @soloestoy의 도움 덕분에 최근 PSYNC2의 여러 잠재적 이슈를 개선하는 작업을 진행했습니다. 지금까지 작성한 모든 패치에 대한 충분한 테스트를 거쳐, 며칠 안에 Redis 4.0.3에 그 작업 결과물이 모두 반영될 예정이었습니다. 하지만 이슈 #4483을 접수한 뒤, 지금까지 발견한 다른 Redis 4 PSYNC2 이슈들보다 훨씬 심각한 문제였기 때문에 Redis 4.0.3 릴리스를 서둘러야겠다고 판단했습니다.

이슈 #4483에 설명된 버그는 상당히 단순하면서도 매우 골치 아픈 문제였습니다. 슬레이브가 재시작되어 RDB로부터 복제 상태를 다시 로드한 뒤, 마스터 복제 백로그에 EVALSHA 명령 형태의 Lua 스크립트 실행 기록이 포함될 수 있었습니다. 하지만 슬레이브의 Lua 스크립팅 엔진은 재시작 후 모든 스크립트가 비워지므로 이러한 명령을 처리할 수 없습니다. 그 결과 Lua 스크립트에서 비롯된 쓰기가 슬레이브에서 처리되지 않게 됩니다. 단, 해당 스크립트가 기본값이 아닌 “명령어 복제(commands replication)” 방식을 사용한 경우는 예외입니다. 기본값은 스크립트 자체를 복제하는 것이기 때문입니다.

저는 다소 패닉 상태에 빠졌고… 몇 가지 대안 패치를 작성해 이슈에 올렸습니다. 결국 이전 버전과의 RDB 호환성을 깨뜨리지 않는 패치가 채택되어, 가능한 한 빨리 Redis 4.0.3을 릴리스했습니다. 하지만 실수를 저질렀습니다. 몇 주 전부터 집이 아니라 사무실에서 일하고 있었는데, 평소에는 밤에 일하지 않지만 4.0.3 때문에 집에 돌아와서는 집 노트북을 열고 패치를 4.0 브랜치에 머지한 뒤 몇 가지 테스트를 진행했습니다. 다음 날 사무실로 돌아와 다른 컴퓨터에서 작업을 이어갔는데, 집에서 머지하고 저장소에 푸시하지 않은 커밋 하나가 빠져 있다는 사실을 깨닫지 못했습니다.

결국 저는 가장 중요한 복제 버그 수정을 제외한 나머지 PSYNC2 수정 사항만 포함된 4.0.3을 릴리스한 셈이었습니다. 곧바로 해당 복제 수정을 포함한 새로운 패치 버전인 Redis 4.0.4를 서둘러 릴리스했습니다. 이것만으로도 이미 좋지 않은 상황이었습니다. Redis 업그레이드는 계획된 작업인데, 제가 릴리스를 준비하면서 실수한 탓에 두 번 업그레이드하고 싶어 하는 사람은 없기 때문입니다. 하지만 최악은 아직 남아 있었습니다. 4.0.4에 추가한 수정, 즉 PSYNC2를 수행하며 재시작된 슬레이브에서의 스크립트 복제와 관련된 수정에 오류가 있었고, 이는 모든 복제 통합 테스트를 완전히 통과해 전혀 발견되지 않았습니다. 해당 수정은 나중에 다시 로드하기 위해 슬레이브 메모리에 있는 Lua 스크립트를 RDB에 직접 저장하는 방식이었습니다. 하지만 스크립트를 로드하는 함수가 이미 메모리에 있는 스크립트가 있으면 assert를 발생시킨다는 점을 고려하지 못했습니다. 그래서 슬레이브가 마스터로부터 전체 동기화를 받게 되면 RDB 파일을 로드하기 시작하면서 이미 메모리에 존재하는 중복 스크립트 때문에 즉시 크래시가 발생했습니다. 한 사용자가 Twitter를 통해 이를 바로 제보했고, 저는 제보 시점부터 4.0.5 배포까지 45분도 채 되지 않아 문제를 수정했지만, 이러한 일련의 수정 배포 실패가 야기한 잠재적 문제들을 모두 상쇄하지는 못합니다.

위와 같은 문제들은 여러 가지 원인으로 발생했습니다:

  1. PSYNC2의 복잡성과 코드 변경량을 고려하면 Redis 4.0이 안정 버전으로 너무 일찍 배포되었습니다. 다음부터는 릴리스가 지연되더라도 더 기다리고, 새로운 Redis 메이저 버전의 마지막 릴리스 후보 단계에 더 많은 시간을 두어 GA 버전을 내보내기 전에 이러한 버그를 찾을 수 있도록 하겠습니다.
  2. 이슈 #4483에 대한 수정을 제공하려고 너무 서둘렀습니다. 버그가 심각하더라도 무슨 일이 일어나고 있는지, 그리고 수정 자체가 야기할 수 있는 잠재적 문제를 확인하는 데 시간을 들이는 것이 더 나았습니다. 게다가 너무 서두른 나머지 4.0.3을 구성하는 커밋들을 충분히 꼼꼼히 확인하지 않아 수정 중 하나가 빠졌고, 결국 새로운 릴리스가 필요하게 되었습니다.
  3. Redis 4.0에는 매 단계마다 데이터 일관성을 검사하며 연속적인 페일오버를 시뮬레이션하는 테스트를 포함해 매우 엄격한 PSYNC2 유닛 및 통합 테스트가 있지만, 이번 버그를 통해 테스트가 PSYNC2와 Lua 스크립트 복제를 함께 섞어 시도해 본 적이 전혀 없다는 사실을 알게 되었습니다. 이 부분은 반드시 개선해야 합니다. 또한 슬레이브의 RDB 재시작 이후 PSYNC2 복제 테스트도 강화해야 합니다.

먼저 “3”번 항목의 개선부터 시작할 것이며, 앞으로는 새로운 릴리스마다 “1”번과 “2”번의 교훈을 되새기겠습니다. 이번 일로 인해 저의 실수를 감당해야 했던 모든 분께 진심으로 사과드리며, 엄청난 도움과 지원을 보내준 @soloestoy에게 큰 감사를 표합니다.

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

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