실수 케이스 · 2
컨슈머가 무한 리밸런스 루프에 빠졌다
프로모션 트래픽으로 건당 처리 시간이 6배 늘어난 순간, 컨슈머 그룹은 처리를 멈추고
리밸런스만 반복하기 시작했습니다. 파드는 살아 있고 CPU도 여유가 있는데 lag만 계속 올라갔습니다.
원인은 max.poll.interval.ms를 배치 크기와 함께 계산하지 않았다는 것 하나였습니다.
이 케이스에서 얻어 갈 것
max.poll.interval.ms초과가 어떤 로그를 남기고, 그 로그를 누가 출력하는지 정확히 구분할 수 있습니다.session.timeout.ms(하트비트 실패)와max.poll.interval.ms(폴 실패)를 헷갈리지 않게 됩니다.max.poll.records× 건당 처리 시간이 왜 이 설정의 실질 기준인지 계산할 수 있습니다.- cooperative sticky와 정적 멤버십이 리밸런스 비용을 어디까지 줄여 주는지, 그리고 줄여 주지 못하는 부분이 무엇인지 압니다.
상황
이미지 후처리 파이프라인입니다. image-uploaded 토픽은 파티션 12개, 복제 계수 3,
브로커 3대에서 평소 일 80만 건을 받습니다.
썸네일 생성 컨슈머는 파드 4개로 돌고, 파드마다 파티션 3개를 맡습니다.
컨슈머 루프는 단순합니다. poll()로 받은 레코드를 순차로 돌며 외부 리사이즈 서비스를 호출하고,
결과를 오브젝트 스토리지에 올리고, 루프가 끝나면 커밋합니다.
평소 리사이즈 호출은 건당 평균 400ms였습니다.
max.poll.records는 손대지 않았으므로 기본값 500입니다.
while (running) {
ConsumerRecords<String, ImageEvent> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, ImageEvent> record : records) {
// 외부 리사이즈 서비스 호출 — 평소 400ms, 부하 시 2.5s
byte[] thumb = resizeClient.resize(record.value().getSourceUrl());
objectStore.put(thumbKey(record), thumb);
}
consumer.commitSync(); // 배치 전체를 처리한 뒤 커밋
}
금요일 20:00 프로모션이 시작되자 리사이즈 서비스의 응답 시간이 건당 2.5초로 올라갔습니다. 업스트림 서비스에는 알림이 걸려 있었지만 "느려졌을 뿐 성공한다"는 이유로 무시됐습니다. 20:06부터 썸네일이 전혀 생성되지 않았고, lag은 시간당 30만 건씩 쌓였습니다.
max.poll.interval.ms 초과가 만드는 리밸런스 루프 —
정상 폴 루프, 초과 시점의 LeaveGroup, 그리고 재할당 후 같은 배치를 다시 처리하며 또 초과하는 순환
관측된 증상
메트릭이 어떻게 보였는가
- consumer lag: 20:06 이후 기울기가 일정한 직선으로 상승. 처리량이 0이 되었기 때문에 유입분이 그대로 누적됩니다. 톱니 모양이 아니라 완전한 직선이라는 점이 단서입니다.
records-consumed-rate: 0에 가까움. 레코드를 받아 오기는 하지만 커밋 전에 파티션이 회수되어 진행이 없습니다.rebalance-latency-avg/failed-rebalance-total: 두 지표가 동시에 급등. 이 조합이 이 사고의 결정적 지문입니다.- 컨슈머 그룹 상태:
--describe --state가PreparingRebalance와CompletingRebalance를 왕복합니다.Stable에 머무는 시간이 거의 없습니다. - 파드 CPU / 메모리: 정상. 그래서 인프라 알림이 하나도 울리지 않았습니다.
컨슈머 애플리케이션 로그
아래는 실제 Kafka 클라이언트가 출력하는 메시지입니다.
AbstractCoordinator의 handlePollTimeoutExpiry()가 찍는 WARN 한 줄이 시작점입니다.
WARN [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] consumer poll timeout has expired. This means 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 processing messages. 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.
INFO [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Member thumbnailer-2-4b7d1c9a-5f31-4e02-9a1d-0c8f7e2b6a44 sending LeaveGroup request to coordinator kafka-2:9092 (id: 2 rack: null) due to consumer poll timeout has expired.
INFO [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Resetting generation and member id due to: consumer pro-actively leaving the group
INFO [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Request joining group due to: consumer pro-actively leaving the group
INFO [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] (Re-)joining group
커밋 실패 — 배치를 다 처리한 뒤에 터진다
더 나쁜 것은 1250초를 들여 배치를 다 처리한 뒤입니다.
그 사이 파티션이 다른 멤버에게 재할당되었으므로 commitSync()가 실패합니다.
CommitFailedException의 기본 메시지는 원인을 그대로 설명합니다.
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.
at org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.sendOffsetCommitRequest(ConsumerCoordinator.java:...)
at org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.commitOffsetsSync(ConsumerCoordinator.java:...)
자동 커밋을 쓰고 있었다면 예외 대신 아래 형태의 WARN이 조용히 반복됩니다. 이쪽이 훨씬 위험합니다. 애플리케이션은 실패를 인지하지 못합니다.
ConsumerCoordinator가 WARN으로만 남깁니다WARN [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Asynchronous auto-commit of offsets {image-uploaded-4=OffsetAndMetadata{offset=1841020, ...}} failed: Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member.
브로커(그룹 코디네이터) 로그
같은 시각 코디네이터를 맡은 브로커의 server.log에는 아래 형태가 반복됩니다.
GroupMetadataManager가 출력하며, client reason에 컨슈머가 보낸 이유가 그대로 실려 옵니다.
INFO [GroupCoordinator id=2] [Group image-thumbnailer] Member thumbnailer-2-4b7d1c9a-... has left group through explicit `LeaveGroup` request; client reason: consumer poll timeout has expired.
INFO [GroupCoordinator id=2] Preparing to rebalance group image-thumbnailer in state PreparingRebalance with old generation 214 (reason: removing member thumbnailer-2-4b7d1c9a-... on LeaveGroup).
INFO [GroupCoordinator id=2] Stabilized group image-thumbnailer generation 215 with 3 members.
INFO [GroupCoordinator id=2] [Group image-thumbnailer] Member thumbnailer-0-1a09f4de-... has left group through explicit `LeaveGroup` request; client reason: consumer poll timeout has expired.
INFO [GroupCoordinator id=2] Preparing to rebalance group image-thumbnailer in state PreparingRebalance with old generation 215 (reason: removing member thumbnailer-0-1a09f4de-... on LeaveGroup).
원인 분석
1단계 — 그룹 상태를 본다
무한 리밸런스는 --state 하나로 거의 확정됩니다.
몇 초 간격으로 두 번 실행해 STATE가 바뀌는지 확인합니다.
Stable이 아니면 리밸런스 중입니다$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--describe --group image-thumbnailer --state
GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS
image-thumbnailer kafka-2:9092 (2) range PreparingRebalance 3
$ # 4초 뒤 다시
GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS
image-thumbnailer kafka-2:9092 (2) range CompletingRebalance 4
2단계 — 오프셋이 전진하지 않는 것을 확인한다
리밸런스 중에 --describe를 실행하면 도구가 표준 에러로 경고를 출력합니다.
CURRENT-OFFSET을 30초 간격으로 두 번 찍어 전혀 변하지 않음을 확인했습니다.
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--describe --group image-thumbnailer
Warning: Consumer group 'image-thumbnailer' is rebalancing.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID
image-thumbnailer image-uploaded 0 1841020 2094882 253862 - - -
image-thumbnailer image-uploaded 1 1839774 2093010 253236 - - -
image-thumbnailer image-uploaded 2 1840119 2093551 253432 - - -
...
$ # 30초 뒤 — CURRENT-OFFSET 이 동일합니다. LAG 만 늘어납니다.
3단계 — 두 개의 타임아웃을 구분한다
여기서 팀이 처음 한 조치는 session.timeout.ms를 올리는 것이었고, 아무 효과가 없었습니다.
두 설정은 서로 다른 실패를 감지합니다.
| 설정 | 기본값 | 무엇을 감지하는가 | 누가 판정하는가 |
|---|---|---|---|
session.timeout.ms |
45000 | 하트비트가 끊김 — 프로세스·네트워크 장애 | 브로커(코디네이터). 하트비트 만료 시 멤버 제거 |
heartbeat.interval.ms |
3000 | 하트비트 전송 주기. session.timeout.ms의 1/3 이하 권장 |
컨슈머의 백그라운드 스레드 |
max.poll.interval.ms |
300000 | poll() 호출 간격 초과 — 처리 로직이 너무 느림 |
컨슈머 자신. 초과 시 스스로 LeaveGroup |
max.poll.records |
500 | 한 번의 poll()이 반환할 최대 레코드 수 |
컨슈머(클라이언트 측 잘라내기) |
4단계 — 왜 무한 루프가 되는가
한 번의 초과가 그 파드만의 문제로 끝나지 않는 이유는 순환 구조 때문입니다.
- 파드 A가 배치를 처리하다 300초를 넘겨 LeaveGroup을 보냅니다.
- 그룹이 리밸런스합니다. A가 맡았던 파티션이 B·C·D로 재할당됩니다.
- A가 처리하던 배치는 커밋되지 않았으므로 B가 같은 오프셋에서 같은 레코드를 다시 받습니다.
- B의 부담이 늘어 B도 300초를 넘깁니다. 동시에 A는 다시 조인합니다.
- 리밸런스가 또 발생합니다. 2번으로 돌아갑니다.
eager 리밸런스(기본 할당 전략 목록의 RangeAssignor가 선택된 경우)에서는
리밸런스마다 모든 멤버가 모든 파티션을 반납합니다.
그래서 리밸런스가 잦아지면 그룹 전체 처리량이 0에 수렴합니다.
할당 전략과 리밸런스 프로토콜의 차이는 5장 Consumer 심화에서 다룹니다.
재현 방법
단일 노드 KRaft 클러스터와 max.poll.interval.ms를 아주 짧게 준 컨슈머 하나로 재현됩니다.
아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다.
3노드 구성은 예제 1 · 로컬 KRaft 클러스터를 쓰세요.
services:
broker:
image: apache/kafka:4.3.1
hostname: broker
container_name: broker
ports:
- '9092:9092'
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.List;
import java.util.Properties;
public class SlowConsumer {
public static void main(String[] args) {
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
p.put(ConsumerConfig.GROUP_ID_CONFIG, "slow-group");
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
// 재현 포인트 1 — 폴 간격 한계를 10초로 줄인다 (기본 300000)
p.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 10_000);
// 재현 포인트 2 — 한 배치에 20건. 20 x 1s = 20s > 10s 이므로 반드시 초과한다
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 20);
try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
c.subscribe(List.of("slow-topic"));
while (true) {
ConsumerRecords<String, String> records = c.poll(Duration.ofSeconds(1));
System.out.println("polled " + records.count());
for (ConsumerRecord<String, String> r : records) {
Thread.sleep(1_000); // 건당 1초 처리
System.out.println(" done " + r.offset());
}
c.commitSync(); // 여기서 CommitFailedException 이 터집니다
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
docker compose up -d
# 1. 파티션 2개 토픽
docker exec -it broker /opt/kafka/bin/kafka-topics.sh --create \
--topic slow-topic --partitions 2 --replication-factor 1 \
--bootstrap-server localhost:9092
# 2. 200건 적재
docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
--topic slow-topic --bootstrap-server localhost:9092 \
< <(seq 1 200)
# 3. SlowConsumer 를 두 개 띄운다 (같은 group.id)
# → 10초마다 WARN "consumer poll timeout has expired" 가 반복되고
# commitSync() 에서 CommitFailedException 이 발생합니다.
java -cp kafka-clients-4.3.1.jar:. SlowConsumer &
java -cp kafka-clients-4.3.1.jar:. SlowConsumer &
# 4. 그룹 상태가 Stable 에 머물지 못하는 것을 확인
watch -n 2 'docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
--describe --group slow-group --state --bootstrap-server localhost:9092'
# 5. CURRENT-OFFSET 이 전진하지 않는 것을 확인
docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
--describe --group slow-group --bootstrap-server localhost:9092
해결
즉시 조치 — 배치를 잘게 쪼갠다
장애 대응 중에 가장 빠르고 안전한 손잡이는 max.poll.records입니다.
max.poll.interval.ms를 올리는 것보다 부작용이 적습니다.
타임아웃을 늘리면 진짜로 멈춘 컨슈머를 감지하는 시간도 같이 늘어납니다.
# 20 x 2.5s = 50s. max.poll.interval.ms 기본 300s 안에 충분히 들어옵니다.
max.poll.records=20
근본 해결 — 폴 루프에서 느린 작업을 분리한다
폴 루프가 외부 호출 지연에 직접 노출되어 있습니다. 업스트림이 느려지는 순간 컨슈머 생존 판정까지 같이 위험해집니다.
group.id=image-thumbnailer
enable.auto.commit=true
# max.poll.records 미지정 → 500
# max.poll.interval.ms 미지정 → 300000
# partition.assignment.strategy 미지정
# → [RangeAssignor, CooperativeStickyAssignor] 중 앞의 것이 선택됨
배치를 작게 유지하고, 커밋을 명시적으로 통제하며, 리밸런스 시 반납 범위를 줄이고, 재배포로 인한 리밸런스를 없앱니다.
group.id=image-thumbnailer
enable.auto.commit=false
max.poll.records=20
max.poll.interval.ms=120000
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
group.instance.id=${POD_NAME}
session.timeout.ms=45000
heartbeat.interval.ms=3000
설정만으로는 부족합니다. 폴 루프의 구조를 바꿔야 합니다.
느린 처리를 워커 풀로 넘기고, 폴 루프는 진행 상황에 따라 파티션을 pause/resume하게 만듭니다.
이렇게 하면 처리 시간이 아무리 늘어나도 poll() 호출 간격은 일정하게 유지됩니다.
while (running) {
ConsumerRecords<String, ImageEvent> records = consumer.poll(Duration.ofMillis(500));
for (TopicPartition tp : records.partitions()) {
for (ConsumerRecord<String, ImageEvent> r : records.records(tp)) {
workerPool.submit(() -> process(r)); // 느린 작업은 여기서
}
// 워커 큐가 깊어지면 그 파티션의 fetch 를 잠시 멈춘다.
// poll() 자체는 계속 호출되므로 max.poll.interval.ms 를 넘기지 않는다.
if (queueDepth(tp) > HIGH_WATERMARK) consumer.pause(List.of(tp));
}
// 워커가 완료한 지점까지만 커밋한다 (완료 오프셋 추적기 필요)
Map<TopicPartition, OffsetAndMetadata> done = completedOffsets();
if (!done.isEmpty()) consumer.commitSync(done);
// 큐가 비면 다시 재개
for (TopicPartition tp : consumer.paused()) {
if (queueDepth(tp) < LOW_WATERMARK) consumer.resume(List.of(tp));
}
}
KIP-848 새 프로토콜을 쓰면 달라지는 것
알림 — 이 사고를 5분 안에 잡는 두 지표
failed-rebalance-rate-per-hour또는failed-rebalance-total의 증가율. 정상 운영에서는 0에 가깝습니다.rebalance-latency-max와records-consumed-rate의 동시 이상. 리밸런스가 잦은데 소비가 없으면 확정입니다.- 애플리케이션 로그에서
poll timeout has expired문자열을 직접 알림 대상으로 잡는 것도 효과적입니다.
메트릭 MBean 이름은 JMX 메트릭 치트시트에 정리되어 있습니다.
예방 체크리스트
시험 포인트
이어서 볼 곳
공식 문서 출처
설정 기본값·동작 설명은 Apache Kafka 4.3 문서에서, 로그·예외 문자열은 4.3 소스에서 확인했습니다.
- Consumer Configs —
max.poll.interval.ms— 기본값 300000, 정적 멤버십일 때의 예외 동작 - Consumer Configs —
max.poll.records— 기본값 500, fetch 동작과의 관계 - Consumer Configs —
session.timeout.ms·heartbeat.interval.ms— 기본값 45000 / 3000 - Consumer Configs —
partition.assignment.strategy— 기본 목록과 각 할당자 설명 - Consumer Rebalance Protocol — KIP-848,
group.protocol=consumer에서 무효화되는 클라이언트 설정 - Monitoring —
rebalance-latency-*,failed-rebalance-*,records-lag-max - Apache Kafka 소스 (4.3) —
AbstractCoordinator,ConsumerCoordinator,CommitFailedException,GroupMetadataManager의 로그·예외 문자열