# KEDA로 Kafka 컨슈머를 늘릴 때 - lagThreshold는 임계값이 아니다
Kafka 컨슈머가 밀리기 시작하면 파드를 늘리고 싶어집니다. 그런데 HPA로는 잘 안 됩니다. lag이 쌓여도 컨슈머의 CPU는 크게 오르지 않고, 애초에 lag은 Kubernetes가 모르는 값이기 때문입니다.
KEDA가 이 자리를 채웁니다. 설정은 간단하지만, 붙이고 나면 레플리카가 최대치에 붙어 내려오지 않거나, 반대로 계속 오르내리며 컨슈머 그룹 리밸런싱만 반복하는 상태가 되기 쉽습니다. 이 글에서는 KEDA와 HPA의 관계, lagThreshold가 실제로 무엇인지, 그리고 Kafka 특유의 상한과 함정을 정리합니다.
# 1. KEDA는 HPA를 대체하지 않는다
첫 오해부터 정리합니다. KEDA는 HPA를 쓰지 않는 별도 오토스케일러가 아닙니다.
KEDA manages autoscaling by generating and controlling a Kubernetes HPA internally. The ScaledObject resource acts as the configuration layer, with KEDA translating its settings into HPA parameters.
ScaledObject를 만들면 KEDA가 HPA를 하나 만들어 줍니다. 실제 스케일 결정은 그 HPA가 내리고, KEDA는 외부 지표를 HPA가 읽을 수 있는 형태로 공급합니다.
여기서 바로 제약이 따라옵니다.
Do not create your own HPA on the same target
같은 워크로드에 직접 만든 HPA가 남아 있으면 두 HPA가 같은 Deployment의 레플리카 수를 서로 다른 값으로 밀게 됩니다. CPU 기반 HPA를 쓰다가 KEDA로 옮길 때 기존 HPA를 지우지 않는 것이 흔한 실수이고, 증상은 레플리카 수가 두 값 사이를 오가는 형태로 나타납니다.
# 2. 0↔1과 1↔N은 다른 메커니즘이다
KEDA의 대표 기능이 0으로 줄이기입니다. HPA만으로는 안 되는 일이지만, KEDA가 그 구간을 따로 처리하므로 가능합니다.
Activation threshold determines when to scale from/to zero (0↔1 transitions) Scaling threshold defines the target metric value for normal autoscaling (1↔N transitions)
두 임계값은 별개의 설정입니다. Kafka 스케일러에서는 이렇게 대응합니다.
| 설정 | 담당 구간 | 의미 |
|---|---|---|
activationLagThreshold | 0 ↔ 1 | 이 값을 넘어야 파드가 하나라도 뜬다 |
lagThreshold | 1 ↔ N | HPA의 목표값 |
그리고 우선순위가 있습니다.
The activation value has more priority than the scaling value in case of different decisions
활성화 판단이 이깁니다. minReplicaCount가 0일 때 activationLagThreshold를 넘지 못하면 HPA가 무엇을 계산하든 0으로 내려갑니다. minReplicaCount가 1 이상이면 스케일러가 항상 활성 상태로 취급되어 활성화 값은 무시됩니다. 두 값을 따로 생각하지 않고 lagThreshold만 설정하면, 활성화 임계값은 기본값(보통 0)이 되어 lag이 조금만 있어도 파드가 뜹니다. 0으로 줄이는 것을 원했다면 두 값을 함께 정해야 합니다.
# 3. lagThreshold는 "넘으면 스케일"이 아니다
가장 자주 틀리는 부분입니다. 이름 때문에 "lag이 이 값을 넘으면 늘린다"로 읽히는데, 문서의 정의는 다릅니다.
lagThreshold: Target value for the total lag (sum of all partition lags) to trigger scaling actions
**목표값(target value)**입니다. HPA가 목표값을 다루는 방식은 정해져 있습니다. 현재 지표를 목표값으로 나눠 필요한 레플리카 수를 구합니다.
필요 레플리카 ≈ ceil( 전체 lag / lagThreshold )
즉 lagThreshold는 **"레플리카 하나가 감당할 lag의 양"**입니다. 임계선이 아니라 나눗셈의 분모입니다.
이 차이가 실제 설정을 크게 바꿉니다. "lag이 10000을 넘으면 늘리자"는 생각으로 lagThreshold: 10000을 넣었다고 해 봅니다. lag이 100000까지 쌓이면 레플리카는 10개가 됩니다. 의도한 것이 "10000 넘으면 조금 늘린다"였다면 결과가 전혀 다릅니다.
값을 정하는 올바른 방향은 이렇습니다.
- 컨슈머 하나가 초당 몇 건을 처리하는지 잰다 (예: 500건/초)
- 얼마나 빨리 따라잡고 싶은지 정한다 (예: 60초 안에)
lagThreshold = 처리량 × 목표 회복 시간(500 × 60 = 30000)
따라잡고 싶은 시간이 짧을수록 값이 작아지고 레플리카가 많아집니다. 이렇게 계산하면 숫자에 근거가 생깁니다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-consumer
spec:
scaleTargetRef:
name: order-consumer
minReplicaCount: 0
maxReplicaCount: 12
pollingInterval: 30
cooldownPeriod: 300
triggers:
- type: kafka
metadata:
bootstrapServers: kafka.example.com:9092
consumerGroup: order-processor
topic: orders
lagThreshold: "30000" # 레플리카당 감당할 lag
activationLagThreshold: "1000" # 0에서 깨어나는 기준
excludePersistentLag: "true"
# 4. 파티션 수가 진짜 상한이다
maxReplicaCount를 크게 잡아도 그만큼 안 늘어나는 경우가 있습니다.
the number of replicas will not exceed the number of partitions on a topic when a topic is specified
Kafka 컨슈머 그룹에서 파티션 하나는 한 컨슈머에게만 할당됩니다. 파티션이 6개면 컨슈머 7번째부터는 아무것도 받지 못합니다. KEDA는 이 사실을 알고 상한을 겁니다.
우회 옵션이 있지만 효과는 없습니다.
Setting
allowIdleConsumerstotruepermits the number of replicas can exceed the number of partitions on a topic, allowing for idle consumers.
이름 그대로 노는 컨슈머가 생길 뿐입니다. 처리량은 늘지 않고 파드만 늘어납니다. 이 옵션은 대기 중인 컨슈머를 미리 띄워 두어 리밸런싱 시 인계를 빠르게 하려는 것과 같은 특수한 목적에서만 의미가 있습니다.
따라서 진짜 확장 한계는 파티션 수입니다. 처리량이 부족하다면 파드를 늘리기 전에 파티션 수를 봐야 합니다. 그리고 파티션은 늘릴 수는 있어도 줄일 수 없고, 키 기반 파티셔닝을 쓰고 있다면 늘리는 순간 같은 키가 다른 파티션으로 가기 시작해 순서 보장이 깨집니다. 오토스케일링을 도입하기 전에 결정해야 하는 값입니다.
# 5. 줄어들지 않는 lag이 스케일을 고정시킨다
특정 파티션의 컨슈머가 처리하지 못하는 메시지에 걸리면 그 파티션의 오프셋이 전진하지 않습니다. lag은 그대로 남습니다.
KEDA는 전체 lag의 합을 보므로, 이 상황에서 레플리카를 최대로 올린 채 유지합니다. 늘려도 그 파티션은 계속 막혀 있으므로 lag이 줄지 않고, 그래서 계속 최대입니다. 파드만 소모됩니다.
excludePersistentLag: When enabled, filters out partition lag for partitions with unchanged offsets
오프셋이 전진하지 않는 파티션의 lag을 계산에서 뺍니다. 켜 두면 막힌 파티션이 스케일 결정을 오염시키지 않습니다.
다만 이것은 증상 완화입니다. 막힌 파티션 자체는 별도로 알아채야 합니다. excludePersistentLag를 켜면 스케일은 정상으로 보이지만 그 파티션의 메시지는 여전히 처리되지 않고 있습니다. 파티션별 lag을 따로 감시하는 알림이 필요합니다.
# 6. 민감하게 반응할수록 처리량이 떨어진다
Kafka 컨슈머 오토스케일링에는 다른 워크로드에 없는 비용이 하나 있습니다. 레플리카 수가 바뀔 때마다 컨슈머 그룹 리밸런싱이 일어납니다. 리밸런싱 동안 컨슈머는 처리를 멈춥니다.
즉 순환이 만들어질 수 있습니다.
lag이 늘어난다 → 레플리카를 늘린다 → 리밸런싱으로 처리가 멈춘다
→ lag이 더 늘어난다 → 또 늘린다 → 또 멈춘다
부하가 출렁이는 토픽에서 폴링 간격을 짧게 잡으면 이 순환에 빠집니다. 반응 속도를 올리는 설정이 처리량을 떨어뜨리는 구조입니다.
관련 설정이 둘입니다.
pollingInterval: 지표를 얼마나 자주 확인할지. 짧을수록 민감합니다.cooldownPeriod: 마지막 활동 이후 0으로 줄이기까지 기다리는 시간입니다.
여기에 HPA 자체의 안정화 창을 함께 조정할 수 있습니다.
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 줄일 때는 천천히
policies:
- type: Percent
value: 25
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 30 # 늘릴 때는 빠르게
policies:
- type: Percent
value: 100
periodSeconds: 30
늘리는 것은 빠르게, 줄이는 것은 느리게가 기본 방향입니다. 늘리는 실수는 비용이고 줄이는 실수는 장애이기 때문입니다. 그리고 줄일 때도 리밸런싱이 일어나므로, 자주 줄이는 것 자체가 손해입니다.
# 7. 지표를 못 가져오면 무슨 일이 생기는가
Kafka 브로커에 접근할 수 없거나 인증이 만료되면 KEDA는 지표를 공급하지 못합니다. 그러면 HPA는 지표 없음 상태가 되어 레플리카 수를 그대로 유지합니다.
부하가 올라가는 중에 이 일이 생기면 스케일이 멈춘 채 밀리기만 합니다. fallback이 이 경우를 위한 설정입니다.
fallback:
failureThreshold: 3
replicas: 6 # 지표를 못 읽으면 이 값으로 고정
안전한 기본값을 정해 두는 것이 요점입니다. 값은 평소 피크의 중간쯤이 무난합니다. 너무 낮으면 장애 시 밀리고, 너무 높으면 조용한 시간대에 낭비합니다.
# 8. 직접 확인하는 방법
KEDA가 만든 HPA와 그 상태를 함께 봅니다.
# ScaledObject 상태 - READY/ACTIVE가 핵심
kubectl get scaledobject -A
# KEDA가 만든 HPA - 이름은 보통 keda-hpa-<scaledobject>
kubectl get hpa -A | grep keda-hpa
# 현재 지표와 목표값 - 여기서 lagThreshold가 target으로 들어간 것을 볼 수 있다
kubectl describe hpa keda-hpa-order-consumer | grep -A5 Metrics
READY가 False면 스케일러 설정이나 연결 문제입니다. ACTIVE가 False인데 lag이 있다면 2절의 활성화 임계값을 확인합니다.
직접 만든 HPA가 남아 있는지도 확인해야 합니다.
# 같은 대상을 가리키는 HPA가 둘 이상인지
kubectl get hpa -A -o json | jq -r '.items[] |
"\(.spec.scaleTargetRef.kind)/\(.spec.scaleTargetRef.name)@\(.metadata.namespace) ← \(.metadata.name)"' \
| sort | awk '{print $1}' | uniq -d
Kafka 쪽에서 실제 lag을 확인하면 KEDA가 보는 값과 대조할 수 있습니다.
# 파티션별 lag - 특정 파티션만 안 줄어드는지 본다 (5절)
kafka-consumer-groups.sh --bootstrap-server kafka.example.com:9092 \
--describe --group order-processor
LAG 열에서 한 파티션만 값이 고정돼 있으면 막힌 파티션입니다. CONSUMER-ID가 비어 있는 파티션이 있으면 컨슈머가 부족하거나 리밸런싱 중입니다.
리밸런싱이 잦은지는 이렇게 봅니다.
# 레플리카 변경 이력 - 자주 바뀌면 6절의 순환을 의심한다
kubectl get events --field-selector reason=SuccessfulRescale \
--sort-by=.lastTimestamp | tail -20
몇 분 간격으로 오르내리는 기록이 이어지면 폴링 간격과 안정화 창을 늘려야 합니다.
# 9. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 레플리카가 두 값 사이를 오감 | 직접 만든 HPA가 남아 있음 | 기존 HPA 삭제 |
| lag이 조금만 있어도 파드가 뜸 | 활성화 임계값 미설정 | activationLagThreshold 지정 |
| 예상보다 훨씬 많이 늘어남 | lagThreshold를 임계선으로 오해 | 처리량 × 목표 회복 시간으로 재계산 |
maxReplicaCount까지 안 늘어남 | 파티션 수가 상한 | 파티션 증설 검토 |
| 파드는 늘었는데 처리량 그대로 | 노는 컨슈머 | allowIdleConsumers 재검토, 파티션 확인 |
| 최대 레플리카에서 안 내려옴 | 특정 파티션 lag이 고정 | excludePersistentLag, 막힌 파티션 조사 |
| 스케일만 반복하고 처리가 안 됨 | 리밸런싱 순환 | 폴링 간격·안정화 창 확대 |
| 부하가 올라가는데 스케일 정지 | 지표 수집 실패 | fallback 설정, 브로커 접근 확인 |
| 0으로 안 내려감 | minReplicaCount 또는 쿨다운 | 값 확인, cooldownPeriod 조정 |
# 10. 마무리
- KEDA는 HPA를 대체하지 않고 만들어 관리합니다. 같은 대상에 직접 만든 HPA가 남아 있으면 둘이 싸웁니다.
- 0↔1과 1↔N은 다른 설정이 담당하고, 활성화 판단이 우선합니다. 두 임계값을 함께 정해야 합니다.
lagThreshold는 임계선이 아니라 레플리카당 감당할 lag입니다. 처리량과 목표 회복 시간에서 역산해야 근거 있는 값이 나옵니다.- 진짜 확장 상한은 파티션 수입니다. 파티션은 줄일 수 없고, 늘리면 키 기반 순서 보장이 깨집니다. 오토스케일링보다 먼저 결정해야 하는 값입니다.
- 전진하지 않는 파티션의 lag이 스케일을 최대에 고정시킵니다.
excludePersistentLag는 증상을 가릴 뿐이므로 파티션별 lag 알림이 따로 필요합니다. - 레플리카가 바뀔 때마다 리밸런싱으로 처리가 멈춥니다. 민감하게 반응하도록 만들수록 처리량이 떨어지는 구조이므로, 늘릴 때는 빠르게 줄일 때는 느리게 설정합니다.
리소스 사용률 기반 오토스케일링과 VPA가 서로 간섭하는 문제는 VPA 권고로 request를 정할 때 (opens new window)에 정리했습니다. KEDA처럼 외부 지표로 스케일하면 그 충돌이 사라진다는 점도 이 방식의 이점입니다.