카프카 컨슈머가 리밸런싱만 반복한다, max.poll.interval.ms

카프카 컨슈머가 일하는 시간보다 파티션을 다시 나누는 시간이 더 길어질 때가 있다. 컨슈머를 늘려도 그대로다. 살아 있는 컨슈머가 처리 시간 때문에 죽은 걸로 판정받고 있어서다.

메시지를 처리하는 컨슈머 그룹이 있다. 평소엔 잘 돌다가 어느 순간부터 처리가 거의 멈춘다.

로그를 열어 보면 리밸런싱이 쉬지 않고 일어나고 있다. 파티션을 나눠 받고, 조금 처리하다가, 다시 리밸런싱하고, 또 나눠 받고. 일하는 시간보다 자리 바꾸는 시간이 더 길다.

컨슈머를 늘려 봐도 소용없다. 오히려 참가자가 많아진 만큼 리밸런싱이 더 오래 걸린다.

그리고 이 상태는 혼자 빠져나오지 못한다. 리밸런싱이 처리를 막고, 처리가 밀리면 그게 다시 리밸런싱을 부르는 고리가 생겨 있어서다.

재시도 증폭과 똑같은 모양이다. 재시도가 부하를 키우고 커진 부하가 또 재시도를 부르듯, 회복하려고 둔 장치가 회복을 막고 있다.

살아 있는데 죽었다고 판정받는다

컨슈머 그룹은 "이 컨슈머 아직 살아 있나"를 주기적으로 확인한다. 정해진 시간 안에 신호가 없으면 죽은 걸로 보고, 그 컨슈머가 맡던 파티션을 다른 컨슈머에게 넘긴다. 이게 리밸런싱이다.

문제는 이 판정이 둘을 구분하지 못할 때 생긴다. 정말 죽은 것과, 살아는 있는데 처리가 오래 걸리는 것.

메시지 하나 처리에 시간이 오래 걸리면 다음 확인 시점을 놓친다. 그러면 죽은 걸로 판정되고 파티션을 뺏긴다. 그런데 그 컨슈머는 멀쩡히 살아 있으니 처리를 끝내고 결과를 기록하려 한다. 이미 자기 것이 아닌 파티션이라 거부당한다. 그 작업은 버려지고, 다른 컨슈머가 같은 메시지를 처음부터 다시 처리한다.

억울한 일이다.

여기서 고리가 생긴다. 리밸런싱이 일어나면 처리가 멈추고, 멈춘 사이 밀린 메시지가 쌓인다. 다시 시작하면 밀린 걸 처리하느라 더 오래 걸려서 또 판정에 걸린다.

리밸런싱 자체도 비싸다. 기본 방식에서는 모든 컨슈머가 자기 파티션을 내려놓고 다시 배정받는데, 그동안 그룹 전체가 아무것도 처리하지 못한다. 참가자가 많을수록 이 멈춤이 길어진다.

배포도 리밸런싱을 일으킨다. 컨슈머가 하나씩 교체될 때마다 그룹 전체가 멈춘다.

처리 시간과 판정 기준을 따로 잡기

처리 시간과 판정 기준을 맞춘다:

코드
· 두 가지 시간을 구분해서 잡는다
 살아 있음 확인 주기 — 별도 스레드에서 보낸다 (짧게)
 처리 시간 상한   — 한 번에 받은 만큼을 처리할 시간 (넉넉히)

 처리 시간이 상한을 넘으면 죽은 것으로 판정된다
 한 번에 가져오는 개수를 줄이면 상한 안에 들어온다
 max.poll.records ↓  또는  max.poll.interval.ms ↑

· 무거운 처리는 받는 것과 분리한다
 받기 스레드: 가져와서 내부 큐에 넣고 바로 다음 확인
 처리 스레드: 큐에서 꺼내 처리
 단, 순서 보장과 기록 시점 관리가 복잡해진다

· 배포 중 리밸런싱을 줄인다
 정적 멤버십 + 협조적 재배치

✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.

판정에 안 걸리거나, 리밸런싱을 싸게 하거나

갈 수 있는 방향은 크게 두 갈래다. 판정에 안 걸리게 하거나, 리밸런싱 자체를 싸게 만들거나.

