본문으로 건너뛰기
KVS 내구성과 클러스터링: Append Log, Raft, 그리고 4시간 soak test

KVS 내구성과 클러스터링: Append Log, Raft, 그리고 4시간 soak test

2026년 10월 3일

들어가며

이전 글에서는 KVS에 Redis 프로토콜을 붙인 과정을 다뤘다. 다만 그때까지는 데이터를 모두 메모리에 두고 있어서 프로세스를 재시작하면 키 공간도 함께 사라졌다.

이번에는 키 공간이 재시작을 견디도록 --data-dir을, 기계 한 대를 잃어도 견딜 수 있도록 --raft-addr를 더한 과정을 살펴보려 한다. 두 플래그는 모두 기본으로 꺼져 있으므로, 옵션 없이 kvs serve를 실행하면 여전히 메모리 노드 하나로 동작한다.

설계뿐 아니라 측정 과정도 함께 다룰 필요가 있었다. 4시간짜리 soak test를 두 번 돌리면서 매번 테스트 하네스의 버그를 발견했고 이를 고치고 다시 측정하자 문서에 적어 둔 숫자 일부도 틀린 것으로 드러났기 때문이다.

참고로 아래 설명은 main 브랜치 기준이다. KVS v1.0.0 태그는 go.mod에서 retract된 상태이고, 본문에서 다룰 리비전과 데이터 디렉토리 format 2도 아직 릴리즈되지 않았다.

append log로 단일 노드 내구성 확보하기

--data-dir을 지정하면 RESP, HTTP, gRPC에서 받은 모든 변경을 해당 디렉토리의 같은 로그에 추가하고, 다음에 시작할 때 이 로그를 재생해 키 공간을 복원한다.

1
2
3
4
5
6
$ kvs serve --data-dir /var/lib/kvs
$ redis-cli -p 6379 set greeting hello
OK
# kvs 재시작
$ redis-cli -p 6379 get greeting
"hello"

이때 약속하는 것은 세 가지다.

  • 응답한 쓰기는 디스크에 있다. 명령이 응답하기 전에 로그를 flush하고 fsync하므로 깨끗한 종료뿐 아니라 크래시도 견딘다. 그 대가로 쓰기 속도는 디스크 sync 속도를 넘지 못한다.
  • 크래시는 진행 중이던 레코드 하나만 잃는다. 마지막 레코드가 잘린 로그는 거기서 읽기를 멈추고 몇 바이트를 버렸는지 알린 뒤, 시작할 때 그 부분을 빼고 다시 쓴다.
  • 로그 압축은 시작할 때 한다. 재생이 끝나면 살아 있는 키 공간 전체가 메모리에 있으니 그 순간 로그를 다시 쓰는 데 추가 비용이 들지 않고, 백그라운드 작업자도 필요 없다.

압축을 시작할 때만 하므로 오래 실행되는 프로세스에서는 로그가 계속 자란다. 실제 증가량을 확인하려고 키 1,000개에 4시간 동안 쓰기를 이어 갔더니, 1,726,455번의 쓰기로 각 키를 약 1,700번씩 덮어쓰는 동안 로그가 87MB까지 커졌다. 키 공간은 1,000개를 넘지 않았어도 쓰기당 51바이트가 쌓인 셈이다. 레코드에는 키와 인코딩된 값이 들어가므로 이 수치는 워크로드의 평균 레코드 크기에 따라 달라지겠지만, 시간이나 살아 있는 데이터 양보다 쓰기 횟수에 비례해 로그가 자란다는 점은 같다. 같은 4시간 동안 메모리는 1.11MB에서 1.22MB 사이에 머물렀으므로, 운영 중에는 디스크 크기를 지켜보고 재시작을 통해 공간을 회수해야 한다.

Raft 클러스터 구성

로그를 디스크에 남겨도 노드 하나는 여전히 디스크 하나에 의존한다. 디스크를 잃으면 데이터도 잃고 프로세스가 멈추면 서비스도 멈추므로, 가용성을 확보하려면 내구성 외에 다른 장치가 필요하다.

