Luft's Road to Elasticity - Part 2: Auto-Scaling with Query History

Luft's Road to Elasticity - Part 2: Auto-Scaling with Query History

Geon Kim — 김건 @KimMachineGun

들어가며

Luft는 AB180에서 자체적으로 개발하여 사용하고 있는 사용자 행동 분석에 특화된 OLAP(Online Analytical Processing) 데이터베이스로, 에어브릿지의 다양한 리포트를 제공하는 데 활용되고 있습니다. Luft에 대한 더 자세한 내용은 이전 글과 발표에서 확인할 수 있습니다.
<Luft's Road to Elasticity> 시리즈에서는 지난 1년간 Luft의 성능 향상과 비용 절감을 위해 시도한 스케일링 최적화에 대해 이야기를 나눠보고자 합니다. 시리즈는 총 두 편의 글로 이루어져 있으며, 이번 글에서는 지난 글에서 소개해드린 작업을 통해 높아진 탄력성을 바탕으로 쿼리 히스토리를 활용한 비용 기반 오토스케일러를 구현한 경험을 공유합니다.
본 글은 지난 글의 내용을 바탕으로 작성되어 있으므로, 지난 글을 읽지 못하신 분들께서는 먼저 읽어보시기를 권해드립니다.

배경

지난 글에서 소개해드린 작업을 통해 Luft의 아키텍처를 Shared Nothing(각 노드가 모든 자원을 독립적으로 관리하는 구조)에서 Shared Storage(각 노드들이 독립된 컴퓨팅 자원을 사용하되, 공통 스토리지를 공유하는 구조)로 전환하여 부족한 탄력성을 개선할 수 있었습니다. 하지만 탄력성 개선을 통해 실질적인 효과를 누리기 위해선 Luft가 오토스케일링 될 수 있는 환경을 구현해야 합니다.
이를 위해 Luft에서 필요한 작업을 크게 아래와 같이 식별하였습니다.
1.
빠른 스케일링 매커니즘: 필요할 때 최대한 지연 없이 노드를 추가할 수 있어야 함
2.
정확한 스케일링 결정 모델: 쿼리 특성에 맞게 정확히 필요한 양의 리소스를 할당할 수 있어야 함
최종적인 작업의 목표는 오버프로비저닝을 하지 않은 상태로 클러스터를 운영하면서, 쿼리 시점에 미리 부하를 계산하여 스케일링이 필요한 경우에만 잠시 스케일링하여 쿼리를 처리할 수 있는 환경을 구성하는 것입니다.

구현

이번 작업의 실질적인 구현은 위에서 식별한 작업에 따라, (1) 빠른 스케일링을 위한 클러스터 매니저를 구현하는 것과 (2) 정확한 스케일링을 결정하기 위한 비용 기반의 오토스케일러를 구현하는 것으로 나뉘어집니다.

빠른 스케일링을 위한 클러스터 매니저

Kubernetes에서의 한계

