High Cardinality의 위험성

1,565 단어·4 분·원문(.md)

High Cardinality 고카디널리티란 데이터베이스나 시스템에서 특정 인덱스 컬럼 또는 메트릭, 라벨 조합이 가질 수 있는 고유한 값 개수가 매우 많은 상태를 의미한다.

수학적 집합내 고유 원소의 수를 뜻하는건데, 데이터 관점에서 성별(남/여), http method(GET/POST/PUT)는 카디널리티가 낮고 이메일 주소나 uuid는 카디널리티가 높다.

모니터링 시스템 내에서 정의는 시계열 데이터베이스 tsdb, prometheus influxdb나 대규모 로그 시스템에서는 metric name, label key-value 쌍의 조합이 무한정에 가깝게 늘어나면서 고유한 시계열 데이터가 폭발적으로 생성되는 현상을 뜻한다.

Problem #

High Cardinality는 단순히 데이터가 많아지는 것을 넘어, 모니터링 시스템의 근간을 흔들고 전체 서비스 가시성을 잃게 만드는 장애 포인트인데

  • 메모리 고갈 OOM: 모니터링 시스템은 빠른 데이터 처리를 위해 인덱스 시계열 메타데이터를 메모리에 적재해둔다. 고유한 조합이 수백만으로 늘어나게 된다면 시스템의 메모리 사용량이 급증해 결국 oom killer에 의해 프로세스가 강제 종료된다.
  • 쿼리 성능 저하 및 대시보드 마비: 특정 조건으로 데이터를 조회할 때 엔진이 스캔해야할 인덱스와 시계열 데이터 블록이 방대해진다. 이로인해 그라파나 같은 대시보드 로딩이 극심해지거나 쿼리 타임아웃이 날수도있다.
  • 디스크 I/O 병목 및 스토리지 낭비: 시계열 데이터베이스는 유사한 패턴의 데이터를 델타 압축하여 저장 효율을 높인다. 하지만 값이 매번 달라지는 high cardinality 데이터는 압축 효율이 극단적으로 떨어져 디스크 쓰기 부하를 일으키고 저장공간을 빠르게 소진시킨다

Example #

웹 서비스 api 응답 상태를 모니터링하기 위해 http_requests_total 이라는 메트릭을 수집한다고 가정해보겠다.

정상적인 상황이라면 http_requests_total{method="GET", "status_code"="200"} 여기서 method는 4~5개 status_code는 10여개 내외로 고유값을 가진다. 이를 모두 조합해봐야 수십에서 수백개의 시계열 데이터만 생성돼서 시스템은 매우 안정적으로 동작한다.

장애를 유발하는 상황 예시를 들어보겠다. 특정 사용자의 에러를 디테일하게 추적하겠다는 목적으로 메트릭에 userid clientid를 라벨에 추가해 배포한다면

http_requests_total{method="GET", status_code="200", user_id="1093A4", client_ip="192.168.1.55"} 이렇게 나오게 될건데

이렇게 되면 만약 서비스의 일간 활성 사용자 dau가 100만명이라면 하나의 메트릭 이름 아래에 수백만개의 이상한 고유 시계열 데이터가 순간적으로 폭발하게 되는것이고. 배포 직후 몇 분 지나지 않아 메트릭 수집기가 메모리 한계를 초과하여 죽어버리고, 정작 장애 상황을 바야할 때 모니터링 화면이 완전히 백지화되는 블라인드 상태에 빠지게된다.

Solution #

이 문제는 인프라 스펙을 늘리는것만으로 해결할 수 없고. 근본적으로 데이터의 차원 자체를 통제해야한다.

간단하게 수집 단계에서 문제가 되는 라벨을 제거하는 것 부터 시작할 수 있고 해당 라벨의 카디널리티가 어느정도 되는지 사전에 유추해서 추가하지 않는 자세도 필요하다.

# prometheus.yml
scrape_configs:
  - job_name: 'api_server'
    static_configs:
      - targets: ['localhost:8080']
    metric_relabel_configs:
      # user_id, client_ip, session_id 라벨이 들어오면 무시하고 수집하지 않음
      - action: labeldrop
        regex: "(user_id|client_ip|session_id)"

TSDB 상태 분석을 통한 주범 색출도 가능하다. 이미 시스템이 메모리 임계치에 도달했다면

어떤 메트릭과 라벨이 범인인지 주범을 색출해내자. TSDB 분석 도구를 활용할 수 있다.

# Prometheus 데이터 디렉토리를 대상으로 tsdb 블록을 분석
$ promtool tsdb analyze /var/lib/prometheus/data

Block ID: 01H8X7A...
Duration: 2h0m0s
Series: 2504192
Label names: 15
Postings (unique label pairs): 3150201

# 1. 가장 높은 카디널리티를 가진 메트릭 이름 TOP 10
High Cardinality Metric Names:
1054320 http_requests_total
 500210 db_query_duration_seconds_bucket
  12000 node_cpu_seconds_total

# 2. 가장 높은 카디널리티를 가진 라벨 이름 TOP 10
High Cardinality Label Names:
1540000 user_id
 900500 client_ip
   5040 instance

# 3. 메모리를 가장 많이 점유하는 라벨 조합
Highest Cardinality Labels:
2014020 {job="api_server", __name__="http_requests_total"}

이 결과를 바탕으로 user_id, client_id 라벨이 http_requests_total 메트릭에서 무수히 많은 조합을 만들어내고 있음을 숫자로 증명이 가능하다.

이를 근거로 코드 수정을 요청하자.

마지막으로 메트릭과 로그 역할의 분리인데 가장 근본적인 아키텍처 개선 방법이다.

유저id, transaction id, ip 주소처럼 값이 무한으로 늘어나는 데이터는 사실상 메트릭 시스템에 저장해선 안된다

이러한 고유값에 대한 디버깅 및 추적이 필요하다면 매트릭에 라벨을 추가할 것이 아니라, 해당 정보를 포함한 애프릴케이션 로그를 ELK Stack, Loki, Datadog Logs등으로 전송하거나

분산 추적 tracing 시스템을 활용하도록 역할을 엄격히 분리해야한다.

SRE/question/q_29.md