Post

Kafka 입문5

Kafka 입문5

컨슈머 오프셋 관리

  • 카프카에서는 메시지의 위치를 나타내는 위치를 오프셋(offset)이라고 브루는데, 숫자 형태로 나타냄
  • 컨슈머 그룹은 자신의 오프셋 정보를 카프카에서 가장 안전한 저장소인 토픽에 저장함
  • 즉, _consumer_offsets 토픽에 각 컨슈머 그룹별로 오프셋 위치 정보가 기록됨

컨슈머 기본 동작

  1. 컨슈머들은 지정된 토픽의 메시지를 읽은 뒤, 읽어온 위치의 오프셋 정보를 _consumer_offsets에 기록
    • 컨슈머 그룹은 컨슈머 그룹, 토픽, 파티션 등의 내용을 통합해 기록함
  2. _consumer_offsets 토픽에 기록된 정보를 이용해 컨슈머 그룹은 자신의 그룹이 속해 있는 컨슈머의 변경이 발생하는 경우(컨슈머 장애 또는 컨슈머 이틸 등) 해당 컨슈머가 어느 위치까지 읽었는지를 추적할 수 있음.

프로듀서의 배치 구성도 이미지

  • 예를 들어 2번 오프셋 메시지 C까지 읽었고 그 다음으로 읽어야 할 3번 오프셋 위치를 _consumer_offsets 토픽에 저장하게 됨

_consumer_offests 토픽은 파티션 수와 리플리케이션 팩터 수를 갖고 있으며, 내부 토픽이지만, server.properties에서 관리지가 원하는 값으로 변경할 수 있음

  • offsets.topic.num.partitions: 기본값 50
  • offsets.topic.replication.factor: 기본값 3

그룹 코디네이터

  • 컨슈머들은 하나의 컨슈머 그룹의 구성원으로 속함
  • 컨슈머 그룹 내의 각 컨슈머들은 서로 자신의 정보를 공유하면서 하나의 공동체로 동작함
  • 컨슈머 그룹 내의 컨슈머들은 언제든지 자신이 속한 컨슈머 그룹엣 떠날 수 있으며 새로운 컨슈머가 합류할 수도 있음
  • 따라서 컨슈머 그룹은 이러한 변화를 인지하고 각 컨슈머들에게 작업을 균등하게 분배해야 함

  • 컨슈머 그룹에서 각 컨슈머들에게 작업을 균등하게 분배하는 동작을 컨슈머 리밸런싱이라고 부름

그룹 코디네이터: 안정적인 컨슈머 그룹 관리를 위해 별도의 코디네이터가 존재

  • 목적 : 컨슈머 그룹이 구독한 토픽의 파티션들과 그룹의 멤버들을 트래킹 하는 것
    • 파티쎤 또는 그룹의 멤버에 변화가 생기면, 작업을 균등하게 재분배하기 위해 컨슈머 리밸런싱 동작이 발생함
  • 그룹 코디네이터는 각 컨슈머 그룹별로 존재하며, 카프카 클러스터 내의 브로커 중 하나에 위치함

프로듀서의 배치 구성도 이미지

  • 컨슈머 그룹이 브로커에 최초 연결 요청을 보내면 브로커 중 하나에 그룹 코디네이터가 생성됨

역할

  • 이 그룹 코디네이터는 컨슈머 그룹의 컨슈머 변경과 구독하는 토픽 파티션 변경 등에 대한 감지를 시작함
  • 토픽의 파티션과 그룹의 멤버 변경이 일어나면 변경된 내용을 컨슈머들에게 알려주기도 함

프로듀서의 배치 구성도 이미지

