Kafka 모니터링: consumer lag 및 추적해야 할 메트릭

1. 개요

Kafka 시스템이 ’건강’해 보일 수 있습니다: broker가 실행 중이고 producer가 메시지를 보낼 수 있습니다. 하지만 consumer가 수백만 개의 메시지 뒤에서 쳐지고 있을 수 있으며 아무도 모를 수 있습니다. 이러한 상태를 반영하는 메트릭이 consumer lag입니다.

이 글에서는 consumer lag이 어떻게 계산되는지 이해하고 세 가지 방법으로 측정하는 방법을 배웁니다: CLI, Java의 AdminClient, 그리고 Spring Boot의 Micrometer. 그 후 가장 중요한 broker, producer, consumer 메트릭과 이들에 대한 알림을 설정하는 방법을 살펴봅니다.

2. Consumer lag이란?

Kafka의 각 partition은 append-only 로그입니다. 주의해야 할 두 가지 offset이 있습니다:

  • Log-end offset (LEO): partition에 다음에 기록될 메시지의 offset입니다.
  • Committed offset: consumer group이 commit한 offset이며, 이는 restart될 경우 group이 다음으로 읽을 위치입니다.

한 partition의 lag은 다음과 같이 계산됩니다:

lag = log-end offset - committed offset

전체 consumer group의 lag은 해당 group이 읽는 모든 partition의 lag 합입니다.

Lag이 0이면 consumer가 따라잡았다는 의미입니다. Message가 계속 도착하므로 lag이 약간 변동하는 것은 정상입니다. Lag이 시간에 따라 지속적으로 증가하는 것이 걱정할 신호입니다. 이 경우 consumer가 producer의 쓰기 속도보다 느리게 처리합니다.

주의: Transaction이나 compaction을 사용하는 topic의 경우, offset에 “간격”이 있을 수 있습니다(control record, 삭제된 메시지). Lag은 offset을 기준으로 계산되므로 실제로 읽어야 할 메시지 수보다 약간 클 수 있습니다. 이는 오류가 아닙니다.

3. CLI로 lag 확인

가장 빠른 방법은 Kafka와 함께 제공되는 스크립트를 사용하는 것입니다:

kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group order-service

결과는 다음과 같습니다:

GROUP          TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG   CONSUMER-ID       HOST
order-service  orders  0          15230           15232           2     consumer-1-a1b2   /10.0.0.5
order-service  orders  1          14980           18410           3430  consumer-1-c3d4   /10.0.0.6
order-service  orders  2          15102           15102           0     consumer-1-e5f6   /10.0.0.7

Partition 1이 3430개 메시지 뒤처져 있고, 다른 두 partition은 거의 따라잡았습니다. 이러한 lag 차이는 보통 두 가지 원인으로 인해 발생합니다. 하나는 ‘핫’ 키가 한 partition에 집중되어 있다는 것입니다. 다른 하나는 호스트 10.0.0.6의 consumer에 문제가 있다는 것입니다.

CLI는 문제를 조사할 때 매우 유용합니다. 그러나 매분마다 이 명령을 실행할 수는 없습니다. 다음 섹션에서는 lag 측정을 자동화합니다.

4. AdminClient로 lag 계산

kafka-clients dependency를 추가합니다(Spring Boot이 버전을 관리함):

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
</dependency>

먼저 계산 부분을 테스트하기 쉽도록 순수 함수로 분리합니다:

public final class LagCalculator {

    private LagCalculator() {}

    public static Map<TopicPartition, Long> compute(Map<TopicPartition, Long> committed,
                                                    Map<TopicPartition, Long> endOffsets) {
        Map<TopicPartition, Long> lag = new HashMap<>();
        committed.forEach((tp, committedOffset) -> {
            Long end = endOffsets.get(tp);
            if (end != null) {
                lag.put(tp, Math.max(0L, end - committedOffset));
            }
        });
        return lag;
    }
}

이 함수는 committed offset과 end offset이 모두 있는 partition에 대해서만 lag을 계산합니다. Math.max(0L, …)는 그 이유가 있습니다. Committed offset과 end offset은 두 개의 다른 요청으로 가져오므로 때로는 committed offset이 방금 가져온 end offset보다 ’최신’일 수 있습니다. 그 결과 음수는 의미가 없으므로 0으로 설정합니다.

