[kafka] Apache Kafka 구경하기 - (2)

Kafka 상세 아키텍쳐

Kafka는 단순히 생각하면 토픽들의 모음이다. 이러한 토픽들은 매우 커질 수 있음으로 성능과 확장성을 위해 더 작은 크기의 파티션으로 분할해야 한다.

토픽 파티션Topic Partition

Kafka topic은 파티션되어 있다. 즉 하나의 topic이 여러 조각fragements로 나누어 저장된다. 각 파티션은 별도의 Kafka 브로커에 배치될 수 있다. 새 메세지가 토픽에 보내지면 해당 토픽의 파티션 중 하나에 추가되게 된다. 생산자는 데이터를 기반으로 메세지를 게시하는 파티션을 제어한다. 예를들어 생산자는 특정 ‘도시’와 관련하여 메세지를 동일한 파티션으로 전달할 수 있다.

기본적으로 파티션은 순서가 지정된 메세지 모음이다. 생산자는 계속해서 새로운 메세지를 파티션에 추가하고 Kafka 파티션은 모든 메세지가 들어온 순서대로 저장되도록 보장한다. 메세지 순서는 토픽 전체에 대해서 보장되는 것이 아니라 파티션 수준에서 유지가된다.

  • 파티션에 추가되는 모든 메세지는 offset이라고 하는 고유한 시퀀스ID를 갖게 된다. 이러한 offset은 파티션 내에서 메세지의 순차적 위치(index 같이)를 식별하는데 사용한다.
  • offset은 파티션 내에서만 고유하기 때문에, 특정 메세지를 찾기 위해서는 topic, partition, offset을 모두 알아야 한다.
  • 생산자는 모든 파티션에 메세지를 전달하도록 선택할 수 있다. 파티션 내에서 순서 지정이 필요하지 않은 경우 Round-Robin 전략을 사용할 수 있음으로 레코드가 파티션에 고르게 분산된다.
  • 각 파티션을 별도 Kafka브로커에 배치하면 여러 소비자가 해당 토픽에 대해 병렬적으로 데이터를 읽을 수 있다. 즉, 서로 다른 소비자가 별도의 브로커에 있는 서로 다른 파티션을 동시에 읽을 수 있다.
  • 토픽의 각 파티션을 별도의 브로커에 배치하면 토픽이 하나의 서버 용량보다 더 많은 데이터를 보유할 수 있게 된다.
  • 파티션에 기록된 메세지는 변경할 수 없으며 업데이트 할 수 없다.
  • 생산자는 게시하는 모든 메세지에 키를 추가할 수 있다. Kafka는 동일한 키를 가진 메세지가 동일한 파티션에 저장되도록 보장한다.
  • 브로커는 서로 다른 토픽에 속하는 파티션의 모음을 관리한다.

Kafka는 멍청한 브로커와 똑똑한 소비자 원칙을 따른다. 이것은 Kafka는 소비자가 읽은 레코드를 추적하지 않는 다는 것을 의미한다. 대신 소비자는 Kafka에서 확인하고자 하는 레코드의 위치(offset)을 알려준다. 이를 통해 원하는대로 offset을 증가/감소시킬 수 있음으로 메세지를 재생하고 다시 처리할 수 있다.

소비자는 특정 오프셋에서 시작하는 메세지를 읽을 수 있으며, 선택한 오프셋 부터 메세지를 읽을 수도 있다. 이를 통해 소비자는 언제든지 클러스터에 추가될 수 있다.

모든 토픽을 여러 Kafka브로커에 복제하여 내 결함성과 가용성을 높일 수 있다. 각 토픽 파티션에는 하나의 리더 브로커(leader)와 여러개의 복제본(follwer)이 있다.

Leader

리더는 주어진 파티션에 대한 모든 read/write작업을 담당한다. 모든 파티션에는 리더역할을 하는 하나의 kafka브로커가 존재한다. (Kafka 2.4 이상부터 Follwer에서도 데이터를 읽을 수 있도록 기능이 추가되었다.)

Kafka는 Zookeeper에 각 파티션의 리더 위치를 저장한다. 모든 write/read가 리더에서 이루어짐으로 생산자와 소비자는 파티션 leader를 찾기 위해 zookeeper와 직접 통신하게 된다.

Follwer

