AWS Lambda 를 활용한 Luft 스케일링

AWS Lambda 를 활용한 Luft 스케일링

Jaewan Park — 박재완 @hueypark

배경

Luft 워크로드

OLAP 데이터베이스인 Luft는 독특한 워크로드를 처리합니다. 성능과 부하 측면에서 살펴보면, 대시보드용 데이터 생성과 배치 처리 작업 등을 위해 다양한 쿼리를 수행하는데, 이 처리량은 시간대별로 매우 유동적이고 예상하기 어렵습니다.
% 로 표기한 Luft 시간별 CPU 부하
위 차트에서 Luft CPU 부하가 특정 시간대에 집중되는 것을 확인할 수 있습니다. 또한, 메모리, 네트워크 I/O, 디스크 사용량 등도 비슷한 패턴을 보입니다. 이를 통해 Luft 리소스가 많은 시간 동안 과잉 공급되어 낭비되고, 부하집중 시간에는 리소스가 부족한 상황이 발생함을 알 수 있습니다.
따라서 적절한 스케일링을 통해 리소스가 낭비되거나 부족한 상황을 막아 사용자 경험을 개선하고, 낭비되는 비용을 줄일 수 있을 것으로 기대하여 AWS Lambda 를 활용한 Luft 스케일링 POC를 진행했습니다.

AWS Lambda 와 성능에 대한 가정

AWS Lambda는 서버를 직접 관리하지 않고 코드를 실행할 수 있는 서버리스 컴퓨팅 서비스입니다. Lambda를 사용하면 간편하게 대규모로 확장할 수 있는 장점이 있어, Luft에서는 부하가 집중되는 시간에만 빠르게 클러스터를 확장하는 시도를 해보았습니다.
다음은 작업 전에 세운 Lambda 에 대한 가정입니다.
클러스터를 빠른 시간(수 초) 안에 확장할 수 있을 것이다.
사용할 수 있는 자원은 매우 많을 것이다.
기존 EC2를 활용한 인프라 구성에서도 스케일링이 가능했지만, 확장하는 데 오랜 시간(수십 초)이 걸려 요구사항을 만족시키기 어렵다고 판단했습니다.

구현

Luft 쿼리와 Historical 노드

Luft 는 분산 map reduce 프레임워크를 활용해 주어진 쿼리를 처리합니다. 이 프레임워크 위에서 쿼리의 각 단계를 스테이지로 구분하는데 이는 크게 아래 세 단계로 구분됩니다.
1.
Scan: 디스크 또는 원격 저장소(S3 등)에서 데이터를 읽어옵니다.
2.
Group by user: 읽어 온 데이터를 유저별로 합치고, 필요시 정렬합니다.
3.
Aggregate: 집계에 필요한 컴퓨팅 작업을 합니다.
각 스테이지는 물리적 및 논리적으로 여러 노드에 분산하여 실행됩니다. 아래 그림처럼 각 물리 노드는 여러 개의 논리 파티션을 가집니다.
Historical 노드와 파티션

주요 컴포넌트

Historical 노드
쿼리를 실행하는 물리 노드로, 클러스터 접근, 파일 시스템 접근, 리소스 관리 등의 다양한 기능을 담당합니다.
여러 개의 task executor를 보유하며, 각각의 task executor가 하나의 파티션을 담당해 데이터를 처리합니다.
Task executor
개별 스테이지에 필요한 특정 기능만을 담당합니다.
하나의 파티션만 처리합니다.

Plan A : Lambda에서 Historical 노드 실행

처음에는 Historical 노드 전체를 Lambda에서 실행하는 것을 시도했습니다. 이 방법은 성능 관점에서 비효율적이지만, Luft 전체 구조에 변화를 주지 않고 네트워크 계층만 수정해 바로 실험해 볼 수 있는 장점이 있었습니다.
그러나 AWS Lambda 에는 특정 Lambda를 지정해 통신하는 것을 허용하지 않는 제약사항이 있습니다. 이를 해결하기 위해 holepunch 또는 proxy 활용을 시도했습니다.
결과
시도 1: Holepunch
Lambda에서 holepunch 는 가능했지만, 같은 VPC 안에서 동작하게 할 방법은 찾지 못했습니다.
시도 2: Proxy 서버 경유 노드 간 통신
Historical 노드는 실행에 너무 많은 자원을 사용하기 때문에 AWS Lambda를 사용하기에 적절하지 않았습니다.
통신을 위해 많은 file descriptor를 열어야 했는데, Lambda 한계인 1,024개를 초과했습니다.