컨슈머 그룹 등록 과정

  1. 컨슈머는 컨슈머 설정값 중에서 bootstrap.brokers 리스트에 있는 브로커에게 컨슈머 클라이언트와 초기 커넥션을 연결하기 위한 요청을 보냄
  2. 해당 요청을 받은 브로커는 그룹 코디네이터를 생성하고 컨슈머에게 응답을 보냄.
    • 컨슈머 그룹의 첫 번째 컨슈머가 등록될 때까지 아무 작업도 일어나지 않음
  3. 그룹 코디네이터는 group.initial.rebalance.delay.ms의 시간 동안 컨슈머의 요청을 기다림
  4. 컨슈머는 컨슈머 등록 요청을 그룹 코디네이터에게 보냄
    • 이때 가장 먼저 요청을 보내는 컨슈머가 컨슈머 그룹의 리더가 됨
  5. 컨슈머 등록 요청을 받은 그룹 코디네이터는 해당 컨슈머 그룹이 구독하는 토픽 파티션 리스트 등 리더 컨슈머의 요청에 응답을 보냄
  6. 리더 컨슈머는 정해진 컨슈머 파티션 할당 전략에 따라 그룹 내 컨슈머들에게 파티션을 할당한 뒤 그룹 코디네이터에게 전달함
  7. 그룹 코디네이터는 해당 정보를 캐시하고 각 그룹 내 컨슈머들에게 성공을 알림
  8. 각 컨슈머들은 각자 지정된 토픽 파티션으로부터 메시지들을 가져옴

그룹 코디네이터와 컨슈머 간에 소통을 하며 안정적으로 메시지를 읽어 갈 수 있도록 유지하기 위해 노력한다는 것을 알 게 되었다.

컨슈머가 장애료 합류(join) / 탈출(leave) 요청을 보내지 못하고 종료되는 경우 그룹 코디네이터는 어떻게 이를 감지하는가?

  • 컨슈머들의 변경을 감지하기 위해 그룹 코디네이터와 컨슈머들ㅇ느 서로 하트비트를 주고 받음
컨슈머 옵션기본값설명
heartbeat.interval.ms3000그룹 코디네이터와 하트비트 인터벌 시간. 해당 시간은 session.timeout.ms보다 낮게 설정해야 하며, 3분의 1 수준이 적절함
session.timeout.ms10000어떤 컨슈머가 특정 시간 안에 하트비트를 받지 못하면 문제가 발생했다고 판단해 컨슈머 그룹에서 해당 컨슈머는 제거되고 리밸런싱 동작이 일어남
max.poll.interval.ms300000컨슈머가 주기적으로 poll()을 호출해 토픽으로부터 레코드들을 가져오는데, poll() 호출 후 최대 5분간 poll() 호출이 없다면 컨슈머가 문제가 있는 것으로 판단해 리밸런싱 동작이 일어남

컨슈머 리밸런싱 동작은 경우에 따라 매우 높은 비용이 지출되므로 가급적 리밸런싱이 자주 발생하지 않도록 주의해야 함

옵션들의 시간을 다운했을 경우

  • 일시적인 컨슈머의 타임아웃이나 일시적인 TCP 패킷 손실로 인해 원하지 않은 리밸런싱이 빈번하게 일어나는 현상이 발생할 수도 있음

옵션들의 시간을 업했을 경우

  • 그 시간만큼 해당 파티션의 메시지를 읽지 못하는 현상이 발생할 수도 있음

따라서 기본값 설정을 유지하기를 권장

스태틱 멤버십

대용량 메시지들을 처리하는 컨슈머 그룹이라면 리밸런싱 동작으로 인해 원래 상태를 복구하는 데 상당한 시간이 소요될 것 => 카프카는 이러한 불필요한 리밸런싱을 방어하기 위해 스태틱 멤버십이라는 개념을 도입했음.

컨슈머 그룹 내에서 컨슈머가 재시작 등으로 그룹에서 나갔다가 다시 합류하더라도 리밸런싱이 일어나지 않게 함

즉, 컨슈머마다 인식할 수 있는 ID를 적용함으로써 다시 합류하더라도 그룹 코디네이터가 기존 구성원임을 인식할 수 있게 하는 것. 또한, 스태틱 멤버십이 적용된 컨슈머는 그룹에서 떠날 때 그룹 코디네이터에게 알리지 않으므로 불필요한 리밸런싱도 발생하지 않음

리밸런싱이 줄어드는 경우

  1. 컨슈머 그룹에서 컨슈머가 떠날 때 리밸런싱이 일어남 -> 스태틱 멤버십이 적용된 컨슈머는 그룹에서 떠날 때 그룹 코디네이터에게 알리지 않으므로 리밸런싱 피함
  2. 해당 컨슈머가 다시 합류할 때 그룹 코디네이터가 컨슈머의 ID를 확인하고 리밸런싱이 일어남 -> 기존 구성원임을 인지하므로 리밸런싱이 발생하지 않음

