개발 일지/Kafka

[Kafka] 내부 동작 원리 파헤치기(2) - 리더에포크

배발자 2023. 10. 22.
반응형

개요

이전 글에서는 리플리케이션의 동작 원리에 대해서 글을 작성하였고 예고한대로 이번글에서는 리더에포크가 무엇인지 알아보려고 한다. 추가적으로 컨트롤러, 세그먼트 로그 컴팩션에 대한 정리도 같이 진행한다.  

 

 

리더에포크와 복구

리더에포크(LeaderEpoch)는 카프카의 파티션들의 복구 동작을 할 때 메시지의 일관성을 유지하기 위한 용도로 이용되는데 복구 동작 시 하이워터마크를 대체하는 수단으로 활용한다. 

 

*하이워터마크 : 마지막 커밋 오프셋 위치 

 

 

이번 예제에서의 파티션 수는 1, 리플리케이션 팩터는 2라고 가정하며 장애가 발생하는 가정을 설명하려고 한다. 아래의 표는 동작과정을 나타내며 마지막 8번 문항에서 장애가 발생하니 그 전에 동작된 과정을 그림을 보면서 이해해보자. 

 

  1. 리더는 프로듀서로부터 message1을 받았고, 0번 오프셋에 저장 후 팔로워는 리더에게 0번 오프셋에 대한 가져오기 요청을 한다. 
  2. 팔로워는 message1을 리더로부터 리플리케이션 한다. 
  3. 리더는 하이워터마크를 1로 올린다
  4. 리더는 프로듀서로부터 message2을 받고, 1번 오프셋에 저장한다. 
  5. 팔로워는 1번 오프셋에 대한 가져오기 오쳥을 보내고, 응답으로 리더의 하이워터마크 변화를 감지하고 자신의 하이워터마크도 1로 올린다. 
  6. 팔로워는 message2를 리더로부터 리플리케이션 한다. 
  7. 팔로워는 2번 오프셋에 대한 요청을 리더에게 보내고, 리더는 하이워터마크를 2로 올린다. 
  8. 장애로 인해 팔로워가 다운된다

 

자, 지금 상태로 보면 팔로워는 message2까지 리플리케이션을 완료했지만, 아직 리더로부터 하이워터마크2로 올리는 내용은 전달받지 못한 상태로 다운되었다. 이제부터 다운된 팔로워가 장애를 해결하고 복구를 어떻게 하는지 알아보려고 한다. 

 

 

 

  1. 팔로워는 자신의 워터마크보다 높은 메시지들은 신뢰하지 못하다고 생각해 삭제한다. 즉, message2는 삭제하게 된다. 
  2. 팔로워는 리더에게 1번 오프셋의 새로운 메시지에 대한 가져오기 요청을 한다. 
  3. 이 순간 리더였던 브로커가 다운되면서, 해당 파티션에 유일하게 남아있던 팔로워가 새로운 리더로 승격한다. 

 

 

 

결국 뉴리더는 message2를 손실한채로 뉴리더로 승격하게 되었다. 이러한 방식을 활용하면, 가용성은 지켜졌지만 데이터의 일관성이 지켜지지 않은 문제점이 발생하였다. 그렇다면 어떻게 해야할까?

 

이를 해결하기 위해 쓰이는 것이 리더에포크인 것이다. 리더에포크를 활용한다면, 하이워터마크보다 앞에 있는 메시지를 무조건 삭제하는 것이 아니라 리더에게 리더에포크 요청을 보내게 된다. 다음 그림을 보면서 이해해보자. 

 

 

  1. 팔로워는 복구 동작을 하면서 리더에게 리더에포크 요청을 보낸다. 
  2. 요청을 받은 리더는 '1번 오프셋의 message2까지'라고 팔로워에게 보낸다. 
  3. 팔로워는 자신의 하이워터마크보다 높은 1번 오프셋의 message2를 삭제하지 않고, 리더의 응답을 확인한 후 message2까지 자신의 하이워터마크를 상향 조정한다. 

 

리더가 예상치 못한 장애로 다운되면서 팔로워가 새로운 리더로 승격되었을 때 리더에포크를 적용하지 않는 경우에는 팔로워가 message2를 가지고 있음에도 복구 과정에서 하이워터마크보다 높은 메시지를 삭제했었지만 리더에포크를 활용하게 되면 삭제 동작을 하기 전에 리더에포크 요청과 응답 과정을 통해 팔로워의 하이워터마크를 올리면서 메시지 손실을 발생하지 않게 된다. 

 