SPoF를 처리하기 위해 Kafka는 파티션을 복제하고 follwer라고 하는 여러 브로커 서버에 분산할 수 있다. 각 follower의 역할은 leader에 대한 백업을 수행하는 것이다. 그렇기 때문에 leader가 내려가게되면 모든 follower가 다음의 leader로 역할을 수행할 수 있다.

In-Sync Replica (ISR)

ISR은 주어진 파티션에 대한 최신 데이터가 있는 브로커이다. 리더는 직접 read/write가 일어나기 때문에 항상 최신화 되어 있지만 팔로워는 변경사항이 반영이 되어야 동기화된 복제본이라고 할 수 있다. ISR은 주어진 파티션에서 항상 동기화를 이루는 팔로워이다. 따라서 ISR만 파티션 리더가 될 수 있다. Kafka는 소비자가 데이터를 읽게되기 전에 필요한 최소 ISR을 설정할 수 있다.

High-water mark

데이터 일관성을 보장하기위해 리더 브로커는 최소 ISR집합에 복제되지 않은 메세지를 리턴하지 않는다. 이를 위해 브로커는 특정 파티션의 모든 ISR이 공유하는 가장 높은 오프셋인 high-water-mark를 추적한다. 리더는 high-water-mark까지만 데이터를 노출하고 high-water-mark 오프셋을 모든 팔로워에게 전달한다.

Segmented log

Kafka는 로그 분할을 사용하여 파티션에 대한 스토리지를 구현한다. kafka는 레코드 삭제를 위해 디스크에서 데이터를 정기적으로 찾아야 하기 때문에 단일 긴 파일은 성능 병목 현상을 발생하고 오류가 발생하기 쉽다. 더 쉬운 관리와 더 나은 성능을 위해 파티션이 세그먼트 단위로 분할된다.


Consumer Group

소비자그룹Consumer Group은 기본적으로 토픽 파티션의 메세지를 사용하기위해 병렬로 함께 작업하는 하나 이상의 소비자 집합이다. 두 명의 소비자가 동일한 메세지를 수신하지 않고 메세지는 그룹의 모든 소비자에게 균등하게 분할된다.

Consumer Group을 통한 분산 파티션 소비

Kafka는 consumer group 내의 하나의 소비자만 하나의 파티션에서 메세지를 읽게 허용한다. 즉, 토픽 파티션은 병렬처리의 단위가 된다. conumer group 내의 서로 다른 소비자가 동일한 하나의 파티션을 소비할 수 없다. 하나의 소비자가 중지되면 kafka는 동일한 consumer group의 나머지 소비자에게 파티션을 분산한다. 마찬가지로 소비자가 그룹에 추가되거나 그룹에서 제거될 때마다 소비량은 그룹 내에서 재분배된다.

소비자는 토픽 파티션에서 메세지를 가져온다. 소비자마다 다른 파티션을 담당할 수 있다. kafka는 많은 수의 소비자를 지원하고 매우 적은 오버헤드로 많은 양의 데이터를 유지할 수 있다. 소비자 그룹을 사용하면 여러 소비자가 토픽의 여러 파티션에서 메세지를 읽을 수 있도록 소비자를 병렬화 하여 매우 높은 처리량을 보장할 수 있다. 파티션 수는 소비자가 파티션 수 보다 많을 수 업식 때문에 소비자의 최대 병렬 처리에 영향을 준다.

kafka는 단일 소비자와 마찬가지로 토픽의 파티션 별로 현재 오프셋을 저장한다. 즉, 고유한 메세지가 소비자 그룹 내의 단일 소비자에게만 전송이되고 부하가 소비자간에 최대한 균등하게 균형을 이루게 된다.

소비자 수가 토픽 파티션 수를 초과하면, 초과되는 모든 새로운 소비자들은 기존 소비자가 해당 파티션에서 구독을 취소할 때까지 idle 모드로 대기하게 된다. 마찬가지로 새로운 소비자가 소비자 그룹에 가입하면 kafka는 파티션 보다 소비자가 더 많은 경우 파티션 재조정을 한다. Kafka에서 사용하지 않는 소비자는 장애를 위한 여유분으로 둔다.

