들어가는 글
우아한형제들의 수많은 시스템에서 남는 로그는 매우 방대한 양을 자랑합니다. 운영환경 기준으로 하루 수십TB의 데이터가 처리되고 있고, 피크타임 기준으로 초당 레코드 수는 100만 단위를 가뿐히 넘기고 있습니다.

이렇게 데이터가 많은 것만으로도 버거운데, 한 술 더 떠서 우아한형제들 서비스 트래픽은 극심한 변동성을 자랑합니다. 사용자의 요청이 많아질수록 로그도 증가하기 마련인데, 우형의 대표적인 서비스 ‘배달의민족’은 점심, 저녁 식사 시간에 대부분의 트래픽이 몰립니다. 로그 유입 수를 기준으로는 최저점인 새벽 시간 대비 저녁 피크타임에는 트래픽이 10배 이상 많아질 정도입니다.
기존에도 소극적이나마 HPA를 적용하고 야간에 스케일을 줄여서 사용하기는 했지만, 늘어가는 비용의 압박 속에서 풀타임 오토스케일링은 불가피했습니다. 밥때가 되면 순식간에 수백만 건의 주문이 들어오는 서비스에서 시스템의 부하가 늘어남에 따라 부드럽고 유연하게 이 거대한 시스템을 확장한다는 것은 엔지니어로서 꽤나 호기로운 도전 과제였습니다.
이 글에서는 비용과 안정성을 둘 다 잡기 위해 우아한형제들의 로그시스템 Grafana Loki에 KEDA를 도입한 과정에 대해서 경험담을 풀어보고자 합니다.
- 사전에 읽어보면 좋은 글
로그시스템 아키텍처와 운영 환경 간략하게 이해하기
먼저 우아한형제들에서 운영 중인 로그시스템의 워크로드를 간단하게 살펴보도록 하겠습니다.

크게 보자면 우아한형제들의 전사 로그시스템 백엔드는 Fluentd + Grafana Loki로 이루어져 있습니다. 우아한형제들의 서비스 시스템들에서 발생한 로그는 Fluentd에 먼저 보내지고, 이후 Loki로 유입됩니다.

출처: Loki Architecture | Grafana Labs
여기서 Loki는 다시 여러 가지의 컴포넌트로 나뉩니다. 크게 보자면 Write Path(로그가 유입되는 경로)의 컴포넌트와 Read Path(로그를 조회하는 경로)의 컴포넌트로 나눠 볼 수 있습니다. 본 글에서는 대부분의 리소스를 차지하는 Write Path에 대해 주목해보겠습니다.