이해가 안되면 쉽게 생각해보자. 하이워터마크라는 기준선을 넘겨버리면 넘어간 데이터들을 버리는 정책이 존재했는데, 그러지말고 기준선을 조정하자는 것이다. 즉, 리더의 기준선인 하이워터마크에 맞게 팔로워도 하이워터마크를 갱신하도록 하는 것이 리더에포크다. 

 

 

컨트롤러

리더에 장애가 발생하면 팔로워들 중에 하나를 새로운 리더로 승격시켜야한다는 말을 계속 해왔다. 그렇다면, 어떻게 새로운 리더로 승격해오는 것인지, 그러면 그걸 해주는 놈이 누군지 고민하게 될텐데 그 놈이 컨트롤러다. 

 

먼저, 카프카 클러스터 중 하나의 브로커가 컨트롤러의 역할을 하게 되며, 파티션의 ISR 리스트 중에서 리더를 선출한다. 리더를 선출하기 위한 ISR 리스트 정보는 안전한 저장소에 보관되어 있어야 하는데, 가용성 보장을 위해 주키퍼에 저장되어 있다. 컨트롤러는 브로커가 실패하는 것을 사주경계하고 있다가 브로커의 실패가 감지되면 즉시 ISR 리스트 중 하나를 새로운 파티션 리더로 선출하게 된다. 이후, 새로운 리더에 대한 정보를 주키퍼에 기록하고, 변경된 정보를 모든 브로커에게 전달한다. 

 

이러한 과정을 빨리 해야하는데, 그 이유는 파티션의 리더가 다운되어 새로운 리더를 선택하지 못하는 상황이 지연되면  모든 읽기/쓰기 동작은 실패될 것이다. 그래서 리더 선출 작업이 빠르게 이뤄져야한다. 

 

 

  1. 파티션 0번의 리더가 있는 브로커 1번이 다운된다. 
  2. 주키퍼는 0번 파티션의 ISR에서 변화가 생겼음을 감지한다. 
  3. 컨트롤러는 주키퍼 워치를 통해 0번 파티션에 변화가 생긴 것을 감지하고, 해당 파티션 ISR 중 3번을 새로운 리더로 선출한다. 
  4. 컨틀롤러는 0번 파티션의 새로운 리더가 3이라는 정보를 주키퍼에 기록한다.
  5. 갱신된 정보는 모든 브로커에게 전파한다. 

 

카프카 초기에는 이러한 과정에서 많은 시간이 발생하였다고 한다. 예를 들어, 하나의 파티션에 대해 리더 선출 작업이 약 0.2초가 걸린다면, 파티션이 1개일 경우 0.2초만에 완료된다. 하지만, 1만개의 파티션에 대해 리더 선출이 이뤄져야한다면, 전체 작업 소요시간은 약 2,000초가 걸린다. 그래서 1대의 브로커가 장애가 발생하고, 리더 선출 작업이 오랜 시간이 지속되면 실시간 데이터 처리를 하는 부서들은 모두 장애 상황을 겪게 될 것이다. 

 

하지만, 이러한 상황을 개선하기 위해 카프카 버전 1.1에서는 불필요한 로깅 작업을 없애고 주키퍼 비동기 API가 반영하면서 6분 30초가 소요되던 작업을 3초만에 완료시켰다고 한다. 현재 카프카는 3.xx 버전까지 나왔기 때문에 이런 문제에 대해서는 안심하고 쓰면 된다. 

 

 

로그(로그 세그먼트)

카프카의 토픽으로 들어오는 메시지(레코드)는 세그먼트(로그 세그먼트)라는 파일에 저장된다. 메시지는 정해진 형식에 맞추어 순차적으로 로그 세그먼트 파일에 저장되며, 메시지의 내용, 메시지의 키, 밸류, 오프셋, 메시지 크기 같은 정보가 함께 저장되며 브로커의 로컬 디스크에 보관된다. 

 