그래서 --raft-addr로 노드를 Raft 클러스터에 참여시킬 수 있게 했으며, 구현에는 hashicorp/raft와 raft-boltdb를 사용했다.

1
2
3
4
5
6
7
8
9
# 첫 노드: 클러스터 시작
$ kvs serve --data-dir /var/lib/kvs1 --raft-addr 127.0.0.1:7901 --resp-addr 127.0.0.1:6381 \
            --http-addr 127.0.0.1:3461 --grpc-addr 127.0.0.1:3471

# 나머지: 이미 클러스터에 있는 노드를 통해 합류
$ kvs serve --data-dir /var/lib/kvs2 --raft-addr 127.0.0.1:7902 --resp-addr 127.0.0.1:6382 \
            --http-addr 127.0.0.1:3462 --grpc-addr 127.0.0.1:3472 --join 127.0.0.1:6381
$ kvs serve --data-dir /var/lib/kvs3 --raft-addr 127.0.0.1:7903 --resp-addr 127.0.0.1:6383 \
            --http-addr 127.0.0.1:3463 --grpc-addr 127.0.0.1:3473 --join 127.0.0.1:6381

한 기계에서 여러 노드를 띄운다면 HTTP와 gRPC 주소도 노드마다 다르게 지정해야 하는데, 기본값인 :3456과 :3457을 그대로 쓰면 두 번째 노드가 HTTP 포트를 바인딩하지 못하고 종료된다.

--join은 Raft 주소가 아니라 기존 노드의 Redis 주소를 받아 RESP 리스너의 KVS.JOIN 명령으로 합류하므로, 별도 포트나 인증 방식이 필요 없다.

스토어가 곧 상태 머신

Raft를 붙이는 작업은 예상보다 작았다. 상태 머신(FSM)에 필요한 세 메서드가 스토어에 이미 있었고 append log용으로 만든 직렬화와 재생도 복제에 그대로 사용할 수 있었기 때문이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// fsm is the store seen the way Raft needs to see it. The three methods it has to provide are
// the three the store already grew for its own log and for replication.
type fsm struct {
	store *kvs.Store
}

func (f *fsm) Apply(entry *raft.Log) interface{} {
	var lines [][]byte
	if err := json.Unmarshal(entry.Data, &lines); err != nil {
		return fmt.Errorf("decode frame: %w", err)
	}

	// The entry's index is the write's revision: every node applies the same entry at the same
	// index, however far behind it was or whichever snapshot it started from.
	rev, err := f.store.ApplyReplicated(int64(entry.Index), lines)
	if err != nil {
		return err
	}

	return rev
}

클러스터 노드에서는 스토어에 replicator를 설정해 모든 쓰기가 먼저 합의를 거치도록 하고 Raft 로그가 단일 노드 append log를 대체하게 했다. 같은 변경을 두 로그에 쓰면 서로 맞춰야 할 대상만 늘어나기 때문이다. Raft 로그를 저장할 곳은 여전히 필요하므로 클러스터에서도 --data-dir을 반드시 지정해야 한다.

아직 릴리즈되지 않은 main 브랜치에서는 모든 쓰기에 리비전을 부여하며 클러스터에서는 Raft 엔트리의 인덱스를 그대로 리비전으로 사용한다. 모든 노드가 같은 엔트리를 같은 인덱스에 적용하기 때문에, 얼마나 뒤처져 있었는지나 어떤 스냅샷에서 시작했는지와 관계없이 같은 리비전이 나온다.

약속하는 것

  • 응답한 쓰기는 소수 노드를 잃어도 남는다. 쓰기에는 Raft 합의 순서를 적용하고 성공 응답 전에 과반수 노드가 로그를 디스크에 보존하므로, 모든 쓰기에 합의 라운드가 한 번씩 필요하다. 다만 읽기는 각 노드의 로컬 상태를 사용해 최신 값을 보장하지 않으니, 이 내구성 보장을 읽기·쓰기 전체의 강한 일관성으로 해석해서는 안 된다.
  • 장애 조치에 사람이 필요 없다. 리더를 멈추면 나머지가 스스로 새 리더를 뽑는다.