아래는 파티션 수와 소비자 수에 따른 동작 방식이다.

  • 그룹 내 소비자 수 = 토픽의 파티션 수 : 각 소비자가 하나의 파티션을 소비한다.
  • 그룹 내 소비자 수 > 토픽의 파티션 수 : 일부 소비자가 대기상태로 기다린다.
  • 그룹 내 소비자 수 < 토픽의 파티션 수 : 일부 소비자는 하나 이상의 파티션을 소비하게 된다.

Kafka Workflow

전체적인 Pub-Sub 메세지 처리 절차

  1. producer는 topic에 대한 메세지를 게시한다.
  2. kafka broker는 특정 topic에 대해 구성된 partition에 메세지를 저장한다. producer가 메세지를 저장할 partition을 지정하지 않은 경우 broker는 메세지가 partition 간에 동등하게 공유되었는지 확인한다. producer가 두개의 메세지를 보내고 두 개의 partition이 있는 경우 kafka는 첫번째 파티션에 메세지를 하나 저장하고 두번째 파티션에 두 번째 메세지를 저장한다.
  3. consumer는 특정 topic을 구독한다.
  4. consumer가 토픽을 구독하면 kafka는 topic의 현재 오프셋을 소비자에게 제공하고 해당 오프셋을 zookeeper에 저장한다. 소비자는 새 메세지에 대해 정기적으로 kafka에 요청한다.
  5. kafka broker는 생산자로부터 메세지를 받으면 이러한 메세지를 소비자에게 전달한다.
  6. 소비자는 메세지를 수신하고 처리한다. 메세지가 처리되면 소비자는 kafka 브로커에 ack을 보낸다.
  7. ack을 받으면 kafka는 오프셋을 증가시키고 zookeeper에 업데이트 한다. 오프셋이 zookeeper에서 유지되기 때문에 소비자는 브로커 중단 중에도 다음 메세지를 올바르게 읽을 수 있다.
  8. 소비자는 요청 전송을 중지할 때까지 위의 절차를 반복한다.
  9. 소비자는 언제든지 원하는 topic의 오프셋으로 되돌아가거나 건너뛰고 후속 메세지를 읽을 수 있다.

Consumer Group에서 메세지 처리 절차

  1. producer는 topic에 대한 메세지를 게시한다.
  2. Kafka broker는 위와 유사하게 특정 topic에 대해서 구성된 partition에 메세지를 저장한다.
  3. 그룹ID가 Group-1이고 Topic이름이 Topic-01이라고 가정하고, Kafka는 새로운 소비자가 Group-1과 동일한 그룹ID로 동일한 토픽인 Topic-01에 구독을 할때까지 위의 시나리오와 유사하게 동작한다.
  4. 새로운 소비자가 추가되면 Kafka는 작업을 공유모드로 전환하여 각 메세지가 소비자 그룹 Group-1의 구독자 중 한명에게만 전달되도록 한다. 이 메세지 전송은 그룹의 한 소비자만 메세지를 소비함으로 큐 기반 메세징과 유사하다. 하지만 큐 기반 메세징과는 달리 사용 후 메세지가 제거되지 않는다.
  5. 이 메세지 전송은 소비자 수가 토픽에 대한 파티션 수에 도달할 때까지 계속될 수 있다.
  6. 소비자 수가 파티션 수를 초과하면 기존 소비자가 구독을 취소할 대까지 새 소비자는 메세지를 받지 않는다. 이 시나리오는 kafka의 각 소비자에게 최소 하나의 파티션에 할당되기 때문에 발생한다. 모든 파티션이 기존 소비자에게 할당되면 새 소비자는 기다려야한다.

Zookeeper의 역할

zookeeper는 kafka 브로커, 생산자 및 소비자 간 코디네이터 인터페이스 역할을 수행한다. kafka는 브로커, 토픽, 파티션, 파티션 리더/팔로워, 소비자 오프셋 등에 대한 정보와 같은 기본 메타 데이터를 zookeeper에 저장한다.

kafka브로커 자체는 stateless로 구성된다. 이는 zookeeper가 상태 정보를 모두 가지고 있기 때문이다.

  • 파티션 당 소비자 그룹의 마지막 오프셋 위치를 유지함으로, 소비자가 실패하더라도 마지막 위치에서 메세지를 소비할 수 있다. (최신 클라이언트는 별도로 kafka topic에 대한 오프셋을 저장한다)
  • topic, topic의 파티션 수, 파티션의 leader/follwer 위치를 저장한다.
  • topic에 대한 ACL을 관리한다. ACL은 접근과 권한 부여를 수행하는데 사용된다.