만약, 이러한 로그 세그먼트 파일들이 너무 커지면 관리하기 어렵기 때문에 최대 크기는 1GB으로 Default로 설정되어있다. 만약, 현재 사용하고 있는 로그 세그먼트가 1GB를 커지게 되면 해당 파일을 Close하고 새로운 로그 세그먼트를 생성하는 방식으로 진행되는데, 이러한 방식을 롤링 전략이라고 한다. 

 

하지만, 1GB 로그 세그먼트 파일이 무한히 늘어날 경우 이 또한 문제가 발생할 것이기에 어떻게 관리를 해야할 것인지도 중요한 부분이다. 방법으로는 삭제와 컴팩션 방식이 존재한다. 해당 포스팅에서는 컴팩션 방식에 대해서 알아보려고 한다. 

 

로그 세그먼트 컴팩션 

로그 컴팩션은 기본적으로 로컬 디스크에 저장되어 있는 세그먼트를 대상으로 실행하는데, 현재 활성화된 세그먼트는 제외하고 나머지 세그먼트들을 대상으로 컴팩션이 실행된다. 이때 어떻게 동작하냐면, 메시지(레코드)의 키값을 기준으로 마지막의 데이터만 보관하게 된다. 

 

조금 더 깊게 들어가자면, 로그 컴팩션 기능을 이용하는 대표적인 예는 카프카의 __consumer_offset 토픽이다. 해당 토픽은 카프카의 내부 토픽으로, 컨슈머 그룹의 정보를 저장하는 토픽이다. 각 컨슈머 그룹의 중요한 정보는 해당 컨슈머 그룹이 어디까지 읽었는지를 나타내는 오프셋 커밋 정보인데, __consuer_offset에 (컨슈머 그룹명, 토픽명)와 밸류(오프셋 커밋 정보) 형태로 메시지가 저장된다. 예를 들면, bae 토픽으로 BB 컨슈머 그룹이 컨슘하고 있을 때 메시지가 커밋되면 키와 밸류 형태의 메시지로 __consumer_offset 토픽에 저장된다.

 

* 키 = 컨슈머 그룹명, 토픽명 /  * 밸류 = 오프셋 커밋 정보

* 키는 BB(컨슈머 그룹), bae(토픽명)이고 밸류는 1(오프셋)인 메시지 저장.  

 

이러한 과정을 2번 더 진행할 경우, __consumer_offset 토픽에 저장된 메시지는 총 3개일 것이며 밸류는 1, 2, 3이다. 이후 로그 컴팩션 동작이 일어나면 키값이 BB, bae인 메시지의 마지막 데이터 3만 남게 된다. 즉, 항상 마지막으로 커밋된 오프셋 정보가 중요하므로, 과거에 커밋된 정보들은 삭제돼도 무방하다는 것이다. 

 

[컴팩션 전 로그]

오프셋 0 1 2 3 4 5 6 7 8
K1 K2 K2 K1 K3 K2 K2 K3 K3
벨류 V1 V2 V3 V4 V5 V6 V7 V8 V9

 

[컴팩션 후 로그]

오프셋 3 6 8
K1 K2 K3
벨류 V4 V7 V9

 

이러한 방식을 활용하게 되면, 빠른 장애 복구가 가능하다는 것이다. 즉, 장애 복구 시 전체 로그를 복구하지 않고, 메시지의 키를 기준으로 최신의 상태만 복구할 경우를 의미한다. 하지만, 빠른 재처리는 가능해도 모든 토픽에 로그 컴팩션을 수행하는 것은 옳지 않다. 해당 작업이 실행되면 브로커의 과도한 입출력 부하가 발생할 수 있으니 브로커의 리소스 모니터링도 병행하면서 로그 컴팩션을 사용해야한다. 

 

 

그럼 어떤 경우일 때 유용하게 쓰일까? 

 

 

 

위의 그림을 보면 배송 관련 상태로 많이 본 적이 있을거다. 즉, 위의 서비스는 메시지의 키값을 기준으로 과거 정보는 중요하지 않고 가장 마지막 값만 필요한 경우이다. 구매한 사용자 아이디를 기준으로 "입금/결제 >> 배송중/픽업대기 >> 배송완료/픽업완료"와 같이 최종 상태만 사용자에게 노출하면 되므로, 카프카의 로그 컴팩션 기능을 활용할 수 있을 것이다. 

 

* 참고서적 : 실전 카프카 개발부터 운영까지 - 고승범

반응형

댓글