쿠버네티스에서 카프카 리밸런싱 방지하기

Seokmo Yoo
NAVER Pay Dev Blog
Published in
12 min readFeb 16, 2023

안녕하세요.
네이버파이낸셜 금융연동개발 팀의 유석모입니다.

쿠버네티스 환경에서 카프카 컨슈머 서비스를 다루면서 발생했던 리밸런싱 문제와 이에 대한 해결 과정에 대해 다룹니다. 😃

들어가며

카프카는 이벤트 스트리밍 플랫폼으로, 팀 내에서 다양한 목적을 위해 사용하고 있습니다. 다른 서비스에서 발생하는 이벤트에 대한 비동기 처리(asynchronous), 프로듀싱과 컨슈밍의 분리(decoupling), 파티션 수준의 데이터 복제로 장애 방지(fault-tolerent) 등 다양한 기능으로 카프카는 MSA 구성에서 중요한 위치를 차지하고 있습니다.

카프카 시스템은 크게 이벤트를 중개하는 브로커 서버와 이벤트를 생산하는 프로듀서, 이를 소비하는 컨슈머 그룹으로 구성되어 있습니다.

카프카 시스템 구성도

카프카 클러스터는 여러 개의 브로커로 구성되며, 각 브로커는 이벤트를 담는 묶음인 토픽(RDB의 테이블로 비유되는)과 토픽이 나누어진 파티션으로 구성되어 있습니다. 각 파티션은 사용자가 설정한 값에 따라 여러 개의 레플리카(복제본)으로 구성되며, 레플리카들을 제어하는 리더 파티션과 이를 복제하는 팔로워 파티션으로 나누어집니다.

제가 속한 팀 내에서는 결제팀에서 프로듀싱하는 결제 이벤트를 브로커를 거쳐 컨슈밍 서비스로 가져오고, 이를 정제하여 비즈니스에 필요한 데이터를 생산하여 API로 제공하거나, 또다른 토픽으로 전송하는 방식 등으로 활용하고 있습니다.

문제의 발생

org.apache.kafka.clients.consumer.CommitFailedException:
Commit cannot be completed since the group
has already rebalanced and assigned the partitions to another member.
This means that the time between subsequent calls to poll()
was longer than the configured max.poll.interval.ms,
which typically implies that the poll loop is spending too much time message processing.
You can address this either by increasing max.poll.interval.ms or
by reducing the maximum size of batches returned in poll() with max.poll.records.

카프카 컨슈머를 배포할 때면 간헐적으로 위와 같은 에러 로그가 남아서 팀 내의 골칫거리로 자리 잡았습니다. 커밋 실패, 컨슈머 그룹 리밸런싱, 파티션 할당 등 로그에 상세히 원인이 적혀있는데요.

위 로그를 이해하려면 카프카 컨슈머 그룹파티션 할당의 개념을 이해할 필요가 있었습니다.

컨슈머 그룹과 파티션 할당

시스템을 단순화해서 단 하나의 브로커와 토픽, 그리고 3개의 파티션이 존재한다고 가정해보겠습니다.

컨슈머는 컨슈머 그룹에 속해있으며, 각 파티션의 오프셋은 컨슈머 그룹 단위로 관리됩니다. 파티션 오프셋은 파티션을 큐라고 생각했을 때 다음 폴링 때 읽어야할 인덱스로 비유할 수 있습니다. 컨슈머 그룹에 속한 컨슈머들은 구독하는 토픽의 파티션을 나누어서 이벤트를 가져옵니다. 이때, 각 파티션은 단 하나의 컨슈머만이 할당될 수 있습니다.

위 그림에서 컨슈머 그룹 1(이하 그룹 1)은 컨슈머 1에 토픽 1의 파티션 1이 할당되고, 컨슈머 2에 파티션 2, 3이 할당된 상태입니다. 컨슈머의 갯수가 파티션 수보다 적기 때문에 일부 컨슈머에 두 개 이상의 파티션이 할당된 것입니다.

그룹 2는 컨슈머1에 토픽 1의 파티션 3, 그리고 컨슈머 2에 파티션 2, 컨슈머 3에 파티션 1이 할당된 상태입니다. 토픽 1의 파티션 갯수인 3개를 초과하여 컨슈머가 존재하기 때문에, 남은 컨슈머 4는 할당된 파티션이 없는 대기 상태(idle)로 남게됩니다.

위에서 살펴본 개념이 파티션 할당이며, 이는 각 컨슈머 그룹 단위로 이루어집니다. 그러면 위 상태에 도달하기 위해 컨슈머가 하나씩 배포되고 있는 상황에서 파티션 할당은 어떻게 진행될까요?