옛날 버전의 kafka에서는 모든 client(생산자/소비자)가 zookeeper와 직접 대화하여 파티션 리더를 찾았다. kafka는 이러한 커플링에서 벗어나 생산자와 소비자가 직접 kafka broker에서 최신 메타 정보를 얻는다. 그리고 kafka 브로커는 zookeeper와 통신하여 최신 메타 데이터를 얻는다.

생산자는 임의의 브로커에 연결하여 partition 1의 리더를 요청한다. 브로커는 zookeeper와 메타를 동기화 하고 있기 때문에 partition 1의 리더 위치를 전달하게되고, 생산자는 리더 브로커에 연결하여 메세지를 전달한다.

모든 중요한 정보는 zookeeper에 저장되고 zookeeper는 이 데이터를 클러스터 전체에 복제함으로 kafka 브로커의 오류는 kafka 클러스터의 상태에 영향을 주지 않는다. zookeeper가 실패하면 kafka는 zookeeper가 실패에서 복구된 뒤에 다시 상태를 복원할 수 있다. zookeeper는 kafka브로커에서 파티션 리더 선택을 조정하는 일도 담당한다.


Controller Broker

Kafka 클러스터 내에서 하나의 브로커가 컨트롤러로 선택된다. 이 컨트롤러 브로커는 토픽 생성/삭제, 파티션 추가, 파티션에 리더 할당, 브로커 장애 모니터링 등과 같은 관리 작업을 담당한다. 또한 컨트롤러는 시스템에 있는 다른 브로커의 상태를 주기적으로 확인한다. 특정 브로커로부터 응답을 받지 못하는 경우, 다른 브로커로 fail-over를 수행한다. 또한 파티션 리더 선택 결과를 시스템의 다른 브로커에 전달한다.

컨트롤러 브로커가 죽으면 kafka는 새 컨트롤러를 선택한다. 한가지 문제는 리더가 중지가 잘 된건지, 아니면 GC나 일시적인 네트워크 중단으로 간헐적 오류가 발생한건지 확인할 수 없다. 그럼에도 불구하고 클러스터는 계속해서 새로운 컨트롤러를 선택해야 한다. 컨트롤러가 간헐적 오류가 발생했다면 결국 좀비 컨트롤러가 된다. 좀비 컨트롤러는 이전에 죽는 것으로 간주되어 다시 온라인 상태가 된 컨트롤러 노드로 설정하지 않는다. 다른 브로커가 그 역할을 수행하지만 좀비 컨트롤러는 계속해서 자신이 컨트롤러 역할을 수행하려고 할 수 있다. 이를 Split-brain이라고 한다.

split-brain으로 인해 두개의 컨트롤러가 존재하며 잠재적으로 충돌이 발생할 수 있는 명령을 병렬로 제공한다. 이 같은 일이 클러스터에 발생하면 불일치 이슈가 발생할 수 있다.

Generation Clock

split-brain을 해결하기 위한 방법으로 단순히 단조롭게 증가하는 숫자인 generation clock을 통해 해결할 수 있다. kafka에서 generation clock은 epoch number로 구현된다. 오래된 리더가 1의 값을 갖는다면 새로운 것은 2의 값을 갖게된다. 이 epch는 컨트롤러에서 다른 브로커로 전송되는 모든 요청에 포함되어 브로커는 가장 높은 수의 컨트롤러를 신뢰하여 동작하게 된다.

가장 높은 번호를 가진 컨트롤러는 의심할 여지 없이 항상 최신인데, 이는 epoch 번호가 항상 증가하고 있기 때문이다. 이 epoch번호도 역시 zookeeper에 저장된다.


Kafka 메세지 전송 방법

생산자 메세지 전송

생산자는 leader broker에만 데이터를 쓰고 follwer에는 비동기적으로 복제하게 된다. 생산자는 데이터가 리더에 성공적으로 저장되었는지, follower가 정상적으로 복제되었는지 어떻게 확인할까? Kafka는 세 가지 방식으로 생산자가 제대로 쓰여졌는지 확인한다.

비동기 Async