Plan B: Lambda에서 Task executor 실행

위 문제를 해결하기 위해 하나의 파티션만을 처리하고, 각 스테이지에 필요한 특정 기능만을 담당하는 task executor를 Lambda에서 실행하는 방법을 시도했습니다.
이를 위해 task executor 유형을 두 가지로 나누고, 필요시 runner를 별도로 실행했습니다.
Task executor 유형
Local: 기존처럼 historical 노드에서 실행됩니다.
Remote: 원격에서 실행 가능한 historical 노드입니다.
Task executor remote runner
Remote task executor가 실행하려는 스테이지를 대신 실행하는 컴포넌트입니다.
AWS Lambda 또는 HTTP server로 실행할 수 있습니다.
Historical 노드 대비 장점
단 하나의 historical 노드와 통신하기 때문에 자원(file descriptor 등)을 많이 사용하지 않습니다.
Historical 노드와 독립이기 때문에 저사양 장비에 최적화된 설정을 할 수 있습니다.
별도 최적화를 통해 메시지 인코딩/디코딩을 줄일 수 있습니다.

결과

무거운 단일 쿼리 성능

무거운 단일 쿼리의 경우, 유의미한 성능 향상을 얻을 수 있었습니다. 예를 들어, 6개월 가량의 데이터를 집계하는 대형 쿼리는 총 실행 시간이 68초에서 44초로 줄어들었습니다. 이는 Scan 스테이지에서 Lambda task executor 512개를 사용한 결과입니다.
다양한 워크로드에 대해 추가 테스트를 해본 결과, Lambda 쿼리의 특성은 아래와 같았습니다.
무거운 쿼리의 경우 응답 시간 감소를 기대할 수 있습니다.
S3에서 매번 새로 파일을 받아야 하기 때문에 네트워크 I/O 병목이 큽니다.
Historical 노드의 CPU, 메모리 사용량은 줄어듭니다.
네트워크 I/O는 소폭 증가합니다.

매우 큰 메모리를 사용하는 쿼리 성능

특정 쿼리의 경우, 메모리 사용량을 줄일 수 있었습니다. Historical 노드 당 47 GiB 이상의 메모리를 요구하는 무거운 쿼리를 23 GiB 정도만 사용해 완료할 수 있었습니다. 이는 Group by user 스테이지에서 Lambda task executor 512개를 사용한 결과입니다.
참고: 이 쿼리는 매일 특정 시간에 요청되는 배치성 쿼리로, 이 쿼리 하나만을 처리하기 위해 인스턴스 스케일 아웃한 경험이 있습니다.

일반적인 작은 쿼리 성능

일반적인 작은 쿼리의 경우, Lambda를 활용해 얻는 이득보다 추가로 발생하는 네트워크 I/O 비용이 더 커 유의미한 성능 개선은 없었습니다.

한계

Lambda 자원은 무한하지 않음

Lambda 자원은 무한하지 않습니다. Historical 노드를 실행할 수 없게 하는 file descriptor 한계뿐만 아니라, vCPU, 메모리, 디스크, 네트워크 I/O에도 제한이 있습니다. 따라서 Lambda를 활용할 때 기존 아키텍처를 유지하기 어렵고, 여러 개의 작은 인스턴스를 실행하도록 아키텍처를 변경해야 합니다.
Lambda 한계: AWS Lambda Quotas
또한, 동시에 실행 가능한 Lambda 수도 무한하지 않습니다. 비용을 지불한다고 해서 자동으로 확장되는 마법의 상자처럼 접근하면 큰 낭패를 볼 수 있으며, 함수가 어떻게 확장되는지 미리 이해해야 합니다.
Lambda 함수 스케일링: AWS Lambda Function Scaling
마지막으로, Lambda의 vCPU는 메모리 크기에 영향을 받기 때문에 vCPU와 메모리를 필요에 따라 적절히 설정할 수 없습니다. Luft의 경우, 2개의 vCPU를 활성화하기 위해 메모리 1,770 MiB를 할당해 사용 중입니다.

비용