오토스케일링 시스템의 첫 번째 핵심 요소는 인프라를 빠르게 확장하고 축소할 수 있는 시스템을 구축하는 것입니다. Luft에서 이러한 역할은 클러스터 매니저라는 컴포넌트가 담당하고 있으며, 클러스터 매니저는 Kubernetes의 Go 클라이언트를 활용하여 Deployment나 StatefulSet의 스케일링을 트리거하고, 이후 Karpenter가 실제 스케일링을 처리하게 됩니다.
Karpenter가 기존 Cluster Autoscaler보다 성능이 매우 뛰어남에도, 스케일링이 트리거된 후 실제 노드가 Luft 클러스터에 합류하여 사용 가능해지기까지 50초 이상이 소요됩니다. 이는 쿼리 시점에 스케일링을 트리거하여 쿼리를 처리하기에는 지나치게 긴 시간이었고, 이러한 이유로 기존 클러스터 매니저는 개발 환경의 노드를 자동으로 시작/종료하거나, 배치 작업을 처리하기 위한 노드를 관리하는 데에만 사용되었습니다.
쿼리 시점 스케일링을 실제로 활용하기 위해선 스케일링 시간을 적어도 10초대로 줄여야 한다고 판단했기에, 스케일링 속도를 개선하기 위한 조사를 진행하였습니다. 당시 해결 방안으로 EC2 Auto Scaling Warm Pools 개념을 차용하여, 매번 EC2 인스턴스를 새로 띄우는 대신 중지된 인스턴스를 재개하여 사용하는 방법을 중점적으로 조사하였습니다.
하지만 아쉽게도 기존에 사용하고 있던 Karpenter에서는 관련 제안(Github Issue #3798)은 존재하나 아직 기능으로 제공되지 않았습니다. 이에 따라 Karpenter를 사용하지 않고 직접 Kubernetes에서 노드를 스케일링하는 실험을 진행하였습니다. 위 실험 결과, 중지 상태를 활용하면 스케일링 시간을 30초 수준까지 단축시킬 수 있었지만, Kubernetes 환경의 추상화 레이어에 의한 오버헤드(Kubelet 초기화, 노드 등록, CNI 설정 등)로 인해 스케일링 성능을 이 이상 최적화하는 데 한계가 있음을 확인하였습니다.

Self-managed

위 Kubernetes에서의 실험 이후, Kubernetes를 사용하는 한 원하는 수준의 성능을 달성하긴 힘들다고 판단하여 Kubernetes를 벗어나 직접 클러스터를 관리하는 방향을 고려하게 되었습니다. 분명 쉽지 않은 작업일 테지만, 과거부터 종종 Luft의 워크로드와 Kubernetes가 잘 맞지 않는 부분이 있어 Kubernetes를 사용하지 않는 방안에 대해 논의했던 경험이 있었기에, 어렵지 않게 클러스터를 자체 운영하는 방향으로 의사결정을 할 수 있었습니다.
이를 위해선 기존 Kubernetes 인프라에 기대어 있던 것들을 따로 구현해줄 필요가 있었는데, 대략적으로 아래와 같은 작업을 진행해야 했습니다.
Scaling: 기존 Kubernetes의 Go 클라이언트를 통해 진행하던 스케일링을 AWS SDK를 활용하여 직접 EC2를 스케일링 하도록 라이브러리를 개발
Deployment: Keel을 통해 배포하던 파이프라인을 AWS CodeDeploy를 사용하여 배포할 수 있도록 배포 파이프라인을 재구성
Configuration: Kubernetes의 Deployment Spec과 ConfigMap 등을 통해 관리되던 각종 설정 작업을 AWS Systems Manager를 통해 구현
Process Management: 컨테이너 형태로 관리되던 서비스를 Systemd를 활용하여 관리될 수 있도록 구성
이 글에서는 위 작업 중 스케일링에 직접적으로 관련이 있는 1번 작업의 구현에 대해서 다뤄볼 예정입니다.
Scaling
Self-managed 클러스터 매니저의 경우 기존 구현체와는 다르게 AWS Go SDK를 사용하여 직접 노드(EC2)를 스케일링해야 합니다. EC2 Auto Scaling Group을 활용하면 스케일링할 컴포넌트를 그룹핑하여 상대적으로 간편하게 스케일링할 수도 있지만, Auto Scaling Group의 성능 문제로 인해 가능한 한 중간 계층의 추상화 레이어를 최대한 없애는 것을 목표로 하였습니다.
우선순위에 따른 인스턴스 스펙 선정
이를 위해 태그와 Launch Template을 기반으로 스케일링 스펙을 정의하고, 이러한 스펙을 바탕으로 EC2 인스턴스 단위의 스케일링을 할 수 있는 라이브러리를 개발하였습니다. 이 과정에서 특정 타입의 인스턴스 부족으로 할당이 정상적으로 되지 않는 상황을 방지하기 위하여, 우선순위를 고려한 다양한 인스턴스 타입과 가용 영역(Availability Zone)을 fallback으로 사용할 수 있도록 하였습니다.
인스턴스 라이프 사이클
또한, 특정 노드 그룹의 스케일링 속도를 높일 수 있도록 인스턴스의 사용이 끝났을 때 해당 인스턴스를 종료하는 대신 잠시 중지시킬 수 있는 기능을 추가하여 구현하였습니다. 이를 통해 다음번 스케일링 시 새로운 인스턴스를 프로비저닝하는 대신 중지된 인스턴스를 재개함으로써 스케일링 시간을 크게 단축할 수 있었습니다.
최종적으로 이 작업을 통해 스케일링이 트리거된 후, 인스턴스가 준비되어 Luft에서 사용 가능한 상태가 되기까지 걸리는 시간을 50초에서 14초 수준까지 줄일 수 있었습니다.

쿼리 부하 예측 기반 오토스케일러

빠른 스케일링이 가능한 인프라를 구축한 다음 단계로, 쿼리에 필요한 리소스를 평가하여 적절한 스케일링 결정을 내릴 수 있는 오토스케일러를 개발해야 했습니다.

기존 오토스케일링 접근법의 한계

오토스케일링을 위한 일반적인 접근법은 CPU/메모리 사용량, 요청 수와 같은 현재 시스템 상태를 나타낼 수 있는 메트릭을 모니터링하고, 이러한 메트릭이 특정 임계값을 초과할 때 스케일링을 트리거하는 것입니다. 이러한 접근법은 부하가 점진적으로 증가하거나, 각 요청이 비슷한 리소스를 사용하는 상황에서 효과적으로 동작할 수 있습니다.
하지만 Luft처럼 스파이크성 패턴을 보이는 워크로드나, 각 요청별 부하의 편차가 매우 큰 상황에서는 잘 동작하지 않으며, 결정적으로 사용량이나 요청 수에 기반한 메트릭은 후행적 메트릭으로, 지금 당장 처리해야 하는 쿼리의 성능을 만족시키는 데에는 사용하기 힘들다는 문제가 있습니다.
부하가 발생한 후에 스케일링을 시작하게 되면 이미 쿼리 성능이 저하된 상태이므로, 사전에 쿼리 비용을 예측하고 대응할 수 있는 시스템이 필요했습니다.

부하 예측 모델

쿼리의 비용을 예측하는 방식은 다양하며, 일반적으로 다음과 같이 분류할 수 있습니다:
휴리스틱 기반
학습 기반
히스토리 기반
예측 방식
데이터 통계와 처리 비용 수식을 사용하여 예측
머신러닝 등을 통해 쿼리 비용을 학습하여 예측
과거 쿼리 실행 기록에서 유사한 쿼리를 찾아 예측
사전 작업
데이터 통계, 단위 처리당 비용, 수식 등에 대한 사전 설정 필요
학습 데이터 수집 및 모델 설계 필요
쿼리 로그 수집 필요
유연성
사전에 정의된 규칙에 의해 완전히 새로운 유형의 쿼리도 예측 가능
일반적으로 가능 (학습 데이터에 없는 유형에 대해서는 상대적으로 부정확할 수 있음)
과거 유사 쿼리가 없는 경우 예측하기 어려움 (cold start 문제)
유지보수
쿼리 환경 변화(로직, 시스템)에 따라 조정 필요
학습 방식에 따라 달라짐
필요 없음
정확성
복잡한 쿼리에서 정확성 낮음
학습 데이터에 따라 달라지나 대체로 높음
유사한 과거 쿼리가 있는 경우 높음
각각의 방식 모두 장단점이 있지만, 구현 복잡성, 유지보수성, 정확성, Luft의 쿼리 패턴 등을 고려해 봤을 때 히스토리 기반의 예측 모델이 가장 적절한 방식으로 판단되었습니다. 또한 히스토리 기반 방식의 단점으로 볼 수 있는 낮은 유연성도, Luft가 처리하는 쿼리의 대부분이 코드에 의해 자동으로 생성되어 반복적으로 이뤄지는 경우가 많다는 특징 덕분에 대부분의 경우 문제가 없을 것으로 판단하였습니다.

쿼리 정규화

히스토리 기반의 예측 모델에서 가장 중요한 것은 예측이 필요한 쿼리와 유사한 쿼리 히스토리를 찾는 알고리즘입니다. 이를 위한 방법으로 벡터 기반 유사도 분석이나 편집 거리 기반의 유사도 분석 등을 고려할 수 있습니다. 하지만 이러한 방식은 구현이나 유지보수 측면에서 많은 노력이 필요하기 때문에, 더 단순하고 직관적인 방식에 대한 조사를 진행하였습니다.
쿼리 정규화 과정; 서로 다른 두 쿼리가 정규화 이후 동일한 형태가 됨
이 과정에서 Presto의 History-Based Optimizer 개발 과정에서의 의사결정이 Luft의 요구사항과 운영 환경에 잘 맞아 떨어지는 것을 확인했고, Presto에서 사용하는 정규화(canonicalization)를 통한 유사도 분석 방식이 구현과 유지보수 측면에서도 큰 이점이 있을 것으로 판단하였습니다.
물론 정규화 방식에도 장점만 있는 것은 아니었습니다. 이 방식은 정규화 과정에서 쿼리 성능에 큰 영향을 줄 수 있는 중요한 세부 정보가 제거될 수 있어 정확한 예측이 어려울 수 있습니다. 그러나 Luft의 쿼리는 SQL에 비해 상대적으로 정형화된 형식으로 작성되기 때문에, 쿼리에서 성능에 큰 영향을 주는 부분과 그렇지 않은 부분을 구분할 수 있었습니다. 위 사실을 참고하여 Luft 쿼리의 정규화 과정에서 성능에 큰 영향을 줄 수 있는 세부 정보는 보존되도록 설계하였습니다.
정규화 수준에 따른 결과
이에 더하여, 동일한 쿼리에 대해 여러 단계의 정규화 과정을 적용하여, 가장 보수적으로 정규화한 형태부터 가장 적극적으로 정규화한 형태까지 순차적으로 유사한 쿼리 히스토리를 찾도록 구현하였습니다. 이러한 접근 방식을 통해 cold start로 인해 발생될 수 있는 문제를 완화할 뿐만 아니라, 너무 적극적인 정규화로 인해 예측 정확성이 떨어지는 것도 방지할 수 있었습니다.

비용 예측

쿼리 히스토리에 가중치를 적용한 비용 함수
위에서 설명한 과정을 통해 쿼리를 정규화된 형태로 변환하고 과거에 처리했던 유사한 쿼리 히스토리를 찾았다면, 이제 이 히스토리를 기반으로 실제 쿼리 비용을 예측하는 작업이 필요합니다.
이러한 예측은 쿼리 히스토리를 인자로 하는 비용 함수를 통해 계산되며, 쿼리 히스토리에 포함되어 있는 쿼리 처리 시간, 사용된 노드 그룹, 노드 수, 노드 스펙 등을 고려합니다.
이 과정에서 최근에 이뤄진 쿼리일수록 더 큰 가중치를 부여하여, 지속적으로 발전하는 Luft의 성능과 변화하는 실행 환경에 더 빠르게 적응할 수 있도록 하였습니다. 예를 들어, 최근 1주일 내의 쿼리는 1개월 전 쿼리보다 더 높은 가중치를 갖게 되어, 최근 이루어진 시스템의 성능 변화나 데이터 패턴 변화에 더 민감하게 반응할 수 있게 됩니다.
위 과정을 통해 계산된 비용은 단일 정수값으로 표현되며, 이 값은 간단한 추가 연산 작업을 통해 특정 환경(특정 노드 그룹, 노드 수 등)에서의 해당 쿼리를 처리하는 데 소요될 것으로 예상되는 시간으로 변환되고, 이 예상 시간을 활용해 오토스케일러가 스케일링 결정을 내리게 됩니다.
이 글의 범위에서 벗어나 자세히 다루진 않지만, 위 과정을 통해 구해진 쿼리 비용은 Luft의 안정성을 보장하기 위한 병렬 쿼리 제어 과정에서도 사용되고 있습니다. 예를 들어, 높은 비용의 쿼리가 동시에 여러 개 실행되는 것을 방지하거나, 쿼리 우선순위를 결정하는 데 활용됩니다.

결론

쿼리 시점 오토스케일링 개요
앞서 설명한 클러스터 매니저와 오토스케일러 작업을 통해 오버프로비저닝 없이 쿼리가 들어왔을 때 쿼리의 부하를 예측하여 적절한 스케일링을 진행하는 시스템을 구축할 수 있었습니다.
작업 이후 인스턴스 수 그래프; 작업 이전에는 항상 4대로 운영되었음
결과적으로 이번 작업을 통해 오버프로비저닝을 하지 않음으로써 인스턴스 비용을 약 40% 절감할 수 있었으며, 기존에는 적절한 수준의 오버프로비저닝으로 해결이 안 됐던 무거운 쿼리에 대해서도 쿼리 시점에 필요한 만큼의 리소스를 동적으로 할당하여 처리가 가능해짐으로써 더 다양한 분석 기능을 제공할 수 있게 되었습니다.

마치며

이번 글에서는 Luft에 쿼리 히스토리를 활용한 비용 기반 오토스케일러를 도입하여 비용 절감과 신기능 개발에 걸림돌이 되는 오버프로비저닝 문제를 해결한 경험을 소개해드렸습니다.
이번 시리즈에서 다룬 이야기는 사실 꽤 오랜 기간의 작업에 대한 이야기였습니다. 그 과정에서 수많은 실험과 실패가 있었고, 아직 개선할 부분도 많겠지만, 개인적으론 이번 작업을 통해 앞으로 Luft의 사용 영역을 더욱 확장시키고, 더 다양하고 복잡한 분석 요구 사항을 충족시키기 위해 필요한 기반을 잘 마련했다는 점에서 큰 의미를 갖는 것 같습니다.
분량상 모든 내용을 자세히 다루진 못 했지만, 이번 시리즈를 통해 Luft의 개선 여정을 간략하게나마 전달해드릴 수 있었기를 바랍니다. 더 궁금하신 부분에 대해서는 댓글 남겨주시면 최대한 자세히 답변드릴 수 있도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
ᴡʀɪᴛᴇʀ
Geon Kim @KimMachineGun Query Engine Engineer @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기