Diskless replication: a few design notes.

Salvatore Sanfilippo

디스크 없는 리플리케이션: 몇 가지 설계 노트

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

한 달 조금 전 Redis 개발에 관심 있는 여러 사람이 런던에 모여 첫 Redis 개발자 미팅을 가졌다. 우리는 함께 시급한 기능들을 추려냈고(지금은 GitHub 이슈에 정리되어 있다: https://github.com/antirez/redis/issues/2045), 그중 하루 동안 여러 번 언급된 이슈가 하나 있었다. 바로 diskless replication이었다.

이 기능은 완전히 새로운 아이디어는 아니다. 여러 번 제안된 적이 있고, 특히 슬레이브 동기화 중에 마스터가 좋은 성능을 내는 게 결코 쉽지 않다는 걸 아는 EC2 사용자들이 주로 제안해 왔다. 하지만 물리 서버에서 돌릴 때조차, 특히 Redis를 캐시로 쓸 때는 디스크를 건드리고 싶지 않은 사용 사례가 적지 않다. 요컨대 Redis 리플리케이션은 디스크 내구성이 필요하지도 원하지도 않는 상황에서도 사용자에게 디스크 사용을 강제하고 있었다.

집으로 돌아온 뒤 미팅에 참석한 개발자들에게 빨리 피드백을 주고 싶어서, 추려낸 이슈 목록 중 가장 중요하면서도 만만치 않아 보이는 기능 구현에 먼저 집중했다. 앞으로 몇 주 동안은 Redis 개발 프로세스 자체에도 관심을 돌릴 예정이다. 이슈를 어떻게 처리하는지, 새로운 아이디어를 Redis 프로젝트에 어떻게 제안할 수 있는지 등에 대해서다. 다른 중요한 일들이 늦어진 점은 미안하게 생각한다. 일단 지금은 적어도 코드라도 내놓을 수 있다 ;-)

Diskless replication은 몇 가지 설계상의 도전을 안겨 줬다. 겉보기엔 단순해 보이지만 실제로는 그렇지 않다. 그래서 좀 더 자주 블로그를 하려는 김에 이 기능의 내부 동작 방식을 기록해 두려고 한다. 블로그 글이 새 기능에 대한 이해와 도입을 더 쉽게 만들어 줄 거라 믿는다.

기존 리플리케이션이 동작하던 방식
===

최근 버전의 Redis는 마스터와의 연결이 끊겨도 다시 연결해 지금까지 쌓인 차이만 가져와 증분 방식으로 리플리케이션을 이어갈 수 있다. 하지만 슬레이브가 오랫동안 끊겨 있었거나, 재시작됐거나, 아예 새로운 슬레이브인 경우에는 소위 “전체 재동기화(full resynchronization)”를 수행해야 한다.

개념 자체는 단순하다. 이 슬레이브를 세팅하기 위해 마스터의 데이터셋 *전체*를 슬레이브로 전송하자는 것이다. 슬레이브는 기존 데이터를 모두 비우고 새 데이터를 처음부터 다시 로드해 마스터 데이터의 정확한 복제본으로 동작하도록 한다. 슬레이브가 마스터의 정확한 복제본이 되고 나면, 이후의 변경 사항은 클라이언트가 보낸 쓰기 명령으로 마스터 데이터셋이 변경되는 대로 일반적인 Redis 명령 형태로 증분 스트리밍된다.

문제는 전체 재동기화에 필요한 이 초기 “벌크 전송”이 수행되던 방식이었다. 기본적으로 마스터는 RDB 파일을 생성하기 위해 자식 프로세스를 하나 만들었다. 자식이 RDB 파일 생성을 마치면 부모 프로세스가 논블로킹 I/O를 이용해 그 파일을 슬레이브들에게 전송했다. 전송이 끝나면 슬레이브들은 RDB 파일을 다시 로드해 온라인 상태가 되고, 이후 새로 들어오는 쓰기의 증분 스트림을 받게 된다.

하지만 이는 마스터 관점에서 전체 동기화를 수행하려면 다음이 필요하다는 뜻이다:

1) RDB를 디스크에 쓴다.
2) 슬레이브에게 보내기 위해 디스크에서 RDB를 다시 읽어 들인다.

“2”도 좋지 않지만 “1”은 훨씬 더 나쁘다. 예를 들어 동시에 AOF가 활성화되어 있으면, 자식이 가능한 한 빠르게 디스크에 쓰는 동안 AOF fsync()가 크게 지연될 수 있다. 설정이 잘못된 경우, 특히 스토리지가 로컬 디스크가 아닐 때, 때로는 커널 파라미터 튜닝이 완벽하지 않아서라도 디스크 부하가 감당하기 힘든 지연 스파이크를 유발했다. Redis 2.8에서 도입된 부분 재동기화(partial resynchronization)가 이 문제를 어느 정도 완화했지만, 가끔은 슬레이브를 재시작해야 하고 슬레이브가 너무 오래 오프라인 상태가 되기도 하므로 전체 재동기화를 피하는 것은 불가능하다.

동시에 이 과정에는 몇 가지 장점도 있었다. RDB 저장 코드가 리플리케이션에도 재사용되어 리플리케이션 코드가 더 단순해졌다. 게다가 자식이 RDB 파일을 생성하는 동안 새로운 슬레이브가 붙으면 큐에 넣어 둘 수 있었다. RDB가 준비되면 여러 슬레이브에게 동시에 전송할 수 있었다.

