brunch

You can make anything
by writing

C.S.Lewis

by 손주식 Aug 26. 2019

Pinterest의 지표 시스템 업그레이드 이야기

Tech Review of Pinterest #4

지난 리뷰

https://brunch.co.kr/@andrewhwan/59

https://brunch.co.kr/@sonjoosik/10

https://brunch.co.kr/@andrewhwan/60


원문

https://medium.com/pinterest-engineering/upgrading-pinterest-operational-metrics-8718d058079a


위 원문을 번역하였습니다. 이해를 위해 의역한 부분도 포함되어 있습니다. 


최근 Pinterest는 월간 활성 사용자 3억 명이라는 중요한 목표를 달성하였다. Pinterest 기술팀은 Pinterest의 거대한 콘텐츠 덩어리를 이 수많은 사용자들에게 안정적으로 제공하기 위해서, 수 천 개의 서비스들이 항상 긴밀하게 연결되어 잘 동작할 수 있도록 유지하고 있다. 각 서비스에서는 상태 모니터링과 경보 시스템을 위한 지표들이 뿜어져 나온다. Visibility 팀은 이러한 지표들의 수집과 보고, 처리, 그리고 시각화를 돕는 시스템의 유지보수를 책임진다.


왜 업그레이드를 하였나요?


최근까지, Pinterest에서는 Java 서비스들의 지표를 수집하기 위해 Twitter의 Ostrich 라이브러리를 사용하였습니다. 그런데 Ostrich는 유지보수가 중단되는 바람에 기술 부채가 되었고, 오래된 버전의 Finagle을 쓸 수밖에 없게 되었습니다. 가장 문제였던 부분은 Ostrich가 오직 Histogram분포의 요약만 표시해주기 때문에 우리가 서비스 단위로 지표들을 집계할 때 정확한 값을 얻을 수 없다는 점이었습니다. 세 개 장비로 구성된 다음 예제를 봅시다. 각 host는 응답 시간의 90th percentile(핑크색으로 표시된 16, 6, 8) 값을 보여준다. 세 개로 샘플링된 데이터 값으로는 실제 90th percentile 값인 12를 찾는 것이 불가능하다.


세 host에서의 세 가지 분포, 각각의 p90


세 host 전체의 p90 값은 p90들만 샘플링해서는 찾을 수 없다.


이것은 몇 가지 문제를 일으킨다.


운영 지표들은 node마다 만들어지기 때문에 Pinterest의 사용자에 비례하여 지표 개수도 많아진다. 이로 인해 지표 저장소의 요구사항이 매년 대폭으로 증가할 수 있다.

우리는 실제 percentile 값을 추정하기 위해 지표 percentile 분포의 평균을 사용하고 있다. 이것은 정확한 percentile 값을 표현하지 못하는데, 중요한 경보가 이 값에 의존하게 될지도 모른다. 정확하지 않은 percentile 지표는 false positive, 심지어는 false negative까지 만들어낼 수도 있다.


이에 대한 해결책은 두 단계이다.


우리는 반드시 현재 방식으로 지표를 수집하는 것을 중단하고, 내부 구현을 직접 관리할 수 있는 지표 수집/정리 도구를 자체 개발하여야 한다. Histogram 지표는 T-Digest 기반으로 만들 것이다. T-Digest는 간단하고, 직렬화가 가능하고, 정확하고, 게다가 병합이 가능한 Histogram 자료구조이다.

서비스 단위 지표를 집계하기 위하여 언어에 구애받지 않는 지표 집계 파이프라인이 반드시 만들어져야 한다. node 단위 지표가 필요한 경우에는, 짧은 기간 동안이라도 node 단위 지표를 유지하는 옵션은 꼭 있어야 한다.


이 두 가지 프로젝트가 Pinterest의 지표 시스템을 훨씬 거 관리하기 쉽게 만들어주었다. 불필요한 node 단위 지표들을 삭제했기 때문에 지표 저장소에 대한 요구사항들은 대폭 감소되어고, 데이터가 적어졌기 때문에 질의 성능 또한 많이 향상되었다. 그리고, 정확한 서비스 단위 percentile 지표가 Pinterest 전체에서 활용 가능해졌다.


