카프카의 내부 매커니즘은 카프카를 사용하는데에 있어서 중요하지는 않지만 운영하면서 튜닝이 필요하거나 트러블슈팅에 필요하다.
클러스터에 있는 브로커의 목록은 주키퍼에 의해 관리되며, /brokers/ids 경로에 저장되어 있다.
브로커는 생성 시 고유한 ID 를 갖으며, 직접 설정해주거나 자동으로 생성할 수 있다.
주키퍼에서 브로커의 ID 를 등록할때는 Ephemeral 노드라는 형태로 저장하며, 브로커가 삭제될 경우 주키퍼 노드도 같이 삭제된다. 그러나, 토픽의 레플리카 목록과 같이 운영중인 카프카에서 모든 브로커 ID가 제거되지는 않는다. 따라서 브로커가 완전히 사라지고 새로운 브로커를 동일한 ID 를 갖도록 설정할 경우 사라진 브로커를 대신해 새로운 브로커가 동작할 수 있게 된다.
주키퍼와 컨트롤러의 동작
컨트롤러는 일반적인 브로커 기능에 더해 파티션 리더를 선출하는 역할을 추가적으로 맡는다.
클러스터에서 가장 먼저 시작되는 브로커는 주키퍼에서 /controller 에 Ephemeral 노드를 생성하여 컨트롤러가 된다.
다른 브로커도 시작시 /controller 에 노드를 생성하려 시도하지만 예외를 받고, /controller 의 변동을 감지하려 watch 를 설정하여 컨트롤러가 하나로 보장된다.
컨트롤러 브로커가 죽을 경우 watch 하고 있던 브로커들이 감지하여 /controller 에 노드를 생성하려 시도하고 제일 먼저 생성한 브로커가 컨트롤러가 되며, conditional increment 연산에 의한 epoch 값을 통해 컨트롤러의 세대를 전달받아 다른 브로커들이 이전 세대 컨트롤러의 요청은 무시하고 동작한다.
클러스터내 브로커가 죽을 경우 컨트롤러가 감지하여 해당 브로커에 존재하는 리더를 맡고 있던 모든 파티션들에 대해 순회하며 새로운 브로커를 할당해준다. 파티션의 레플리카 목록에서 바로 다음 레플리카를 리더로 선출하는 간단한 방식으로 이루어진다.
새롭게 리더를 맡아야하는 브로커는 컨트롤러에서 오는 LeaderAndISR 요청을 받아 새로운 리더와 팔로워 정보를 갖게 된다.
브로커가 새롭게 추가되는 경우에도 비슷하게 동작하지만 다른 점은 리더를 새로 선출할 필요 없이 추가되는 브로커에 파티션 레플리카들은 모두 팔로워가 된다.
주키퍼와 컨트롤러의 단점
컨트롤러가 주키퍼에 메타데이터를 쓰는 작업은 동기적이지만 업데이트를 하거나 메시지를 보내는 작업은 비동기적이라 메타데이터 불일치가 발생할 수 있다.
컨트롤러 재시작 시, 모든 브로커와 파티션에 대한 메타데이터를 읽어와야 하고 다시 모든 브로커에게 전송해야해서 컨트롤러 재시작이 느리다.
주키퍼는 그 자체로 분산시스템이여서 이해가 필요하다. 배워야 할 양이 많아진다.
KRaft: 카프카의 새로운 래프트 기반 컨트롤러 (Raft란? https://seongjin.me/raft-consensus-algorithm/)
주키퍼 기반 컨트롤러는 메타데이터를 관리하여 다양한 기능을 수행했는데, 새로운 컨트롤러 설계에서는 로그 기반 아키텍쳐로, 여러 컨트롤러 노드들이 메타데이터의 이벤트 로그를 관리하는 방식으로 동작한다.
단일 컨트롤러가 아닌 여러 개의 팔로워 컨트롤러와 하나의 리더 컨트롤러(액티브 컨트롤러라고 부른다.)로 구성하여 컨트롤러가 죽더라도 팔로워 컨트롤러를 리더로 올리기만 하면 되기 때문에 재시작이 빠르다.
액티브 컨트롤러에서 로그를 쓰고 팔로워 컨트롤러들은 복제하여 갖고 있기 때문에 주키퍼와 컨트롤러가 나뉘어져 동작하여 발생한 메타데이터 불일치가 발생하지 않는다.
브로커는 컨트롤러에서 MetadataFetch API 를 통해 메타데이터를 읽어와 사용한다.
브로커 vs 컨트롤러
주키퍼와 컨트롤러: 브로커 중 하나가 컨트롤러로서, 주키퍼와 함께 파티션 리더 선출, 메타데이터의 관리 역할을 맡아 수행함.
KRaft 컨트롤러: 브로커와 별개의 카프카 프로세스로 동작하며, 리더 컨트롤러에서 파티션 리더 선출, 동적인 메타데이터를 관리함. Raft 기반 여러 개의 컨트롤러가 존재.
카프카의 복제기능은 개별적으로 노드가 동작할 경우 필연적으로 장애가 발생할 수밖에 없는데, 다수의 레플리카를 통해 신뢰성과 지속성을 보장하기 위한 매우 중요한 기능이다.
리더 레플리카
파티션에 대한 모든 클라이언트의 읽기, 쓰기 요청을 받아 처리한다.
팔로워 레플리카
리더 레플리카의 데이터를 복제하여 최신 상태를 유지하는 것이 주요한 일이다.
리더 레플리카가 죽을 경우 팔로워 레플리카 중 하나가 리더 레플리카로 승격된다.
rack 설정값에 따라 팔로워 레플리카도 읽기 요청을 수행할 수 있다. 부하가 분산되고 가까운 팔로워에서 읽어와 트래픽 비용이 감소하는 장점이 있지만, 일관성 유지를 위해 리더 레플리카에서 어디까지 커밋됐는지 알아와야 하기 때문에 컨슈머에서 지연이 추가적으로 발생할 수 있다.
리더 선출
리더 레플리카는 팔로워 레플리카가 어디까지 복제했는지 확인하고, 최신 상태를 유지하는 in-sync 레플리카와 다양한 장애 원인으로 인해 복제가 뒤쳐진 out-of-sync 레플리카를 분류한다. 이후에 리더 레플리카에서 크래시가 날 경우 in-sync 레플리카에서만 리더가 선출될 수 있다. 두 그룹을 나누는 뒤쳐진 시간은 replica.lag.time.max.ms 설정을 통해 설정할 수 있다.
현재 리더 레플리카 외에도 선호(Preferred) 리더 레플리카가 존재하는데, 선호 리더 레플리카가 in-sync 상태일 경우 리더 선출을 실행시켜 선호 리더 레플리카를 리더 레플리카로 승격시킨다. 선호 리더는 파티션이 제일 처음 생성되던 시점의 리더 파티션으로, 처음 파티션 배치 시 브로커간 리더 파티션의 부하를 균등하게 분포하여 배치했기 때문에 이를 유지하여 부하를 분산할 수 있도록 하기 위해 존재한다.
카프카 브로커는 TCP 이진 프로토콜을 사용해 클라이언트와 통신한다.
내부 요청 처리 로직
브로커는 연결을 받는 포트별로 acceptor 스레드를 하나씩 실행시킨다. acceptor 스레드는 받은 요청을 processor 스레드(네트워크 스레드라고도 부름)로 보내어 처리하도록 한다.
프로세서 스레드는 들어온 요청을 요청 큐에 넣고, 응답 큐에서 응답을 받아 클라이언트로 보낸다.
요청 큐에 넣어진 요청은 I/O 스레드(request 핸들러 스레드 라고도 부름)에 의해 처리되어 완료된 요청에 대한 응답을 응답 큐에 넣는다.
응답을 지연시켜야 하는 경우(컨슈머가 읽을 데이터가 아직 브로커에 준비되지 않은 경우 등)에는 purgatory 라는 곳에 완료될때까지 저장된다.
메타데이터 요청
클라이언트는 읽기, 쓰기 요청을 리더 파티션이 있는 브로커에게 보내야 한다. 그런데 클라이언트가 어떻게 알고 요청을 보낼까?
클라이언트는 일정 주기로 메타데이터를 아무 브로커에게 보내서 메타데이터를 읽어오고, 클라이언트에 캐시하여 사용한다.
메타데이터 요청에는 해당 클라이언트가 사용하는 토픽들의 목록이 포함되어 있고, 브로커로부터 받아온 메타데이터에는 해당 하는 토픽 목록에 대해 어떤 파티션들이 있고, 레플리카에는 무엇이 있으며, 리더 레플리카는 어떤 것인지 등의 정보를 포함하고 있어 클라이언트가 요청을 보낼 수 있는 것이다.
파티션 리더가 변경된 경우, 클라이언트는 리더가 속한 브로커에 요청을 보냈음에도 'Not a Leader' 에러를 받을 수 있다. 이 경우 다른 브로커에게 무작정 요청을 보내는 것이 아니라, 메타데이터를 먼저 최신화하고 바뀐 리더 파티션이 속한 브로커에게 요청을 전송하게 된다.
쓰기 요청
쓰기 요청을 받은 브로커는 acks 설정에 따라 쓰기 요청이 완료되어 응답을 보내는 시점이 바뀌게 된다. acks=0 일 경우 바로 응답, acks=1 일 경우 리더 브로커만 완료되면 응답, acks=all 일 경우 모든 레플리카에서 복제가 완료되어야 응답을 한다.
리더 브로커는 자신의 쓰기 요청을 처리하고 레플리카의 응답을 기다리는 동안 자신의 응답을 지연시켜야하는데, 위에서 말한 퍼거토리 버퍼에 응답을 저장해 놓았다가 복제에 대한 응답을 받으면 그때 클라이언트에게 응답을 보낸다.
읽기 요청
클라이언트는 읽고자하는 토픽, 파티션, 오프셋, 읽어올 데이터의 한도 등의 정보를 리더 브로커에게 보낸다.
리더 브로커는 오프셋이 유효한지 등의 유효성 검사를 진행하고 에러를 리턴하거나 메시지를 전송한다.
리더 브로커가 파일에서 메시지를 읽어올 때, zero-copy 최적화를 통해 성능을 향상 시킨다. zero-copy 최적화란 데이터를 읽어올 때 로컬 캐시를 통해 데이터를 중간 버퍼를 두어 관리하는 데이터베이스 시스템들과 달리 바로 네트워크 채널로 보내어 오버헤드를 없애는 방법이다.
클라이언트는 응답받을 메시지의 상한 뿐만 아니라 하한을 지정하여 적은 메시지를 여러 번 보내지 않고 한 번에 보낼 수 있도록 할 수 있다.
클라이언트가 읽고 있는 파티션이 많을 경우 매번 목록을 지정해서 보내고, 그에 대한 메타데이터를 모두 받아오는 것은 비효율적이다. 따라서 카프카는 fetch session cache 를 두어 변경사항이 발생할 경우에만 업데이트하고 요청에 캐시된 내용은 포함하지 않도록 하여 최적화 한다. fetch session cache 는 한도가 정해져 있기 때문에 세션을 생성하려 해도 생성되지 않거나, 갑자기 해제될 수 있는데 이런 경우에도 적절한 에러 메시지를 보내게 된다.
기타 요청
대표적으로 사용되는 메타데이터, 쓰기, 읽기 요청을 살펴보았지만 이 외에도 수많은 요청이 존재한다.
카프카 커뮤니티에 의해 지속적으로 추가되고 있으며, 하위호환성을 유지하며 발전하고 있다.
클라이언트는 상위 버전 브로커에게 요청을 보내도 정상적으로 처리할 수 있지만, 상위 버전 클라이언트가 브로커에게 요청을 보내면 정상적으로 처리할 수 없기 때문에 브로커 업데이트를 먼저 하는 것을 권장한다.
카프카의 기본 저장 단위는 파티션 레플리카이다. 파티션 레플리카를 저장할 때 실제로 내부적으로 어떻게 동작하는지 자세하게 살펴보자.
계층화된 저장소
기존 카프카는 대용량의 데이터를 처리하기 위해서 설계되어 있는데, 다음과 같은 문제점이 존재한다.
파티션별 저장 가능한 데이터에 한도가 있다. 최대 보존 기한, 파티션 수 등은 제품의 요구 사항뿐만 아니라 물리적 디스크 크기도 신경써서 설계해야 한다.
디스크와 클러스터의 크기가 저장소 요구 조건에 의해 결정되어 지연, 처리량을 위해 디스크뿐만 아니라 클러스터까지 불필요하게 크게 잡아야 한다.
파티션의 수가 많고, 파티션의 크기가 많아지면 파티션을 다른 브로커로 옮기는 등의 작업에 시간이 많이들어 클러스터가 탄력적으로 크기를 조정하는 작업이 어려워진다.
계층화된 저장소 기능
로컬, 원격 저장소를 구분해서 사용한다.
로컬 저장소는 기존 카프카와 동일하게 로컬 디스크에 저장하는 방식으로 동작한다.
원격 저장소는 완료된 로그 세그먼트를 저장하기 위해 HDFS나 S3와 같은 전용 저장소 시스템을 사용한다.
로컬 저장소와 원격 저장소의 보존 정책을 따로 설정할 수 있다.
로컬 저장소에서는 빠르게 읽어와 처리할 데이터만 보관하여 사용하고, 원격 저장소에는 이미 처리된 로그 세그먼트를 저장해두었다가 이후에 복구 등의 작업에서 필요로 할 시 사용하게 된다.
계층화된 저장소 기능의 장점
저장소가 이중화되어 있으므로 카프카 클러스터의 메모리와 CPU에 상관없이 저장소를 확장할 수 있다.
로컬 저장소에 저장해야할 데이터의 양이 작아져 복구와 리밸런싱 작업 등을 빠르게 처리할 수 있다.
카프카를 단순히 고성능 큐 시스템으로 사용하여 외부 저장소에 저장하는 작업을 별도로 할 필요 없이 원격 저장소에 결과들을 저장하고, 클라이언트에게 바로 응답할 수 있다.
오래된 데이터를 읽어와야 할 경우 로컬 디스크에서는 페이지 캐시와의 경합 때문에 지연이 증가하지만, 계층화된 저장소를 이용할 경우 원격 저장소에서 읽어오는 네트워크 비용만 처리하면 되므로 지연이 크게 증가하지는 않는다.
파티션 할당
파티션을 할당할 때는 다음과 같은 목표를 갖고 파티션 할당 작업을 수행하게 된다.
파티션 레플리카들을 가능한 한 브로커들에 고르게 분산시킨다.
같은 파티션의 레플리카들은 각각 다른 브로커에 배치되도록 한다.
브로커에 렉 정보가 설정되어 있다면, 가능한 한 각 파티션의 레플리카들을 서로 다른 랙에 할당한다.
파티션 할당이 끝난 이후에 새로운 파티션을 할당하는 경우에는 파티션 수가 가장 적은 브로커에 파티션을 할당한다. 파티션의 크기, 서버 부하 등은 고려하지 않으므로 주의할 필요가 있다.
파일 관리
카프카는 보존 기한 정책만을 보고 특정 기한을 넘기거나, 특정 데이터양을 넘기게 될 경우 파일을 삭제한다.
파티션은 여러 개의 세그먼트로 나뉘어지며, 각 세그먼트당 하나의 파일로 저장된다. 세그먼트가 다 찰 경우 세그먼트를 닫고 새로운 세그먼트를 생성한다.
현재 사용중인 세그먼트는 active 세그먼트라 불리며, active 세그먼트는 삭제되지 않는다.
카프카 브로커는 각 파티션의 모든 세그먼트 파일의 파일 디스크럽터를 열고 유지하기 때문에 이에 맞춰 OS를 튜닝해줄 필요가 있다.
파일 형식
카프카는 기본적으로 프로듀서가 생성하고, 세그먼트 파일에 저장하고, 컨슈머에게 보내지는 데이터 형식이 동일하다. (그래서 zero-copy 최적화를 할 수 있기도 하다.) 따라서, 메시지 형식을 변경하고자 하면 네트워크 프로토콜, 디스크 저장 형식을 모두 수정해주어야 한다.
카프카 메시지는 사용자 페이로드와 시스템 헤더, 두 부분으로 나뉘며, 사용자 페이로드는 실제 사용자 데이터인 키값과 밸류값, 헤더 모음을 포함하고, 시스템 헤더는 전송에 필요한 자체적인 키/밸류 순서쌍을 갖고 있다.
카프카 메시지는 배치로 묶어서 전송되게 되는데, 메시지 배치 헤더에는 해당 메시지를 저장하기 위한 오프셋, 타임스탬프, 체크섬, 에포크 값 등 다양한 정보를 포함하고 있다. 메시지의 시스템 헤더는 배치의 첫번째 메시지로부터의 타임스탬프 차이, 오프셋 차이 등을 저장하여 매번 메타데이터를 확인하는 오버헤드를 줄여줄 수 있다.
메시지 배치에는 사용자 메시지만 보내는 배치 외에도 컨트롤 배치라는 것이 존재하는데, 트랜잭션 커밋, 롤백, 오프셋 커밋 등 카프카 시스템 내에서 동작을 위해 사용하는 메시지 배치를 위해 사용되며, 사용자 메시지는 포함하지 않는다.
인덱스
카프카는 특정 오프셋부터 메시지를 읽어오는 기능을 제공하는데, 모든 레코드의 오프셋을 확인하며 특정 오프셋을 찾아가는 방식은 데이터가 커질 경우 시간이 많이 소요되므로 오프셋값과 세그먼트 파일의 위치를 매핑하는 인덱스를 활용한다.
오프셋을 기준으로 매핑하는 인덱스 외에도 타임스탬프를 기준으로 세그먼트의 위치를 매핑하는 인덱스도 존재하여 장애 복구 상황 등에서 유용하게 사용된다.
오프셋, 타임스탬프 인덱스도 세그먼트 파일 별로 별개의 파일로 존재하며, 세그먼트가 삭제될 때 같이 삭제된다.
인덱스가 오염될 경우 다시 메시지들을 읽어서 재생성하는 방식으로 복구한다.
압착
카프카는 두 가지 보존 정책을 허용하는데, 앞서 살펴본 기한에 따른 삭제 보존 정책과, 키 값에 대해 가장 최근의 밸류값만 저장하고 이전 메시지를 삭제하는 압착 보존 정책이 존재한다.
두 가지 보존 정책을 혼용해서 사용하는 옵션도 존재하며, 키 값에 대한 최신 메시지일지라도 보존 기한이 넘기면 삭제하는 방식으로 동작한다.
압착의 작동 원리
압착 기능이 활성화되어 있을 경우(log.cleaner.enabled 설정으로 관리 가능), 각 브로커는 압착 매니저 스레드와 다수의 압착 스레드를 시작시킨다.
압착을 수행하기 위해 파티션에 있는 메시지들은 클린 또는 더티 상태로 존재하게 된다. 클린 상태란 압착이 이미 수행된 메시지를 의미하며, 더티 상태란 압착이 수행되지 않아 새롭게 압착을 수행해야하는 메시지를 의미한다. 클린, 더티를 나누는 기준은 마지막 압착이 일어난 오프셋을 전후로 나뉜다.
클리너 스레드(압착 스레드)는 각 스레드별로 파티션의 더티 영역을 읽어서 키값과 오프셋을 키밸류로 하는 인메모리 맵을 생성한다. 이후 클리너 스레드는 최신 상태의 메시지만 새로운 세그먼트로 복제하는 작업을 수행한다. 클린 세그먼트들을 오래된 것부터 읽어들이면서, 인메모리 맵에 존재하지 않는 키값이라면 최신 키값이고, 맵에 존재하는 키값이라면 오래된 키값이므로 건너뛰는 방식으로 최신 메시지를 복제한다.
클리너 스레드가 사용할 인메모리 맵의 메모리 제한도 설정해줄 수 있으며, 최소한 하나의 세그먼트를 처리할 수 있는 양으로 설정해야 한다. 여러 압착 대상 세그먼트가 존재하는데 메모리 제한 때문에 한 번에 처리할 수 있는 세그먼트의 수는 적을 경우, 가장 오래된 세그먼트부터 압착을 수행한다.
삭제된 이벤트
압착을 통해 최신 상태의 키 밸류 값을 유지하지만, 아예 해당 키값에 대한 데이터를 삭제해야하는 경우가 있을 수 있다. 이런 경우 키값의 밸류로 null 값을 넘겨 삭제할 수 있다.
밸류로 null 값을 갖는 메시지는 tombstone 메시지라고 불리며, 이 메시지는 특별히 설정된 시간만큼 보존하고 삭제된다.
tombstone 메시지가 들어오면 압착에 의해 키 값에 대한 밸류값은 null 만 존재하게 될 것이고, 이후에 툼스톤 메시지도 삭제되어 영구적으로 키값에 해당하는 메시지가 존재하지 않게 된다.
툼스톤 메시지가 보존되는 기간동안 컨슈머가 해당 메시지를 읽어 데이터베이스에서도 삭제하는 방식으로 동작하는 애플리케이션이 많이 있는데, 툼스톤 메시지가 보존되는 기간동안 컨슈머가 작동을 멈추게 되면 툼스톤 메시지는 삭제되었기 때문에 데이터베이스에는 남아있는 상황이 발생할 수 있다. 따라서 카프카 어드민 클라이언트에 있는 deleteRecords 메서드를 통해 파티션의 최하위 오프셋을 올려 잡아 그보다 작은 오프셋을 갖는 메시지는 접근이 불가능하도록 만드는 방법을 통해 확실하게 삭제하는 방법도 존재한다.
토픽은 언제 압착되는가?
삭제 보존 정책에서도 active 세그먼트는 삭제하지 않듯이, 압착 역시 active 세그먼트에서는 일어나지 않는다.
기본적으로 세그먼트의 50% 이상이 더티 상태인 세그먼트들이 압착 대상에 해당한다. 모든 active 세그먼트가 아닌 세그먼트들을 압착하면 디스크 공간을 절약할 수 있겠지만, 압착이 일어나는 동안 I/O 성능이 하락할 수 있기 때문에 무분별하게 사용하는 것은 좋지 않다.
min.compaction.lag.ms 를 설정하면 메시지가 쓰여진 뒤 최소 그정도의 시간이 지나야 압착을 실행할 수 있도록 설정할 수 있다.
max.compaction.log.ms 를 설정하면 메시지가 쓰여진 뒤 압착이 최대로 딜레이될 수 있는 시간을 설정하여 그 시간안에 압착이 일어남을 보장할 수 있다.