컨슈머 그룹에 새로운 컨슈머가 추가되고 있는 상황

위와 같이 그룹1에 이미 2개의 컨슈머가 배포되어 있고, 새로운 컨슈머 3가 배포되는 중이라고 생각해봅시다. 이때 컨슈머 2는 2개의 파티션을 컨슈밍하고 있기 때문에 컨슈머 3에게 하나의 파티션을 넘겨주어야합니다. 이 과정을 리밸런싱이라고 합니다. 특정 컨슈머가 지나치게 많은 파티션을 담당하지 않도록 재분배해주는 과정입니다.

카프카는 이 문제를 해결하기 위해 그룹 멤버십 프로토콜을 사용해 리밸런싱을 진행합니다. 이때 크게 4개의 요청이 사용되는데, 이는 JoinGroup , SyncGroup , Heartbeat , LeaveGroup으로 이루어져있습니다.

컨슈머가 컨슈머 그룹에 들어갈 때 해당 컨슈머는 브로커에 FindCoordiantor 요청을 보내서 해당 그룹을 담당하고 있는 Coordinator 컴포넌트를 탐색합니다. 그리고 해당 CoordinatorJoinGroup 요청을 보내 그룹 참여를 희망하는 컨슈머가 존재함을 알립니다. Coordinator 는 해당 요청을 받고 리밸런싱 프로세스를 시작하여 해당 그룹에 모두 JoinGroup 요청을 요구하고, 응답하지 않는 컨슈머는 제외합니다. Coordinator 는 이 중 한 컨슈머에게 Leader 역할을 부여하고, 나머지는 Member 로서 동작하게 됩니다. LeaderMember 가 모두 정해진 그룹에 대해 CoordinatorSyncGroup 요청을 통해 밸런스가 맞는 파티션을 할당해줍니다. 그리고 HeartBeat 를 통해 주기적으로 컨슈머의 상태를 체크해 비정상적인 컨슈머를 발견한다면 해당 리밸런싱 프로세스를 다시 진행합니다. 마지막으로 LeaveGroup 의 경우 컨슈머가 정상 종료(graceful)될 때 해당 그룹을 떠나면서 보내는 요청으로 이 경우도 리밸런싱 프로세스가 진행됩니다. 리밸런싱 과정에서는 stop-the-world가 발생해 처리 지연이 필연적으로 발생합니다.

문제는 리밸런싱 과정에서 파티션 할당이 크게 바뀐다는 점입니다.

리밸런싱 후 파티션 할당 상태

처음 그림에서 컨슈머 1은 파티션 1을, 컨슈머 2는 파티션 2, 3을 담당하고 있었지만, 리밸런싱 후 컨슈머 1은 파티션 3을, 컨슈머 2는 파티션 2를, 컨슈머 3은 파티션 1을 담당하고 있습니다.

이 경우 컨슈머 1은 원래 처리하고 있던 파티션 1에 더이상 할당되어 있지 않기 때문에 poll 을 해오고 commit 을 하지 않은 이벤트가 있다면 이에 대해 커밋 실패 익셉션을 발생시키게 됩니다. 컨슈머 3의 참여로 인해 컨슈머 그룹 내의 다른 컨슈머가 피해를 보는 상황이 발생하게 되는 것입니다.

이와 같은 문제는 컨슈머 3이 그룹에 참여하게 되었을때, 커밋되지 않은 이벤트를 다시 컨슘하여 같은 이벤트에 대해 중복 처리라는 부작용을 발생시킵니다. 이와 같은 시스템은 exactly once delivery 를 보장할 수 없기 때문에 매회 배포 시 보정을 수행해야하는 등 많은 불편함을 야기시킵니다.

스태틱 멤버십

카프카 버전 2.3.0에서는 이러한 문제를 해결하기 위해 스태틱 멤버십 프로토콜을 도입합니다. 스태틱 멤버십 프로토콜은 각 컨슈머에 고유한 이름이 지정되어 있다면, 컨슈머 그룹의 변동이 발생해도 해당 컨슈머가 아닌 다른 컨슈머의 리밸런싱을 방지하는 프로토콜입니다.

컨슈머의 고유한 이름은 컨슈머 컨피그 내에 group.instance.id 설정으로 지정할 수 있습니다. 해당 설정을 적용하면 자동으로 스태틱 멤버십 프로토콜에 해당하는 컨슈머로 간주합니다.