생산자가 kafka 브로커에 메세지를 보내고 서버의 승인을 기다리지 않는다. 요청이 전송되는 순간 쓰기가 성공한 것으로 간주한다. 이러한 fire-and-forget 방식은 최상의 성능을 제공하지만 이 경우 서버가 레코드를 수신했다고 보장할 수는 없다.

리더 Commit

생산자는 leader의 승인을 기다린다. 이렇게 하면 데이터가 leader에서 commit된다. 데이터가 리더의 disk에 기록되어야 함으로 async보다 느리다. 이 방식에서는 leader는 follwer의 승인을 기다리지 않고 응답한다. 이 경우 leader가 쓰이고 follwer에 복제되기 전에 오류로 leader가 내려간다면 메세지가 손실된다.

리더 Commit & 쿼럼

생산자는 leader와 일정 정원의 follower의 승인을 기다린다. 즉, leader는 ISR이 전체 쓰여지고 ack을 받을 때 까지 기다린다. 이것은 셋 중 가장 느린 방식이지만 ISR이 최소한 하나로 남아 있다면 가장 강한 보장 방법이다.

메세지가 kafka에 안전하게 저장되도록 하려면 Commited to Leader and Qurum 방식을 사용해야 한다. 내구성보다 지연 시간과 처리량을 더 중요시 여긴다면 처음 두 가지 옵션 중 하나를 선택할 수 있다. 이러한 옵션은 메세지 손실가능성이 더 높지만 속도와 처리량이 더 좋다.

소비자 메세지 전달

소비자는 동기화된 복제본 집합에 기록된 메세지만 읽을 수 있다. 소비자에게 일관성을 제공하는 세 가지 방법이 있다.

At-most-once : 메세지가 손실될 수 있지만 다시 전송 안함. 이 옵션은 최대 한번만 메세지가 전송된다. 이 옵션에서 소비자는 메세지를 수신할 때 브로커에 대한 오프셋을 커밋 한다. 이후에 소비자가 메세지를 완전히 사용하기 전에 충돌이 발생한다면, 소비자가 다시 시작될 때 마지막으로 커밋된 오프셋에서 다음 메세지를 수신함으로 메세지가 손실됨

At-least-once : 메세지가 손실되지 않지만 다시 배달될 수 있음. 이 옵션은 메세지가 두 번 이상 배달될 수 있지만 메세지가 손실되지 않아야 한다. 이 시나리오는 소비자가 kafka로 부터 메세지를 수신할 때 발생하며 오프셋을 즉시 커밋하지 않는다. 대신 처리가 완료된 후 커밋하기 때문에 중간에 에러가 발생하게 되면 다시 메세지를 읽을 수 있다. 이러한 시나리오는 중복 메시지 전달이 발생할 수 있다.

Exactly-once : 각 메세지는 한번만 전달됨. 소비자가 트랜잭션 시스템으로 작업하지 않는 한 이를 달성하기 어렵다. 이 옵션에서 소비자는 메세지 처리 및 오프셋 증분을 하나의 트랜잭션에 넣는다. 이렇게 하면 전체 트랜잭션이 완료된 경우에만 오프셋이 증가한다. 처리 중에 소비자가 충돌하면 트랜잭션이 롤백하고 오프셋이 증가되지 않는다. 이 옵션은 데이터 중복 및 손실을 일으키지 않지만 처리량을 감소시킬 수 있다.


Kafka 특징

메세지를 disk에 저장

kafka는 메세지를 로컬 디스크에 쓰고 RAM에 아무것도 보관하지 않는다. 디스크 스토리지는 내구성을 위해 중요함으로 시스템이 종료되고 다시 시작해도 메세지가 사라지지 않는다. 디스크는 일반적으로 느린것으로 간주되나, kafka는 모든 쓰기/읽기가 순차적으로 발생하기 때문에 kafka는 처리량이 매우 높다. (disk의 순차 읽기는 임의읽기보다 수천배 더 빠르다)

디스크에서 순차적으로 쓰거나 읽는 작업은 read-ahead(다수의 대형 블록을 미리 가져옴) 와 write-behind(작은 논리적 쓰기를 큰 물리적 쓰기로 그룹화) 기술을 통해 OS의해 최적화 된다.