따라서 스태틱 멤버십을 적용하면 컨슈머 재시작 시 총 두 번의 불필요한 리밸런싱을 회피할 수 있음

스태틱 멤버십 적용 주의사항

  • session.timeout.ms를 기본값보다는 큰 값으로 조정해야함
  • 컨슈머를 재시작한 후 session.timeout.ms 값에 지정된시간 동안 그룹 코디네이터가 하트비트를 받지 못한다면 강제로 리밸런싱이 일어나므로, 불필요한 리밸런싱 동작을 최소화하기 위한 스태틱 멤버십의 목적에 위배되기 떄문
    • 예를 들어 컨슈머 재시작 시간이 총 2분 소요된다면, session.timeout.ms 값은 2분보다 큰 값으로 설정해야 불필요한 리밸런싱 동작을 사전에 방지할 수 있음

스태틱 멤버십이 적용 안되어 있는 경우

  • 컨슈머 리밸런싱은 컨슈머 그룹 내 전체 컨슈머를 대상으로 동작함
  • 컨슈머 리밸런싱은 일시적으로 모든 컨슈머가 일시 중지하게 되는데, 대량의 메시지를 컨슘하는 컨슈머 그룹에서 매우 부담이 크며 고비용이 드는 작업
  • 따라서 불필요한 리밸런싱은 최대한 줄여야 함

예시 (파티션 3개, 컨슈머 3개 환경에서 C2가 재시작되는 상황)

토픽 1개, 파티션 3개(P0, P1, P2), 컨슈머 3개(C1, C2, C3)로 구성된 컨슈머 그룹

[STEP 1] 초기 상태 - 각 컨슈머가 파티션을 하나씩 할당받음

1
2
3
4
5
6
7
8
9
10
11
 Consumer Group
 ┌────────┐ ┌────────┐ ┌────────┐
 │   C1   │ │   C2   │ │   C3   │
 │  <- P0 │ │  <- P1 │ │  <- P2 │
 └────────┘ └────────┘ └────────┘
      │          │          │
      ▼          ▼          ▼
 ┌───────────────────────────────┐
 │       Group Coordinator       │
 │  C1->P0, C2->P1, C3->P2       │
 └───────────────────────────────┘

[STEP 2] C2가 재시작을 위해 종료 → LeaveGroupRequest 전송 → 1차 리밸런싱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 ┌────────┐            ┌────────┐ ┌────────┐
 │   C1   │            │   C2   │ │   C3   │
 │        │            │(restart│ │        │
 │        │            │  ing)  │ │        │
 └────────┘            └────────┘ └────────┘
      │                                │
      ▼                                ▼
 ┌───────────────────────────────┐
 │       Group Coordinator       │
 │  Recv LeaveGroupRequest(C2)   │
 │  -> Rebalance #1              │
 │  -> P1 reassigned to C1/C3    │
 └───────────────────────────────┘
      │                                │
      ▼                                ▼
 ┌────────┐                       ┌────────┐
 │   C1   │                       │   C3   │
 │<- P0,P1│                       │  <- P2 │
 └────────┘                       └────────┘

[STEP 3] C2가 재시작 완료 후 새 멤버로 재합류 → 2차 리밸런싱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 ┌────────┐ ┌────────┐ ┌────────┐
 │   C1   │ │   C2   │ │   C3   │
 │(P0, P1)│ │(rejoin,│ │  (P2)  │
 │        │ │new mId)│ │        │
 └────────┘ └────────┘ └────────┘
      │          │          │
      ▼          ▼          ▼
 ┌────────────────────────────────┐
 │       Group Coordinator        │
 │ JoinGroupRequest(new memberId) │
 │ -> Unknown as existing member  │
 │ -> Rebalance #2                │
 └────────────────────────────────┘
      │          │          │
      ▼          ▼          ▼
 ┌────────┐ ┌────────┐ ┌────────┐
 │   C1   │ │   C2   │ │   C3   │
 │  <- P0 │ │  <- P1 │ │  <- P2 │
 └────────┘ └────────┘ └────────┘

C2 하나가 재시작됐을 뿐인데, 이탈할 때(1차)와 재합류할 때(2차) 총 두 번의 리밸런싱이 발생하면서 그 사이 C1, C3까지 함께 파티션을 재할당받고 일시적으로 컨슘이 중단됨