다음은 AdminClient를 호출하는 클래스입니다:

public class ConsumerLagChecker {

    private final Admin admin;

    public ConsumerLagChecker(Admin admin) {
        this.admin = admin;
    }

    public Map<TopicPartition, Long> lagOf(String groupId) throws Exception {
        Map<TopicPartition, Long> committed = admin.listConsumerGroupOffsets(groupId)
                .partitionsToOffsetAndMetadata()
                .get(10, TimeUnit.SECONDS)
                .entrySet().stream()
                .filter(e -> e.getValue() != null)
                .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().offset()));

        Map<TopicPartition, OffsetSpec> request = committed.keySet().stream()
                .collect(Collectors.toMap(tp -> tp, tp -> OffsetSpec.latest()));

        Map<TopicPartition, Long> endOffsets = admin.listOffsets(request)
                .all()
                .get(10, TimeUnit.SECONDS)
                .entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().offset()));

        return LagCalculator.compute(committed, endOffsets);
    }
}

각 단계는 다음과 같습니다:

  • listConsumerGroupOffsets은 group의 committed offset을 반환합니다. Partition이 한 번도 commit되지 않았으면 값이 null일 수 있으므로 필터링합니다.
  • OffsetSpec.latest()와 함께 listOffsets을 사용하면 각 partition의 log-end offset을 가져옵니다.
  • 각 호출에는 10초 timeout이 있습니다. 이렇게 하면 클러스터에 문제가 있을 때 thread가 무한정 hang되지 않습니다.

사용법:

try (Admin admin = Admin.create(Map.of(
        AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"))) {
    new ConsumerLagChecker(admin).lagOf("order-service")
            .forEach((tp, lag) -> System.out.println(tp + " -> " + lag));
}
orders-0 -> 2
orders-1 -> 3430
orders-2 -> 0

4.1. LagCalculator 테스트

JUnit 5 및 AssertJ를 사용합니다(spring-boot-starter-test에 포함됨):

class LagCalculatorTest {

    private final TopicPartition p0 = new TopicPartition("orders", 0);
    private final TopicPartition p1 = new TopicPartition("orders", 1);

    @Test
    void shouldComputeLagPerPartition() {
        Map<TopicPartition, Long> lag = LagCalculator.compute(
                Map.of(p0, 100L, p1, 50L),
                Map.of(p0, 120L, p1, 50L));

        assertThat(lag).containsEntry(p0, 20L).containsEntry(p1, 0L);
    }

    @Test
    void shouldClampNegativeLagToZero() {
        Map<TopicPartition, Long> lag = LagCalculator.compute(
                Map.of(p0, 130L),
                Map.of(p0, 120L));

        assertThat(lag).containsEntry(p0, 0L);
    }

    @Test
    void shouldSkipPartitionWithoutEndOffset() {
        Map<TopicPartition, Long> lag = LagCalculator.compute(
                Map.of(p0, 10L), Map.of());

        assertThat(lag).isEmpty();
    }
}

5. Spring Boot에서 Micrometer로 lag 내보내기

Actuator 및 Prometheus registry를 추가합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
management.endpoints.web.exposure.include=health,prometheus

5.1. Client 측 기본 제공 메트릭

MeterRegistry가 있으면 Spring Boot는 자동으로 MicrometerConsumerListener를 ConsumerFactory에 연결합니다. Kafka consumer의 모든 내부 메트릭이 /actuator/prometheus에 나타나며, 그 중에는:

kafka_consumer_fetch_manager_records_lag_max{client_id="consumer-order-service-1",...} 3430.0
kafka_consumer_fetch_manager_records_lag{topic="orders",partition="1",...} 3430.0

records-lag-max는 이 consumer가 관찰한 최대 lag입니다. Kafka client가 fetch 위치에서 계산하며, committed offset에서가 아닙니다.

5.2. 외부에서 측정한 lag gauge

Client 측 메트릭에는 큰 약점이 있습니다. Consumer가 죽으면 메트릭도 사라집니다. Lag이 가장 빠르게 증가할 때 정확히 데이터가 없습니다. 따라서 AdminClient를 사용하는 독립적인 프로세스에서 추가로 lag을 측정해야 합니다:

@Component
public class ConsumerLagMetrics {

    private final ConsumerLagChecker checker;
    private final MeterRegistry registry;
    private final Map<TopicPartition, AtomicLong> gauges = new ConcurrentHashMap<>();

    public ConsumerLagMetrics(KafkaAdmin kafkaAdmin, MeterRegistry registry) {
        this.checker = new ConsumerLagChecker(Admin.create(kafkaAdmin.getConfigurationProperties()));
        this.registry = registry;
    }

    @Scheduled(fixedDelay = 30_000)
    public void refresh() throws Exception {
        checker.lagOf("order-service").forEach((tp, lag) ->
                gauges.computeIfAbsent(tp, this::register).set(lag));
    }

    private AtomicLong register(TopicPartition tp) {
        AtomicLong holder = new AtomicLong();
        Gauge.builder("kafka.consumer.group.lag", holder, AtomicLong::get)
                .tag("group", "order-service")
                .tag("topic", tp.topic())
                .tag("partition", String.valueOf(tp.partition()))
                .register(registry);
        return holder;
    }
}

한 @Configuration 클래스에서 @EnableScheduling으로 scheduling을 활성화하는 것을 잊지 마세요. 30초마다 gauge kafka_consumer_group_lag이 업데이트됩니다. 이 값은 committed offset을 기반으로 하므로 group의 모든 consumer가 중지되었을 때도 정확합니다.

프로덕션에서 많은 팀이 위 코드 대신 kafka-exporter 또는 Burrow와 같은 전문 도구를 사용합니다. 그들의 원리는 동일합니다: committed offset과 log-end offset을 비교합니다.

주의: 여러 instance가 있는 consumer service에 이 gauge를 배치하지 마세요. 각 instance가 동일한 lag 값을 내보내므로 dashboard 합계가 잘못될 것입니다. 별도의 모니터링 service에서 실행하거나 한 instance에서만 실행하세요.

6. 추적해야 할 메트릭

Lag은 문제가 있는지 알려줍니다. 아래의 메트릭은 문제가 어디에 있는지 찾는 데 도움이 됩니다.

6.1. Broker

메트릭 (JMX) 의미 원하는 값
UnderReplicatedPartitions replica가 동기화되지 않은 partition 0
OfflinePartitionsCount leader가 없는 partition 0
ActiveControllerCount 활성 controller 수(전체 cluster) 정확히 1
IsrShrinksPerSec / IsrExpandsPerSec ISR 축소/확대 0 근처, 지속적으로 변동하지 않음
RequestHandlerAvgIdlePercent request handler thread의 유휴 수준 > 0.3
NetworkProcessorAvgIdlePercent network thread의 유휴 수준 > 0.3

OfflinePartitionsCount > 0는 심각한 문제입니다: 해당 partition을 읽거나 쓸 수 없습니다. 지속되는 UnderReplicatedPartitions는 보통 broker 과부하, 느린 디스크 또는 네트워크 문제를 나타냅니다.

6.2. Producer

  • record-error-rate: 재시도가 모두 소진된 후 전송 실패율입니다. 0이 아닌 모든 값을 조사할 가치가 있습니다.
  • request-latency-avg: broker에 대한 요청 지연입니다. 증가하면 throughput이 감소하는 것을 볼 수 있습니다.
  • record-retry-rate: 많은 재시도는 broker 또는 네트워크가 불안정함을 의미합니다.
  • buffer-available-bytes: buffer가 고갈되면 send()가 차단됩니다.

6.3. Consumer

  • records-lag-max: 위에서 이미 언급했습니다.
  • records-consumed-rate: 소비 속도입니다. Producer가 topic에 쓰는 속도와 비교하세요.
  • rebalance-rate-per-hour 및 last-rebalance-seconds-ago: 지속적인 rebalance로 consumer가 “얼어붙습니다”. 이것이 lag을 증가시키는 일반적인 원인입니다.
  • commit-latency-avg: 느린 commit은 throughput을 감소시킵니다. 특히 동기 commit일 때 그렇습니다.
  • last-poll-seconds-ago: max.poll.interval.ms를 초과하면 consumer가 group에서 제거됩니다.

7. 합리적인 알림 설정

가장 흔한 실수는 ’1000 > lag이면 알림’과 같이 절대값 임계값을 설정하는 것입니다. 초당 50,000개 메시지의 topic에서 1000은 의미가 없습니다. 분당 몇 개 메시지인 topic에서는 1000이 이미 몇 시간 지연될 수 있습니다.

더 나은 두 가지 접근 방식이 있습니다. 첫 번째는 lag이 지속적으로 증가할 때 알림하는 것입니다:

groups:
  - name: kafka-consumer
    rules:
      - alert: KafkaConsumerLagGrowing
        expr: sum by (group, topic) (deriv(kafka_consumer_group_lag[10m])) > 0
          and sum by (group, topic) (kafka_consumer_group_lag) > 500
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.group }} group의 {{ $labels.topic }} topic에서 lag이 지속적으로 15분 동안 증가했습니다"

