들어가며
카프카의 내부 동작 원리와 구현에서 가장 중요한 부분 중 하나는 리플리케이션 동작이다. 카프카는 물론이고 일반적인 분산 시스템에서는 애플리케이션의 고가용성을 위해 내부적으로 리플리케이션 동작을 하게 되는데, 이러한 리플리케이션 동작의 구현은 매우 어려운 부분일 뿐만 아니라 애플리케이션의 성능 저하도 불러오게 된다.
이를 보완하기 위해 카프카는 안정성을 높임과 동시에 최대한 성능에 영향을 주지 않도록 설계되었다. 이번 글에서는 이러한 카프카 내부 동작을 살펴본다.
카프카 리플리케이션
카프카는 스스로를 고가용성 분산 스트리밍 플랫폼이라고 정의하는 만큼 무수히 많은 데이터 파이프라인의 정중앙에 위치하는 메인 허브 역할을 한다. 이렇게 중앙에서 메인 허브 역할을 하는 카프카 클러스터가 만약 하드웨어의 문제나 점검 등으로 인해 정상적으로 동작하지 못한다거나, 카프카와 연결된 전체 데이터 파이프라인에 영향을 미친다면 이는 매우 심각한 문제다. 이때 파이프라인의 안정성을 확보하기 위해 카프카 내부에서는 리플리케이션이라는 동작을 하게 된다.
리플리케이션 동작 개요
카프카는 브로커의 장애에도 불구하고 연속적으로 안정적인 서비스를 제공함으로써 데이터 유실을 방지하며 유연성을 제공한다. 카프카의 리플리케이션 동작을 위해 토픽 생성 시 필숫값으로 replication factor라는 옵션을 설정해야 한다. 아래와 같은 옵션은 리더 파티션을 포함한 파티션이 1개, 파티션당 복제본은 3개로 지정한다는 의미다.
--create --topic test-topic --partition 1 --replication-factor 3
이렇게 리플리케이션 설정하면 설정한 수의 N - 1까지의 브로커 장애가 발생해도 메시지 손실 없이 안정적으로 메시지를 주고받을 수 있다. 위 경우라면 브로커 2개까지 장애가 발생해도 남은 1대의 브로커가 클라이언트의 요청을 처리할 수 있다는 뜻이다.
리더와 팔로워
파티션은 리더와 팔로워라는 개념이 존재한다. 모두 동일한 데이터를 가지고 있더라도, 리더만의 역할이 따로 있기 때문에 카프카에서 리더를 특별히 강조해 표시한다.
카프카는 내부적으로 모두 동일한 리플리케이션들을 리더와 팔로워로 구분하고, 각자의 역할을 분담시킨다. 리더는 리플리케이션 중 하나가 선정되며, 모든 읽기와 쓰기는 그 리더를 통해서만 가능하다. 즉, 프로듀서는 모든 리플리케이션에 메시지를 보내는 것이 아니라, 리더에게만 메시지를 전송한다.
위 그림은 test-topic이라는 토픽의 파티션이 1개이고, 복제 개수는 3이다.(리더 브로커 포함) 또한 리더 파티션을 통해서만 프로듀스와 컨슘 작업을 진행하는 것을 볼 수 있다.
복제 유지와 커밋
리더와 팔로워는 ISR(In Sync Replica)라는 논리적인 그룹으로 묶여있다. 리더와 팔로워를 별도의 그룹으로 나누는 이유는 기본적으로 해당 그룹 안에 속한 팔로워들만이 새로운 리더의 자격을 가질 수 있기 때문이다. 반면, ISR 그룹에 속하지 못한 팔로워는 새로운 리더로 승격할 수 없다.
ISR 내의 팔로워들은 리더와의 데이터 일치를 유지하기 위해 지속적으로 리더의 데이터를 따라가게 되고, 리더는 ISR 내 모든 팔로워가 메시지를 받을 때까지 기다린다.
하지만 팔로워가 여러 이유로 리더로부터 리플리케이션하지 못하는 경우가 발생할 경우, 리더와 팔로워 사이에는 데이터 차이가 발생하게 되는데, 이때 해당 팔로워에게 리더를 넘겨주게 된다면 데이터 정합성이나 메시지 손실 등의 문제가 발생한다.
따라서 파티션의 리더는 팔로워들이 뒤처지지 않고 리플리케이션 동작을 잘하고 있는지를 감시한다. 즉, 리더에 뒤처지지 않고 잘 따라잡고 있는 팔로워들만이 ISR 그룹에 속하게 되며, 리더에 장애가 발생할 경우 새로운 리더의 자격을 얻을 수 있는 것이다.
카프카가 리플리케이션이 잘 되었는지 판단하는 법
그럼 카프카는 어떤 방식으로 리플리케이션이 잘 되었는지 판단하고 있을까? 그리고 리더와 팔로워 중 리플리케이션 동작을 잘하고 있는지 여부는 누가 판단하고, 어떤 기준으로 판단할까?
리더는 읽고 쓰는 동작은 물론, 팔로워가 리플리케이션 동작을 잘 수행하고 있는지도 판단한다. 만약 팔로워가 특정 주기의 시간만큼 복제 요청을 하지 않는다면, 리더는 해당 팔로워가 리플리케이션 동작에 문제가 발생했다고 판단해 ISR 그룹에서 제외한다. 조금 전 ISR에 속한 팔로워만 새로운 리더 자격을 가질 수 있다고 했으므로, 해당 팔로워는 새로운 리더가 될 자격을 박탈당하게 되는 것이다.
ISR 내에서 모든 팔로워의 복제가 완료디면, 리더는 내부적으로 커밋되었다는 표시를 하게 되는데, 이때 마지막 커밋의 위치를 하이워터마크(high water mark)라고 부른다. 따라서 커밋이 되었다는 것은 모든 리플리케이션이 전부 메시지를 저장했음을 의미한다.
그리고 컨슈머는 이렇게 커밋된 메시지만 읽을 수 있다. 카프카에서 커밋되지 않은 메시지를 컨슈머가 읽을 수 없게 하는 이유는 메시지의 일관성을 유지하기 위해서이다. 만약 커밋되지 않은 메시지를 컨슈머가 읽을 수 있다면, 동일한 토픽의 파티션에서 컨슘 했음에도 메시지가 일치하지 않는 현상이 발생할 수 있다.
그럼 컨슈머는 어떻게 메시지가 어디까지 커밋이 되었는지 알 수 있을까? 모든 브로커는 재시작될 때, 커밋된 메시지를 유지하기 위해 로컬 디스크의 replication-offset-checkpoint라는 파일에 마지막 커밋 오프셋 위치를 저장한다. 아래는 test-topic 토픽의 replication-offset-checkpoint 파일 내용이다. 0은 파티션 번호, 1은 커밋된 오프셋이다.
test-topic 0 1
리더와 팔로워의 단계별 리플리케이션 동작
파티션의 리더는 모든 읽기와 쓰기를 처리하기 때문에 매우 바쁘게 동작한다. 그렇기 때문에 리더가 리플리케이션 동작까지 모두 관리하면 성능이 떨어질 수밖에 없을 것이다.
따라서 카프카는 리더와 팔로워 간의 리플리케이션 동작을 처리할 때 서로의 통신을 최소화할 수 있도록 설계함으로써 리더의 부하를 줄여준다. 카프카가 리플리케이션을 진행하는 과정은 다음과 같다.
- test-topic 토픽으로 message1 메시지를 발송한다.
- 리더는 message1을 저장한다.
- 같은 ISR 그룹에 있는 팔로워들은 메시지를 복제하기 위해 리더에게 요청한다.
- 리더는 ISR 그룹에 있는 팔로워가 일정시간동안 복제 요청을 보내지 않으면 ISR 그룹에서 제외한다.
리더는 팔로워들이 리플리케이션 동작을 성공했는지, 실패했는지 알지는 못한다. 전통적인 메시지 큐 시스템인 래빗 MQ의 트랜잭션 모드에서는 모든 미러(mirror, 카프카에서는 팔로워)가 메시지를 받았는지에 대한 ACK를 리더에게 리턴하므로, 리더는 미러들이 메시지를 받았는지 알 수 있다.
하지만 카프카의 경우에는 리더와 팔로워 사이에서 ACK를 주고받는 통신이 없다. 오히려 카프카는 리더와 팔로워 사이에 ACK 통신을 제거함으로써 리플리케이션 동작의 성능을 더욱 높였다.
리더와 팔로워 사이에서 한두 번의 ACK 통신을 주고받는 것은 성능상에 별다른 문제가 없지만, 카프카처럼 대량의 메시지를 처리하는 애플리케이션은 이러한 작은 차이도 크게 부각된다.
카프카의 또 다른 장점은 리플리케이션 동작에서 ACK 통신을 제외했음에도 불구하고 팔로워와 리더 간의 리플리케이션 동작이 매우 빠르면서도 신뢰할 수 있다는 점이다.
카프카에서 리더와 팔로워들의 리플리케이션 동작은 리더가 푸시(push)하는 방식이 아니라 팔로워들이 풀(pull)하는 방식으로 동작하는데, 풀 방식을 채택한 이유도 리플리케이션 동작에서 리더의 부하를 줄여주기 위해서이다.
리더 에포크(leader epoch)와 복구
epoch는 영어로 '시대'라는 뜻이다. 즉, 리더의 변경(시대의 변화)와 비슷한 어조라고 보면 될 것 같다. 리더 에포크(leader epoch)는 카프카의 파티션들이 복구 동작을 할 때 메시지의 일관성을 유지하기 위한 용도로 이용된다. 리더 에포크는 컨트롤러에 의해 관리되는 32비트의 숫자로 표현된다.
해당 리더 에포크 정보는 리플리케이션 프로토콜에 의해 전파되고, 새로운 리더가 변경된 후 변경된 리더에 대한 정보는 팔로워에게 전달된다. 리더 에포크는 복구 동작 시 하이워터마크를 대체하는 수단으로도 활용된다.
기존의 리더 파티션에 들어온 메시지들 중 일부가 commit 되지 않은 상태라면 아래와 같이 파티션 간의 데이터 불일치가 생길 수밖에 없다. 아래는 리더 에포크랄 사용하는 경우와 사용하지 않는 경우를 나타낸다.
리더 에포크를 사용하지 않는 경우
위 상황은 기존 리더 파티션의 하이워터마크가 메시지3(오프셋2)인 상태이지만, 팔로워의 하이워터마크가 메시지 2(오프셋 1)인 상태이다. 하지만 리더 에포크를 사용하지 않기 때문에 새로운 리더가 될 팔로워의 하이워터마크보다 높은 오프셋의 메시지는 삭제처리된다.
따라서 위 그림에서 리더 에포크로 동작하지 않을 경우 메시지3은 유실되는 것이다.
리더 에포크를 사용하는 경우
이 경우에는 위 상황처럼 하이워터마크에 일치하지 않는 메시지를 삭제처리하지 않는다.
대신 기존 리더 파티션에 에포크 요청을 보내 자신보다 높은 오프셋의 메시지들을 불러오고, 하이워터마크를 1 증가시킨다. 카프카는 위와 같은 내용(leader-follower, ISR, leader epoch 등등)으로 고가용성을 유지하게 된다.
컨트롤러(Controller)
지금까지는 카프카의 핵심 기능을 위주로 살펴보았다. 이제 리더 선출을 맡고 있는 컨트롤러에 대해 알아보자. 카프카 클러스터 중 하나의 브로커가 컨트롤러 역할을 하게 되며, 파티션의 ISR 리스트 중에서 리더를 선출한다.
리더를 선출하기 위한 ISR 리스트 정보는 안전한 저장소에 보관되어 있어야 하는데, 가용성 보장을 위해 주키퍼에 저장되어 있다. 컨트롤러는 브로커가 실패하는 것을 항상 모니터링하고 있으며, 브로커에 실패가 감지되면 즉시 ISR 리스트 중 하나를 새로운 파티션 리더로 선출한다.
그리고 새로운 리더의 정보를 주키퍼에 기록하고, 변경된 정보를 모든 브로커에게 전달한다. 파티션의 리더가 다운됐다는 것은 해당 파티션의 리더가 없는 상태를 의미하며, 카프카 클라이언트인 프로듀서나 컨슈머가 해당 파티션으로 읽기나 쓰기가 불가능하다.
이렇게 카프카 클라이언트가 읽기/쓰기를 하지 못하면 모든 읽기/쓰기 동작은 실패하고, 클라이언트에 설정되어 있는 재시도 숫자만큼 재시도한다. 장애로 인한 새로운 파티션의 리더가 선출되는 과정은 다음과 같다.
- 파티션 0번의 리더가 있는 브로커 1번이 예기치 않게 다운된다.
- 주키퍼는 1번 브로커와 연결이 끊어진 후, 0번 파티션의 ISR에서 변화가 생겼음을 감지한다.
- 컨트롤러는 주키퍼 워치를 통해 0번 파티션에 변화가 생김을 감지하고, 해당 파티션 ISR 중 하나를 리더로 선출한다.
- 컨트롤러는 0번 파티션의 새로운 리더의 정보를 주키퍼에게 전달한다.
- 이렇게 갱신된 정보는 현재 활성화 상태인 모든 브로커에게 전파된다.
이번엔 제어된 브로커 종료, 즉 graceful shutdown 상황을 살펴보자.
- 관리자가 브로커 종료 명령어를 실행하고, SIG_TERM 신호가 브로커에게 전달된다.
- SIG_TERM 신호를 받은 브로커는 컨트롤러에게 알린다.
- 컨트롤러는 리더 선출 작업을 진행하고, 해당 정보를 주키퍼에 기록한다.
- 컨트롤러는 새로운 리더 정보를 다른 브로커들에게 전송한다.
- 컨트롤러는 종료 요청을 보낸 브로커에게 정상 종료한다는 응답을 보낸다.
- 응답을 받은 브로커는 캐시에 있는 내용을 디스크에 저장하고 종료한다.
장애로 인한 다운과 graceful shutdown의 차이는 무엇일까? 바로 다운타임(down time)이다. graceful shutdown을 사용하면 브로커가 종료되기 전, 컨트롤러는 해당 브로커가 리더로 할당된 전체 파티션에 대해 리더 선출작업을 진행하기 때문이다.
물론 일시적으로 다운타임이 발생하지만, 결과적으로 각 파티션들은 다운타임을 최소화 할 수 있다.
로그(로그 세그먼트)
카프카의 토픽으로 들어오는 메시지는 세그먼트(segment)라는 파일에 저장된다. 메시지는 정해진 형식에 맞추어 순차적으로 로그 세그먼트 파일에 저장된다. 로그 세그먼트에는 메시지의 내용만 저장되는 것이 아니라 메시지의 키, 밸류, 오프셋, 메시지 크기 같은 정보가 함께 저장되며, 로그 세그먼트 파일들은 브로커의 로컬 디스크에 보관된다.
하나의 로그 세그먼트가 너무 커져버리면 파일을 관리하기 어렵기 때문에, 로그 세그먼트의 최대 크기는 1GB가 기본값으로 설정되어 있다. 로그 세그먼트가 1GB보다 커지는 경우에는 기본적으로 롤링(rolling) 전략을 사용한다.
다시 말해, 하나의 로그 세그먼트에 카프카로 인입되는 메시지들을 계속해서 덧붙이다가 로그 세그먼트의 크기가 1GB에 도달하면 해당 세그먼트 파일을 클로즈(close)하고, 새로운 로그 새그먼트를 생성하는 방식으로 진행된다.
카프카가 기본적으로 롤링 전략을 지원하긴 하지만, 카프카를 관리하는 사람도 계획을 수립해놓아야 한다. 로그 세그먼트를 관리하는 방법은 크게 로그 세그먼트 삭제와 컴팩션(compaction)으로 구분할 수 있다.
로그 세그먼트 삭제
로그 세그먼트 삭제 옵션은 브로커의 설정 파일인 server.properties에서 log.clenup.policy가 delete로 명시되어 있어야 한다. 해당 값은 기본값으로 적용되므로, 관리자가 server.properties에 해당 옵션을 따로 명시하지 않았다면 로그 세그먼트는 삭제 정책이 적용된다.
로그 세그먼트의 삭제 작업은 일정 주기를 가지고 체크하는데, 카프카의 기본값은 5분 주기이므로 5분 간격으로 로그 세그먼트 파일을 체크하면서 삭제 작업을 수행한다.
따라서 명령어 실행 뒤 즉시 로그 세그먼트 파일이 삭제되는 것이 아니라 약 5분 후에 삭제 작업이 일어난다. 삭제가 일어나기 전 상태를 비교해 보기위해 현재의 로그 세그먼트 상태를 확인하기 때문이다.
또한 세그먼트는 일정 주기로 삭제할 수 있는데, 이의 단위는 시간이 될 수도 있고 용량이 될 수도 있다. 기본적으로 카프카에서 지원하는 값은 리텐션 시간 단위로 일주일이다.
로그 세그먼트 컴팩션
컴팩션(compaction)은 카프카에서 제공하는 로그 세그먼트 관리 정책 중 하나로, 로그를 삭제하지 않고 컴팩션하여 보관할 수 있다. 로그 컴팩션은 기본적으로 로컬 디스크에 저장되어 있는 세그먼트를 대상으로 실행되는데, 현재 활성화된 세그먼트는 제외하고 나머지 세그먼트들을 대상으로 컴팩션이 실행된다.
컴팩션을 하더라도 카프카의 로컬 디스크에 로그를 무기한 보관하면, 로그의 용량은 감당할 수 없이 커져서 한계에 다다를 것이다. 따라서 카프카에서는 단순하게 메시지를 컴팩션하여 보관하기보다는 좀 더 효율적인 방법으로 컴팩션한다. 바로 로그 세그먼트 메시지들의 키 값을 기준으로 가장 마지막의 데이터만 보관하게 된다.
메시지의 키 값을 기준으로 컴팩션하는 방법이 다소 생소할 수 있는데, 로그 컴팩션 기능을 이용하는 대표적인 예시는 바로 카프카의 __consumer_offset 토픽이다. __consumer_offset 토픽은 카프카 내부 토픽으로, 컨슈머 그룹의 정보를 저장하는 토픽이다.
각 컨슈머 그룹의 중요한 정보는 해당 컨슈머 그룹이 어디까지 읽었는지를 나타내는 오프셋 커밋 정보인데, __consumer_offset에 키(컨슈머 그룹명, 토픽명)와 밸류(오프셋 커밋 정보) 형태로 메시지가 저장된다.
'Apache > Kafka' 카테고리의 다른 글
[Kafka] 카프카(Kafka)의 구성요소 (1) | 2024.01.21 |
---|