EKS KEDA 설치 설정 회고: SQS·Kafka 트리거로 오토스케일링 다시 설계한 3개월

목차

$ kubectl get hpa -n worker-prod
NAME              REFERENCE                 TARGETS              MINPODS   MAXPODS   REPLICAS
order-consumer    Deployment/consumer       87%/70%, 45%/60%     3         30        28
notify-worker     Deployment/notify         12%/70%, 8%/60%      3         30        4
inventory-sync    Deployment/inventory      94%/70%, 78%/60%     5         40        40

EKS KEDA 설치 설정을 본격적으로 들어가기 전, 위 출력은 작년 11월 운영 클러스터에서 캡처해 둔 HPA 상태다. 큐는 밀려 있는데 CPU만 보면서 확장이 늦거나 과하게 늘어났다. 이 상태로 분기를 더 끌고 가지 않기로 결정한 뒤, KEDA로 옮긴 3개월의 기록을 시간순으로 적는다.

11월 — HPA로 버티려고 했다

물론, 처음부터 KEDA를 깔자고 누가 말하지는 않았다. 5년차가 되니 안 건드리고 굴러가는 시스템을 자꾸 만지지 않게 된다. 내부적으로 "잘 되면 안 건드린다"는 룰이 강해진다. 그해 11월부터 워커 클러스터에서 같은 패턴의 알람이 반복되면서 결정이 미뤄지기 어려워졌다.

문제는 두 가지였다.

물론, 첫째, SQS 컨슈머가 CPU 70% 임계치에 늦게 닿는다. 메시지 처리당 CPU 비용이 낮은 워커는 큐에 1만 개가 쌓여도 CPU가 60%에 머문다. HPA는 안 늘리고, 큐 깊이만 자란다. 결과적으로 큐 적체로 인한 SLA 5분짜리 알람이 반복됐다.

이처럼, 둘째, 트래픽이 거의 없는 구간에도 minReplicas: 3이 그대로 떠 있었다. 워커 3개가 24시간 떠 있으면서 m5.large 노드 1대를 통째로 점유하는 구조였다. EC2 비용 청구서를 처음 자세히 봤을 때, 이 구간이 전체의 약 18%였다. 회피 가능한 비용이었다.

예를 들어, CloudWatch Container Insights 대시보드는 그동안 매일 봤지만, "큐 길이를 보고 스케일하는" 옵션을 만들기 위해 HPA에 external metrics를 붙이는 작업은 여러 번 미뤘다. CW Metrics Adapter를 도입한 동료의 사례를 옆에서 봤기 때문이다. 메트릭 동기화 지연, RBAC 설정, IAM 분리… 들이는 노력 대비 결과가 약했다.

그래서, (개인적으로 이건 ‘안 건드리는 게 낫다’와 ‘지금 건드려야 한다’ 사이의 분기점이었다.)

12월 — 후보 비교와 KEDA로 결정

12월 첫 주에 후보 셋을 정리했다. 직접 운영하는 입장에서 따진 기준은 단순했다. 큐 길이 기반 스케일링이 되는가, scale-to-zero가 되는가, 운영 중 깨질 부분이 적은가.

항목 HPA + CPU HPA + CW Adapter KEDA
큐 길이 기반 불가 가능(지연 있음) 가능
Scale-to-zero 불가 불가 가능
폴링 주기 세밀 제어 불가 어댑터 의존 가능
설정 복잡도 낮음 높음 중간
운영 사례·문서 풍부 보통 풍부
CNCF 단계 Graduated (2023.08)

KEDA는 2023년 8월 CNCF Graduated 프로젝트로 승격되었다(출처: CNCF blog 2023-08-23). 사내에서 "프로덕션에 써도 되는가"를 설득할 때 이 한 줄이 의외로 큰 무게를 가졌다. 5년차 실용주의자 입장에서, 신기술 도입의 가장 큰 적은 "이거 아직 베타 아니야?"다.