Pinterest StatsCollector


Ostrich 사용을 중단하기 위해 Java 지표 수집/정리 라이브러리인 Pinterest StatsCollector를 만들었다. StatsCollector는 thread-safe 한 방식으로 세 종류(Counter, Gauge, Histogram)의 지표를 수집할 수 있어야 했고, 지표들이 시계열 데이터베이스(Time series database, TSDB)에서 영구적으로 보관될 수 있도록 매 분마다 집계 파이프라인으로 보내주어야 했다. 추후에 있을 마이그레이션 단계를 수월하게 하기 위하여, 우리는 개발 과정에서 CPU 성능과 기존 레거시 시스템과의 API 호환성에 대한 최적화에 신경 썼다.


큰 그림으로 보면, 이 라이브러리는 아래와 같이 구성되어 있다.


StatsCollector: Counter, Gauge, Histogram을 만들어내는 인터페이스

Stats: 하나의 StatsCollector를 감싸는 간단한 static singleton class wrapper. JVM 하나 당 하나 존재. 사용자들은 이 객체를 호출.

Metrics: Counter, Gauge, Histogram의 thread-safe 한 구현체

LogPusher: push 기반 모델을 사용. 이 클래스는 지표들을 StatsCollector에서 metrics-agent로 보냄. metrics-agent는 Pinterest의 모든 장비에서 실행되면서 지표들을 후처리 하여 적절한 목적지로 전달해주는 역할

ThreadLocalStats: thread-local 배치 지표를 위한 편리한 인터페이스. 성능 목적.


지표의 흐름


API 설계 결정


StatsCollector와 Stats 인터페이스를 설계할 때, 우리는 기존 지표 코드의 패턴을 찾기 위해서 Java 코드 베이스를 전반적으로 분석해보았다. 몇 가지 치명적인 사용 방식과 성능 병목지점이 발견되었고 이러한 문제를 완화시키는 방향으로 라이브러리는 설계하였다. 아래는 그런 응용 레벨과 사용자 레벨의 최적화에 대한 내용이다.


최적화 1: synchronized hashmap lookup 캐싱하기


먼저, 우리는 지표가 StatsCollector로 보내지는 과정부터 변경하였다. 증가하는 Counter가 하나 있다고 생각해보자. 기존 방법은 Stats singleton에 존재하는 incr를 직접 호출하는 방식이었다. 그러나, 각 지표의 이름과 Counter를 mapping 시킬 때 매번 synchronized hashmap loopup이 발생하고, 이것은 queries-per-second (QPS)가 높은 함수에서 호출될 때 성능 문제를 야기하였다. 각 JVM에는 정확히 하나씩 mapping 되는 지표 이름과 Counter가 존재하기 때문에 모든 synchronized lookup들은 하나의 hashmap에서 수행되었고, lock 동작 시 user mode에서 kernel mode로 넘어가는 비싼 process-level swithcing이 발생하기 때문에 lock thrashing이 일어났다.


최적화를 위하여, 우리는 synchronized lookup을 효과적으로 캐싱하는 새로운 API로 넘어갔다. 사용자는 이제 Stats singleton을 분배하는 Counter 객체에 대한 하나의 reference만 유지하면 되고, 모든 지표 연산은 이 객체에서 수행된다.

https://gist.github.com/jonfung/6371e472bbbb2a4e9987b24b675a4756#file-optimization-java


최적화 2: Thread-Local Stats


그러나, 우리의 지표 이름이 가변적으로 바뀌게 되면서 첫 번째 최적화가 더 이상 먹히지 않게 되었다. 여러 이벤트를 처리하는 하나의 반복문과 같은 배치 연산에서 가변적인 이름을 가진 counter 지표를 증가시켜야 한다고 가정해보자. 앞에서 말한 방법으로는 함수 호출마다 매번 synchronized hashmap lookup이 발생하기 때문에 성능 이득이 없을 것이다.