스태틱 멤버십이 적용 되어 있는 경우

예시 (동일한 환경에서 group.instance.id로 스태틱 멤버십 적용)

토픽 1개, 파티션 3개(P0, P1, P2), 컨슈머 3개(C1, C2, C3)에 각각 group.instance.id=consumer-1,2,3 부여

[STEP 1] 초기 상태 - group.instance.id로 각 컨슈머를 식별

1
2
3
4
5
6
7
8
9
10
11
12
 Consumer Group
 ┌────────────┐ ┌────────────┐ ┌────────────┐
 │     C1     │ │     C2     │ │     C3     │
 │id=consumer1│ │id=consumer2│ │id=consumer3│
 │    <- P0   │ │    <- P1   │ │    <- P2   │
 └────────────┘ └────────────┘ └────────────┘
        │              │              │
        ▼              ▼              ▼
 ┌─────────────────────────────────────────────┐
 │              Group Coordinator              │
 │ consumer1->P0, consumer2->P1, consumer3->P2 │
 └─────────────────────────────────────────────┘

[STEP 2] C2가 재시작을 위해 종료 → LeaveGroupRequest를 보내지 않음 → 리밸런싱 없음

1
2
3
4
5
6
7
8
9
10
11
12
13
 ┌────────────┐               ┌────────────┐ ┌────────────┐
 │     C1     │               │     C2     │ │     C3     │
 │            │               │(restarting,│ │            │
 │            │               │ no notify) │ │            │
 └────────────┘               └────────────┘ └────────────┘
        │                                           │
        ▼                                           ▼
 ┌───────────────────────────────────────────┐
 │             Group Coordinator             │
 │ No LeaveGroupRequest -> No rebalance      │
 │ P1 assignment kept during session.timeout │
 │ (consumer2 -> P1 mapping retained)        │
 └───────────────────────────────────────────┘

[STEP 3] C2가 재시작 완료 후 동일한 group.instance.id로 재합류 → 리밸런싱 없음

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 ┌────────────┐ ┌────────────┐ ┌────────────┐
 │     C1     │ │     C2     │ │     C3     │
 │            │ │id=consumer2│ │            │
 │            │ │  (rejoin,  │ │            │
 │            │ │ same id)   │ │            │
 └────────────┘ └────────────┘ └────────────┘
        │              │              │
        ▼              ▼              ▼
 ┌─────────────────────────────────────────┐
 │            Group Coordinator            │
 │ Recognized consumer2 as existing member │
 │ -> No rebalance, restores P1 assignment │
 └─────────────────────────────────────────┘
        │              │              │
        ▼              ▼              ▼
 ┌────────────┐ ┌────────────┐ ┌────────────┐
 │     C1     │ │     C2     │ │     C3     │
 │    <- P0   │ │    <- P1   │ │    <- P2   │
 └────────────┘ └────────────┘ └────────────┘

group.instance.id를 통해 그룹 코디네이터가 C2를 기존 구성원(consumer2)으로 계속 인식하기 때문에, 재시작 과정에서 C1과 C3는 아무런 영향을 받지 않고 자신의 파티션(P0, P2)을 계속 컨슘함

컨슈머 파티션 할당 전략

파티션 할당 전략설명
레인지 파티션 할당 전략파티션 할당 전략의 기본값으로서 토픽별로 할당 전략을 사용함. 동일한 키를 이용하는 2개 이상의 토픽을 컨슘할 떄 유용함
라운드 로빈 파티션 할당 전략사용 가능한 파티션과 컨슈머들을 라운르 로빈으로 할당함. 균등한 분배 가능
스티키 파티션 할당 전략컨슈머가 컨슘하고 파티션을 계속 유지할 수 있음
협력적 스티키 파티션 할당 전략스티키 방식과 유사하지만, 전체 일시 정지가 아닌 연속적인 재조정 방식임

레인지 파티션 할당 전략

  • 구독하는 토픽에 대한 파티션을 순서대로 나열한 후 컨슈머를 순서대로 정렬함
  • 그런 다음 각 컨슈머가 몇 개의 파티션을 할당해야 하는지 전체 파티션 수를 컨슈머 수로 나눔
  • 컨슈머 수와 파티션 수가 일치하면 균등하게 할당될 수 있지만 균등하게 나눠지지 않는 경우에는 앞쪽의 컨슈머들ㅇ느 추가 파티션을 할당하게 됨

