[Prometheus] 메모리 사용 구조 및 최적화
Prometheus는 메트릭 관리에 주로 사용되는 특수한 형태의 시계열 데이터베이스다.
시계열 데이터 삽입 처리량, 통계 쿼리 등에 최적화되어있으나, 특유의 구조 탓에 메모리를 굉장히 비효율적으로 소비한다는 단점이 있다.
그냥 이런 식으로 띄우고 좀 때리다보면 메모리가 급격하게 치솟는 것을 쉽게 경험할 수 있다.
prometheus의 내부 메모리 사용 구조와 대응책에 대해서 한번 정리해보겠다.
메모리 사용 구조
쓰기 버퍼
이건 시계열 데이터베이스들의 공통점이기도 한데, 대용량 쓰기 처리능력을 얻기 위해서 디스크에 쓰기 전에 메모리 버퍼에 모아놨다가 한번에 디스크에 flush하는 구조를 갖고 있다.
그래서 쓰기가 대량으로 발생할 시점에는 급격하게 메모리가 튈 수 있다는 단점이 있다.
Prometheus의 경우에는 tsdb를 기본 백엔드로 사용하고, 2시간 동안 메모리에 쓰기 데이터를 보관하다가 2시간이 지나면 디스크에 쓰는 구조를 가지고 있다.
https://devthomas.tistory.com/67
쓰기가 발생했을때 도착하는 최초 지점을 Head라고 부른다.
이 시점에는 메모리에 데이터를 로드해놓고, 혹시모를 장애상황을 대비해 WAL만을 쌓는다.
그리고 block duration(기본 2시간)이 지나면 Head 데이터를 디스크인 Block으로 이관하는 것이다.
레이블과 카디널리티
각 row의 레이블 값은 메모리 사용량이 비대한 영향을 줄 수 있다.
빠른 통계 쿼리 기능을 제공하기 위해서 각 레이블들의 고유한 조합을 또 데이터로 저장하고 메모리에도 로드해놓기 때문이다.
그래서 레이블의 절대적인 갯수가 많거나, 레이블의 값의 종류가 다양하다거나 하면 비상식적인 정도로 메모리 소비가 튈 수 있다.
여기서 특정 필드 값의 범위 다양성을 카디널리티라고 한다. influxDB 등의 시계열 DB들이 공통적으로 안고 있는 한계점이다.
GO와 GOGC
prometheus는 go로 만들어진 데이터베이스다.
그래서 go가 가진 선천적인 단점도 갖고 있는데, 게으른 메모리 해제로 인한 메모리 누수가 반드시 발생한다는 것이다. tcmalloc이란 끔찍한 allocator를 사용해서 메모리 풀 계층이 2단계라 발생하는 문제다.
이런 까닭에 gogc로 memory limit을 잡고, gc가 메모리를 해제하더라도 그게 진짜 os에 반납하기까지는 딜레이가 존재한다.
GO가 인지하는 메모리 사용량과 실제 메모리 사용량이 3배까지 차이나는 것도 흔하다. prometheus pprof 뽑고 호스트 기준에서 측정한 메모리 사용량과 비교해보면 얼마나 대충 처리가 되고 있는지 알 수 있다.
수치 분석
prometheus는 자체적인 관리용 대시보드를 제공한다.
여기서 사용량이 많은 레이블을 바로 볼 수도 있다.

여기서 Count가 높을 수록 나쁜 레이블이고, 가급적 제거나 최적화 대상으로 삼는게 좋다.
예를 들어 값의 범위가 굉장히 다양할 수 있는 response body나 url 같은 저런 필드들은 prometheus에 넣으면 안된다.
prometheus 자체의 실행 옵션도 볼 수 있다.
그냥 GO 기반의 옵션도 몇개 다.
GOMEMLIMIT은 기본값으로 현재 시스템의 90%를 잡는다. 근데 사실 누수가 많아서 크게 의미가 없더라. 무시하고 넘겨서 곧잘 터진다.
또 go 기반의 pprof 데이터도 제공한다.
많은걸 GO 시스템에 기대서 날로먹는거같다.

이러면 그냥 다운되고

시각화해서 보면 된다.
물론 큰 도움이 되기는 어렵다.
전략 A. label 갯수 줄이기
label이 너무 많다면 그것 자체도 메모리 사용에 지대한 영향을 미칠 수 있다.
레이블은 가능한 한 필드 자체가 적어야 하고, 레이블의 카디널리티(값 분포도) 또한 낮아야 한다.
특히 spanmetric 같은걸로 metric을 구성한다면 남용할 여지가 좀더 많기도 하다.
적당히 빼고
기존에 있던것도 좀 지워주자.
전략 B. Block Duration 조절
단시간 내에 매우 많은 데이터가 들어온다면 기본 block-duration(2시간) 안에 너무 많은 데이터가 올라가있어서 메모리가 부풀어오를 수 있다.
이 값이 2시간인 이유는 사실 쿼리 성능만을 위해서 이렇게 되어있는 것이고, 쿼리가 좀 느려지더라도 안정적인 메모리 사용이 필요하다면 block duration을 좁은 범위로 줄이는게 좋은 방법일 수 있다.
옵션은 실행할때 인자로 넘기면 된다.
- --storage.tsdb.max-block-duration=30m
- --storage.tsdb.min-block-duration=30m
이래놓고 다시 띄우면 된다.
나는 이것만으로 절반 정도의 메모리 사용량 절감 효과를 봤다.
전략 C. GOGC 옵션 조절
GOGC나 GOMEMLIMIT을 조절하는 것도 하나의 방법이 될 수 있다.
GOMEMLIMIT의 기본값은 실제 메모리의 90%를 차지하게 하는데, 특유의 누수 패턴으로 인해서 신뢰하기 어렵다.
이걸 임의로 지정하고 싶다면 실행시에 인자를 넘겨주면 된다.
- --auto-gomemlimit.ratio=0.5
하드코딩하는 식으로는 안되고, 비율로만 지정이 된다.
0.5면 시스템 메모리의 절반만 차지하게 하는 것이다.
근데 이렇게 너무 빡세게 걸면 GC에 리소스 소모하면서 버벅대니까, 적절히 요령껏 잘 잡아야 한다.
참조
https://prometheus.io/docs/prometheus/1.8/storage/
https://signoz.io/guides/why-does-prometheus-consume-so-much-memory/
https://prometheus.io/docs/prometheus/latest/command-line/prometheus/
https://grafana.com/blog/2022/03/21/how-relabeling-in-prometheus-works/
https://devthomas.tistory.com/67