Fluentd를 거친 로그는 Loki의 Write Path 첫 번째 컴포넌트인 Distributor에 도착하며, 이후 Ingester를 거쳐 최종 저장소, Object Storage인 AWS S3에 저장됩니다. 모든 컴포넌트는 복수의 Replicas로 운영하며, 모두 EKS에서 구동되고 있습니다(Managed Service인 S3 제외).
각 컴포넌트에 대한 상세한 설명은 생략하고, 본 KEDA 적용기를 이해하기 위해 필요한 만큼만 간략히 설명하자면 다음과 같습니다.
Fluentd
- 로그의 1차 도착점으로 데이터 파이프라인 솔루션입니다. (Loki를 구성하는 컴포넌트는 아닙니다.)
- 로그를 재가공하고 목적에 따라 다양하게 라우팅할 수 있습니다.
- 버퍼로서 작동하는 중요한 컴포넌트입니다. Loki의 리소스가 부족하여 처리 속도가 늦어지거나, 장애가 발생해 로그 송신에 실패해도 로그를 디스크에 저장해 놓기 때문에 데이터 유실을 방지할 수 있습니다.
- CPU 의존도가 비교적 높은 컴포넌트로, 스케일아웃이 제때 되지 않으면 병목이 생길 수 있습니다.
- Loki 시스템이 로그를 처리하는 속도가 늦어지면 버퍼 사용률이 높아지므로, 유실을 막기 위해서는 Fluentd의 스케일아웃이 필요합니다.
- StatefulSet으로 운영합니다.
Distributor
- 고가용성(HA)을 위해 로그를 복제(replication)하는 곳입니다. 복제된 만큼 다수의 Ingester에게 로그를 보내고 나면, Fluentd에 응답을 보내고 버퍼에서 로그가 삭제됩니다.
- 상대적으로 가볍습니다. 리소스를 많이 차지하지 않아 여유롭게 늘려두어도 비용이 크게 부담되지 않습니다.
- Deployment로 운영합니다.
Ingester
- Object Storage(S3)에 로그를 보내기 전에 로그를 쌓아두는 곳입니다. 매 레코드마다 로그를 송신하면 지나치게 많은 HTTP 호출로 API Throttling에 막힐 수 있기 때문으로, 일정 수준의 로그가 쌓이거나 시간이 경과하면 S3에 로그를 씁니다.
- 로그 시스템 전체에서 독보적으로 리소스를 가장 많이 차지하는 무거운 컴포넌트입니다. 특히 메모리 의존도가 높습니다.
- 새로운 Ingester가 구동되는 데에 시간이 오래 걸립니다. 짧은 경우 1~2분 내외에서 끝나지만, 로그량이 많고 Ingester 한 대의 리소스 할당량이 높은 경우 5분 이상 걸리기도 합니다.
- StatefulSet으로 운영합니다.
위 모든 컴포넌트 중 하나라도 리소스가 부족하면 시스템이 정상적으로 작동하지 않고, fluentd의 버퍼가 가득 찰 경우 로그 유실이라는 큰 장애로 이어지게 됩니다.
시작은 HPA부터
Kubernetes는 별도의 외부 Add-On을 설치하지 않아도 HPA(Horizontal Pod Autoscaler)를 제공합니다. 이 기본 HPA의 작동 방식은 매우 심플한데, 바로 대상이 되는 컴포넌트들(주로 Deployment, StatefulSet)의 CPU, Memory 사용률의 평균을 구하고, 이 값이 정해진 임계치를 넘어갈 경우 계산식에 의해 필요한 만큼 스케일아웃이 되는 방식입니다(스케일인도 비슷한 방식으로 작동합니다).
다음의 그림은 K8s 내부 컴포넌트가 Pod의 메트릭을 수집하고, HPA가 스케일링하는 과정을 간략히 보여줍니다.

시스템 로드는 오토스케일링의 제1의 지표이기 때문에 단순히 보면 상당히 합리적으로 보이기도 하고, KEDA에 비하면 상대적으로 구성이 단순하기 때문에 로그시스템에서도 우선은 기본 HPA를 도입하여 운영한 시기가 있었습니다.
그러나 K8s에서 기본으로 제공하는 HPA로는 여러 이유로 금방 한계에 부딪히기 마련입니다.
평균의 함정 1. Pod마다 리소스 사용률이 균일하지 않다면…
평균으로 스케일아웃을 트리거링하는 방식은 일부 Pod의 리소스가 치솟는 상황에서 매우 위험합니다.
일반적으로 Stateless한 서버를 운영한다면 균일한 로드밸런싱으로 어느 정도 리소스 사용률이 평준화되나, 컴포넌트 특성에 따라 트래픽 분배만으로는 균등한 리소스 사용률을 보장하기 힘든 상황들이 있습니다. State가 있는 시스템들이 대표적인데, 저희 로그 시스템의 Fluentd나 Loki의 컴포넌트인 Ingester를 예로 들 수 있습니다.
Fluentd는 버퍼가 있기 때문에, 버퍼의 부하를 인식하고 스케일아웃이 되었다면 새로 뜬 Pod의 버퍼는 상대적으로 낮을 수밖에 없습니다. Ingester의 경우에도, 로그가 들어오면 메모리와 디스크에 우선 로그를 쓰고, 일정 시간이 경과한 후 스토리지로 flush합니다. 따라서 신생 Pod의 경우 메모리 사용률이 낮은 편입니다. 즉, 균일하게 트래픽이 발생하고 있더라도 리소스 사용률이 균일하지 않을 수 있다는 것이죠. 이외에도 시스템의 특성상 다양한 상황이 존재할 수 있습니다.
평균의 함정 2. OOM Kill이 평균을 낮춘다
OOM Kill이 발생하여 Pod가 다운된 상황이라고 해보겠습니다. 다시 구동된 Pod는 이제 막 부하를 받기 시작해서 리소스 사용률이 점진적으로 높아지고 있는 상태입니다. 이때, 순간적으로 사용률이 낮아진 Pod의 존재로 인해 전체 메트릭의 평균은, 오히려 OOM이 나기 직전보다 낮아진 상태를 유지할 수 있습니다. 그래서 시스템 부하가 심해진 나머지 어떤 Pod가 죽고 재시작하기를 반복하는데, HPA가 상정하는 리소스 사용률 평균은 오히려 낮아지고 있는 괴이(?)한 현상을 겪을 수 있습니다.