동일한 레코드(메시지) 키를 사용하고 하나의 컨슈머 그룹이 동일한 파티션 수를 가진 2개 이상의 토픽을 컨슘할 때 유용할 수 있음

예시 1 (파티션 5개, 컨슈머 3개로 구성된 토픽 1개를 컨슘하는 경우)

토픽 1개, 파티션 5개(P0~P4), 컨슈머 3개(C1, C2, C3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 ┌───────────────────────────────┐
 │ Topic-A Partitions (in order) │
 │ P0   P1   P2   P3   P4        │
 └───────────────────────────────┘

 ┌────────────────────────┐
 │   Consumers (sorted)   │
 │ C1        C2        C3 │
 └────────────────────────┘
                │
                ▼
 ┌──────────────────────────────────────────────────┐
 │                  Range Assignor                  │
 │ numPartitions / numConsumers = 5 / 3 = 1 (floor) │
 │ remainder = 5 % 3 = 2                            │
 │ -> first 2 consumers (C1, C2) get +1 partition   │
 └──────────────────────────────────────────────────┘
                │
                ▼
 ┌────────┐   ┌────────┐   ┌────┐
 │   C1   │   │   C2   │   │ C3 │
 │ P0, P1 │   │ P2, P3 │   │ P4 │
 └────────┘   └────────┘   └────┘

5개 파티션을 3개 컨슈머로 나누면 몫은 1, 나머지는 2가 되어 앞쪽 컨슈머인 C1, C2가 파티션을 하나씩 더 할당받음 (C1: 2개, C2: 2개, C3: 1개)

예시 2 (동일한 키를 사용하는 토픽 2개를 컨슘하는 경우 - 레인지 할당 전략의 장점)

토픽 2개(Topic-A, Topic-B), 각각 파티션 3개(P0~P2), 컨슈머 3개(C1, C2, C3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 ┌────────────┐    ┌────────────┐
 │  Topic-A   │    │  Topic-B   │
 │ P0  P1  P2 │    │ P0  P1  P2 │
 └────────────┘    └────────────┘

 ┌────────────────────────┐
 │   Consumers (sorted)   │
 │ C1        C2        C3 │
 └────────────────────────┘
                │
                ▼
 ┌──────┐   ┌──────┐   ┌──────┐
 │  C1  │   │  C2  │   │  C3  │
 │ A-P0 │   │ A-P1 │   │ A-P2 │
 │ B-P0 │   │ B-P1 │   │ B-P2 │
 └──────┘   └──────┘   └──────┘

레인지 할당 전략은 토픽마다 같은 순서 규칙(파티션 번호 순 + 컨슈머 이름 순)을 적용하므로, 서로 다른 토픽이라도 같은 인덱스의 파티션(P0, P1, P2)이 항상 같은 컨슈머에게 할당됨. 따라서 동일한 키를 기준으로 두 토픽을 조인(join)해야 하는 경우, 각 컨슈머가 관련된 파티션을 모두 가지게 되어 유용함

라운드 로빈 파티션 할당 전략

  • 컨슘해야 하는 모든 파티션과 컨슈머 그룹 내 모든 컨슈머를 나열한 후 라운드 로빈으로 하나씩 파티션과 컨슈머를 할당하는 전략
  • 레인지 파티션 할당 전략에 비해 라운드 로빈 파티션 할당 전략 쪽이 파티션과 컨슈머를 더욱 균등하게 매핑함

예시 (파티션 수가 다른 토픽 2개를 컨슘하는 경우 - 레인지 전략과의 비교)

토픽 2개(Topic-A: 파티션 2개, Topic-B: 파티션 4개), 컨슈머 3개(C1, C2, C3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 ┌────────────────────────────────────────────────────┐
 │ All Partitions (topic order, then partition order) │
 │ A-P0  A-P1  B-P0  B-P1  B-P2  B-P3                 │
 └────────────────────────────────────────────────────┘

 ┌────────────────────────┐
 │   Consumers (sorted)   │
 │ C1        C2        C3 │
 └────────────────────────┘
                │
                ▼
 ┌──────────────────────────────────────────────┐
 │             Round Robin Assignor             │
 │ partitions[i] -> consumers[i % numConsumers] │
 │ i=0 A-P0->C1   i=1 A-P1->C2   i=2 B-P0->C3   │
 │ i=3 B-P1->C1   i=4 B-P2->C2   i=5 B-P3->C3   │
 └──────────────────────────────────────────────┘
                │
                ▼
 ┌──────┐   ┌──────┐   ┌──────┐
 │  C1  │   │  C2  │   │  C3  │
 │ A-P0 │   │ A-P1 │   │ B-P0 │
 │ B-P1 │   │ B-P2 │   │ B-P3 │
 └──────┘   └──────┘   └──────┘

두 토픽의 파티션(총 6개)을 하나로 이어 붙인 뒤 컨슈머 3명에게 순서대로 하나씩 돌아가며 할당하므로 C1, C2, C3 모두 2개씩 균등하게 받음

같은 조건을 레인지 전략으로 처리하면 토픽별로 따로 나누기 때문에 Topic-A(2개/3명)는 C1, C2에게만 1개씩 가고 C3는 0개, Topic-B(4개/3명)는 C1이 2개(나머지 몫)를 더 가져가 결과적으로 C1: 3개, C2: 2개, C3: 1개로 편중됨. 라운드 로빈은 이런 편중 없이 항상 최대 1개 차이로만 분배됨

스티키 파티션 할당 전략

레인지 파티션 전략과 라운드 로빈 파티션 할당 전략 모두 파티션 재할당 작업이 발생하면, 기존에 매핑됐던 파티션과 동일한 컨슈머가 다시 매핑되리라고는 보장할 수 없음

목적

  • 가능한 한 균형 잡힌 파티션 할당
  • 재할당이 발생할 떄 되도록 기존의 할당된 파티션 정보를 보장하는 것
  • 스티키 파티션 할당 전략이라고 해서 무조건 기존의 파티션과 컨슈머를 유지하지는 않음
    • 최대한 컨슈머를 균등하게 분배하는 것을 우선하므로, 일부 파티션은 기존의 컨슈머와 매핑을 유지하지 못하고 새로운 컨슈머와 연결될 수도 있음

규칙

  • 컨슈머들의 최대 할당된 파티션의 수의 차이는 1
  • 기존에 존재하는 파티션 할당은 최대한 유지함
  • 재할당 동작 시 유효하지 않은 모든 파티션 할당은 제거함
  • 할당되지 않은 파티션들은 균형을 맞추는 방법으로 컨슈머들에 할당

예시 (파티션 5개, 컨슈머 3개 환경에서 C3가 그룹을 이탈하는 상황)

토픽 1개, 파티션 5개(P0~P4), 컨슈머 3개(C1, C2, C3)로 시작해 스티키 전략으로 초기 할당됨

[STEP 1] 초기 상태 - 스티키 전략으로 균형 잡힌 할당 (맨 처음에는 라운드 로빈 방식과 유사하여 최대 차이 1)

1
2
3
4
5
6
7
8
9
10
11
12
 Consumer Group
 ┌─────────┐ ┌─────────┐ ┌──────┐
 │   C1    │ │   C2    │ │  C3  │
 │ <-P0,P3 │ │ <-P1,P4 │ │ <-P2 │
 └─────────┘ └─────────┘ └──────┘
      │           │          │
      ▼           ▼          ▼
 ┌────────────────────────────────────────┐
 │       Sticky Assignor (initial)        │
 │ C1->P0,P3   C2->P1,P4   C3->P2         │
 │ max diff between consumers = 1 (2,2,1) │
 └────────────────────────────────────────┘

[STEP 2] C3가 그룹을 이탈 → P2가 미할당 상태가 됨 → 기존 매핑은 유지한 채 재분배

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 ┌─────────┐ ┌─────────┐
 │   C1    │ │   C2    │
 │ <-P0,P3 │ │ <-P1,P4 │
 └─────────┘ └─────────┘
      │           │
      ▼           ▼
 ┌────────────────────────────────────────────┐
 │      Sticky Assignor (C3 left group)       │
 │ Keep C1->P0,P3 and C2->P1,P4 as is         │
 │ P2 (was on C3) becomes unassigned          │
 │ C1, C2 are tied at 2 (both minimum)        │
 │ -> assign P2 to either, e.g. C2, diff <= 1 │
 └────────────────────────────────────────────┘
      │           │
      ▼           ▼
 ┌─────────┐ ┌────────────┐
 │   C1    │ │     C2     │
 │ <-P0,P3 │ │ <-P1,P2,P4 │
 └─────────┘ └────────────┘

C3가 이탈해도 C1의 P0,P3와 C2의 P1,P4 매핑은 그대로 유지됨. 이 시점에 C1, C2는 각각 2개로 동률(둘 다 최소)이므로 C3가 갖고 있던 P2는 둘 중 어느 쪽에 할당해도 “최대 차이 1” 규칙을 지킬 수 있음 (여기서는 C2가 선택됨)

만약 레인지나 라운드 로빈 전략처럼 남은 파티션 전체를 처음부터 다시 계산했다면 결과가 달라질 수 있음. 예를 들어 라운드 로빈으로 5개 파티션을 C1, C2에 처음부터 다시 나누면 C1: P0,P2,P4 / C2: P1,P3가 되어, 이미 각자 컨슘 중이던 P3(C1)와 P4(C2)가 서로 컨슈머를 바꾸는 불필요한 재할당이 발생함. 스티키 전략은 이런 불필요한 이동 없이 P2 하나만 새로 옮김

협력적 스티키 파티션 할당 전략

  • 협력적 스티키는 내부 리밸런싱 프로토콜인 EAGER(이거)가 아닌 COOPERATIVE(협력적) 프로토콜을 적용하기 시작.
  • 이 프로토콜로 리밸런싱이 동작하기 전의 컨슈머 상태를 유지할 수 있게 함

기존의 리밸런싱 동작은 한 번에 모든 파티션 할당 작업이 끝난다는 장점이 있지만, 전체 컨슈머가 일시적으로 멈춘 상태에서 리밸런싱이 이뤄진다는 제약이 있음

하지만 협력적 스티키 파티션 전략에서는 되도록 동작 중인 컨슈머들에게 영향을 주지 않는 상태에서 몇 차례에 걸쳐 리밸런싱이 이루어짐

예시 (파티션 5개, 컨슈머 3개 환경에서 C3가 그룹을 이탈하는 상황 - COOPERATIVE 방식)

토픽 1개, 파티션 5개(P0~P4), 컨슈머 3개(C1, C2, C3)로 시작 (STEP1은 위 스티키 파티션 할당 전략 예시와 동일한 초기 상태)

[STEP 1] 초기 상태

1
2
3
4
 ┌─────────┐ ┌─────────┐ ┌──────┐
 │   C1    │ │   C2    │ │  C3  │
 │ <-P0,P3 │ │ <-P1,P4 │ │ <-P2 │
 └─────────┘ └─────────┘ └──────┘

[STEP 2] C3가 그룹을 이탈 → 1차 리밸런싱: 변경이 필요한 파티션만 회수

1
2
3
4
5
6
7
8
9
10
11
12
 ┌─────────┐ ┌─────────┐
 │   C1    │ │   C2    │
 │ <-P0,P3 │ │ <-P1,P4 │
 └─────────┘ └─────────┘
      │           │     
      ▼           ▼     
 ┌───────────────────────────────────────────┐
 │      Group Coordinator (COOPERATIVE)      │
 │ C1, C2 assignment unchanged -> no revoke  │
 │ P2 (owned by departed C3) is now orphaned │
 │ -> only P2 goes through revoke/assign     │
 └───────────────────────────────────────────┘

[STEP 3] 2차 리밸런싱: 비어 있는 P2만 새로 할당, C1/C2는 중단 없이 컨슘 유지

1
2
3
4
5
6
      │           │     
      ▼           ▼     
 ┌─────────┐ ┌────────────┐
 │   C1    │ │     C2     │
 │ <-P0,P3 │ │ <-P1,P2,P4 │
 └─────────┘ └────────────┘

C3가 이탈해도 C1, C2는 어떤 파티션도 반납(revoke)하지 않고 계속 컨슘을 이어감. C3가 갖고 있던 P2 하나만 두 차례(1차: 소유권 정리, 2차: 새 컨슈머에 할당)의 점진적 재조정을 거쳐 이동함

반면 EAGER 프로토콜(레인지/라운드 로빈/일반 스티키)이었다면 C1, C2도 리밸런싱이 시작되는 순간 자신의 파티션(P0,P3 / P1,P4)까지 일단 전부 반납했다가 재합류 후에야 다시 할당받으므로, 최종 결과는 같아도 그 사이 잠깐이나마 전체 컨슈머가 함께 컨슘을 멈추게 됨

정확히 한 번 컨슈머 동작

  • 컨슈머의 경우 트랜잭션 프로듀서가 보낸 메시지만 가져올 수 있는지에 대해서만 옵션으로 선택할 수 있음
  • 컨슈머는 트랜잭션 코디네이터와 통신하는 부분이 없으므로 정확하게 메시지를 한 번 가져오는지는 보장할 수 없음
  • 또한 컨슈머에 의해 컨슘된 메시지가 다른 싱크 저장소로 중복 저장될 수 있음

따라서 컨슈머의 동작까지 정확히 한 번 처리가 가능해지려면 ‘컨슘-메시지 처리-프로듀싱’ 동작이 모두 하나의 트랜잭션으로 처리돼야 함

‘컨슘-메시지 처리-프로듀싱’의 트랜잭션에서는 sendOffsetsToTransaction 메소드를 이용하여 컨슈머 그룹의 오프셋 커밋을 트랜잭션에 포함시킴

만약 이 처리 과정에서 트랜잭션 실패가 발생하면, 해당 컨슈머 그룹의 커밋 오프셋이 증가하지 않게 함으로써 실패한 트랜잭션을 다시 시작할 수 있음

일부 컨슈머 애플리케이션에서 정확히 한 번을 지원하는 경우도 있으므로, 사용하려는 컨슈머 애플리케이션 가이드 문서를 읽어보고 구현 가능한지 여부를 꼭 확인해 보면 됨

보충: 왜 “카프카”가 아니라 “컨슈머 애플리케이션마다” 지원 여부가 다른가

  • beginTransaction → send → sendOffsetsToTransaction → commitTransaction으로 보장되는 정확히 한 번은 어디까지나 카프카 내부(토픽 → 토픽, __consumer_offsets 토픽)에서 벌어지는 일임. 트랜잭션 코디네이터가 카프카 클러스터 안에서 일어나는 일들만 원자적으로 묶어줄 수 있기 때문
  • 반면 컨슈머가 읽은 메시지를 카프카가 아닌 외부 시스템(DB, 외부 API, S3 등)에 저장하는 순간부터는 카프카의 트랜잭션이 관여할 수 없는 영역이 됨
    • 컨슈머가 DB에 쓰고 나서 오프셋 커밋 직전에 죽으면, 재시작 후 같은 메시지를 다시 읽어 DB에 또 쓸 수 있음 (중복)
    • 카프카는 “이 컨슈머가 어디까지 읽었는지”만 알 뿐, 그 데이터가 외부 시스템에 정확히 한 번만 반영됐는지는 알 수 없음
  • 따라서 이 마지막 구간의 정확히 한 번 여부는 그 컨슈머(싱크) 애플리케이션이 직접 구현해야 하는 몫이 됨
    • 예: upsert(멱등 쓰기)로 설계해서 같은 메시지가 두 번 와도 결과가 같게 만들거나, 처리한 오프셋/ID를 대상 시스템에도 같이 저장해 중복을 걸러내거나, 대상 시스템의 트랜잭션 안에서 오프셋 처리 완료를 같이 커밋하는 방식 등
    • 실제로 Kafka Connect의 JDBC Sink 같은 일부 커넥터는 upsert 방식으로 이 문제를 해결해 exactly-once에 가깝게 동작하지만, 단순 append만 하는 커넥터나 직접 짠 컨슈머 로직에는 이런 보장이 없을 수 있음
  • 정리하면: 카프카(브로커+트랜잭션)는 “카프카 안”의 정확히 한 번만 보장하고, 그 데이터를 카프카 밖으로 내보내는 마지막 단계의 정확히 한 번은 이를 구현하는 애플리케이션(또는 사용하는 커넥터)마다 지원 여부가 달라짐
This post is licensed under CC BY 4.0 by the author.

Trending Tags