이러한 경우에도 빠르게 동작하기 위해, 우리는 모든 hashmap lookup과 지표 증가에서 synchronization 과정을 제거하였다. StatsCollector와 그 내부 지표들의 thread-local 버전이고, 어떠한 lock 동작도 포함하지 않는 Thread-Local Stats를 만들었다. 사용자가 그 Thread-Local Stats를 StatsCollector 쪽으로 flush 하기 전까지는 Thread-Local Stats가 하나의 배치를 위한 지표 바구니와 같은 역할을 해야 한다. Thread-Local Stats는 thread-local이기 때문에, 지표 연산을 하는 동안 어떤 synchronization 작업도 필요 없고, user mode에서 kernel mode로의 switching 과정을 제거함으로써 성능을 최적화할 수 있었다.


https://gist.github.com/jonfung/93e2de84218826e3b83e069fd2b46448#file-threadlocalstatscollector-java


최적화 3: Gauge API 설계


특정 이벤트의 발생률을 측정하는 Counter와는 반대로, Gauge는 특정 값을 모니터링하는 함수로 생각하면 된다. Gauge의 고전적인 예제는 리스트의 크기를 모니터링하는 함수이다. Gauge가 한번 초기화된 다음에는, LogPusher가 지표를 보낼 때마다 계속 그 값이 전달된다.


Gauge를 초기화하는 예전 방식은, Java Supplier나 Scala의 function0 타입의 익명 클래스를 인라인 화하는 것이다. 이것은 parameter가 없고 하나의 double 값을 만들어내는 lambda로 생각해볼 수 있다. 이것은 모니터링되는 프로그램의 memory footprint를 증가시키기 때문에 사용자 입장에서 좀 다루기 까다롭고, 성능도 썩 좋지 못하다. 이 객체에 대한 reference는 반드시 익명 인라인 클래스 내부에 유지되어야 하는데, 그러면 JVM에 영영 이 객체를 garbage collection 대상으로 마킹을 하지 못하게 된다. 왜냐하면 객체에 대한 모든 reference가 사라졌을 때만 garbage collection이 될 수 있기 때문이다. 이것은 좋은 방식이 아닌데, 모니터링 시스템은 사용자의 시스템 성능에 영향을 주지 말아야 하기 때문이다.


https://gist.github.com/jonfung/e4dd0db2b5060b2c822ec74cffa609bf#file-gauges-java


최적화를 위하여, 우리는 Micrometer 라이브러리에서 영감을 얻어서 Java의 WeakReference를 우리의 Gauge에 적용하기로 하였다. 객체에 대한 weak reference는 garbage collection을 방해하지 않는다. 따라서 Gauge에 의해서 모니터링되고 있는 객체에 대한 strong reference를 유지하는 것은 라이브러리 사용자의 책임이 된다. 사용자가 모니터링을 그만두고 strong reference를 제거하면, JVM의 garbage collector는 그 객체를 수집할 수 있게 된다. 이러한 개선 덕분에, 우리 모니터링 시스템의 Gauge는 사용자에게 영향을 주지 않고 추가 메모리도 사용하지 않게 되었다.


서비스 단위 지표 집계 (MABS)


자체 개발한 Pinterest StatsCollector를 통해 지표들이 흘러나오게 되면서, 우리는 좀 더 이 지표들의 처리에 신경을 쓰게 되었다. 그래서 이 언어에 구애받지 않고 서비스 단위로 지표들을 집계하여 제공해주는 MABS 파이프라인을 개발하게 되었다. 완성된 파이프라인은 한 서비스에 포함되는 모든 node들이 MABS를 통해 그들의 지표들을 제공할 수 있게 하고, node 단위 지표들로부터 지표 당 하나의 숫자 값이 만들어지도록 집계한다. 우리는 또한 사용자들이 node 단위 지표를 남겨둘 수 있게 하는 옵션을 제공하였다.


큰 그림으로 보면, MABS는 아래와 같이 구성된다.



Metrics-Agent: 새로운 명령어를 처리할 수 있어야 하고, 적절한 kafka topic으로 전달하기 전에 필요한 전처리를 수행한다.