스태틱 멤버십 프로토콜에 따른 파티션 할당 상태

컨슈머 1, 2, 3에 고유한 이름이 지정되어 있다면 컨슈머 3이 배포되지 않은 상황에서도 각 컨슈머들은 해당 컨슈머가 이전부터 계속 컨슘하던 파티션에만 할당될뿐 남아있는 파티션 3에 할당되지 않습니다. 위 그림의 경우 컨슈머 3이 이전에 동작하다가 잠시 내려간 상태일 경우를 가정했습니다.

스태틱 멤버십 프로토콜에서 새로운 컨슈머가 추가되었을 때

새로운 컨슈머 3이 배포된다면 마찬가지로 이전부터 계속 컨슘하던 파티션 3에 할당되고 리밸런싱 프로세스는 발생하지 않습니다. 따라서 리밸런싱에 따른 커밋 실패와 중복 처리 문제를 해결할 수 있습니다.

쿠버네티스 환경에서의 스태틱 멤버십

스태틱 멤버십을 적용하려면 컨슈머마다 일정한, 고유한 이름이 필요합니다. 하지만 k8s의 Deployment 오브젝트의 경우 각 Pod에 랜덤한 이름을 배정하므로 일정한 이름을 유지할 수 없습니다. 이는 Deployment 오브젝트의 철학 때문인데요. Deployment 오브젝트는 stateless workloads를 위해 설계된 것으로, 여러 replicaPod 을 배포하더라도 각 Pod 은 구분이 불가해야합니다. 따라서 랜덤한 이름이 배정되어 스태틱 멤버십에는 부적합한 오브젝트입니다.

k8s의 StatefulSet 오브젝트는 이 문제를 해결해줍니다. Deployment 와 반대로 StatefulSet 은 stateful workloads를 위해 설계된 것으로 각 Pod 에 고유한 index를 부여해줍니다. Pod이름에 StatefulSet-0, StatefulSet-1와 같은 방식으로 0부터 시작되는 서수가 suffix로 할당됩니다.

StatefulSet 오브젝트 내 컨테이너에 Pod 이름을 전달해주기 위해서는 $.spec.template.spec.containers[].env[] 에 환경 변수 설정이 필요합니다. 아래의 경우 METADATA_NAME이라는 이름으로 Pod 의 이름을 설정해주었습니다.

Deployment 오브젝트가 기본적으로 동시에(병렬적으로) 배포되는 것에 비해 StatefulSet 오브젝트는 OrderedReady 배포 전략이 기본적으로 사용되어 Pod이 하나하나 배포되므로, Deployment처럼 동시 배포를 통해 시간을 단축하기 위해 Parallel 배포 전략을 추가로 설정할 수 있습니다($.spec.podManagement).

StatefulSet 오브젝트 설정 예시

스프링 어플리케이션의 경우 아래와 같이 컨슈머 컨피그 프로퍼티를 설정할 수 있습니다.

카프카 컨슈머 설정 예시(kotlin)

이 경우 kafka-conumser-0, kafka-conumser-1, …, kafka-conumser-4의 컨슈머가 생성됩니다. 컨슈머 0번은 브로커에 의해 처음 파티션이 할당되면, 컨슈머 0번이 재배포되더라도 계속 같은 파티션을 컨슈밍합니다.

스태틱 멤버십에도 리밸런싱 타임아웃이 존재하는데, session.timeout.ms 시간 안에 컨슈머 그룹 내에서 모든 파티션이 할당되지 않는다면 리밸런싱이 발생합니다. 세션 타임 아웃은 브로커(server side)의 설정에도 영향을 받으므로 컨슈머 설정의 session.timeout.ms이 브로커 설정의 group.min.session.timeout.ms 보다 크고, group.max.session.timeout.ms 보다 작도록 조정해야합니다.

정리하며

카프카의 기본 개념과 파티션 할당, 그리고 스태틱 멤버십에 대해 간단히 살펴보았습니다. 카프카는 고성능의, 확장이 용이한 이벤트 스트리밍 플랫폼으로 현대적인 아키텍처 구성에 있어 필수적입니다. exactly once delivery는 단순히 스태틱 멤버십만을 적용한다고 얻을 수 있는 개념은 아니지만, 적어도 리밸런싱 방지를 통해 배포 과정에서 중복 처리되는 이벤트를 방지하고, stop-the-world를 최소화할 수 있는 좋은 수단이 될 수 있습니다.

오류 정정이나 피드백은 항상 환영합니다.
읽어주셔서 감사합니다. 😃

--

--