이 규칙은 두 조건이 모두 참일 때만 트리거됩니다: lag이 증가하는 추세(deriv > 0)이고 최소 수준을 초과했습니다. 조건이 15분 동안 지속되어야 합니다. 이렇게 하면 배포 후 단기적인 증가를 무시할 수 있습니다.

두 번째 방식은 lag을 시간으로 변환하는 것입니다. Lag을 소비 속도로 나누어 consumer가 몇 초 뒤처져 있는지 추정합니다. 이 숫자는 SLA과 직접 연결됩니다. 예를 들어 ’주문은 2분 내에 처리되어야 함’은 ’lag은 5000 이하’보다 훨씬 쉽게 이해됩니다.

또한 absent(kafka_consumer_group_lag)과 같이 메트릭이 사라지는 경우에 대한 별도의 알림을 가져야 합니다. 데이터 없음이 모든 것이 괜찮다는 의미는 아닙니다.

8. 일반적인 실수

  • records-lag-max에만 의존합니다. Consumer가 죽으면 이 메트릭이 사라집니다. 항상 외부에서 lag을 측정하는 소스를 가지세요.
  • 활성 consumer가 없는 group의 lag을 읽습니다. CLI는 여전히 committed offset을 표시하지만 CONSUMER-ID 열이 비어 있습니다. 여기서 lag이 증가하면 아무도 처리하지 않고 있다는 의미입니다.
  • partition 수를 초과하는 수의 consumer를 증가시킵니다. 여분의 consumer는 유휴 상태로 유지되고 lag이 감소하지 않습니다. 병렬성을 높이려면 partition 수를 늘려야 합니다.
  • rebalance를 무시합니다. Batch 처리가 너무 오래 걸려 max.poll.interval.ms를 초과합니다. Consumer가 제거되고 group rebalance가 발생한 후 반복됩니다. Lag이 톱니 패턴으로 증가합니다. max.poll.records를 줄이거나 무거운 처리를 다른 thread로 푸시하세요.
  • AdminClient를 너무 자주 호출합니다. 수백 개 group에 대해 매초 offset을 쿼리하면 broker에 불필요한 부하가 생깁니다. 대부분의 시스템에는 15-60초 간격이면 충분합니다.

9. 결론

Consumer lag은 Kafka 시스템이 데이터를 따라잡는지를 가장 직접적으로 나타내는 메트릭입니다. Lag은 log-end offset에서 committed offset을 뺀 값으로 계산됩니다. kafka-consumer-groups.sh로 lag을 보거나, AdminClient로 계산하거나, Micrometer를 통해 Prometheus로 내보낼 수 있습니다.

효과적으로 모니터링하려면 client 측 메트릭을 독립적인 측정 소스와 결합하세요. Lag 외에도 UnderReplicatedPartitions 및 OfflinePartitionsCount와 같은 broker 메트릭과 consumer 측 rebalance 빈도를 모니터링하세요. 마지막으로 고정된 lag 숫자가 아닌 추세와 지연 시간 기반으로 알림을 설정하세요. 이렇게 하면 거짓 경보는 적게 받으면서 실제 문제를 놓치지 않을 수 있습니다.

kafkamonitoringspring bootmicrometerprometheus