약속하지 않는 것

오히려 문서에서는 이 부분을 더 길게 썼다.

  • 노드 셋으로 클러스터를 구성한다. 둘의 과반수는 둘이므로, 2노드 클러스터는 한 노드가 죽는 순간 쓰기를 멈춰 오히려 1노드보다 나쁘다.
  • 선거 중에는 쓰기가 멈춘다. 한 기계 위의 3노드 클러스터에서 리더를 멈춘 뒤 쓰기가 돌아오기까지 8번 측정해 보니 1.3~3.0초가 걸렸고, 실제 네트워크에서는 이보다 길어진다.
  • 읽기는 뒤처질 수 있다. 모든 노드가 자기 사본으로 읽기에 답하기 때문에, 따라잡는 중인 팔로워나 과반수와 끊긴 노드는 리더가 이미 바꾼 값을 돌려줄 수 있다.
  • Pub/Sub는 노드를 넘지 않는다. 채널은 키 공간이 아니므로 복제되지 않는다.
  • /healthz는 클러스터를 모른다. 과반수와 끊겨 쓰기를 하나도 받지 못하는 노드도 건강하다고 답하므로, 프로세스가 살아 있다는 뜻으로만 읽어야 한다.
  • 샤딩은 없다. 모든 노드가 키 공간 전체를 가지는 가용성 위주의 설계이고, 처리량을 늘리는 것이 목적은 아니다.

리더가 아닌 노드에 쓰면

리더만 쓰기를 받으며 다른 노드로 들어온 쓰기 요청에는 각 프로토콜의 방식으로 이를 알린다.

프로토콜응답리더 주소
RESPMOVED 0 <leader>, 선거 중에는 CLUSTERDOWN포함. Redis Cluster의 응답 형식을 빌렸다
HTTP409 Conflicterror 메시지 본문에만
gRPCFAILED_PRECONDITION상태 메시지 본문에만

RESP 응답 형식은 Redis Cluster에서 가져왔지만 KVS에는 샤딩이나 CLUSTER 명령이 없다. MOVED에 들어가는 0도 실제 슬롯 번호가 아니라 응답 형식을 맞추기 위한 값이므로, 클라이언트가 이 응답을 어떻게 처리하는지 확인해야 한다.

  • 클러스터 모드 클라이언트(go-redis ClusterClient 등)는 초기화할 때 CLUSTER SLOTS 같은 명령으로 슬롯 맵을 읽는데, KVS에는 이 명령이 없으므로 기본 설정으로는 붙지 않는다.
  • 단일 노드용 클라이언트는 MOVED를 따라가지 않고 에러로 돌려주므로, 애플리케이션이 응답에 담긴 리더 주소로 다시 연결하거나, INFO의 master_host/master_port로 리더를 찾아 처음부터 리더에 연결해야 한다.

4시간 soak test

make soak는 3노드 클러스터에 부하를 주는 동안 30초마다 노드 하나를 멈췄다가 다시 띄우는 테스트로, 일반 make test와 CI에서는 건너뛰고 -soak에 실행 시간을 지정했을 때만 수행한다.

하네스 버그 1: 노드가 쓰기를 놓친 적이 없었다

첫 4시간 동안 노드가 457번 재시작했는데도 응답한 쓰기 350,260개가 모든 노드에 남아 있어 실행 결과는 깨끗해 보였다.

그런데 하네스를 다시 읽어 보니 부하와 장애 주입을 같은 고루틴에서 실행하고 있었다. 노드를 멈췄다가 다시 띄우는 동안에는 쓰기도 멈춰 있었으니 해당 노드가 놓칠 쓰기 자체가 없었던 것이다. 확인하려던 것은 “노드 하나가 빠진 채로 클러스터가 쓰기를 받고, 돌아온 노드에 넘겨주는” 상황이었는데, 실제로는 프로세스를 두 번 시작하는 동작만 측정한 셈이다.