실제로 운영했던 Ingester가 이러한 현상이 심했는데, 재기동 후 비어 있는 메모리에 데이터를 올리는 사이, HPA는 낮아진 평균 사용률로 인해 스케일아웃을 시도하지 않았고, 결국 부하를 견디지 못한 다른 Pod들이 차례로 죽어나갔습니다.
HPA를 도입하면서 위의 과정들을 겪고 나니, HPA만 도입한 상태에서는 타이트한 오토스케일링이 힘들었습니다. 결국 낮에는 꽤 높은 스케일을 유지하고, 야간에는 배치를 통해 스케일을 줄여 사용하면서 HPA는 소극적으로 사용하는 유명무실한 상태로 운영했습니다.
KEDA
이런 배경으로 KEDA의 필요성이 생겨납니다. KEDA는 Kubernetes Event Driven Autoscaling의 약자입니다. Event Driven이라는 말이 거창하게 들릴 수 있지만, 결국 제3의 소스를 이용해서 오토스케일링을 해보자는 것입니다.
지원하는 이벤트 소스는 매우 다양합니다. Prometheus, Datadog, Dynatrace, AWS CloudWatch, Kafka, Cassandra, Etcd… 등 2.18 버전 기준 79개의 스케일러를 지원하는데요, 공식 홈페이지에서 지원되는 스케일러를 확인하실 수 있습니다.
- 지원되는 스케일러 보러 가기: Scalers | KEDA
ScaledObject
KEDA는 ScaledObject라는 핵심 커스텀 리소스를 이용하여 스케일링을 합니다. ScaledObject에 비로소 어떠한 이벤트로부터 스케일링을 트리거링할지 명시할 수 있는데요, 백문이 불여일견, ScaledObejct의 예시를 보면 직관적으로 이해할 수 있습니다. (이하 모든 코드 블럭은 실제 운영 환경의 리소스에 기반한 가상의 예시입니다)
apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: loki-ingester-scaled-object labels: scaledobject.keda.sh/name: loki-ingester-scaled-object spec: maxReplicaCount: 12 minReplicaCount: 4 scaleTargetRef: apiVersion: apps/v1 kind: StatefulSet name: loki-ingester triggers: - type: prometheus metadata: serverAddress: 'http://prometheus-server.in' query: avg(100 - fluentd_output_status_buffer_available_space_ratio{app="fluentd"}) threshold: '5' metricType: Value - type: memory metadata: value: '60' metricType: Utilization pollingInterval: 60 cooldownPeriod: 900 advanced: horizontalPodAutoscalerConfig: behavior: scaleUp: policies: - periodSeconds: 300 type: Pods value: 2 stabilizationWindowSeconds: 300ScaledObject는 또 다시 HPA를 만든다
ScaledObject를 선언하고 나면 HPA가 생겨납니다. 즉, ScaledObject는 내부적으로 또 하나의 HPA를 만들어내고, 이 HPA를 조정하여 스케일을 관리하는 방식입니다. 위 ScaledObject에 의해 생겨나는 HPA의 예시입니다.
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: keda-hpa-loki-ingester ownerReferences: - apiVersion: keda.sh/v1alpha1 name: loki-ingester-scaled-object blockOwnerDeletion: true controller: true kind: ScaledObject spec: minReplicas: 4 maxReplicas: 12 scaleTargetRef: apiVersion: apps/v1 kind: StatefulSet name: loki-ingester metrics: - type: External external: metric: name: s0-prometheus selector: matchLabels: scaledobject.keda.sh/name: loki-ingester-scaled-object target: type: Value value: "5" - type: Resource resource: name: memory target: type: Utilization averageUtilization: 60 behavior: scaleUp: stabilizationWindowSeconds: 300 selectPolicy: Max policies: - periodSeconds: 300 type: Pods value: 2본격 도입기
메트릭 선정
부하의 기본 지표는 CPU/Memory이므로 우선 해당 지표를 ScaledObject의 spec.triggers 요소로 지정했습니다. 아래 예시는 평균 사용률이 60% 이상이면 스케일 아웃되도록 설정하는 것을 보여줍니다.
spec: triggers: - type: memory metadata: value: "60" metricType: Utilization - type: cpu metadata: value: "60" metricType: Utilization*주의사항: Utilization을 구할 때 hpa는 모수로 requests를 사용합니다. 즉 ‘Utilization = 실제 사용량 / Pod에 할당된 requests’ 입니다. limits이 아닙니다.
그리고 우형의 로그시스템의 경우, 시스템이 충분히 로그를 처리하고 있지 못하면 Fluentd에 버퍼가 쌓이게 됩니다. 따라서 Fluentd의 버퍼가 쌓이는 상황을 위기 상황으로 취급하여 버퍼 사용률이 높아지면 스케일 아웃하도록 했습니다. 아래의 예시는 Prometheus로부터 메트릭을 가져와 5%의 버퍼 사용률을 초과할 경우 스케일 아웃되도록 하는 예시입니다.
spec: triggers: - type: prometheus metadata: serverAddress: http://prometheus-server.in query: avg(100 - fluentd_output_status_buffer_available_space_ratio{app="fluentd"}) threshold: "5" metricType: Value # 미기입시 metricType: AverageValue삽질주의: metricType을 기입하지 않으면 AverageValue를 기본으로 사용하는데요, ‘metricType: AverageValue’는 쿼리의 결과를 현재 replica의 값으로 나누어 사용하게 됩니다.
e.g., 쿼리의 결과가 20이고, replicas가 10이라면, ‘20 / 10 = 2’가 되어 threshold인 5에 미치지 못하므로 스케일링이 되지 않습니다.
쿼리 결과 값이 무엇을 의미하는지 이해하고, 적절한 타입을 설정해 주시기 바랍니다.
과한 스케일링(OverProvisioning) 방지하기
위와 같이 설정할 경우 3가지(CPU, 메모리, 버퍼사용률)의 메트릭 중 하나라도 트리거링 되면 스케일아웃이 됩니다. 이때, CPU나 메모리 사용률로 인해 트리거링될 경우는 별다른 문제가 발생하지 않지만, 버퍼로 인해 트리거링이 되는 경우 과대하게 스케일링되는 OverProvisioning 현상이 발생하게 됩니다. 이유는 desired replicas 계산 방식을 고려하면 이해할 수 있습니다.
-
metricType = Value일 때
desiredReplicas = ceil( currentReplicas * ( metricValue / targetValue ) ) -
metricType = AverageValue일 때
desiredReplicas = ceil( metricValue / targetAverageValue )
*ceil: 소수점 올림
따라서 쿼리 결과 값이 10이고 현재 replicas가 5라면, 1) 계산식에 의해 다음과 같이 desiredReplicas가 계산되어, 현재 스케일의 두 배(10대)로 스케일아웃을 시도합니다.
desiredReplicas = ceil( 5 * ( 10 / 5 ) ) = 10
Ingester와 같이 무거운 컴포넌트는 실행에도 몇 분씩 걸립니다. 그 시간 동안에도 버퍼 메트릭은 계속해서 치고 올라가기 때문에, 약간의 버퍼 상승에도 Desired Replicas가 Max Replicas로 지정한 값을 향해 천정부지로 계속 솟구치는 경우가 종종 생기곤 했습니다. (버퍼를 10%만 넘겨도 desired 값이 기존의 2배가 넘어가게 됩니다.)
이를 해결하고자 ScaledObject의 spec.advanced.horizontalPodAutoscalerConfig 설정을 활용했습니다.
앞선 문단에서 ‘KEDA는 또 다른 HPA를 만든다’고 설명했는데요, 위 설정은 K8s의 HPA가 기본적으로 제공하는 설정을 정의할 수 있게 해줍니다. 따라서 위 설정의 스펙은 KEDA가 아닌 K8s의 공식 문서를 참고하여 확인할 수 있습니다.
ref: Horizontal Pod Autoscaler – Configurable Scaling Behavior | Kubernetes
스펙을 참고하여, 다음과 같이 스케일아웃에 대한 제약을 지정했습니다. 아래의 예시는 스케일아웃 할 때, 300초 동안 2대의 Pod 증설만을 허락하는 예시입니다.
advanced: horizontalPodAutoscalerConfig: behavior: scaleUp: policies: - periodSeconds: 300 # 300초 동안 type: Pods value: 2 # 2대의 Pod를 늘리는 것을 허용합니다. stabilizationWindowSeconds: 0*stabilizationWindowSeconds란?
설정된 시간 동안의 과거 데이터(메트릭)를 살펴보고, 가장 보수적인 수치를 기준으로 replicas를 설정합니다. 예를 들어 위 설정이 300초로 설정되어 있다고 가정했을 때, 현재 메트릭을 기준으로 보자면 2대를 늘려야 하지만, 5분전 메트릭을 확인했을 때에는 1대만 늘려도 되는 수치였다면 1대만을 늘리도록 합니다. 이는 급격한 스파이크로부터 원치 않는 스케일링을 방어하기 위함입니다. (Fluentd 버퍼 메트릭의 경우 갑작스러운 스파이크가 발생하지 않습니다. 오히려 조회 시점의 메트릭을 즉시 반영하는 것이 좋기 때문에 0초로 설정했습니다. 각 설정은 메트릭의 특성을 반영해 주시기 바랍니다)
결과
기존에는 실질적으로 주간/야간으로 스케일 값을 크게 다르게 두고, hpa를 부분적으로 이용했습니다. 따라서 Pod에 할당된 requests의 총량이 주/야간 2구간으로 크게 다르고 정적인 것을 볼 수 있습니다.
AS-IS