부하가 예상 가능하다면 Lambda 쿼리가 EC2 노드를 직접 운영하는 것보다 비쌀 가능성이 높습니다. 갑자기 들어오는 쿼리를 예상하기 어렵기 때문에 Lambda를 활용하는 것이 유리한 상황도 있지만, 예상 가능한 부하는 EC2 노드를 직접 운영하는 것이 더 유리합니다.
예상 가능한 부하의 예시:
일반적으로 부하가 많아지는 시간의 쿼리
매일 주기적으로 실행되는 배치 쿼리
이런 예상 가능한 부하를 포함해 모든 쿼리를 Lambda로 처리한다면 비용 면에서 손해일 수 있습니다.
예시로 든 쿼리 비용을 간단히 계산해 보면, 단일 쿼리의 Lambda 비용은 약 0.03 USD입니다. Luft에 들어오는 모든 쿼리를 Lambda로 처리하면 EC2 인스턴스를 사용하는 것보다 비쌀 수 있습니다.

Scan 스테이지의 네트워크 I/O 문제

쿼리 테스트 중 Lambda의 네트워크 I/O가 (S3 -> Lambda) 충분히 빠르지 않다는 것을 알게 되었습니다. 실제 쿼리에서 1 GiB 정도 되는 파일을 다운로드 받고 쳐리하는 데 20~30초가 소요되었습니다.
이를 해결하기 위해 꼭 필요한 컬럼만 다운로드받아 쿼리할 필요가 있습니다. 테스트 쿼리의 경우 전체 용량의 1.46%만 실제로 필요한 데이터였습니다.
참고: 캐시로 S3 Express One Zone을 사용해도 I/O 관점에서 유의미한 성능 개선을 얻지 못했습니다.

Lambda 성능은 가변적

Lambda 성능은 EC2만큼 일정하게 제공되지 않습니다. 실제로 테스트에 사용된 쿼리의 응답 속도는 1.3배까지 증가하는 경우가 있었습니다. OLAP 워크로드에서는 크게 중요하지 않을 수 있지만, 이 점을 인지하고 있어야 합니다.

마치며

지금까지 AWS Lambda를 활용하여 Luft 쿼리 성능을 개선하고자 했던 시도와 그 결과에 대해 살펴보았습니다. Lambda를 통한 스케일 아웃은 예상하지 못한 부하를 빠르게 처리할 수 있는 유연성을 제공하지만, 기존의 EC2 기반 인프라보다 비용이 더 많이 들고, 네트워크 I/O 성능 등의 한계가 있었습니다.
이번 실험을 통해 얻은 교훈은 다음과 같습니다.
Lambda 자원은 무한하지 않으므로 그 특성과 제한사항을 고려해야 합니다.
예상하기 어려운 부하를 Lambda로 처리하고, 예상 할 수 있는 부분은 미리 준비된 EC2로 처리하는 등, 둘을 혼합해 사용하는 것이 가장 호율적입니다.
네트워크 I/O 개선 등의 특화된 성능 튜닝이 아직도 중요한 역할을 합니다.
이를 바탕으로 OLAP 데이터베이스에서 AWS Lambda를 활용한 스케일 아웃은 상황에 따라 큰 성능 개선을 가져올 수 있지만, 비용 및 자원 제한을 고려한 접근이 필요하다는 것을 알 수 있었습니다.
이상적인 최종 상태는 부하 기반으로 Historical 노드를 스케일링하고, 그 사이의 빈 틈을 Lambda 기반 쿼리로 채워주는 것입니다. 하지만, 엔지니어링 리소스는 제한적이기 때문에, 실용적인 방법을 먼저 적용해보려 합니다.
1.
매우 눈에 띄는 특이한 워크로드를 Lambda로 처리
2.
갑자기 요청되는 큰 워크로드: 부하 기반으로 Historical 노드 스케일링 (약간의 지연을 감내)
3.
예상되는 큰 워크로드: 시간 등 예상 가능한 데이터 기반으로 Historical 노드 스케일링
앞으로 더 많은 실험과 최적화를 통해 더욱 발전된 방법을 찾아 나갈 예정이며, 이번 시도에서 배운 점들을 바탕으로 더욱 효율적인 환경을 만들어 나가겠습니다. 긴 글 읽어주셔서 감사합니다.
ᴡʀɪᴛᴇʀ
Jaewan Park @hueypark Backend Engineer @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기