디스크리스 복제: 몇 가지 설계 노트
한 달여 전 Redis 개발에 관심 있는 여러 명이 런던에 모여 첫 Redis 개발자 모임을 가졌습니다. 우리는 함께 시급한 기능들을 추렸고(이제는 Github 이슈에 정리되어 있습니다: https://github.com/antirez/redis/issues/2045), 그중 하루 동안 여러 차례 언급된 이슈가 하나 있었습니다. 바로 디스크리스 복제였습니다. 이 기능은 전혀 새로운 아이디어는 아닙니다. 여러 번 제안된 바 있으며, 특히 슬레이브 동기화 도중에 마스터가 좋은 성능을 내는 것이 결코 간단하지 않다는 것을 아는 EC2 사용자들에 의해 주로 제안되었습니다. 하지만 물리 서버에서 실행할 때조차, 특히 Redis를 캐시로 사용할 때는 디스크를 건드리고 싶지 않은 사용 사례가 적지 않습니다. 한마디로 기존 Redis 복제는 디스크 내구성이 필요하지도 원하지도 않는 경우에도 사용자에게 디스크 사용을 강제하고 있었습니다. 집으로 돌아온 뒤 모임에 참석한 개발자들에게 빠르게 피드백을 주고 싶어, 가장 먼저 추려진 이슈 목록 중 가장 중요하면서도 간단하지 않아 보이는 기능의 구현에 집중했습니다. 앞으로 몇 주 동안은 Redis 개발 프로세스 자체에도 관심을 기울일 예정입니다. 이슈를 어떻게 처리하는지, 새로운 아이디어를 Redis 프로젝트에 어떻게 제안할 수 있는지 등에 대해서 말입니다. 다른 중요한 일들이 늦어져 죄송합니다. 일단 지금은 적어도 코드라도 보여드릴 수 있습니다 ;-) 디스크리스 복제는 몇 가지 설계상의 과제를 안겨 주었습니다. 보기에는 간단해 보이지만 실제로는 그렇지 않습니다. 블로그를 더 자주 쓰려고 하는 김에 이 기능의 내부 동작 방식을 문서로 남기면 좋겠다고 생각했습니다. 이 글이 새 기능에 대한 이해와 도입을 더 쉽게 만들어 줄 것이라 확신합니다. 기존 복제 동작 방식 === 비교적 최신 버전의 Redis는 마스터와의 연결이 끊어져도 다시 연결해 지금까지 누적된 차이만큼을 가져와 점진적으로 복제를 이어갈 수 있습니다. 하지만 슬레이브가 오랫동안 연결이 끊겼거나 재시작되었거나 아예 새로운 슬레이브인 경우에는 Redis가 이른바 “전체 재동기화”를 수행해야 합니다. 개념 자체는 단순합니다. 이 슬레이브를 설정하기 위해 마스터의 데이터셋 전체를 슬레이브로 전송한다는 뜻입니다. 슬레이브는 기존 데이터를 비우고 새 데이터를 처음부터 다시 로드해 마스터 데이터의 정확한 복제본으로 동작하도록 합니다. 슬레이브가 마스터의 정확한 복제본이 된 뒤에는, 클라이언트가 보낸 쓰기 명령으로 마스터 데이터셋이 변경됨에 따라 그 이후의 변경 사항들은 일반적인 Redis 명령 형태로 점진적으로 스트리밍됩니다. 문제는 전체 재동기화에 필요한 이 초기 “벌크 전송”이 수행되던 방식이었습니다. 기본적으로 마스터가 자식 프로세스를 생성해 RDB 파일을 만들었습니다. 자식이 RDB 파일 생성을 마치면 부모 프로세스가 논블로킹 I/O로 그 파일을 슬레이브에게 전송했습니다. 마지막으로 전송이 완료되면 슬레이브는 RDB 파일을 다시 로드해 온라인 상태가 되고, 이후 새로 발생하는 쓰기의 점진적 스트림을 받게 됩니다. 하지만 이는 마스터 관점에서 전체 동기화를 수행하려면 다음 두 가지가 필요하다는 뜻입니다: 1) RDB를 디스크에 쓴다. 2) 슬레이브에게 보내기 위해 RDB를 디스크에서 다시 읽어 들인다. “2”도 좋지 않지만 “1”은 훨씬 더 심각합니다. 예를 들어 AOF가 동시에 활성화되어 있다면, 자식이 가능한 한 빠르게 디스크에 쓰는 동안 AOF의 fsync()가 크게 지연될 수 있습니다. 특히 로컬이 아닌 디스크를 쓰는 잘못된 설정에서는 물론이고, 커널 파라미터 튜닝이 완벽하지 않은 경우에도 디스크 부하가 처리하기 어려운 지연 스파이크를 일으켰습니다. Redis 2.8에서 도입된 부분 재동기화가 이 문제를 다소 완화했지만, 슬레이브를 재시작하거나 너무 오랫동안 오프라인 상태가 되는 일은 종종 발생하므로 전체 재동기화를 완전히 피할 수는 없었습니다. 동시에 이 과정에는 몇 가지 장점도 있었습니다. RDB 저장 코드가 복제에도 재사용되어 복제 코드가 더 단순해졌습니다. 게다가 자식이 RDB 파일을 생성하는 동안 새로운 슬레이브가 접속해 대기열에 들어갈 수 있었습니다. RDB가 준비되면 여러 슬레이브에게 동시에 전송할 수 있었던 것입니다. 종합하면 많은 환경에서는 잘 동작하며 여러 슬레이브를 동시에 동기화할 수 있게 해 주었습니다. 또한 많은 사용자가 마스터 쪽에서 RDB 퍼시스턴스를 활성화한 채로, 하지만 AOF는 끄고 실행하므로 어차피 주기적으로 디스크에 저장하고 있었습니다. 대부분의 베어메탈 사용자들은 Redis가 퍼시스턴스를 수행하는 동안 전혀 지연을 겪지 않으며, 특히 로컬 디스크는 성능을 예측하기 쉽습니다. 자식이 저장을 시작하면 타임아웃을 확인하거나 너무 오래 걸리는지 신경 쓸 필요 없이, 결국 끝나고 보통은 합리적인 시간 안에 완료되었습니다. 이런 이유로 디스크 기반 복제는 여전히 기본 복제 전략이며 지금까지 제거할 계획도 없습니다. 다만 기존 방식이 적합하지 않았던 사용 사례들을 위해 이제 대안이 생겼습니다. 그렇다면 디스크리스 복제란 무엇일까요? 자식 프로세스에서 중간 단계 없이 소켓을 통해 슬레이브에게 직접 쓰는 방식입니다. 소켓은 디스크가 아니다 === 디스크리스 복제의 당연한 문제는 디스크에 쓰는 것과 소켓에 쓰는 것이 다르다는 점입니다. 우선 API 자체가 다릅니다. RDB 코드는 C의 FILE 포인터에 쓰는 데 익숙했지만, 소켓에 쓰는 것은 파일 디스크립터에 쓰는 문제입니다. 게다가 디스크 쓰기는 하드웨어 I/O 오류(예를 들어 디스크가 가득 찬 경우)가 아닌 한 실패하지 않으므로, 쓰기가 실패하면 프로세스가 중단된 것으로 간주하면 됩니다. 소켓은 다릅니다. 수신자가 느리고 로컬 커널 버퍼가 가득 차면 쓰기가 지연될 수 있기 때문입니다. 또 다른 흥미로운 문제는 타임아웃을 처리해야 한다는 점입니다. 수신 측에 장애가 발생해 우리로부터 읽기를 멈추면 어떻게 할까요? 혹은 TCP 연결이 끊겼는데 리셋을 받지 못하는 경우는요? RDB 파일을 슬레이브에게 보내는 자식을 영원히 활성 상태로 둘 수는 없습니다. 타임아웃을 감지할 방법이 반드시 있어야 합니다. 다행히 RDB 코드를 파일 디스크립터에 쓰도록 수정하는 일은 간단했습니다. 전혀 다른 문제(Redis Cluster를 위한 MIGRATE/RESTORE) 때문에 이미 “rio”(redis I/O)라는 추상화가 있었기 때문입니다. 이 추상화는 RDB 형식의 Redis 값을 직렬화하고 역직렬화하는 과정을 추상화해, 값을 디스크에 쓰거나 메모리 버퍼에 쓸 수 있게 해 줍니다. 제가 한 일은 “fdset”, 즉 파일 디스크립터 집합이라는 새로운 “rio” 대상을 지원하는 것이었습니다. 나중에 설명하겠지만 여러 파일 디스크립터에 동시에 써야 하기 때문입니다. 하지만 이것만으로는 충분하지 않았습니다. 주요 설계 트레이드오프 중 하나는 메모리 내 RDB 전송을 다음 두 가지 방식 중 어느 쪽으로 할지 결정하는 것이었습니다: 1) 방식 #1: 버퍼 안의 메모리에 완전한 RDB 파일을 만든 뒤 전송한다. 2) 방식 #2: RDB가 생성되는 대로 슬레이브 소켓에 직접 점진적으로 쓴다. 방식 #1은 기본적으로 RAM 디스크에 쓰는 것과 같아 훨씬 단순합니다. 하지만 명백한 위험은 메모리를 너무 많이 사용한다는 점입니다. 방식 #2는 RDB 파일을 생성하는 자식이 활성 상태인 동안 전송해야 하므로 조금 더 위험합니다. 하지만 이 기능의 본질은 어쩌면 디스크가 느린 환경, 그러나 *네트워크는 빠른* 환경을 대상으로 하면서 너무 많은 추가 메모리를 요구하지 않는 것이었습니다. 그렇지 않으면 기능 자체가 무용지물이 될 위험이 있기 때문입니다. 그래서 방식 #2가 선택되었습니다. 하지만 이렇게 RDB 파일을 스트리밍하면 해결해야 할 새로운 문제가 생깁니다… 슬레이브는 EOF에 도달했음을 어떻게 알 수 있을까요? 전송을 시작할 때 전송 크기를 알 수 없기 때문입니다. 반면 디스크 기반 복제에서는 크기를 알고 있었으므로 전송은 길이가 앞에 붙은 Redis 프로토콜의 “bulk” 문자열 형태로 이루어졌습니다. 예를 들면 다음과 같습니다: $92384923423\r\n … 데이터가 뒤따름 … 점진적인 블록 크기를 알리기 위한 복잡한 청크 프로토콜을 구현하기에는 너무 번거로워서, 더 단순무식한 접근법을 택했습니다. 마스터는 추측할 수 없고 충돌 가능성이 극히 낮은 160비트 랜덤 문자열을 생성해 슬레이브에게 다음과 같은 것을 보냅니다: $EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n … 데이터가 뒤따름 … 796f255829a040e80168f94c9fe7eda16b35e5df 즉, 파일 내부의 어떤 내용과도 충돌하지 않을 것이 거의 확실한(충돌 확률이 극히 낮기 때문에 보장되는) 이 문자열을 파일 끝 마크로 사용하는 것입니다. 단순하지만 아주 잘 동작하고 간단합니다. 타임아웃 처리는 블로킹 쓰기 프로세스이기 때문에(저장 중인 자식 프로세스의 컨텍스트에서 실행되므로) 그냥 SO_SNDTIMEO 소켓 옵션을 사용했습니다. 이렇게 하면 반드시 진행이 이루어져야 하며, 그렇지 않으면 복제 프로세스가 중단됩니다. 따라서 현재로서는 자식의 수명에 대한 하드 타임아웃을 두는 방법은 없으며, 이론적으로는 슬레이브가 타임아웃-1초마다 1바이트만 받아 아주 느린 전송 상태를 만드는 병적인 상황도 가능합니다. 아마 향후에는 자식이 전송 속도를 모니터링해 합리적인 수준 이하로 떨어지면 오류와 함께 종료하도록 할 것입니다. 여러 슬레이브에 동시에 서비스하기 === 이 구현의 또 다른 목표는 여러 슬레이브에 동시에 서비스할 수 있도록 하는 것이었습니다. 언뜻 보면 RDB 전송이 시작되면 새로운 슬레이브는 붙을 수 없고 현재 자식이 종료되고 새로운 자식이 시작될 때까지 기다려야 하므로 불가능해 보입니다. 하지만 상당수 사용 사례를 커버하는 아주 간단한 트릭이 있습니다. 첫 번째 슬레이브가 복제를 원하면 다른 슬레이브들도 함께 도착할 수 있도록 몇 초간 기다리는 것입니다. 이는 예를 들어 여러 슬레이브가 대량으로 재동기화를 하는 경우를 커버합니다. 이런 이유로 I/O 코드는 여러 파일 디스크립터에 동시에 쓸 수 있도록 설계되었습니다. 게다가 블로킹 I/O를 사용하더라도 전송을 병렬화하기 위해 코드는 루프 안에서 각 fd에 소량의 데이터를 쓰려고 시도하므로, 커널이 백그라운드에서 여러 슬레이브에게 동시에 패킷을 보내게 됩니다. 아마 코드 자체는 이해하기 꽤 쉬울 것입니다: while(len) { size_t count = len < 1024 ? len : 1024; int broken = 0; for (j = 0; j < r->io.fdset.numfds; j++) { … error checking removed … /* Make sure to write 'count' bytes to the socket regardless * of short writes. */ size_t nwritten = 0; while(nwritten != count) { retval = write(r->io.fdset.fds[j],p+nwritten,count-nwritten); if (retval <= 0) { … error checkign removed … } nwritten += retval; } } p += count; len -= count; r->io.fdset.pos += count; … more error checking removed … } 쓰기는 rio.c의 쓰기 대상에 의해 버퍼링된다는 점에 유의하세요. 일정량의 데이터가 모였을 때만 쓰려고 하기 때문입니다. 그렇지 않으면 5바이트짜리 데이터가 들어 있는 TCP 패킷을 보낼 위험이 있습니다. 부분 실패 처리하기 === 여러 슬레이브를 처리하는 일은 단순히 여러 FD에 쓰는 것만이 아니며, 그 자체는 꽤 간단합니다. 이야기의 큰 부분은 실제로 일부 슬레이브가 실패하더라도 다른 모든 슬레이브의 프로세스를 블로킹하지 않도록 처리하는 것입니다. 오류가 발생한 파일 디스크립터는 관련 오류 코드와 함께 표시되고, 다시 쓰기를 시도하지 않습니다. 또한 코드는 모든 FD가 오류 상태인지 감지해 프로세스 자체를 중단시킵니다. 하지만 RDB 쓰기가 종료되면 자식은 어떤 슬레이브가 RDB를 받았고 복제 프로세스를 계속할 수 있는지를 보고해야 합니다. 이 작업을 위해 프로세스 간에 유닉스 파이프가 사용됩니다. 자식은 슬레이브 ID와 해당 오류 상태의 배열을 반환하므로 부모가 오류를 로깅하는 등 적절한 처리를 할 수 있습니다. 이 변화가 Redis를 더 근본적으로 바꾸는 방식에 대해 생각해 본 것 === 디스크리스 복제는 마침내 Redis 마스터-슬레이브 구성에서 완전히 디스크 없는 경험을 가능하게 합니다. 이는 우리가 이 사용 사례를 더 잘 지원해야 함을 의미합니다. 현재는 퍼시스턴스를 비활성화한 채로 복제를 실행하는 것이 위험합니다. 어차피 복제가 퍼시스턴스를 촉발할 텐데 퍼시스턴스를 끄는 경우가 있을 리 없다고 생각했기 때문입니다. 하지만 이제 상황이 바뀌었습니다… 그 결과, 디스크 백업이 없는 환경에서도 복제가 더 잘 동작하도록 지원할 계획이 이미 있습니다. Redis Cluster에도 동일하게 적용될 것입니다… Redis Cluster 역시 특히 캐싱 사용 사례에서 디스크리스 운영의 좋은 후보입니다. 캐싱에서는 레플리카가 데이터 중복성을 제공하는 데 큰 역할을 하지만, 여러 인스턴스가 동시에 크래시 후 재시작하면서 클러스터의 일부 해시 슬롯 데이터가 손실되더라도 그리 치명적이지 않을 수 있기 때문입니다. 출시 예정 === 코드는 이미 베타 버전으로 이곳에서 볼 수 있습니다: https://github.com/antirez/redis/commits/memsync 앞으로 며칠 안에 unstable 브랜치에 병합될 예정이지만, 계획은 잠시 피드백과 버그 리포트를 기다린 뒤 3.0과 2.8에도 병합하는 것입니다. 이 기능은 매우 유용하며 꺼져 있을 때는 Redis 코어의 나머지 부분과 상호작용이 거의 없습니다. 계획은 단순히 모든 곳에 백포트해 한동안 “실험적” 기능으로 릴리스하는 것입니다.
글을 무작위로 읽기