먼저 처리 시간을 판정 기준 안으로 넣는 쪽. 제일 단순한 건 한 번에 가져오는 메시지 개수를 줄이는 거다. 백 개를 가져와 처리하는 데 5분이 걸린다면 열 개씩 가져오면 30초에 끝난다. 상한을 늘리는 길도 있긴 하다. 그러면 진짜 죽은 컨슈머를 알아채는 데도 그만큼 오래 걸린다. 맞바꿈이다.

받는 일과 처리하는 일을 떼어 놓는 방법도 있다. 받기만 하는 흐름은 계속 확인 신호를 보낼 수 있으니 판정에 안 걸린다. 대신 결과를 언제 기록하느냐가 까다로워진다. 받자마자 기록하면 처리 전에 죽었을 때 메시지를 잃고, 처리 뒤에 기록하면 순서를 관리해야 한다.

리밸런싱을 싸게 만드는 쪽은 이렇다. 전부 내려놓고 다시 받는 대신, 영향받는 파티션만 옮기는 협조적 방식이 있다. 그러면 나머지 컨슈머는 하던 일을 계속한다. 컨슈머에 고정된 식별자를 주면 재시작할 때 같은 파티션을 그대로 다시 받아서 리밸런싱을 아예 건너뛸 수도 있다. 배포가 잦은 곳이라면 이 차이가 크다.

그리고 꼭 지켜야 할 원칙이 하나 있다. 메시지 처리는 멱등해야 한다. 리밸런싱 중에 같은 메시지가 두 번 처리될 수 있고, 이건 설정으로 완전히 없앨 수가 없다. 아웃박스 패턴에서 나오는 결론과 같다. 메시지는 적어도 한 번 전달되고, 중복은 소비자가 견뎌야 한다.

파티션 수와 컨슈머 수의 관계도 알아 둬야 한다. 컨슈머가 파티션보다 많으면 남는 컨슈머는 아무것도 못 받는다. 늘려도 처리량이 안 느는 이유가 대개 이거다.

그룹이 다시 돌게 하려면

  1. 한 번에 가져오는 메시지 수부터 줄인다. 처리 시간이 판정 기준 안에 들어오는 게 첫 목표다.
  2. 처리 시간 상한과 살아 있음 확인 주기는 다른 값이다. 감지하는 것도 다르다. 둘을 섞어 생각하면 설정이 꼬인다.
  3. 무거운 처리는 받는 흐름과 떼어 놓을 수 있다. 다만 기록 시점과 순서 관리가 복잡해지니 필요할 때만 한다.
  4. 협조적 재배치를 쓰면 영향받는 파티션만 옮기고 나머지는 계속 일한다.
  5. 배포가 잦다면 컨슈머에 고정 식별자를 주자. 재시작할 때 같은 파티션을 다시 받아 리밸런싱을 건너뛴다.
  6. 중복 처리는 설정으로 못 없앤다. 그래서 메시지 처리는 멱등해야 한다.
  7. 컨슈머를 파티션 수보다 많이 두면 남는 컨슈머는 놀기만 한다.
  8. 리밸런싱 횟수와 밀린 양을 지표로 걸어 둔다. 둘이 같이 오르면 고리에 들어간 거다.
  9. 계속 실패하는 메시지라면 횟수 상한을 두고 별도 큐로 보낸다. 독이 든 메시지 하나가 그룹 전체를 마비시킬 수 있다.

우리 그룹은 왜 리밸런싱을 도나

리밸런싱이 왜 도는가:

코드
· 밀린 양과 리밸런싱 횟수를 겹쳐 본다
 consumer_lag  — 계속 늘면 처리가 못 따라간다
 rebalance 횟수 — 배포도 안 했는데 잦으면 판정 문제

· 처리 시간의 분포 — 평균이 아니라 최댓값
 한 배치를 처리하는 데 걸린 시간의 p99
 이 값이 상한에 가까우면 언제든 넘어간다

· 컨슈머 수와 파티션 수
 컨슈머 > 파티션 이면 남는 컨슈머는 아무것도 안 받는다

· 특정 메시지에서 막혀 있지 않은지
 같은 오프셋에서 반복 실패하는 로그를 찾는다
 독이 든 메시지 하나가 그룹 전체를 마비시킬 수 있다

핵심 한 줄느린 컨슈머는 죽은 컨슈머로 취급된다. 처리 시간을 판정 안에 넣어라.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

장애·확장 글 22편 모두 보기 →