Luft's Road to Elasticity - Part 1: From Shared Nothing to Shared Storage

Luft's Road to Elasticity - Part 1: From Shared Nothing to Shared Storage

Geon Kim — 김건 @KimMachineGun

들어가며

Luft는 AB180에서 자체적으로 개발하여 사용하고 있는 사용자 행동 분석에 특화된 OLAP 데이터베이스로, 에어브릿지의 다양한 리포트를 제공하는 데 활용되고 있습니다. Luft에 대한 더 자세한 내용은 이전 글과 발표에서 확인할 수 있습니다.
<Luft’s Road to Elasticity> 시리즈에서는 지난 1년간 Luft의 성능 향상과 비용 절감을 위해 시도한 스케일링 최적화에 대해 이야기를 나눠보고자 합니다. 시리즈는 총 두 편의 글로 이루어져 있으며, 이번 글에서는 Luft의 아키텍처를 개선하여 S3에서 직접 쿼리가 가능하도록 개선한 경험을 소개할 예정입니다.

배경

Shared Nothing 아키텍처

Shared Nothing 아키텍처에서의 Luft 구조 (Local Storage로는 AWS EBS 또는 Instance Store를 사용하며, Deep Storage로는 AWS S3를 사용하고 있습니다.)
Luft는 Shared Nothing 아키텍처를 기반으로 구축되어있습니다. Shared Nothing 아키텍처에서는 각 노드가 독립적인 프로세서, 메모리, 로컬 스토리지를 보유하여 데이터를 처리하게 됩니다. Shared Nothing 아키텍처는 여러 노드가 함께 공유하는 자원이 없다는 특징 덕분에 병목 현상이 최소화되며, SPOF(Single Point of Failure) 또한 최소화되어 높은 확장성과 가용성을 가진다는 장점이 있습니다. 이러한 개념은 1986년 Michal Stonebraker의 ‘The Case for Shared Nothing’ 논문에서 처음으로 소개되었습니다.

스파이크성 워크로드에서의 Shared Nothing 아키텍처

Shared Nothing 아키텍처에서의 데이터 분산 배치
이와 같은 Shared Nothing 아키텍처에서는 각 노드별로 데이터의 스토리지 또한 독립적으로 관리되기 때문에, 사전에 각 노드에 데이터를 적절히 분산하여 배치하는 것이 중요합니다. Luft에서 데이터 분산 배치 작업은 상황에 따라 자동 또는 수동으로 이루어지며, 각 데이터가 속한 테이블의 정책에 따라 Deep Storage에 백업되어있는 데이터가 각각의 노드에 배치됩니다. 이러한 분산 배치 작업은 배치가 필요한 데이터의 양에 따라 수분에서 수시간까지도 소요되곤 합니다.
이처럼 데이터의 스토리지 또한 독립적으로 운영된다는 특징은 높은 확장성과 가용성을 가져다주지만, 한편으론 다루는 데이터의 크기가 매우 큰 OLAP 데이터베이스의 특성과 맞물려 구조적으로 탄력성을 떨어뜨리는 결과를 낳게 됩니다.
이는 매우 일반적인 특성임에도 Luft의 워크로드에선 더욱 치명적으로 작용하고 있습니다.
시간대별 Luft 쿼리 분포
피크 시간대인 오전 10시의 CPU 사용량 추이
위 자료에서 확인할 수 있듯, Luft가 처리하는 쿼리 중 약 50%는 업무 시간대에 집중적으로 이뤄지며, 개별 쿼리별 부하의 차이가 크고 스파이크성 패턴을 보입니다. 이러한 상황에서 Luft는 부족한 탄력성을 이유로, 스파이크 상황에서도 처리 성능을 보장하기 위해 오버프로비저닝 되어 운영되어 왔습니다.
하지만 시간이 지남에 따라 사용자의 수가 늘어나고, Luft가 더욱 복잡한 쿼리를 처리하게 되면서 위 상황은 점점 악화되었고, 결국 납득 가능한 수준의 오버프로비저닝으로는 감당할 수 없는 상황에 이르게 되었습니다.

Shared Nothing + Compute-Storage Separation = Shared Storage?