또한 최신 운영체제는 disk를 RAM의 여유공간에 캐시한다. 이것을 pagecache라고 하는데 kafka는 전체 흐름에서 수정되지 않는 표준화된 이진 형식으로 메세지를 저장함으로 zero-copy optimization을 사용할 수 있다. OS가 pagecache의 데이털르 소켓으로 직접 복사하여 kafka 브로커 어플리케이션을 완전히 우회할 수 있다.

kafka는 여러 메세지를 한번에 그룹화 하는 프로토콜이 있다 이를 통해 네트워크 요청에 메세지를 그룹화 하여 네트워크 오버헤드를 줄일 수 있다. 서버는 차례로 메세지 청크를 한번에 유지하고 소비자는 한번에 큰 선형 청크를 가져올 수 있다. 이러한 최적화 작업을 통해 kafka는 거의 네트워크 속도로 메세지를 전달 할 수 있다.

메세지의 retention 수행

기본저긍로 kafka는 디시크 공간이 부족할 때까지 레코드를 유지한다. 시간 기반(retention 설정), 크기 기반 제약 또는 압축(키를 사용하여 최신 버전의 레코드를 유지) 을 설정하여 레코드를 삭제할 수 있다. topic의 레코드는 시간, 크기 또는 압축에 의해 폐기될 때까지 사용할 수 있다.

Client quota 적용

Kafka의 생산자와 소비자는 매우 많은 양의 데이터를 생산/소비하거나 매우 빠른 속도로 요청을 생성하여 브로커 리소스를 독점하고 네트워크 포화를 유발하여 일반적으로 다른 클라이언트 및 브로커에 대한 서비스를 거부할 수 있다. 할당량이 있으면 이러한 문제로부터 보호된다. 잘못 동작하는 작은 클라이언트 집합이 제대로 작동하는 클라이언틍츼 사용자 경험을 저하시킬 수 있는 다중 테넌트 클러스터에서 할당량을 더욱 중요해진다.

Kafka에서 할당량은 client ID별로 정의된 바이트 속도 임계값이다. clinet ID는 요청을 만드는 응용 프로그램을 논리적으로 식별한다. 단일 client ID는 여러 생산자 및 소비자 인스턴스에 걸쳐 있을 수 있다. 할당량은 모든 인스턴스에 단일 항목으로 적용된다. 예를들어 client ID에 10 MB/s 의 생산자 할당량이 있는 경우 해당 할당량은 동일한 ID를 가지는 모든 인스턴스에서 공유된다.

브로커는 client가 할당량을 초과하는 경우 오류를 반환하지 않고 대신 클라이언트 속도를 늦추려고 한다. 브로커가 client 할당량을 초과했단고 계산하면 클라이언트는 할당량 미만으로 유지하기에 충분한 시간동안 client응답을 보류하여 client 속도를 늦춘다. 이러한 접근 방식은 client에게 할당량 위반을 투명하게 유지한다. 또한 client가 특수한 백 오프 재시도 동작을 구현하지 않아도 된다.

Kafka 성능

Kafka가 인기있는 이유는 다음과 같다

확장성

kafka 클러스터는 운영 중에 중단없이 쉽게 확장/축소할 수 있다. kafka 토픽을 확장하여 더 많은 파티션을 포함할 수 있다. 파티션은 여러 브로커로 확장할 수 없기 때문에 해당 용량은 브로커 디스크 공간에 의해 제한된다. 파티션 수와 브로커 수를 늘릴 수 있다는 것은 단일 토픽이 저장할 수 있는 데이터양에 제한이 없다는 것을 의미한다.

내결함성 및 안정성

kafka는 zookeeper 및 클러스터의 다른 브로커가 브로커 오류를 감지할 수 있도록 설계되어 있으며 각 토픽을 브로커에 복제할 수 있기 때문에 클러스터는 브로커 장애에서 복구하고 서비스 중단 없이 계속 작동할 수 있다.

처리량

소비자 그룹을 사용하여 소비자를 병렬화 할 수 있음으로 여러 소비자가 주제의 여러 파티션에서 읽을 수 있음으로 매우 높은 메세지 처리 처리량이 가능하다.

짧은 지연시간

99.99%의 시간, 데이터는 디스크 캐시 및 RAM에서 읽는다. 아주 드물게 disk에서 충돌이 발생한다.