종합하면 많은 환경에서는 아주 잘 동작했고 여러 슬레이브를 동시에 동기화할 수 있었다. 또 많은 사용자가 마스터 쪽에서 RDB 영속성을 활성화하되 AOF는 켜지 않은 채로 운영하므로 어차피 가끔은 디스크에 영속화가 일어나고 있었다. 대부분의 베어메탈 사용자들은 Redis가 영속화하는 동안 아무런 지연도 겪지 않으며, 게다가 특히 로컬 디스크는 성능을 예측하기 쉽다. 자식이 저장을 시작하면 타임아웃을 체크하거나 너무 오래 걸리는지 확인할 필요 없이 결국 끝나고, 보통은 합리적인 시간 안에 끝난다.

이런 이유로 디스크 기반 리플리케이션은 *여전히* 기본 리플리케이션 전략이며 현재로서는 제거할 계획이 없지만, 이제는 기존 방식이 썩 좋지 않았던 사용 사례를 위한 대안이 생겼다.

그렇다면 diskless replication이란 무엇인가? 자식 프로세스에서 중간 단계 없이 소켓을 통해 슬레이브에게 직접 쓰는 방식이다.

소켓은 디스크가 아니다
===

Diskless replication의 명백한 문제는 디스크에 쓰는 것과 소켓에 쓰는 것이 다르다는 점이다. 우선 API부터 다르다. RDB 코드는 C의 FILE 포인터에 쓰는 반면, 소켓에 쓰는 것은 파일 디스크립터에 쓰는 문제이기 때문이다. 게다가 디스크 쓰기는 심각한 I/O 에러(예를 들어 디스크가 가득 찬 경우)가 아닌 한 실패하지 않으므로, 쓰기가 실패하면 프로세스가 중단된 것으로 간주하면 된다. 소켓은 다르다. 수신 측이 느리고 로컬 커널 버퍼가 가득 차면 쓰기가 지연될 수 있기 때문이다. 또 하나 흥미로운 문제는 타임아웃을 처리해야 한다는 점이다. 수신 측에 장애가 생겨 우리로부터 읽기를 멈추면 어떻게 할 것인가? 혹은 TCP 연결은 끊겼는데 리셋을 받지 못하는 경우는 어떤가. RDB 파일을 슬레이브에게 보내는 자식을 영원히 살려둘 수는 없으므로 타임아웃을 감지할 방법이 반드시 있어야 한다.

다행히 RDB 코드가 파일 디스크립터에 쓰도록 수정하는 것은 간단했다. 전혀 다른 문제(MIGRATE/RESTORE를 위한 Redis Cluster) 때문에 이미 “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
… 데이터가 이어짐 …

나는 증분 블록 크기를 알리기 위한 복잡한 chunked 프로토콜을 구현하기엔 귀찮아서, 좀 더 단순무식한 접근을 택했다. 마스터는 추측 불가능하고 충돌 가능성이 극히 낮은 160비트 랜덤 문자열을 생성해 슬레이브에게 다음과 같은 것을 보낸다:

$EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n
… 데이터가 이어짐 …
796f255829a040e80168f94c9fe7eda16b35e5df

즉, (무시할 정도로 낮은 확률 덕분에) 파일 내부의 어떤 내용과도 충돌하지 않는 것이 보장된 이 문자열을 파일 끝 마커로 사용하는 것이다. 단순하지만 아주 잘 동작하고 간단하다.

타임아웃의 경우, 블로킹 쓰기 프로세스(저장 중인 자식 프로세스의 컨텍스트에 있으므로)이기 때문에 그냥 SO_SNDTIMEO 소켓 옵션을 사용했다. 이렇게 하면 계속 진행해야 한다는 것이 보장되고, 그렇지 않으면 리플리케이션 프로세스가 중단된다. 그래서 현재로서는 자식의 수명에 대한 하드 타임 리밋을 둘 방법은 없고, 이론상으로는 슬레이브가 timeout-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를 더 깊은 방식으로 바꾼다고 생각한 이유
===

Diskless replication은 마침내 Redis 마스터-슬레이브 셋에서 완전히 디스크 없는 경험을 가능하게 한다.
이는 이런 사용 사례를 더 잘 지원해야 한다는 뜻이다. 현재는 영속성을 비활성화한 채로 리플리케이션을 실행하는 것이 위험하다. 어차피 리플리케이션이 영속성을 촉발할 텐데 영속성을 끄는 경우가 있을 거라고 생각하지 않았기 때문이다. 하지만 이제는 상황이 바뀌었다... 그 결과 이미 디스크 없는 환경에서 리플리케이션을 더 잘 지원할 계획이 있다. Redis Cluster에도 동일하게 적용될 것이다... Redis Cluster 역시 특히 캐싱 사용 사례에서 디스크 없는 운영에 좋은 후보이며, 이런 경우 레플리카가 데이터 중복성을 제공하는 데 좋은 역할을 하지만 여러 인스턴스가 동시에 크래시 후 재시작하면서 클러스터의 일부 해시 슬롯 데이터가 손실되더라도 그리 치명적이지 않을 수 있다.

ETA
===

코드는 이미 베타로 여기에서 볼 수 있다: https://github.com/antirez/redis/commits/memsync
앞으로 며칠 안에 unstable 브랜치에 머지될 예정이지만, 계획은 피드백과 버그 리포트를 위해 잠시 기다린 뒤 3.0과 2.8에도 머지하는 것이다. 이 기능은 매우 유용하고 꺼져 있을 때는 Redis 코어의 다른 부분과 상호작용이 거의 없다. 계획은 그냥 모든 곳에 백포트해서 한동안 “실험적” 기능으로 릴리즈하는 것이다.

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

댓글