KEDA SQS 스케일러가 큐가 비어도 파드를 안 죽인 이유
처음엔 KEDA 버그를 의심했다
지난 금요일 오후, 대시보드를 열었다가 눈을 의심했다. SQS 큐 depth는 0인데 워커 파드가 40대씩 떠 있었다. minReplicaCount 는 2로 잡아뒀는데 왜 안 내려가지?
KEDA를 SQS와 붙여 쓰는 팀이라면 한 번쯤 겪는 함정인 것 같아 정리해둔다.
우리 팀은 이미지 후처리 워커를 EKS에서 KEDA로 굴린다. SQS 큐 depth 기반으로 스케일 아웃/인 시키는 흔한 구조다. 큐가 비면 minReplicaCount인 2대까지 내려가야 하는데, 그날은 40대에서 요지부동이었다. kubectl describe scaledobject를 찍어보니 metricValue가 계속 큰 값으로 나왔다.
근데 SQS 콘솔에서 큐 depth는 진짜 0이다. Approximate Number of Messages도 0, 재확인해도 0.
여기서 한 시간 정도 헤맸다. KEDA 2.20으로 최근 업그레이드했는데 그 탓인가 싶어서 changelog까지 뒤졌다.
범인은 scaleOnInFlight
ScaledObject 매니페스트를 다시 열어보고서야 짚었다.
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.ap-northeast-2.amazonaws.com/xxx/image-worker
queueLength: "5"
awsRegion: ap-northeast-2
scaleOnInFlight: "true"
identityOwner: operator
scaleOnInFlight: "true". 이게 뭐냐면, 스케일 계산할 때 ApproximateNumberOfMessages(눈에 보이는 메시지)만 보지 않고 ApproximateNumberOfMessagesNotVisible(in-flight, 워커가 처리 중이라 다른 워커에 안 보이는 메시지)까지 더해서 본다는 뜻이다.
원래 이 옵션은 2026년에 KEDA 팀이 밀고 있는 권장 설정이다. 이유는 명확하다. 큐가 비어 보인다고 무작정 스케일 인 시켰다가, 실제로는 워커들이 아직 처리 중인 메시지가 있는데 파드를 죽이면 visibility timeout 만료 후 그 메시지들이 재처리되면서 중복 처리가 터진다. 그래서 in-flight까지 감안해서 여유 있게 유지하라는 것.
문제는 우리 워크로드 특성이었다.
visibility timeout 30분과 만난 in-flight 카운터
이미지 후처리 워커 중 일부가 대용량 파일을 다루는데, 최악의 경우 25분까지 걸린다. 그래서 SQS 큐의 visibility timeout을 30분으로 잡아뒀다. 그날 오후에 대용량 요청이 40건쯤 몰렸다가 다 처리됐는데, 각 워커가 메시지를 delete 하지 않고 그냥 컨텍스트에서 놓친 케이스가 있었다.
Python 워커 코드 어딘가에서 예외가 나면서 sqs.delete_message() 호출을 건너뛴 것이다. 코드 자체는 몇 달 전 커밋인데, 그날 처음으로 그 예외 케이스가 대량으로 발생했다. 로그에는 남았지만 알람은 안 걸어놨었다.
결과적으로:
- 큐의 visible messages: 0
- in-flight messages: 40개 (visibility timeout 30분간 살아있음)
- KEDA가 보는 큐 depth: 0 + 40 = 40
- queueLength: 5 → 필요한 파드 수: ceil(40/5) = 8… 인데 왜 40대?
여기서 두번째 함정. HPA가 이미 40대까지 올라간 상태였고, KEDA의 cooldownPeriod가 300초로 잡혀 있어서 down-scale이 굉장히 조심스럽게 일어난다. 게다가 in-flight 카운트가 5분에 한 번씩 조금씩 사라져도(메시지 timeout 만료로 다시 visible로 돌아오거나, DLQ로 넘어가거나) 그 사이에 새 요청이 들어오면 다시 40대를 유지하게 된다.
임시 조치와 근본 대응
당장은 두 가지로 잡았다.
첫째, 문제의 in-flight 메시지들을 강제로 정리했다. AWS 콘솔에서 큐 purge를 하기엔 다른 정상 메시지도 있어서 부담스러웠고, 대신 CLI로 30분 기다렸다. visibility timeout이 만료되면 in-flight로 잡혀있던 메시지들은 다시 visible 상태로 돌아오는데, 그중 delete_message가 호출 안 된 것들은 receiveCount가 늘어난다. maxReceiveCount를 3으로 잡아둔 덕에 3번째 재시도에서 DLQ로 넘어갔다. 그러고 나니 파드가 순차적으로 사라졌다.
둘째, 워커 코드에서 try/finally로 delete_message를 감쌌다. 예외가 나든 말든 처리 로직 진입한 메시지는 반드시 delete 하거나 명시적으로 change_message_visibility(0) 해서 즉시 다시 보이게 하는 방향으로. 이건 사실 처음부터 그렇게 짰어야 했다.
근본 대응은 좀 더 크다. visibility timeout 30분 자체를 줄일 방법을 논의 중이다. 대용량 파일을 별도 큐로 분리하고, 그 큐만 visibility timeout을 길게 가져가는 안이 유력하다. 나머지 일반 워크로드는 timeout을 5분 이내로 짧게. 그러면 워커 예외 상황에서도 오래 in-flight로 잡혀있는 시간이 줄어든다.
놓치기 쉬운 지점 몇 가지
이번 일 겪으면서 다시 확인한 것들.
scaleOnInFlight는 기본값이 false인데, 최근 KEDA 문서와 여러 벤더 가이드들이 true를 권장하는 쪽으로 기울고 있다. 안전한 선택이긴 한데, visibility timeout이 긴 워크로드에서는 다른 방향으로 문제를 만들 수 있다는 걸 알고 켜야 한다.
DLQ depth에 CloudWatch 알람을 안 걸어놨다면 지금 걸어두는 게 낫다. KEDA의 큐 depth 메트릭은 DLQ를 세지 않기 때문에, 실패 메시지가 아무리 쌓여도 스케일링에는 영향이 없다. 그래서 워커가 조용히 실패하고 있는 상황을 놓치기 쉽다.
kubectl get hpa로 KEDA가 생성한 HPA의 targetValue와 currentValue를 항상 같이 보는 습관을 들이는 것도 좋다. describe scaledobject만 보면 스케일러 상태만 나오는데, 실제 desiredReplicas 계산이 어떻게 되고 있는지는 HPA 쪽에서 봐야 감이 잡힌다.
마무리
돌이켜보면 워커 코드의 delete_message 누락이 근본 원인이고, KEDA 설정은 그걸 증폭시킨 매개였다. 그래도 scaleOnInFlight의 의미를 그날 처음 제대로 이해했다. 문서만 읽었을 때는 "아 in-flight도 세는 옵션이구나" 정도였는데, 40대 파드가 몇 시간째 안 죽는 걸 보고 나서야 이게 얼마나 큰 영향인지 체감했다.
다음에는 시간 여유가 생기면 KEDA의 여러 스케일러들이 어떤 식으로 메트릭을 fetch 하는지, poll 간격이 실제 스케일링 반응성에 어떻게 영향을 미치는지도 정리해보려 한다. 큐 depth 기반 스케일링이 결코 실시간이 아니라는 걸 이번에 다시 절감했으니까.
혹시 KEDA + SQS 조합에서 다른 함정 겪으신 분 있으면 어떻게 잡았는지 궁금하다.