Shared Storage 아키텍처에서의 Luft 구조 (개념적 구분을 위해 용어를 변경하였지만, Remote Storage는 Deep Storage에 대응되며, Local Cache는 Local Storage에 대응됩니다.)
사실 위와 같은 Shared Nothing 아키텍처의 한계와 그로 인한 어려움은 이미 많은 기업에서 경험하고 있었습니다. 실제로 이러한 어려움에 대응하고자 최근 다수의 데이터베이스, 데이터웨어하우스 등 대용량의 데이터 처리가 필요한 서비스에서 컴퓨팅 자원과 스토리지를 분리하여 서로 독립적으로 스케일링이 가능하도록 하는 Compute-Storage Separation에 대한 개념을 채택하고 있는 상황입니다.
이렇듯, Shared Nothing 아키텍처에서 Compute-Storage Separation에 대한 개념을 차용하여 컴퓨팅 자원(CPU, 메모리 등)은 여전히 독립적으로 관리하되, 스토리지(또는 데이터)를 공유하기 위한 아키텍처를 Shared Storage 아키텍처라 합니다. (세부적인 구현에 따라 Shared Storage, Shared Data 등의 이름으로 불리기도 합니다.)
Luft는 앞서 설명한 구조적 한계를 극복하고, 앞으로 처리하게 될 더 복잡하고 무거운 쿼리를 비용 효율적으로 처리하기 위해 Shared Storage 아키텍처를 도입하기 위한 작업을 진행하게 되었습니다.
작업의 주요 목표는 Local Storage에 데이터를 사전에 배치하지 않고도 Remote Storage에서 직접 쿼리 할 수 있는 환경을 구성하는 것이며, 성능 향상을 위해 선택적으로 캐싱을 위한 추가 스토리지를 활용할 수 있도록 하는 것을 함께 고려하였습니다.

구현

Luft 쿼리 실행 레이어와 데이터/제어 흐름도
Luft에서 쿼리는 총 세가지의 추상화 레이어에 걸쳐 처리됩니다. API로부터 전달 받은 쿼리는 Query Layer를 통해 쿼리 플랜 형태로 변환되며, 변환된 쿼리 플랜은 Processing Layer로 전달되어 Storage Layer로부터 데이터를 읽고, 처리하여 다시 Query Layer를 통해 사용자에게 반환됩니다.
Shared Storage 아키텍처를 구현하기 위해선, 위 레이어 중 Query Layer와 Storage Layer에서의 작업이 필요했습니다. 이 글에서는 실제 Remote Storage에서의 쿼리에 더욱 직접적으로 관여되는 Storage Layer에 대해 먼저 다뤄보겠습니다.

Storage Layer

Ziegel은 Luft와 함께 개발한 Go 기반의 스토리지 엔진입니다. Ziegel 개발에 대한 이야기는 TrailDB to Ziegel(Cgo to Go)에서 확인하실 수 있습니다.
앞서 간단히 언급했듯, Storage Layer는 Processing Layer로부터 전달받은 쿼리 플랜을 바탕으로 Ziegel로부터 데이터를 읽어 전달하는 역할을 수행합니다. 따라서 실제 데이터에 대한 접근이 이루어지는 Storage Layer에서 Remote Storage(AWS S3)에 위치한 Ziegel로부터 직접 데이터를 읽고 처리할 수 있도록 구현하는 작업이 필요합니다.
이 때의 구현은 Remote Storage에 대한 접근을 어느 레벨에서 추상화하느냐에 따라 크게 두 가지로 나눠볼 수 있었습니다.

시스템 레벨 추상화

mountpoint-s3를 통한 S3 접근 흐름도; 위 과정 중 (1)에서 성능 문제가 발생하였음
시스템 전체의 아키텍처를 개선하는 것은 매우 큰 작업이기에, 처음 Storage Layer 작업을 시작할 때는 코드 작업을 최소화하기 위해 시스템 레벨에서 추상화할 수 있는 방안을 우선적으로 조사하게 되었습니다.
그중에서도 관리 편의성을 위해 AWS에서 제공하는 서비스(EFS, Storage Gateway, FSx 등)를 통해 Remote Storage를 구현하고, 네트워크 기반의 파일 시스템을 마운트하여 사용하는 방식을 가장 먼저 고려해보았습니다. 하지만 위 서비스들은 관리 편의성 측면에서 분명한 이점이 있음에도, 그 이점을 상회하는 비용이 문제가 되어 비용 효율성 측면에서 이번 작업에 도입하는 것은 적절치 않다는 결론을 내리게 되었습니다.
위 과정에서의 경험을 바탕으로, 이후 별도의 추가 스토리지를 구축하지 않고 기존 Deep Storage(S3)를 그대로 Remote Storage로 사용할 수 있는 방안을 조사하였고, goofys, s3fs, mountpoint-s3와 같은 FUSE 구현체가 비슷한 수준의 관리 편의성을 제공하면서도 추가 비용 없이 S3를 파일 시스템 형태로 마운트하여 사용할 수 있음을 확인하였습니다.
조사 과정에서 FUSE가 아닌 User-Space Page Fault Handling을 사용하는 방법 등도 확인할 수 있었지만, 최종적으로 FUSE를 사용하는 방식이 유지보수 측면에서 가장 유리하다고 판단하여 FUSE를 사용한 POC를 진행하였습니다.
인스턴스 수준에서의 간단한 셋업만으로 FUSE를 세팅할 수 있었기에 POC 작업은 순조롭게 진행되었지만, POC 결과가 각 프로젝트에서 제공하는 벤치마크 자료를 통해 예상했던 결과와 큰 차이가 나는 것을 확인하였습니다. 제한된 상황에서의 벤치마크 결과와 실제 쿼리 성능 사이엔 어느 정도 차이가 있을 것임을 감안해도, 예상했던 것과는 다르게 프로덕션 환경에서 적용하기엔 매우 부족한 성능을 보여주었습니다.
FUSE를 사용하는 방식이 관리 편의성 측면에서 정말 뛰어난 강점을 보였기에, 성능 문제에 대한 원인을 파악하고 해결하고자 프로파일링을 진행했지만, 아쉽게도 근본적인 문제를 확인하게 되었습니다.
프로파일링 결과, FUSE를 통해 파일 IO 형태로 추상화된 네트워크 IO를 Go 런타임이 인지할 수 없어 발생되는 문제임을 확인할 수 있었습니다. Go 런타임은 네트워크 IO를 별도의 network poller를 통해 효율적으로 처리하는데, 위와 같이 추상화되어 Go 런타임이 인지할 수 없는 네트워크 IO는 최적화가 될 수 없었고, 오히려 적절치 않은 시스템콜 최적화 로직에 의해 성능이 악화되는 상황이 발생하고 있었습니다.
Go 런타임은 최적화를 위해 시스템콜의 대부분은 빠르게 완료될 것이라는 낙관적 가정 하에 설계되었습니다. 이러한 설계로 인해 시스템콜 호출 시 즉시 preemption을 수행하지 않고, 일정 시간(10ms) 대기한 후에도 시스템콜이 완료되지 않은 경우에만 preemption을 수행합니다. 그러나 대부분의 S3 요청은 10ms 이상 소요되기 때문에, FUSE 파일 시스템을 통한 S3 접근은 항상 10ms의 유휴 시간을 발생시키게 됩니다.
이 문제는 애플리케이션 레벨에서의 수정이나 파일 시스템에 대한 작업만으로 쉽게 해결할 수 있는 성격의 문제가 아니었기에, 아쉽게도 시스템 레벨에서의 추상화는 포기할 수밖에 없었습니다.

애플리케이션 레벨 추상화

애플리케이션 레벨에서의 추상화는 시스템 레벨에서의 추상화 작업과는 다르게 꽤 많은 작업을 필요로 합니다. 가장 먼저 S3를 파일 시스템 형태로 추상화 해야하며, 기존 MMAP이 수행하던 다양한 역할을 대체할 수 있는 컴포넌트도 별도로 구현해야 합니다.
그나마 다행히도 Deep Storage의 오브젝트 관리를 위해 S3를 추상화한 파일 시스템이 어느 정도 구현이 된 상태태였기에, 첫 번째 작업은 기존 구현체에 S3의 byte-range fetch 기능을 사용하여io.ReaderAt 인터페이스를 추가로 구현함으로써 비교적 쉽게 처리할 수 있었습니다.
한편, MMAP 대체 작업에선 상용 데이터베이스에서 흔히 쓰이는 버퍼 풀 매니저(Buffer Pool Manager)에 상응하는 컴포넌트를 구현하는 것을 목표로 하였습니다. 이 과정에서 메모리 관리, 캐싱, IO 관리 등의 기능을 개발하며 다양한 최적화 작업을 진행하였지만, 이 글에선 시스템 전반의 아키텍처 개선에 대한 내용을 주로 다루기에 세부적인 내용은 추후 기회가 있을 때 자세히 다루도록 하겠습니다.
Luft의 Buffer Pool Manager 구조
최종적으로 위와 같은 구조의 버퍼 풀 매니저 구현체를 개발하여 Luft에서의 MMAP의 역할을 대체할 수 있었습니다.

Query Layer

Query Layer는 API로부터 전달받은 쿼리를 분석하고, 적절한 쿼리 플랜을 세워 쿼리를 실행한 후 결과를 사용자에게 반환하는 역할을 담당합니다. Shared Storage 아키텍처를 구현하기 위해서는, 이 중 쿼리 플랜을 세우는 과정에서의 작업이 필요했습니다.
글 초반에 언급했던 것처럼 기존 Luft는 쿼리에 필요한 데이터를 사전에 각각의 노드에 배치하고, 이를 쿼리에 사용하는 식으로 동작하였습니다. 하지만, Shared Storage 구조에서 Local Storage는 Local Cache로서 역할을 하기 때문에, 사전에 데이터가 배치되어 있지 않은 상황을 고려하여 쿼리 플랜을 세울 필요가 있습니다.
새로운 로직을 설명하기에 앞서 기존 쿼리 플랜을 세우는 과정 중 수정이 필요한 부분에 한하여, 간단히 알아보도록 하겠습니다.

Query Planning (Query Parsing ~ TimeChunk Assignment)

가장 먼저 Luft는 Query API를 통해 들어온 쿼리를 분석합니다. 편의상 SQL 형태로 작성했지만, 실제론 JSON 형태의 쿼리를 분석하게 됩니다.
이후 쿼리 분석 결과를 바탕으로 읽어야할 데이터를 파악합니다. 이 때 각각의 데이터 조각을 TimeChunk라 합니다.
위 과정을 통해 파악한 쿼리에 필요한 TimeChunk들이 어떤 노드에 배치되어 있는지 확인합니다. 위 자료에서는 총 3개의 TimeChunk가 노드 A와 C에 배치되어 있는 상황을 나타냅니다. (연한 파란색의 Part 2에 해당하는 TimeChunk는 어떤 노드에도 배치되어 있지 않은 상황입니다.)
마지막으로 TimeChunk의 배치 현황을 기반으로, 각 TimeChunk를 어떤 노드가 처리할지 결정합니다. 이 때, 어떤 노드에도 배치되어 있지 않은 TimeChunk의 경우, 최적화 로직에 의해 선정된 한 노드가 쿼리 시점에 Deep Storage로부터 해당 TimeChunk를 다운받아 처리하도록 합니다.
이와 같은 과정을 On-Demand Download라고 하며, 이는 기본적으로 매우 느린 과정이기에 기존 로직에서는 가능한 한 On-Demand Download를 발생시키지 않는 방향으로 쿼리 플랜을 세우게 됩니다.

Query Planning Without Data Distribution

하지만, Shared Storage 아키텍처에서는 On-Demand Download를 통해 전체 TimeChunk를 받아올 필요 없이, TimeChunk 중 쿼리에 필요한 부분만 읽어 쿼리할 수 있고, Local Storage가 캐시의 개념으로 동작하기에, 쿼리 플랜을 세울 때 상대적으로 TimeChunk의 배치 현황을 크게 고려하지 않아도 됩니다.
따라서 이전 절차 중, 배치 현황을 파악하는 작업(3)을 생략하고, 각 노드가 처리할 TimeChunk를 가능한 고르게 분배할 수 있도록 로직을 작성하였습니다. 이 과정에서 특정 TimeChunk를 처리할 노드를 선정 알고리즘을 개선하여, 배치 현황을 파악하지 않더라도 Local Cache의 효율을 높일 수 있도록 설계하였습니다.

Storage Mode

위에서 소개드린 새로운 로직은 Luft가 새로운 아키텍처에서 탄력적으로 운용되는 경우 효과적으로 동작하지만, 경우에 따라 기존 로직이 더욱 효과적으로 동작할 때도 있을 것입니다. 따라서, 기존 로직과 새로운 로직을 필요에 따라 선택적으로 사용할 수 있도록, 쿼리를 실행할 때 어떤 종류의 스토리지를 어떻게 사용할지 결정하는 일종의 플래그인 Storage Mode라는 개념을 함께 도입하였습니다.
Storage Mode는 총 세가지 모드로 구현되어 있으며, 모든 모드는 쿼리 시 옵션을 추가하는 것만으로 쿼리별로 독립적으로 제어할 수 있도록 하였습니다.
Local Storage Mode
Local Storage Mode는 기존 Shared Nothing Architecture에서의 로직과 동일한 방식으로, 모든 데이터를 Local Storage로부터 읽어 쿼리를 처리합니다. 쿼리 플랜을 세울 때 데이터의 배치 현황을 고려하며, 필요시 On-Demand Download를 하게됩니다. 쿼리에 필요한 데이터가 각각의 노드에 적절히 분산 배치되어 있을 것으로 예상되는 환경에서 가장 효율적으로 동작합니다.
Remote Storage Mode
Remote Storage Mode는 Local Storage Mode와 정반대의 개념으로 모든 데이터를 Remote Storage로부터 읽어 쿼리를 처리합니다. Local Storage를 전혀 사용하지 않기 때문에 Lambda와 같이 Local Storage를 사용할 수 없는 상황에서 활용할 수 있는 방식입니다.
Tiered Storage Mode
Tiered Storage Mode는 기본값으로 사용되는 모드이며, 쿼리에 필요한 TimeChunk가 Local Cache(Local Storage)에 있는 경우 Local Cache로부터 데이터를 읽어 처리하며, 없는 경우 Remote Storage로부터 데이터를 읽어 쿼리를 처리하게 됩니다. 이러한 스토리지 사용에 대한 우선순위는 티어를 통해 결정되며, 현재의 구현에선 Local Cache > Remote Cache > Remote Storage 순의 우선순위를 갖습니다.
Remote Cache는 구현 초기 S3 Express One Zone을 통해 구축되었으나, 관리 비용 대비 성능에서 큰 메리트가 없다고 판단하여 현재는 사용되지 않고 있습니다.
위 과정에서 하위 티어의 스토리지에서 읽힌 TimeChunk는 백그라운드 작업을 통해 상위 티어의 스토리지에 자동으로 캐싱되어, 이후 쿼리에서 더욱 빠른 접근이 가능하도록 개발하였습니다.

결론

Storage Mode에 따른 쿼리 성능 비교
최종적으로, 앞서 설명한 Storage Layer와 Query Layer에서의 개선 작업을 통해 Luft의 아키텍처를 Shared Nothing에서 Shared Storage로 전환할 수 있었습니다.
기존에는 데이터가 사전에 적절히 분산 배치되어있지 않은 경우, 쿼리에 필요한 TimeChunk 전체를 다운받아 쿼리를 처리해야 했지만, 이번 개선을 통해 컬럼 기반 스토리지 엔진의 특성을 적극 활용하여 쿼리를 처리하는 데 필요한 데이터만 Remote Storage로부터 읽어 바로 처리할 수 있게 되었습니다.
이를 통해 쿼리 성능에 많은 영향을 미치는 네트워크 부하를 효과적으로 줄일 수 있었으며, 쿼리 유형에 따라 성능 개선 폭에는 차이가 있지만, 가장 기본적인 유형의 쿼리에서 70% 이상의 성능 개선을 확인할 수 있었습니다.

마치며

이번 글에서는 Luft의 아키텍처를 개선하여 탄력성을 확보하기 위한 작업을 진행했던 경험을 소개해드렸습니다. 다음 글에서는 이렇게 향상된 탄력성을 바탕으로 쿼리의 부하를 예측하여 클러스터를 자동으로 스케일링 할 수 있도록 개선한 경험을 공유하고자 합니다.
분량상 일부 세부 내용은 생략되었지만, 이 글을 통해 Shared Storage 구조와 Luft의 개선 여정을 간략하게나마 전달해드릴 수 있었기를 바랍니다. 더 궁금하신 부분에 대해서는 댓글 남겨주시면 최대한 자세히 답변드릴 수 있도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
ᴡʀɪᴛᴇʀ
Geon Kim @KimMachineGun Query Engine Engineer @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기