TO-BE

KEDA 도입 이후 시스템이 지연 없이 안정적으로 운행되는 것을 경험하고, 임계치를 더욱 공격적으로 적용했습니다. 또한 클러스터 오토스케일링(실질적으로 EKS를 구성하는 노드를 축소하는 스케일링을 의미합니다)도 더욱 엄격하게 적용하여, 실시간으로 필요한 만큼의 Pod와 노드를 가져갈 수 있도록 하였습니다.

클러스터 오토스케일링은 별도입니다.
이 글에서는 Pod의 오토스케일링만을 다루었습니다. 그러나 실제로 비용을 줄이려면 해당 K8s를 구성하는 노드의 수가 줄어들어야 합니다. 이를 ‘클러스터 오토스케일링’이라고 합니다.
저희는 사내에 K8s를 전문으로 다루는 인프라팀의 지원을 받아 Karpenter를 이용한 클러스터 오토스케일링을 적용했습니다. 이에 따라 Pod의 Requests가 줄어들어 노드의 총 유후 자원이 많아지면, 알아서 Pod를 옮기고 노드를 제거하는 작업이 진행됩니다. 반대로 Pod의 Requests 요구량이 증가하면 신규 노드를 투입합니다. 즉 Pod Autoscaling이 Node Autoscaling의 조건을 형성하는 체인이 자연스럽게 형성됩니다.








![[한경에세이] AI 협력 앞당길 경주 APEC](https://static.hankyung.com/img/logo/logo-news-sns.png?v=20201130)
![[속보] SK텔레콤 3분기 영업익 484억원…전년 대비 90.92%↓](https://img.hankyung.com/photo/202510/AD.41815821.1.jpg)



English (US) ·