Kafka topic: Kafka는 MABS의 각 컴포넌트들 사이에서 스트리밍 버퍼로 사용된다.

Spark aggregator: 지표들을 집계하기 위해 항상 돌아가는 Job이다.

Ingestors: 데이터를 저장소 시스템으로 전달하는 서비스이다.

Time series database: Pinterest의 Storage&Caching 팀에서는 자체 제작한 TSDB인 Goku를 유지 보수하고 있다. Goku는 다양한 저장소 단위를 제공하기 때문에 우리가 짧은 기간 동안 node 단위 지표를 저장하기에도 딱 알맞다.


MABS 파이프라인의 앞뒤 모습


실제 사용


각 서비스는 TCP 위에서 평문 MABS 명령어를 보내는 식으로 Metrics-Agent와 통신한다. 이 선을 통해 Counter와 Gauge는 각각 integer와 double 형태로 전달된다. Histogram은 T-Digest 기반의 Base64로 인코딩 된 직렬화 형태로 전달된다. 명령어에 by_node=True 옵션이 붙으면, host 단위 지표를 짧은 기간 동안 보관하라는 표시가 된다.


수집 후에는 Metrics-Agent는 적절한 후처리를 수행하고 적절한 Kafka topic으로 지표들을 보낸다. Kafka topic은 지표들을 Spark 집계 작업으로 보내기 전에 버퍼링 역할을 하고, Spark 집계 작업의 결과는 Goku TSDB에 기록되기 위하여 Ingestor로 보내진다.


영향도


Pinterest의 StatsCollector와 MABS는 우리의 지표 파이프라인에 중요한 가치를 제공한다. 서비스들을 MABS 파이프라인으로 이전함으로써, 우리는 전체 서비스에 대해 수직으로 집계된 지표들을 볼 수 있게 되었다. 이는 우리가 TSDB에 저장하는 데이터의 크기를 대폭 감소시켰다.


모든 장비군의 p95 값들이 아닌, 오직 정확한 p95 값 하나만 저장된다.


MABS를 Pinterest 내부 지표에 적용하면서, 우리는 지표에서 host 태그를 제거할 수 있었다. 이는 지표 저장소의 99%를 절약하게 만들어주었다.


저장소 공간과 운영 비용 절약뿐만 아니라, MABS는 최종적으로 Pinterest가 정확한 Histogram 지표에 접근할 수 있게 해 주었다. Percentile 지표는 서비스의 상태를 모니터링할 때 매우 중요하다. 정확한 지표를 얻을 수 있다면, false positive와 false negative 경보를 줄일 수 있다. 아래의 그래프는 우리가 MABS를 통해 향상한 실제 응답 시간이다. 붉은색 라인이 기존 방식의 max 함수로 집계된 histogram 지표이고, 파란색 라인은 MABS에서 집계한 실제 지표이다. MABS 지표가 훨씬 덜 흔들리고 실제 그림의 더 정확한 표현을 보여주는데, 사실 가장 중요한 점은 더 이상 거짓으로 경보를 발생시키지 않는다는 점이다.


MABS 파이프라인에는 false spike가 없다.


결론


소프트웨어 회사에서 지표는 중요한 역할을 수행한다. 지표를 수집하고 보여주는 믿을만한 방법이 없으면, 소프트웨어 개발자들은 장님이다 다름없다. 속도, 방향, 고도 계기판이 아예 없는 비행기가 날아다니는 것을 상상해보라! MABS는 우리 지표 시스템이 좀 더 확장 가능하고, 튼튼하며 정확해지는 다음 단계로 나아갈 수 있게 해 주었다. 우리의 인프라스트럭쳐는 현재 월간 3억 명의 Pinner들이 그들이 원하는 삶을 살 수 있도록 도와주고 있다. Pinterest가 또 다음 3억 명의 Pinner들을 향해 움직이고 있기 때문에, Visibility 팀도 Pinner들에게 더 부드럽고 영감을 일으키는 경험을 제공할 수 있는 인프라를 만들고, 또 더 발전하도록 계속해서 노력할 것이다.



브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari