Cloudflare 정도 규모에서 운영한다는 것은 기술 스택 전체에서 다양한 부하 조건을 처리하는 데 많은 시간을 들여야 한다는 것을 의미합니다. 이번 블로그에서는 Postgres 클러스터로 성능 문제를 해결한 방법에 대해 설명합니다. 이러한 클러스터는 많은 수의 테넌트와 매우 가변적인 부하 조건을 지원해야 하기 때문에 테넌트가 다른 테넌트로부터 너무 많은 자원과 시간을 빼앗기지 않도록 테넌트 활동을 격리해야 할 필요가 있습니다.
저는 Cloudflare의 인턴으로서 데이터베이스 클러스터가 부하 상황에서 작동하는 방식을 개선하고 그 결과를 담은 코드를 오픈 소스화하는 작업을 하게 되었습니다.
Cloudflare는 데이터 센터의 여러 지역에 걸쳐 Postgres 클러스터를 운영합니다. DNS Resolver, 방화벽, DDoS 보호와 같은 초기 서비스 중 일부는 OLTP 워크로드를 위해 Postgres 클러스터의 고가용성에 의존하고 있습니다. 고가용성 클러스터 매니저인 Stolon은 모든 클러스터에서 사용되어 Postgres 인스턴스 전반에서 데이터를 독립적으로 제어 및 복제하고, 부하가 높은 시나리오에서 Postgres leader 를 선출 하고 Fail-over 합니다.
PgBouncer와 HAProxy는 각 클러스터에서 게이트웨이 계층의 역할을 합니다. 각 테넌트는 Postgres 로 부터 직접 연결하지 않고 PgBouncer로 부터 클라이언트 측 연결을 획득합니다. PgBouncer는 Postgres에 대한 최대 connection pool 을 보유하며, 여러 테넌트에 걸쳐 이를 할당하여 Postgres 연결 고갈을 방지합니다. 연결이 이루어지면 이제 PgBouncer는 쿼리를 HAProxy로 전달하고 HAProxy는 Postgres primary와 읽기 복제본에 걸쳐 쿼리에 대한 로드 밸런싱을 합니다.
문제
Cloudflare의 멀티테넌트 Postgres 인스턴스들은 컨테이너화 되지 않은 환경의 베어메탈 서버에서 작동합니다. 각 백엔드 애플리케이션 서비스는 하나의 테넌트로 간주되며, 이 테넌트는 여러 역할로 나뉜 Postgres 인스턴스 중 하나를 사용할 수 있습니다. 각 클러스터가 여러 테넌트를 지원하기 때문에 모든 테넌트는 각 클러스터 컴퓨터에서 CPU 시간, 메모리, 디스크 IO와 같은 사용 가능한 시스템 리소스는 물론 서버 측 Postgres connection pool 및 table lock 같은 유한한 데이터베이스 리소스를 공유하고 서로 경합을 벌입니다. 각 테넌트는 다양한 시스템 수준 리소스 사용량 특징을 갖는 워크로드를 가지고 있으므로 공통적인 전역 값을 사용하여 데이터베이스 사용을 스로틀링(Throttling)하는 것이 불가능합니다.
이런 상황은 저희 운영 환경에서 인접 이웃 테넌트에 영향을 미치는 문제가 되었습니다. 문제들은 이것들 입니다:
- 처리량(Throughput): 한 테넌트가 트랜잭션을 대량으로 발행하여 다른 테넌트의 공유 리소스를 고갈시키고 성능을 저하시킬 수 있습니다.
- 지연 시간(Latency): 단일 테넌트가 ETL 추출을 위한 대규모 테이블 스캔이나 긴 테이블 잠금이 있는 쿼리와 같이 매우 길거나 비용이 많이 드는 쿼리를 동시에 실행할 수 있습니다.
이 두 가지 시나리오 모두 인접한 이웃 테넌트의 쿼리 실행을 저하시킬 수 있습니다. CPU 공유 시간 감소 또는 오작동하는 테넌트의 빈번한 데이터 탐색으로 인해 디스크 IO 작업 속도가 느려져 트랜잭션이 중단되거나 실행 시간이 상당히 오래 걸릴 수 있습니다(지연 시간 증가). 또한, 기존 테넌트가 오래 수행되고 비용이 많이 드는 쿼리를 실행 하느라 대기 중인 다른 테넌트가 데이터베이스 프록시 수준(PgBouncer)에서 데이터베이스 연결을 획득하지 못하도록 차단될 수 있습니다.
이전 해결책
데이터베이스 클러스터 부하가 크게 증가하면 어떤 테넌트가 이 부하에 대한 원인을 제공하는 찾는 것이 가장 먼저 해결해야 할 과제입니다. 이를 위한 몇 가지 기술이 있는데 예를 들어 모든 테넌트의 이전 쿼리를 검색하고 Postgres의 pg_stat_activity view 에서 비용이 많이 드는 새로운 쿼리가 요청 되었는지 확인하는 방법이 있습니다.
데이터베이스 동시성/연결(connection) 스로틀링(Throttling)
비정상적인 테넌트가 식별되면 Postgres 쿼리를 사용하여 Postgres 서버 측 연결 제한을 수동으로 적용합니다.
ALTER USER "some_bad-user" WITH CONNECTION LIMIT 123;
이렇게 하면 기본적으로 단일 사용자의 동시 처리량을 제한하거나 "압박(Squeezes)"하게 되며, 각 테넌트는 자신의 몫으로만 할당된 연결만을 소진할 수 있습니다.
이런식의 수동 동시성/연결(connection) 스로틀링은 프로덕션 워크로드가 많은 동안 Postgres의 부하를 줄이는 데 개선 효과를 보여주었습니다:
이 접근 방식은 성공을 거두긴 했지만 완벽하지 않고 끔찍한 수작업으로 적용됩니다. 또한 다음과 같은 문제도 있습니다:
- 새 사용자 제한이 설정되면 Postgres는 기존 테넌트 연결을 즉시 종료하지 않으며, 사용자는 계속해서 버스트 쿼리 또는 고비용 쿼리를 발행할 수 있습니다.
- 테넌트는 동시성(connection pool 크기)이 줄어든 경우에도 여전히 매우 비싸고 리소스 집약적인 쿼리(인접 이웃 테넌트에 영향을 미침)를 실행할 수 있습니다.
- 잘못된 행동을 하는 테넌트에 대해 수동으로 연결 제한을 적용하는 것은 번거로운 작업이며, 하루 중 언제든지 SRE 팀을 호출하여 물리적으로 새 제한을 적용해야만 합니다.
- 특히 인시던트 발생 시 쿼리를 기반으로 잘못된 동작을 하는 테넌트를 수동으로 분석하고 탐지하는 작업은 프로덕션 수준의 SQL 분석 경험이 있어야 하므로 시간과 스트레스가 많이 소요될 수 있습니다.
- 또한 할당된 연결 수와 같이 user/pool 단위로 새로운 스로틀링 제한을 적용하는 것은 임의적이고 실험적일 수 있으며 테넌트 워크로드에 대한 광범위한 이해가 필요합니다. (*즉 테넌트의 워크로드 편차에 대한 고려가 누락될 수 있음)
- 때때로 Postgres는 너무 많은 부하를 받아 중단(hang)되기 시작할 수 있습니다(CPU 고갈). 이런 상황에서 SRE팀은 과부하 상황 해소를 위해 기본 인터페이스를 통해 테넌트를 수동으로 조절을 시도하지 못할 수 있습니다.
새로운 해결책
게이트웨이 동시성/연결(Connection) 스로틀링(Throttling)
일반적으로 쿼리의 시스템 수준 리소스 소비는 실행을 위해 서버 또는 데이터베이스 시스템에 제출된 후에는 제어 및 격리하기가 어렵습니다. 그러나 일반적인 접근 방식은 게이트웨이 계층에서 연결 또는 쿼리를 차단하고 스로틀링하여 시스템 리소스 소비에 따라 사용자별/풀 트래픽 특성을 제어하는 것입니다.
저희는 데이터베이스 프록시 서버/연결 풀링을 관리하는 PgBouncer 레벨 에서 연결 스로틀링을 구현했습니다. 이전에는 PgBouncer의 사용자 수준 연결 제한이 기존 연결을 종료하지 않고 제한을 초과하는 것만 방지했습니다. 하지만 이제 설정을 통해 정적으로 또는 새로운 관리 명령을 통해 런타임 중에 각 사용자 또는 각 사용자의 연결 풀이 소유한 기존 연결을 스로틀링하고 종료할 수 있도록 했습니다.
PgBouncer 설정
[users]
dns_service_user = max_user_connections=60
firewall_service_user = max_user_connections=80
[pools]
user1.database1 = pool_size=90
PgBouncer Runtime Commands
SET USER dns_service_user = 'max_user_connections=40';
SET POOL dns_service_user.dns_db = 'pool_size=30';
이를 위해서 저희는 저희 PgBouncer folk 버전에 주요 버그 수정, 리팩토링 및 구현을 더해야했습니다. 저희는 또한 PgBouncer 오픈 소스에 기여하기 위해 이 모든 기능을 담아 몇 차례 PR(pull request)를 올렸습니다. PgBouncer에 더한 저희의 모든 작업에 대해 알아보려면 이 블로그를 참조하세요.
이제 이러한 새로운 기능을 통해 오작동하는 테넌트의 동시성(연결 풀, 사용자 및 데이터베이스 쌍)에 대해 더 빠르고 세분화된 '부하 분산'이 가능하며, 성능 격리를 더욱 엄격하게 수행할 수 있습니다.
향후 해결책
테넌트별 리소스 소비를 모니터링하고 과거 기준선과 비교한 시스템 리소스 지표를 기반으로 어떤 테넌트가 잘못 작동하는지 감지하는 인프라 구성 요소를 계속 구축하고 있습니다. 이러한 새로운 관리 명령을 사용해 테넌트에 대한 연결 및 쿼리 스로틀링을 자동화하는 것을 목표로 하고 있습니다.
또한 테넌트 성능 격리를 엄격하게 적용하기 위해 다양한 자동화된 접근 방식을 실험하고 있습니다.
혼잡 방지
TCP Vegas 혼잡 회피 알고리즘을 채택하면 각 테넌트의 최적 동시성을 추정하고 적용하는 동시에 인접한 이웃 테넌트에 대한 낮은 지연 시간과 높은 처리량을 유지할 수 있습니다. 이 접근 방식은 리소스 소비 프로파일링, 수동 임계값 조정, 기본 시스템 하드웨어에 대한 지식 또는 고비용 계산이 필요하지 않습니다.
전통적으로 TCP Vegas는 초기에 알려지지 않은 최적의 혼잡 window(동시에 전송할 수 있는 최대 패킷 수)으로 수렴합니다. 같은 논리로 알수 없는 혼잡 구간을 데이터베이스 쿼리에 대한 최적의 동시성 또는 연결 풀 크기로 간주할 수 있습니다. 구체적으로 설명 하자면, 게이트웨이 계층인 PgBouncer에서 각 테넌트는 작은 연결 풀 크기로 시작하며, 각 테넌트의 트랜잭션의 왕복 시간(RTT)을 Postgres와 비교하여 동적으로 샘플링합니다. 트랜잭션 RTT가 악화되지 않는 한 테넌트의 연결 풀 크기(혼잡 window)를 점진적으로 늘립니다.
반면 테넌트의 샘플링된 트랜잭션 지연 시간이 증가하면 샘플링된 요청 지연 시간 비율에 의한 최소 계산(minimum request latency / sampled request latency)이 감소하여 결국 테넌트가 사용 가능한 동시성/연결이 자연스럽게 감소하여 데이터베이스 부하가 감소합니다.
기본적으로 이 알고리즘은 높은 데이터베이스 부하를 나타내는 지표로서 높은 쿼리 대기 시간이 관찰되면 이 대기 시간이 CPU 시간 또는 디스크/네트워크 IO 차단 등으로 인한 것인지 여부에 관계없이 일정한/랜덤한 시간동안 대기후 다시 동작 합니다.이 공식은 sampeld request latency 가 충분히 커질 수 록 *latency 비율(**minimum request latency / sampled request latency)이 항상 0에 수렴 하므로 최적의 동시성 제한(Connection pool 크기)을 찾을수 있습니다. current tenant pool size 의 제곱근이 connection pool 크기의 빠른 성장 속도와 작은 pool 크기 대비 상대적으로 큰 값 때문에 고정 요청 “Burst head room”으로 선택되었습니다. 대기 시간이 낮을 때(pool 크기가 작은 경우) 이 값은 크지만, 대기 시간이 높을 때(pool 크기가 줄어드는 경우) 수렴합니다.
혼잡 회피 방식은 부하를 사후적으로 줄이는 대신 부하로 인한 성능 저하가 문제가 되기 전에 트래픽을 예방적으로 또는 "원활하게" 조절합니다. 이 알고리즘은 다른 쿼리를 중단 시키는 데이터베이스 서버 리소스 고갈과 같은 문제를 방지하는 것을 목표로 합니다.
이론적으로 한 테넌트가 잘못 동작하여 다른 테넌트에 지연 시간을 유발하는 경우, 이 TCP 혼잡 알고리즘은 모든 테넌트를 맹목적으로 스로틀링할 수 있습니다. 따라서 시스템 성능이 저하될 때 CPU와 지연 시간의 상관관계가 높은 테넌트에 대해서만 이 적응형 스로틀링을 적용해야 할 수도 있습니다.
테넌트 리소스 할당량
각 테넌트별로 리소스 할당량을 도입할 수 도 있습니다. 즉 데이터베이스로 요청을 보내는 애플리케이션 서비스 테넌트는 초당 CPU 사용률 및 최대 메모리로 표시되는 할당된 리소스 점유율로 제한될 수 있습니다. 테넌트가 자신의 몫을 넘어 과도하게 사용하는 경우, 데이터베이스 게이트웨이(PgBouncer)는 동시성, 초당 쿼리 및 ingress 바이트를 스로틀링하여 할당된 슬라이스 내에서 소비하도록 강제해야 합니다.
테넌트의 리소스 제한은 다른 테넌트가 같은 클러스터에 액세스하는 것에 영향을 주거나 "넘쳐흐르지" 않아야 합니다. 그렇지 않으면 다른 고객 대면 애플리케이션의 가용성이 저하되고 SLO(서비스 수준 목표)를 위반할 수 있습니다. 리소스 제한은 각 테넌트로 격리되어야 합니다.
Postgres 인스턴스에 대한 트래픽이 적은 경우 테넌트가 할당 한도를 초과할 수 있도록 허용해야 합니다. 그러나 클러스터에 대한 부하로 인해 시스템의 전체 성능(지연 시간)이 저하되는 경우, 테넌트의 제한은 게이트웨이 계층인 PgBouncer에서 다시 적용되어야 합니다. 이를 위해 사전 정의된 임계값에 대한 평균 쿼리 지연 시간의 변화율과 같은 지표를 기반으로 전체 데이터베이스 서버의 상태를 추론할 수 있습니다. 모든 테넌트는 리소스 소비가 초과되면 특정 패턴의 쿼리 스로틀링이 발생할 수 있다는 데 동의해야 합니다.
각 테넌트는 고유하고 가변적인 워크로드를 가지고 있어 언제든지 멀티 테넌트 성능이 저하될 수 있습니다. 빠른 탐지를 위해서는 각 테넌트(또는 connection pooling 된 테넌트의 워크로드)의 기준 리소스 소비량을 거의 실시간으로 각 로컬 Postgres 서버(백엔드 pid) 기준으로 프로파일링 해야 합니다. 여기에서 "기준" 트래픽 특성을 데이터베이스 인스턴스당 시스템 수준 리소스 소비량과 연관시킬 수 있습니다.
분산 노드 전체에 걸쳐 평균을 내거나 통계적 측정값을 일반화하는 것은 리더(leader) 인스턴스와 복제 인스턴스에 대한 트래픽의 편차가 크기 때문에 정확하지 않을 수 있습니다(이 경우 각 테넌트의 Postgres 인스턴스 리소스 소비량). 이로 인해 사용자에게 잘못된 스로틀링 결정이 적용될 수 있습니다. 예를 들어, 사용자가 기본 데이터베이스 인스턴스에서 과도한 리소스를 사용하더라도 유휴 읽기 복제본에 대한 사용자의 동시성을 스로틀링해서는 안 됩니다. 따라서 테넌트 사용량을 Postgres 인스턴스 수준에서 캡처하고 전체 클러스터가 아닌 인스턴스별로 스로틀링을 적용하는 것이 바람직합니다.
독립 변수(동시성, 초당 쿼리 수, 수집된 바이트 수)와 종속 변수(시스템 수준 리소스 소비량) 간의 관계를 모델링하기 위해 다변량 회귀를 사용할 수 있습니다. 부하가 높은 시나리오에서 테넌트별로 최적의 독립 변수를 계산하고 적용할 수 있습니다. 워크로드 변화를 고려 하려면 워크로드 소비를 캡처할 때 슬라이딩 윈도우 크기(프로파일링된 데이터 포인트를 유지하는 시간)를 조정하여 회귀 적응성과 정확도를 조정해야 합니다.
게이트웨이 쿼리 대기(queuing)
Postgres 로 보내지는 사용자 쿼리는 게이트웨이 계층(PgBouncer)에서 우선순위를 분류될 수 있습니다. 하나 또는 여러 개의 글로벌 우선순위 대기열 내에서 모든 테넌트의 쿼리 제출은 테넌트의 connection pool 또는 테넌트 자체의 현재 리소스 소비량을 기준으로 순서가 지정됩니다. 또는 각 쿼리가 독립적으로 프로파일링되는 각 쿼리의 과거 리소스 소비량을 기준으로 순서를 정할 수도 있습니다. 각 Postgres 인스턴스의 서버에서 캡처한 테넌트 리소스 소비의 변경 사항을 기반으로, 스케줄러가 제출할 쿼리를 전달할 때마다 큐에 대기 중인 모든 쿼리의 순서를 다시 지정할 수 있습니다.
우선순위 큐 고갈(한 테넌트의 쿼리가 큐의 끝에 위치하여 실행되지 않는 현상)을 방지하기 위해, 게이트웨이 수준 쿼리 큐잉은 Postgres 인스턴스에 대한 최대 부하/트래픽이 있을 때만 활성화되도록 구성할 수 있습니다. 또는 쿼리를 큐에 대기시키는 시간을 우선 순위 지정에 반영할 수 있습니다.
이 접근 방식은 문제가 없는 테넌트가 연결을 계속 예약하고 쿼리(예: 중요한 상태 모니터링 쿼리)를 실행할 수 있도록 허용하여 테넌트 성능을 격리합니다. 더 많은 리소스를 사용하는 테넌트(트랜잭션이 많거나 비용이 많이 드는 테넌트)에서만 더 높은 지연 시간이 관찰될 수 있습니다. 이 접근 방식은 이해하기 쉽고, 애플리케이션에서 일반적으로 사용되는 접근이며(다른 입력 메트릭을 기반으로 트랜잭션을 큐에 대기시킬 수 있음), 클라이언트/서버 연결을 종료하지 않으므로 비파괴적인 방법 입니다. 그리고 이 방법은 인메모리 우선순위 큐가 용량에 도달할 때만 쿼리를 드롭 해야 합니다.
결론
멀티테넌트 스토리지 환경에서의 성능 격리는 OS 리소스 관리, 데이터베이스 내부, 큐잉 이론, 혼잡 알고리즘, 심지어 통계까지 포함하는 영역에 영향을 미치는 매우 흥미로운 과제입니다. 커뮤니티에서 테넌트 성능을 대규모로 격리하여 "시끄러운 이웃" 문제를 어떻게 해결했는지 여러분의 의견을 듣고 싶습니다!
안녕하세요 주간 SaaS 입니다. 오늘은 Cloudflare 의 멀티 테넌트 데이터베이스 운영 사례를 담은 아티클을 소개 했습니다. 원문은 이곳에서 확인할 수 있습니다.
B2B SaaS 서비스에서 격리(Isolation)는 선택의 문제가 아니라 필수 입니다. 또한 테넌트 데이터 격리 실패로 보안 문제로 이어진다면 생존의 문제가 되기도 합니다. 중요한 문제인 만큼 SaaS 서비스 아키텍처 모든 레벨에서 격리 구조를 설계하는 것은 분명 도전 과제 입니다.
특별히 데이터베이스 계층에서 테넌트 격리를 구현할 때는 여러 요소가 고려되어야 합니다. 이 과정에서 때로는 규모의 경제 달성, 테넌트 성능 격리/보장, 시끄러운 이웃 문제 해결, 테넌트 간 교차 접근 방지 등 상충되는 목표들이 서로 충돌하기도 합니다. 오늘 소개한 Cloudflare 의 멀티 테넌트 성능/격리에 관한 사례는 이 과정에서 최적의 해결책을 찾는데 도움이 되는 내용을 담고 있습니다. 꼭 읽어보세요!
의견을 남겨주세요