게다가 검증도 실행이 끝난 뒤 한 번만 하고 있었다. 중간에 값이 사라졌더라도 나중 라운드에서 같은 키를 덮어쓰면 손실의 증거까지 사라지므로, 이 방식으로는 중간의 손실을 확인할 수 없었다.

그래서 하네스를 다음과 같이 고쳤다.

  • 멈춘 노드는 10초 동안 내려가 있고 그동안에도 쓰기는 계속되며, 이 상태에서 받은 쓰기 수를 따로 센다.
  • 노드가 돌아올 때마다 부하가 다시 쓰기 전에 모든 키의 마지막 응답 값과 대조하고, 마지막에 세 노드를 한 번 더 검사한다.

하네스를 고친 뒤 다시 4시간을 실행하자 응답한 쓰기 329,631개 중 111,516개가 노드 하나가 빠진 상태에서 이루어졌다. 479번의 재시작 동안 검사에서 발견된 손실은 0, 크래시도 0이었으며 현재 문서에는 이 결과를 싣고 있다.

여기서 “손실 0"은 키 단위 검사의 결과다. 검사 사이에 키 수보다 많은 쓰기가 들어오면 같은 키를 여러 번 덮어쓰게 되고 검사할 때는 마지막으로 응답한 값만 비교할 수 있다. 그전에 덮어써진 쓰기는 개별적으로 확인하지 못하므로 하네스에서도 그 수를 따로 보고하고 있으며, 따라서 이 결과는 “검사 시점의 최신 응답 값 중 사라진 것은 없었다"는 범위로 읽어야 한다.

하네스 버그 2: 힙이 자란 것은 KVS가 아니었다

첫 실행에서는 메모리 수치도 의심스러웠다. 노드를 멈추자마자 다시 띄우는 조건에서 힙이 4시간 동안 10MB에서 139MB까지 자랐는데, 프로파일을 보니 도달 가능한 메모리의 절반 이상이 Raft 네트워크 전송 계층에 있었다. 연결마다 256KB 읽기 버퍼와 256KB 쓰기 버퍼를 잡는다는 점을 보고, 당시 문서에는 이 조건에서 메모리 상한을 설정하도록 권했다.

부하와 장애 주입을 분리한 하네스로 다시 측정할 때도 세 노드를 사용해 479번 모두 즉시 재시작했는데, 이번에는 힙이 6.7MB에서 7.0MB로 바뀌는 동안 5.0MB~8.6MB 범위에 머물렀고 고루틴도 26~27개로 안정적이었다. 재시작당 증가량으로 환산하면 이전 결과는 269KB였지만 새 결과는 600바이트에 불과했다.

버퍼 자체는 여전히 있었지만 재시작할 때마다 쌓이지는 않았으니 KVS에서 고칠 문제는 아니었다. 이전 수치는 부하와 장애 주입을 분리하기 전의 하네스에서 나온 것이었고, 커밋 제목에도 이 결론을 그대로 남겼다.

The heap does not grow in a crash loop; the harness did

다시 측정한 결과를 바탕으로 soak test의 판정 방식도 바꿨다. 처음에는 힙이 수렴하지 않는 것처럼 보여서 수치만 출력했는데, 그 상태에서 임계값을 정하면 통과하도록 맞춘 기준이 되거나 원인을 알려 주지 못하는 실패만 낼 것 같았기 때문이다. 이제는 힙이 두 배를 넘으면 테스트가 실패한다.

스냅샷을 만들지 못하는 노드의 로그 증가

하네스를 고친 뒤에도 실제로 남아 있는 문제는 스냅샷을 만들지 못하는 노드의 로그 증가였다.

Raft가 버릴 수 있는 로그는 스냅샷에 포함된 엔트리뿐인데, hashicorp/raft는 2~4분마다 스냅샷 생성을 검토하되 마지막 스냅샷 이후 엔트리가 8,192개 이상일 때만 만들고 만든 뒤에도 최근 10,240개는 남긴다. soak test의 노드는 90초마다 내려가므로 스냅샷을 만들 만큼 오래 살아 있지 못했고, 키는 1,000개뿐이었지만 버릴 수 있는 로그가 없어 4시간 뒤 노드마다 Raft 로그 126MB가 쌓였다. 즉시 재시작하는 조건에서는 쓰기가 558,813개까지 들어와 노드당 202MB가 됐으며 이 증가량도 시간보다 쓰기 횟수에 비례했다.

이 때문에 크래시 루프에 빠진 노드는 재시작을 반복하는 동안에도 디스크를 계속 채운다. 계속 살아 있는 노드는 정상적으로 스냅샷을 만들 수 있어 같은 문제가 생기지 않으며 문서에는 이 한계를 “약속하지 않는 것"으로 명시했다.

데이터 디렉토리에 포맷 버전 찍기

데이터가 재시작 후에도 남게 되면 버전 사이의 포맷 변경도 고려해야 한다. 다음 버전에서 디렉토리의 바이트 배치를 바꾸고도 기존 데이터를 그대로 재생하면, 나중에 문제가 버전 불일치가 아니라 데이터 손상처럼 드러날 수 있기 때문이다.

그래서 데이터 디렉토리에 format 파일을 두고, 디렉토리를 여는 모든 경로에서 데이터를 읽기 전에 버전을 확인하도록 했다. 이해하지 못하는 버전이면 두 버전 번호와 필요한 조치를 알린 뒤 시작을 거부한다.

1
2
3
4
5
6
// Version is what this build writes. Raise it whenever the bytes in a data directory change
// shape — including when a dependency that owns part of the directory, the Raft log store among
// them, changes its own format under us. A build reads its own version and those back to
// oldestReadable, and nothing newer: there is no conversion code, which is why the check has to
// be loud.
const Version = 2

Raft 저장소 파일은 라이브러리가 관리하므로 KVS의 헤더를 넣는 대신 데이터 옆에 별도 마커 파일을 둔다. 디렉토리 전체에 버전 하나를 부여하는 방식이라 Raft 라이브러리가 자체 포맷을 바꿀 때도 KVS의 버전을 올려야 한다.

버전 파일 자체도 리뷰를 거치며 세 번 다듬었다.

  • stat이 “파일 없음” 이외의 이유로 실패한 것을 “데이터 없음"으로 읽으면, 이 패키지가 막으려던 바로 그 일인 옛 버전의 키 공간이 든 디렉토리에 새 버전을 찍는 일이 생긴다. 그래서 지금은 그 에러를 그대로 돌려준다.
  • 버전을 제자리에 쓰다가 중단되면 버전이 절반만 남아 영원히 거부되므로, 지금은 임시 파일에 쓰고 sync한 뒤 rename하고 디렉토리까지 sync한다.
  • 파일을 통째로 읽으면 누군가 거대한 파일로 바꿔치기했을 때 프로세스가 죽을 수 있어서, 지금은 64바이트에서 읽기를 멈춘다.

업그레이드할 때는 제자리에서 데이터를 읽고 버전을 다시 기록한다. 각 빌드는 자기 버전보다 오래된 포맷 중 읽을 수 있다고 명시한 것만 받아들이며 리비전을 추가한 format 2는 format 1을 읽을 수 있다. 반대로 더 새로운 포맷은 거부하므로, 다운그레이드하려면 디렉토리를 옮겨 두고 데이터를 다시 넣어야 한다.

마치며

KVS 클러스터에서는 과반수 노드의 연결 상태뿐 아니라 로그 크기도 함께 살펴야 한다. 쓰기는 합의를 거치더라도 각 노드의 로컬 읽기는 뒤처질 수 있고 스냅샷을 만들지 못한 채 재시작을 반복하는 노드는 로그가 계속 커지기 때문이다. 본문의 soak test 결과도 하네스가 검사한 최신 응답 값과 해당 실행 조건 안에서 해석해야 한다.

전체 소스 코드는 github.com/skyoo2003/kvs에서 확인할 수 있다.