반면, 12월 둘째 주에 PoC 클러스터에 KEDA 2.13.1을 깔고 SQS 트리거 하나만 붙여봤다. 큐 깊이 2000개에서 Pod 8개로 30초 안에 늘어났다. 같은 상황에서 CPU 기반 HPA는 4분이 지나도 5개에 머물렀다. PoC 결과를 본 다음에 결정이 빨라졌다.

1월 초 — Helm 설치와 IRSA 연결

그러나, EKS 클러스터에 KEDA를 깔 때 Helm을 썼다. 매니페스트를 직접 관리할 이유가 없었다.

# KEDA 공식 Helm 저장소 추가
helm repo add kedacore https://kedacore.github.io/charts
helm repo update

# 전용 네임스페이스 생성
kubectl create namespace keda

# KEDA 2.13.1 설치 (2026-04 기준 stable 계열)
helm install keda kedacore/keda \
  --namespace keda \
  --version 2.13.1 \
  --set podIdentity.aws.irsa.enabled=true

결국, 설치 자체는 5분이면 끝난다. 시간이 들어간 곳은 IRSA 연결이었다.

OIDC provider 등록

EKS에서 KEDA가 SQS, MSK 같은 AWS 리소스에 접근하려면 IAM Role을 Pod에 붙여야 한다. IRSA(IAM Roles for Service Accounts)가 표준이다. OIDC provider가 클러스터에 등록되어 있어야 한다.

# OIDC 발급자 URL 확인
aws eks describe-cluster --name worker-prod \
  --query "cluster.identity.oidc.issuer" --output text

# IAM OIDC provider 등록 (이미 있으면 무시됨)
eksctl utils associate-iam-oidc-provider \
  --cluster worker-prod --approve

identityOwner — keda vs workload

여기서 한 번 헤맸다. KEDA의 IRSA는 두 가지 모드가 있다. identityOwner: keda는 KEDA Operator의 IAM Role이 모든 큐 권한을 모아서 가지고 동작한다. identityOwner: workload는 컨슈머 Pod의 ServiceAccount가 직접 권한을 갖는다. 처음에는 디폴트인 keda 모드로 갔는데, 워커 종류가 늘어날 때마다 KEDA Operator IAM 정책에 큐 ARN이 누적되었다. 결국 workload 모드로 바꿔서 워크로드별 ServiceAccount + Role을 분리했다.

eksctl create iamserviceaccount \
  --cluster worker-prod \
  --namespace worker-prod \
  --name order-consumer-sa \
  --attach-policy-arn arn:aws:iam::123456789012:policy/OrderQueueRead \
  --approve

IAM 정책은 큐 ARN을 명시한다. 와일드카드는 가능한 한 좁게 잡았다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sqs:GetQueueAttributes",
        "sqs:GetQueueUrl",
        "sqs:ReceiveMessage",
        "sqs:DeleteMessage"
      ],
      "Resource": "arn:aws:sqs:ap-northeast-2:123456789012:order-events"
    }
  ]
}

1월 중 — SQS ScaledObject 첫 적용

또한, 워크로드용 ServiceAccount가 IAM Role을 가지면, KEDA의 TriggerAuthentication이 그걸 가져다 쓴다. ScaledObject는 두 개의 리소스로 분리해서 정의했다.

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: sqs-aws-auth
  namespace: worker-prod
spec:
  podIdentity:
    provider: aws
    identityOwner: workload
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-consumer-scaler
  namespace: worker-prod
spec:
  scaleTargetRef:
    name: order-consumer
  minReplicaCount: 2
  maxReplicaCount: 30
  pollingInterval: 15        # 15초마다 큐 깊이 폴링
  cooldownPeriod: 300        # 마지막 이벤트 후 5분 뒤 축소
  triggers:
    - type: aws-sqs-queue
      authenticationRef:
        name: sqs-aws-auth
      metadata:
        queueURL: https://sqs.ap-northeast-2.amazonaws.com/123456789012/order-events
        queueLength: "20"     # Pod 1개당 처리할 목표 메시지 수
        awsRegion: ap-northeast-2
        scaleOnInFlight: "true"

queueLength는 "Pod 하나가 감당할 메시지 수"다. 20으로 잡으면 큐에 200개 쌓이면 Pod 10개를 만든다. 이 숫자는 메시지당 처리 시간과 SLA를 곱해서 정한다. 처음에는 50으로 잡았다가 SLA가 못 따라가서 20으로 내렸다.

scaleOnInFlight: true는 in-flight(이미 받았지만 ACK 전인) 메시지도 큐 깊이에 합산한다는 옵션이다. false면 워커가 받자마자 큐에서 사라진 것처럼 계산되어 스케일이 줄어든다. 공식 문서에 적혀 있긴 하지만 디폴트가 true라서 별로 의식하지 않다가 한 번 데였다(GitHub kedacore/keda#5298 관련 동작 참고).

1월 말 — Kafka Lag 기반 스케일링

SQS만 붙였을 때는 운영이 깔끔했다. 진짜 손이 많이 간 곳은 결제 도메인의 Kafka 컨슈머였다. MSK 클러스터를 쓰고 있었고, partition 수가 24개였다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: payment-consumer-scaler
  namespace: payment
spec:
  scaleTargetRef:
    name: payment-consumer
  minReplicaCount: 3
  maxReplicaCount: 24       # 파티션 수와 동일
  pollingInterval: 20
  cooldownPeriod: 180
  triggers:
    - type: kafka
      metadata:
        bootstrapServers: b-1.msk:9092,b-2.msk:9092,b-3.msk:9092
        consumerGroup: payment-cg
        topic: payment.events
        lagThreshold: "500"
        offsetResetPolicy: latest
        allowIdleConsumers: "false"

그러나, Kafka 트리거를 만질 때 가장 자주 잘못 잡는 게 maxReplicaCount다. 컨슈머 수가 파티션 수보다 많아지면 잉여 컨슈머는 idle이 된다. 24개 파티션에 30개 컨슈머를 띄우면 6개는 그냥 메모리만 잡고 가만히 있는다. KEDA의 allowIdleConsumers: false는 이 상황을 방지해서 파티션 수 이상으로 늘어나지 않는다. 그래서 maxReplicaCount는 파티션 수와 맞췄다.

lagThreshold는 컨슈머 1개당 감당할 lag이다. 500으로 잡으면 lag이 6000일 때 12개를 띄운다. 결제 도메인은 처리 시간이 안정적이라 lag이 곧 지연으로 이어졌다. 이 값은 SLO에서 직접 산출했다(SLO p99 1.5초 ÷ 메시지당 처리 0.05초 = 30, 안전 계수를 곱해 500).

2월 — 운영 중 터진 것들

3개월 회고에서 가장 길게 쓰고 싶은 부분이 여기다. 잘 돌아간 부분보다 깨진 부분에서 배운 게 많다.

SQS 폴링 비용이 갑자기 튀었다

게다가, 처음에 pollingInterval: 5로 잡았다. 빠를수록 좋다고 생각했다. 실제로는 SQS GetQueueAttributes 호출이 분당 12회 × 워커 종류 18개 = 분당 216회였다. 한 달이면 약 940만 회다. SQS API 호출 비용이 그달 청구서에 약 2.7만 원 추가됐다. 큰 금액은 아니지만 "빠른 게 무료가 아니다"라는 사실을 잊고 있었다. 15~20초로 올리면서 정상화됐다.

HPA와 KEDA가 동시에 떠 있었다

KEDA는 내부적으로 HPA 리소스를 자동 생성한다. 같은 Deployment에 사용자가 만든 HPA와 KEDA가 만든 HPA가 동시에 존재하면 둘이 경쟁한다. ScaledObject를 만들기 전에 기존 HPA를 반드시 삭제해야 한다(공식 문서에도 명시되어 있다).

kubectl delete hpa order-consumer -n worker-prod
kubectl apply -f order-consumer-scaler.yaml

결국, 이걸 빼먹은 워크로드에서 Pod 수가 11분 동안 진동했다. 알람으로 잡고 1차 원인 파악에 30분 정도 썼다.

RDS 커넥션 풀이 터졌다

특히, KEDA가 잘 동작해서 Pod 수가 빠르게 늘어났다. Pod당 DB 커넥션 풀 크기 5, max 30 Pod, 합산 150이었다. RDS 인스턴스의 max_connections는 200. 다른 서비스 몫까지 합치면 한도를 넘는다. 이건 KEDA의 잘못이 아니라 다운스트림 의존성을 의식하지 않은 채 maxReplicaCount를 키운 운영 실수다. 도입 후 1주일쯤 지나 부하 테스트에서 잡았다.

한편, :::tip 스케일러를 새로 깔 때 다운스트림(DB, 외부 API rate limit, 캐시 커넥션 풀)의 한계를 함께 점검하라. KEDA가 잘 동작할수록 상류는 더 빠르게 깨진다. :::

3개월 운영 결과

도입 시점부터 측정한 변화는 다음과 같다(2026-02-01 ~ 2026-04-25).

:::stats

  • 워커 노드 평균 사용량: 30% 감소
  • 트래픽 저점 구간 노드 수: 평균 9대 → 평균 3대
  • 큐 적체로 인한 SLA 알람: 월 평균 17건 → 1~2건
  • KEDA 자체로 인한 사고: 1건 (HPA 충돌)
  • KEDA Operator Pod 메모리: 약 180MB :::

물론, 수치로만 보면 깔끔하지만, 비용으로 환산하면 EC2 절감이 월 22~25만 원 수준이다. 회사 규모에 따라 평가가 달라진다. 우리 클러스터에서는 의미 있는 숫자였고, 더 큰 곳에서는 더 컸을 것이다.

예를 들어, 체감으로 가장 좋았던 건 트래픽 저점 시간대 알람이 줄었다는 점이다. 큐가 쌓이기 전에 Pod이 미리 늘어나서, 알람이 울리는 임계치 자체에 닿지 않는다. 한밤중에 호출 받지 않는 가치는 비용 그래프에 잡히지 않는다.

다음에는 이렇게 할 것

반면, 3개월 끝나고 회고하면서 다시 정리한 액션 셋이다. 같은 길을 갈 사람이 시간을 줄일 수 있도록 적는다.

  1. HPA 동작을 먼저 이해하라: 처음부터 KEDA를 깔고 싶은 유혹이 있다. 그런데 ScaledObject도 결국 HPA를 자동 생성한다. HPA 동작을 먼저 손에 익혀 두면 디버깅이 훨씬 쉽다.
  2. identityOwner: workload로 시작하라: KEDA Operator IAM에 모든 큐 권한을 모으면 나중에 분리하기 어렵다. 처음부터 워크로드별 ServiceAccount를 분리하는 편이 낫다.
  3. pollingInterval은 15초 이상: 5초는 거의 항상 과하다. 큐 길이가 폭발할 때조차 15초로 충분히 빠르게 반응한다.
  4. maxReplicaCount는 다운스트림과 함께 결정해라: DB 커넥션, 외부 API rate limit, Redis pool을 넘기지 않도록 묶어서 정해야 한다. 따로 결정하면 사고로 이어진다.

특히, 개인적으로는, KEDA를 좀 더 일찍 도입했으면 좋았겠다는 생각이 든다. 새 기술에 보수적인 편이라 1년쯤 미뤘는데, 막상 들어가니 운영 위험은 생각보다 낮았다.

관련 글