🐌 학습노트/낙서장

[모니터링의 새로운 미래 관측가능성 #4] 4장. 오픈소스 관측가능성, 그라파나

mini_world 2024. 2. 27. 22:40

📌 이 내용은 책 내용 메모입니다.
개인적인 생각과 경험 그리고 여러 잡담이 있으니, 책을 읽으며 의견을 나누고싶은분이 봐주시면 좋겠습니다.

 

이번 4장에서는 그라파나 LGTM스택을 설치하고, 기반기술을 이해할 수 있도록 설명한다.

 

4.1 그라파나 관측가능성

 

4.1.1 목적과 범위

이 책에서 말하는 관측가능성은 LGTM Stack(Loki, Grafanam Tempo, Mimir)이다.
그 많은 툴 중에 LGTM Stack을 선정한 이유는 아래와 같다고 한다. 

  • 대중적이고 라이선스에 자유로운 오픈소스
  • 지속적이고 장기적인 로드맵과 많은 커뮤니티
  • 기술적으로 우수하고 클러스터 구상이 가능하며 많은 API 제공

생각해보면 ElastiSearch는 이제 더이상 오픈소스가 아니라 OpenSearch로 갈라선 반면에 LGTM은 오픈소스로 끊임없이 개발되고 관리되는것 같다. 이 책에서 기반기술에 대해 배우면서도 얼마나 기술적으로 우수한지도 확인했기때문에 LGTM을 선정한 이유는 납득이 가능하다. 👍

그럼 LGTM Stack에 대해 알아보자!

데이터 수집기(예: Promtail, Fluentbit/Fluentd, Telegraf, Vector, Exporter)가 다양한 출처로부터 데이터를 수집하여OpenTelemetry로 전달하고, OpenTelemetry는 이 데이터를 처리한 후 LGTM 스택(Loki, Grafana, Tempo, Mimir)으로 전송한다.
LGTM 스택은 데이터를 분석/시각화/관리하는 도구이며, 오브젝트 스토리지에 데이터를 저장하여 장기적으로 활용할 수 있도록 한다.

 

  • L, Loki: 로그 관리
  • G, Grafana: 대시보드
  • T, Tempo: 추적 관리
  • M, Mimir: 메트릭 관리

 

 

4.1.2 인프라 구성

일단 minikube, helm, kubectl을 설치한다.
다만, 설치 자체가 너무 간단하고, 시간이 지나면 변하기 마련이라 설치방법을 적어놓지 않고 링크만 첨부한다.

- minikube:  https://minikube.sigs.k8s.io/docs/start/
- helm: https://helm.sh/docs/helm/helm_install/
- kubectl: https://kubernetes.io/docs/tasks/tools/install-kubectl-macos/

단, minikube설치 후 실행할때 리소스를 넉넉하게 할당해주어야 모든 리소스를 켤 수 있다.

minikube start --memory=12000 --cpus=4

 

4.1.3 애플리케이션 구성

LGTM 스택의 기반이 되는 애플리케이션을 설명하고 설치한다.

 

Minio

오픈소스 객체 스토리지이며, S3와 호환되는 API를 제공한다.
LGTM 스택에서는 디스크 IO에 가장 큰 성능감소가 발생하며, 이를 모니터링하고 성능을 개선하기 위해 많은 튜닝작업이 필요하다.
이때, Minio는 상세한 메트릭과 수치를 제공하므로 많은 도움을 받을 수 있다.

minio 설치는 앞서 이전 포스팅에서 다룬적 있다.
이 책에서는 helm으로 설치하지만 바이너리로 실행시키는걸 아래 더보기로 적어놓는다.

  👇👇 더보기로 Minio 로컬 설치방법 확인하기

더보기

[Minio 로컬 실행 방법]
스토리지로 사용할 minio를 다운받고 실행시킨다. 

# 참고: https://min.io/download#/macos

# 서버 다운로드 
curl --progress-bar -O https://dl.min.io/server/minio/release/darwin-arm64/minio

# minio가 사용할 디렉토리 생성
mkdir minio-dir

# minio 실행
chmod +x minio
MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=password ./minio server ./minio-dir --console-address ":9001"

이렇게 실행시키면 로컬에서 브라우저로 접속할 수 있다. (http://127.0.0.1:9001)

 

Redis

Redis는 가장 대중적인 NoSQL데이터베이스이자 인메모리 데이터 저장소이다. 
샤딩으로 클러스터 구성이 가능하며, 메모리에서 운영되므로 디스크에 비해 수십 배 빠른 성능을 제공한다.
LGTM스택에서 Redis를 사용하여 고성능 캐시를 구현한다. (쿼리 처리 속도 개선)

  👇👇 더보기로 Redis(+Redis 툴) 로컬 설치방법 확인하기

더보기

[Redis 로컬 실행 방법]

참고링크:https://redis.io/docs/install/install-redis/install-redis-on-mac-os/

# mac에 설치
brew install redis

# redis 실행
brew services start redis

# redis 정보 확인
brew services info redis

# redis cli 
redis-cli

Redis를 사용하기 위한 툴들이 여기(https://redis.io/resources/tools/)에 소개되어있다.
이 책에서는 redis-commander(Redis 웹인터페이스)를 사용하니, 로컬에 한번 설치해보자

# nodejs가 설치되어있다면 아래 명령어로 바로 설치할 수 있다.
npm install -g redis-commander

# 실행
redis-commander

# 지울땐
# npm uninstall -g redis-commander

설치하고나면 로컬에서 http://127.0.0.1:8081/ 여기로 접속하면 된다.

 

Memcached

Memcached도 마찬가지로  NoSQL데이터베이스이자 인메모리 데이터 저장소이다. (홈페이지)
Memcached는 멀티쓰레드를 지원하는 장점이 있으며, LGTM스택에서 기본적으로  Memcached를 사용한다. 
하지만 이 책에서는 다양한 구성을 소개하고자(?) Redis 위주로 실습을 진행한다.

항목 Memcached Redis
데이터 분할 지원 (ClientSide Sharding) 지원 (ServerSide Sharding)
다양한 데이터 구조 지원 미지원 (string) 지원 (string, hash, list 등)
스레드 모델 멀티스레드 싱글스레드
데이터 저장 미지원 지원
데이터 복제 미지원 지원 (Master-Slave 복제)
트랜젝션 지원 미지원 지원
Pub/Sub 미지원 지원

 

Consul

HashiCorp에서 관리하는 오픈소스 서비스 레지스트리 이다.
LGTM은 단독서버가 아니며 클러스터내 다수의 서버로 구성된다.
그러므로 네트워크 구성 및 정보 관리를 위해 사용하는것이 Consul이다.

과거에는 주키퍼를 사용했으나, 최근에는 Consul을 사용한다고 한다.

LGTM 스택에서는 키-값 관리를 위해 Etcd, Memberlist, Consul 세가지 오픈소스를 지원한다.
기본적으로는 Memberlist를 기본적으로 사용한다. 

다만, 이 책에서는 Consul을 사용하여 실습을 진행한다.

Consul이 어떻게 상호작용하는건지 알아보니 동작하는 방식 각 서버에 Consul 에이전트를 설치하고, 모든 에이전트가 Consul 서버 또는 서버 클러스터와 통신할 수 있도록 설정하는것 이다.

예를들어 5개의 hadoop cluster가 ec2에 설치되어있다고 한다면, 각 ec2서버에 consul client를 설치하고, 
consul서버에 등록한다.(consul 서버는 3~5개 사용하도록 권장)

# 참고: https://www.atlantic.net/vps-hosting/how-to-install-consul-server-on-ubuntu/
# Consul Server 구성
vi /etc/consul.d/config.json
---------------------------------
{
  "server": true,
  "node_name": "consul-server-1",
  "data_dir": "/var/consul",
  "bind_addr": "0.0.0.0",
  "client_addr": "0.0.0.0",
  "bootstrap_expect": 1,
  "ui": true
}
---------------------------------
consul agent -config-dir=/etc/consul.d/ -server -bootstrap-expect=1 -ui


# 각 서버에서 서비스 정의 파일로 Consul Agent 실행
vi consul-hadoop.json
---------------------------------
{
  "service": {
    "name": "hadoop",
    "tags": ["hadoop-cluster"],
    "address": "localhost",
    "port": 8020,
    "checks": [
      {
        "id": "hadoop-hdfs",
        "name": "HDFS Check",
        "tcp": "localhost:8020",
        "interval": "10s",
        "timeout": "1s"
      }
    ]
  }
}

---------------------------------

consul agent -config-dir=/etc/consul.d/ -join=consul.server.com

다만, 이건 전통적인 방식(물리서버, VM..)에서 사용하는 방법이고,
Kubernetes환경에서는 서버에 등록하고 뭐고 할것없이 그냥 등록되는것 같다.
어떻게 동작되는건지 아직 다 이해는 못하겠다.

 👇👇 더보기로 Consul 로컬 설치방법 확인하기

더보기

[Consul 로컬 실행 방법]

# consul설치 
# 참고: https://github.com/hashicorp/consul.git

brew tap hashicorp/tap
brew install hashicorp/tap/consul

# consul 시작
consul agent -dev -ui

설치 후 명령어를 실행하고 http://127.0.0.1:8500/ 에 접속하면 웹에 접속할 수 있다. 

 

Kafka

이 책 에서는 Kafka를 사용하여 실습을 하지 않지만, 로그 수집에 사용되는 Promtail, Fluentbit는 로키에 직접적으로 파이프라인을 생성하는것보다 중간에 카프카를 사용하는것을 권장한다고 한다.
카프카 커넥터를 사용하면 안정적으로 데이터를 전달할 수 있고, 안정적인 LGTM 스택 구축을 위해서는 카프카가 필수라고 한다.

그런데, 안타깝게도 우리의 Loki 앞에는 Kafka가 없다. Loki혼자 쓸쓸하게 부하를 호되게 두들겨 맞고 있었다. 😰
읽기 캐싱을 위한 Memcached/Redis도 없고 쓰기를 위한 Kafka도 없다.
내가 알기론 Kafka는 그 자체로 운영 비용이 매우 크다고 알고있는데(러닝커브도 크고, 클러스터 운영에 비용이 많이든다고 알고있다.), 추천하는 이유가 있을테니 나중에 알아보자 🧐

 

4.2 로키 로그 관리

 

4.2.1. 로키 기능

Loki는 여러 clients에서 로그를 수집하고, 백엔드 스토리지에 저장하고, 저장된 로그를 검색할 수 있는 로그 집계시스템이다.

배포모드

Loki는 여러 배포 모드를 제공한다. [참고]

  • 모놀리식 배포 모드 monolithic deployment mode
    • Loki의 모든 컴포넌트(Ingester, Distributor, Querier 등)를 단일 프로세스에서 실행시킨다.
    • 이 모드는 설정이 간단하여, 작은 규모의 환경이나 초기 테스트에 적합합니다.
    • 하루 최대 약 20GB의 작은 읽기/쓰기에 적당하다.
  • 단순 확장 가능 배포 모드 simple scalable deployment mode
    • 여러 프로세스로 나뉘어 각각의 컴포넌트가 동작하며, Loki의 확장성을 유지하면서도 관리복잡도를 낮춘 모드이다.
    • 크게 쓰기/읽기/백엔드로 나눈다.
      쓰기 - Ingester/Distributor
      읽기  - Query-frontend/Querier
      백엔드 - Compactor/Ruler/Index-Gateway/Query-scheduler
    • 하루에 최대 몇 TB의 읽기/쓰기에 적당하다.
  • microservices deployment mode
    • 각 컴포넌트를 독립된 프로세스로 실행하여 높은 확장성과 유연성을 제공하는 모드이다.
    • Loki를 효율적으로 사용할 수 있지만 설정 및 유지관리가 복잡해진다.
    • 매우 큰 Loki클러스터나 정확한 제어가 필요한 경우에 권장된다.

 

어떤 모드에서라도 Loki 데이터 흐름처럼 데이터가 쓰이고 읽힌다.
이때 중요하게 봐야할 부분을 확인해보자.

Loki 데이터 흐름

  • Distributor는 클라이언트로부터 로그 데이터를 수신하고, 이를 여러 Ingester 인스턴스에 분산시키는 역할을 한다.
    이때 데이터의 일관성을 보장하기 위해 Replication_foctor 개수(복제수)설정을 참고하여 로그 데이터를 여러 Ingester에 전달한다. 
    Ingester에게 전달하는 데이터는 일관된 해싱(Consistent Hashing)을 통해 분산하여 전달한다. (로드벨런싱)
  • Ingester는 WAL(Write ahead Logging)을 사용하여 장애발생을 대처한다.
    • 이 책에서는 `각 샤드간에는 가십을 사용해서 헬스체크를 수행한다.` 라고 설명했지만, 어떤 샤드를 어떻게 가십프로토콜로 헬스체크를 한다는건지 이해하지 못했다.
  • 읽기 요청을 받은 Querier는 캐시, Ingester, WAL 메모리, 블록스토리지 순으로 결과를 조회한다.

 

주요 컴포넌트 1.  디스트리뷰터 Distributor(상태비저장 컴포넌트)

참고링크: https://grafana.com/docs/loki/latest/get-started/components/#distributor (내용이 완전 똑같음🫨)

Distributor는 들어오는 로그 스트림을 수신하고, Ingester에 전달하는 역할을 한다. 
동작하는 순서는 아래와 같다.

  1. 유효성 검증:
    • 수신된 로그 데이터가 Loki의 사양과 일치하는지 확인한다.
    • 유효한 레이블인지, 타임스탬프가 올바른지, 로그 줄이 너무 길지 않은지 데이터가 처리 파이프라인으로 진행되기 전에 기본적인 검증을 수행한다.
    • 테넌트당 최대 비트 전송률(Maximum per-tenent bitrate)를 기반으로 수신 로그의 속도를 제한한다.
      예를들어 A 테넌트의 속도제한이 10MB/s라면, 디스트리뷰터가 5개일땐 각 2MB/s를, 디스트리뷰터가 10개일땐 각 1MB/s를 처리하도록 한다.이런 비율 제한(rate limiting)을 통해 로키 클러스터를 안전하게 동작할 수 있다. (DDos로 부터의 보호) 
  2. 해시 링을 통한 인제스터 선택 [참고 Loki 해시 링]:
    • 로그 데이터는 하나 이상의 레이블(label) 세트로 구성된 스트림(stream)이며, 이 레이블 세트의 해시값을 계산하여 어느 Ingester가 해당 데이터를 처리할지 결정한다.
    • 해시 링(Consistent Hashing Ring)을 사용하여 데이터를 Ingester에 분산한다. 
      레이블 세트의 해시값이 해시링에서 특정 위치를 가리키고, 이 위치를 기반으로 하나 이상의 Ingester가 선택된다.
    • 최근 버전의 Loki는 Memberlist를 통해 해시링을 구현하지만, 이 책에서는 Consul을 통해 실습하고 개념을 설명한다. 
  3. 레플리케이션 팩터와 데이터 복제: 
    • 이 부분은 2장, 데이터 다중화 부분에서 다룬적이 있는데, 데이터의 가용성과 내구성을 높이기 위해 중요한 설정이다.
      데이터 손실 가능성을 줄이기 위해서 사용하며, 복제를 통해 롤아웃(서비스 재배포, 업데이트), 재시작 중에서도 데이터의 손실없이 작업을 계속 할 수 있다.
    • 레플리케이션 팩터 설정을 통해 데이터가 몇개의 Ingester에 복제될지 결정되며, 레플레케이션 팩터가 3 이라면, 해시링을 통해 선택된 Ingester와 이웃하는 두 Ingester에 동일한 데이터가 복제되어 총 3개의 데이터가 저장된다.
    • Distributor 컴포넌트는 로그 데이터를 받아서 여러 Ingester 인스턴스에게 전달하는데, 이때 설정된 레플리케이션 팩터에 따라(기본값 3) 동일한 데이터를 여러 Ingester에 복제한다. 
  4. 동일한 데이터의 쓰기 시도 (포워딩): 
    • 해시링을 통해 선택된 Ingester에게 데이터를 전달한다.
    • Distributor는 해시 링과 레플리케이션 팩터를 사용하여 결정된 모든 Ingester에 동일한 데이터를 전송한다. 이때, 쓰기 쿼럼은 floor(replication_factor / 2) + 1 으로 정의한다.
      레플리케이션팩터가 3이라면 쓰기 성공을 위해서는 최소 2개의 Ingester가 성공적으로 데이터를 받아야 한다.
      이때 만약 2개 미만의 쓰기 작업만 성공한다면, Distributor는 오류를 반환하고 쓰기작업을 다시 시도한다.

 

주요 컴포넌트 2. 인제스터 Ingester

참고링크: https://grafana.com/docs/loki/latest/get-started/components/#ingester (내용이 완전 똑같음🫨)

Ingester는 장기저장소(S3등)에 로그 데이터를 쓰고, 읽기 경로의 메모리 내 쿼리의 로그데이터를 반환하는 역할을 한다.
Ingester가 로그 데이터를 수신, 처리 및 저장하는 핵심 역할을 하기 때문에 Ingester 컴포넌트의 수명주기(lifecycler)가 중요하다.

  • Ingester의 수명주기
    • PENDING: Leaving상태인 다른 인제스터의 핸드오프를 기다리는상태
    • JOINING:  현재 토큰을 링에 삽입하고 초기화 중인 상태, 소유한 토큰에 대한 쓰기 요청을 받을 수 있음
    • ACTIVE:  완전히 초기화된 상태의 인제스터 상태, 소유한 토큰에 대한 쓰기 요청과 읽기 요청을 모두 받을 수 있음
    • LEAVING:  종료 중인 상태, 메모리에 남아 있는 데이터에 대한 읽기 요청을 받을 수 있음
    • UNHEALTHY:  하트비트에 실패한 상태, distributor가 주기적으로 확인하여 UNHEALTHY 상태로 만들 수 있음.

Loki Write 수명주기 참고 (http://<loki-server>:<port>/ring) 해시링이 여기있었네!

Ingester가 수신하는 각 로그 스트림은 메모리 내 다수의 청크세트로 구성되고, 구성가능한 주기로 백업 스토리지 백엔드에 플러시 된다.
즉, 아래 세가지 경우에 Ingester에 있던 청크가 영구스토리지 (s3)에 저장된다.

  • 현재 청크가 용량(구성 가능한 값)에 도달했을 때
  • 현재 청크가 업데이트 되지 않은 채로 많은 시간이 경과했을 때
  • 플러시가 발생했을 때

로그 데이터는 "청크(chunk)"라고 하는 데이터 블록에 저장되고 시간이 지나거나 데이터가 쌓이면 백업 스토리지(S3)로 Flush(저장)되어 읽기 전용으로 표시된다. 백업 스토리지로 Flush되기 위해 청크가 읽기 전용으로 설정되면, 새로운 데이터를 계속 수신할 수 있도록 새로운 쓰기 가능한 청크가 생성된다.
청크가 영구저장소로 플러시 될때, 해당 청크는 테넌트, 레이블, 콘텐츠(실제로그) 기반으로 해싱되어 동일한 데이터를 가진 여러 인제스터가 동일한 데이터를 영구 저장소에 두번 쓰지 않는다.

Ingester에서 중요하게 처리하는 것 몇가지를 더 확인해보자.

  • Timestamp Ordering
    • 로키는 비순차적쓰기(out-of-order writes)를 기본적으로 허용한다.
    • 비순차적쓰기를 허용하지 않은 경우,
      Ingester는 수집된 로그 줄이 순서대로 되어있는지 확인하고, 순서를 따르지 않는다면 해당 라인은 거부되고 오류를 반환한다.
      특정 스트림(레이블의 고유한 조합)으로 들어온 로그 데이터는 항상 이전에 수신된 라인보다 최신 타임스탬프를 가져야 한다.
      만약 입력된 라인이 이전에 수신된 라인과 비교해서 타임스탬프, 로그 모두 일치한다면 중복된것으로 처리하고 무시한다.
      단, 타임스탬프는 같지만 내용이 다른경우에는 허용한다.
  • Hand Off (Deprecated, WAL사용으로 대체)
    • Handoff 절차는 한 Ingester가 종료되고 해시 링을 떠날 때, 그리고 새로운 Ingester가 해시 링에 들어오려고 할 때 관련된 데이터와 토큰을 직접 전달하는 과정을 말한다.
    • 하지만 WAL(Write-Ahead Log) 도입으로 인해 이런 HandOff절차가 필요하지 않게 되었다.
  • Filesystem 지원
    • 로그 데이터를 저장하기 위해 파일 시스템을 사용할 수 있으며, 이 경우 BoltDB라는 임베디드 데이터베이스를 사용하는 방식을 지원한다.
    • 다만, BoltDB는 동시에 하나의 프로세스만이 데이터베이스 파일에 대한 락(Lock)을 획득할 수 있도록 설계되어있어 단일프로세스모드일때만 BoltDB를 사용할 수 있다. (IngesterQuerier 등 여러 컴포넌트를 포함한 분산 시스템으로 운영될 경우, 모든 컴포넌트가 같은 BoltDB 인스턴스에 접근하는 것은 불가능)

 

주요 컴포넌트 3.  쿼리 프론트엔드 Query-frontend (상태비저장 컴포넌트 / 옵션)

참고링크: https://grafana.com/docs/loki/latest/get-started/components/#query-frontend (내용이 완전 똑같음🫨)

쿼리 프런트엔드는 쿼리 프론트엔드는 쿼리의 처리 과정을 최적화하고 확장성을 높이기 위해 디자인된 컴포넌트이다.
사용자로부터의 쿼리는 Query-frontend로 전달되며, Querier와 함께 작동하여 로그 데이터에 대한 쿼리 처리를 보다 효율적으로 처리할 수 있다. 

Query-frontend에서 중요한 기능 몇가지를 살펴보자

  • Queueing
    • querier에 메모리 부족(OOM) 오류를 일으킬 수 있는 대규모 쿼리는 실패 시 재시도하도록 한다. 이를 통해 더 작은 쿼리를 병렬로 실행할 수 있다.
    • 선입 선출 대기열(FIFO)을 사용하여 모든 쿼리어에 분산시킴으로써 여러 개의 대규모 요청이 단일 쿼리어에 집중되는 것을 방지한다.
    • 테넌트 간에 쿼리를 공정하게 예약하여 단일 테넌트가 다른 테넌트를 서비스 거부(DOS)하는 것을 방지한다.
  • Splitting
    • 큰 쿼리를 여러 개의 작은 쿼리로 분할하여 다운스트림 쿼리러에서 병렬로 실행하고 그 결과를 다시 하나로 연결한다.
    • 대규모(수일 등) 쿼리로 인해 단일 쿼리러에서 메모리 부족 문제가 발생하는 것을 방지하고 더 빠르게 실행할 수 있
  • Caching
    • 메트릭 쿼리 결과 캐싱을 지원하여 후속 쿼리에서 재사용한다.
    • 캐시된 결과가 불완전한 경우 쿼리 프런트엔드는 필요한 하위 쿼리를 계산하여 다운스트림 쿼리어에서 병렬로 실행한다.
    • 쿼리 결과의 캐시 가능성(cacheability)를 개선하기 위해 다양한 내부연산을 처리하며, 결과 캐시는 모든 Loki 백엔드(memcached, redis, in-memory cache)와 호환된다.
    • 곧 메트릭 쿼리 뿐만 아니라 로그 쿼리 캐싱도 지원할 예정이라고 하니 아주 기대가 된다.
      그런데, 다시 말하면 쿼리가 캐싱되는건 아니라는건데.. 대용량 쿼리에서 발생하는 OOM는 어떻게 해결할 수 있을지 더 확인해봐야겠다.
      • 메트릭쿼리: 시계열 데이터에 대한 쿼리, 로그 데이터에서 추출된 메트릭(예: 요청 수, 에러 비율, 시스템의 성능 지표 등)
      • 로그 쿼리: 텍스트 기반의 로그 데이터에 대한 쿼리, 특정 키워드, 정규 표현식, 또는 필터 조건을 사용하여 쿼리한 데이터

 

주요 컴포넌트 4. 쿼리어 Querier

참고링크: https://grafana.com/docs/loki/latest/get-started/components/#querier

Querier는 LogQL 쿼리 언어를 사용하여 쿼리를 처리하고, Ingester와 영구스토리지(S3) 모두에서 로그를 가져온다.

백엔드 저장소에 대해 동일한 쿼리를 실행하기 전에 모든 Ingester 대해 인메모리 데이터를 쿼리한다. 이때, Replication Factor로 인해 쿼리가 중복된 데이터를 수신할 수 있는데, 이 문제를 해결하기 위해 Querier는 내부적으로 동일한 나노초 스탬프, 레이블세트 및 로그 메세지를 가진 데이터를 중복제거한다.

읽기에서도 Replication Factor가 중요한데, Replication Factor가 3인 경우 2개의 쿼리가 실행되어야 한다. (읽기 쿼럼)

 

일관된 해시링  Consistent hash rings

참고링크: https://grafana.com/docs/loki/latest/get-started/hash-rings/

해시 링은 클러스터 내의 다양한 컴포넌트 인스턴스(예: Ingester, Querier 등) 사이에서 데이터와 요청을 분산시키는 데 사용되는 데이터 구조이다.

일관된 해시링은 로키 아키텍쳐에 밀접하게 통합되어 아래와 같은 장점을 제공한다.

  • 로그 라인의 샤딩 지원
  • 고가용성 구현
  • 클러스터의 수평적 스케일업 및 스케일다운 지원

해시링에 연결되어있어야 하는 컴포넌트는 아래와 같고, 각 컴포넌트의 해시 링은 그 컴포넌트의 인스턴스들 사이에서만 작동한다.

  • distributors (필수): 클라이언트로부터 로그 데이터를 받아, 여러 Ingester에게 분산시킨다.
  • ingesters (필수): 로그 데이터를 수신하여 임시로 저장하고, 영구 스토리지에 쓰기를 담당한다.
  • query schedulers (필수): 쿼리 작업을 관리하고 쿼리어에게 작업을 분배하는 역할을 한다.
  • compactors (필수): 로그 데이터를 압축하고 최적화하여 저장 공간을 효율적으로 사용한다.
  • rulers (필수): 사용자 정의 규칙에 따라 쿼리를 실행하고 알림을 보내는 역할을 한다.
  • Index Gateway (옵션): 인덱스 데이터를 관리하고 쿼리 속도를 향상시키는 역할을 한다.

 

 

링에 있는 사각형(노드)은 컴포넌트 인스턴스를 나타낸다. (Distributer1, 2, 3/ Ingester1, 2, 3)
각 노드는 키-값 저장소를 가지고 있으며, 이 저장소는 해시 링 내의 모든 노드에 대한 통신 정보를 보관한다.
노드는 주기적으로 키-값 저장소를 업데이트하여 모든 노드에서 일관된 내용을 유지한다.
각 노드에대해 키 값 저장소는 다음을 보유한다.

  • 컴포넌트 노드의 ID
  • 다른 노드에서 통신 채널로 사용하는 컴포넌트 주소
  • 컴포넌트 노드의 상태 표시

 

 

로키 레이블

참고링크: https://grafana.com/docs/loki/latest/get-started/labels/

레이블은 키-값으로 정의된 쌍이며 무엇이든 정의할 수있다. 로그 스트림을 설명하기 위한 메타데이터라고도 한다.
Loki는 '시리즈(series)' 대신 '스트림(stream)'이라는 용어를 사용하여, 각각의 로그 데이터 집합을 나타낸다.

{job="apache",env="dev",action="GET",status_code="200"} 11.11.11.11 - frank [25/Jan/2000:14:00:01 -0500] "GET /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
{job="apache",env="dev",action="POST",status_code="200"} 11.11.11.12 - frank [25/Jan/2000:14:00:02 -0500] "POST /1986.js HTTP/1.1" 200 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
{job="apache",env="dev",action="GET",status_code="400"} 11.11.11.13 - frank [25/Jan/2000:14:00:03 -0500] "GET /1986.js HTTP/1.1" 400 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
{job="apache",env="dev",action="POST",status_code="400"} 11.11.11.14 - frank [25/Jan/2000:14:00:04 -0500] "POST /1986.js HTTP/1.1" 400 932 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7 GTB6"
<--------------- 레이블 --------------------------------->

⭐️중요⭐️ 레이블의 모든 키와 값의 조합이 고유한 로그 스트림을 정의하며, 레이블의 값이 단 하나라도 변경되면 새로운 로그 스트림을 생성하는것과 같다.
레이블의 값이 너무 많을 경우, 고유한 스트림의 수가 급격히 증가하면서 카디널리티(Cardinality) 성능저하 문제가 발생할 수 있다. 😱😱

Prometheus에서 사용되는 개념인 '시리즈(series)'와 유사하다.
하지만 Prometheus는 메트릭 이름이라는 추가적인 차원을 가지고 있지만 Loki에서는 메트릭 이름을 사용하지 않고, 오로지 레이블만을 사용하여 데이터를 구분한다.

값이 너무 많은 레이블을 사용하는것이 좋지 않다면, 로그를 어떻게 쿼리해야하나? 인덱싱되지 않으면 쿼리가 느려지지 않을까?
이런 의문이 든다.  특히 ElasticSearch 의 Indexing에 익숙하다면, 빠른 쿼리를 위해 레이블 정의가 필수라고 생각하게 된다.

하지만 로키에서는 다르다.

Loki는 쿼리를 작은 조각으로 나누어 병렬로 전달하므로 짧은 시간에 엄청난 양의 로그 데이터를 쿼리할 수 있다. (참고)

# 특정 IP 주소에 대한 액세스 로그 데이터 쿼리 예시
# 레이블 대신 필터표현식 사용
{job="apache"} |= "11.11.11.11"

Loki는 해당 쿼리를 더 작은 조각(샤드)으로 나누고 레이블과 일치하는 스트림에 대한 각 청크를 열고 이 IP 주소를 찾기 시작한다.
샤드의 크기와 병렬화 양은 프로비저닝한 리소스에 따라 구성 가능하다. 원하는 경우 샤드 간격을 5m까지 구성하고, 쿼리어 20개를 배포하고, 기가바이트의 로그를 몇 초 만에 처리할 수 있다.  아니면 200개의 쿼리어를 프로비저닝하고 테라바이트 규모의 로그를 처리할 수도 있다.

이 구조는 기존 Indexing을 사용하던 로그시스템(ElasticSearch)보다 비용효율적이다.
Indexing에서는 로그 데이터의 전체 텍스트를 인덱싱하는 방식 때문에, 인덱스 크기가 로그 데이터 자체와 같거나 더 커지고 큰 메모리가 필요하게 된다. 반면 Loki는 인덱스를 최소화 하여 로그 크기보다 인텍스가 작게 유지된다.

Loki의 이러한 쿼리 구조는 원하는 쿼리 능력을 결정할 수 있고 필요에 따라 변경할 수 있다는 것을 의미한다. (쿼리 성능은 비용에 비례)
또한 영구 데이터는 S3 및 GCS와 같은 저가 객체 저장소에 크게 압축되어 저장되어 고정 운영 비용을 최소화하는 동시에 믿을 수 없을 만큼 빠른 쿼리 기능을 사용할 수 있다. 

 

4.3 미미르 메트릭 관리

 

4.3.1 미미르 기능

이전 포스팅에서 타노스에 대해 알아보았다. 
현시점 가장 많이 사용하는것은 타노스지만, 타노스는 튜닝이 어렵고 문제가 발생하면 해결하는데 시간이 오래걸린다는 단점이 있다.
코텍스(cortex)에 비하면 성능도 부족한 편이라고 한다.

타노스를 대체할 수 있는 미미르(mimir)를 알아보자.

 

미미르 컴포넌트

미미르는 위에서 알아본 로키랑 구성이 비슷하다.
하위에서 계속해서 시리즈와 샘플에 대해 나오는데, 용어를 정확히 알고 봐야한다.
- 시리즈(seris)란, 시간에 따라 측정된 데이터 포인트의 연속적인 시퀀스 즉 시계열 데이터 말한다.
- 샘플(sample)이란, 시계열에서 단일 시점에 해당하는 데이터 포인트를 의미한다.

https://grafana.com/docs/mimir/latest/get-started/about-grafana-mimir-architecture/

  • 쓰기경로 컴포넌트
    • 프로메테우스 prometheus
      • 프로메테우스는 다양한 대상에서 샘플을 스크랩하고 프로메테우스의 원격쓰기 API를 사용하여 미미르로 푸시한다.
      • 원격쓰기 API는 HTTP Put 요청 본문 내에서 일괄 처리된 스내피(snappy) 압축 프로토콜 버퍼 메세지를 보낸다.
      • HTTP요청에는 테넌트ID 헤더가 있어야 한다.
    • 디스트리뷰터 distributor [참고]
      • Prometheus 또는 Grafana 에이전트로부터 시계열 데이터를 수신하는 상태 비저장 컴포넌트이다.
      • 데이터의 정확성을 검증하고 해당 데이터가 특정 테넌트에 대해 구성된 제한 내에 있는지 확인한다.
      • 이후 데이터를 여러 Ingester에 병렬로 보낸다. (여기서도 Replication-factor)
    • 인제스터 ingester [참고]
      • Distributer에서 들어온 시리즈를 쓰기경로의 영구스토리지에 쓰고, 읽기 경로의 쿼리에 대한 시리즈 샘플을 반환하는 상태저장 컴포넌트 이다.
      • Distributer에서 들어오는 시계열 데이터는 Ingester 메모리에 보관되고 WAL에 기록되며, 최종적으로 영구스토리지에 업로드 된다. (기본적으로 2시간마다)
    • 컴펙터 compactor [참고]
      • 컴펙터는 여러 수집기의 블록을 단일 블록으로 병합하고 중복 샘플을 제거한다.
  • 읽기경로 컴포넌트
    • 쿼리프런트엔드 query-frontend [참고]
      • Querier와 동일한 API를 제공하며, 읽기를 가속화하는데 사용하는 상태 비저장 컴포넌트 이다.
      • 필수 구성요소는 아니지만 배포하는것을 권장한다.
      • 쿼리가 들어오면, query-frontend는 쿼리를 여러개의 더 작은 쿼리로 분할한다.
      • 쿼리 결과가 캐시에 있다면 캐시된 결과를 반환하고, 캐시에서 응답할 수 없는 쿼리는 프런트엔드 내의 메모리큐에 넣는다.
    • 쿼리어 querier [참고]
      • 쿼리어는 읽기 경로에서 시계열과 레이블을 가져와 PromQL 식을 평가하는 상태 비저장 구성 요소이다.
      • querier는 quert-frontend 대기열(메모리큐)에서 작업을 가져와서 실행하고 집계를 위해 쿼리 프런트엔드에 결과를 반환하는 작업자 역할을 한다.
      • Storage Gateway와 Ingester에 연결하여 쿼리를 실행하는데 필요한 모든 데이터를 가져온다. 
    • 스토리지 게이트웨이 store-gateway [참고]
      • 스토리지 게이트웨이는 상태저장 컴포넌트이며, 영구스토리지에서 블록을 조회하는 기능을 수행한다.
  • 스토리지 방식
    • 미미르는 각 테넌트의 시계열을 자체 TSDB에 저장함으로써 블록의 시리즈를 유지한다.
    • 기본적으로 블록의 범위는 두시간이며, 각 블록 디렉터리는 인덱스파일, 메타데이터가 포함된 파일과 시계열 청크를 포함한다.
    • TSDB 블록 파일에는 여러 시리즈에 대한 샘플이 들어있다. 블록 내부의 시리즈는 블록 파일의 시계열에 대한 메트릭 이름과 레이블을 모두 색인(인덱싱) 한다.
    • 블록 파일을 저정하기 위해서 객체 저장소가 필요하다.

 

인덱스 헤더 index-header

참고링크:
https://grafana.com/docs/mimir/latest/references/architecture/binary-index-header/
https://grafana.com/docs/mimir/latest/references/architecture/bucket-index/#how-its-used-by-the-querier

읽기경로에서 사용자가 querier에게 조회를 요청하거나, ruler가 레코딩 규칙을 처리할때는 항상 store gateway를 사용한다.
객체 스토리지에서 블록 내부 시리즈를 쿼리하려면 store gateway는 블록 인덱스에 대한 정보를 얻어야 한다.

Grafana 패밀리(loki, mimir, tempo, grafana)는 다양한 형태로 인덱스 정보를 관리할 수 있다.
인덱스는 AWS Dynamodb에 저장하고, 실제 데이터는 S3에 저장하는 구성이 가능하다.
즉, 인덱스와 데이터를 분리해서 관리하거나 함께 관리하는것이 가능하다.

필요한 정보를 얻기 위해 스토어 게이트웨이는 블록에 대한 인덱스 헤더(index-header)를 만들고 로컬디스크에 저장한다.

  • Index-Header란?
    • Index-header는 데이터 블록의 인덱스에서 특정 섹션들을 포함하는 구조이며, 데이터 블록을 효율적으로 조회하기 위해 사용되는 메타데이터이다.
    • 쿼리 시, store-gateway는 이 index-header를 사용하여 필요한 데이터를 빠르게 찾아내고, 전체 데이터 블록을 스캔하는 대신 특정 데이터에 직접 접근한다.
  • GET Byte Range 요청
    • store-gateway는 GET byte range 요청을 사용하여 원본 블록의 인덱스에서 특정 섹션들만을 다운로드한다.
    • 이는 HTTP 프로토콜의 기능을 활용하여 파일의 전체를 다운로드하는 대신 필요한 부분만을 선택적으로 다운로드하는 방식이다.
    • 이 방식은 네트워크 대역폭과 처리 시간을 절약하며, 전체 인덱스를 로드하고 파싱하는 것보다 훨씬 효율적이다.
  • Index-Header의 저장 방식
    • Index-header는 계산적으로 생성하기 쉬운 구조이기 때문에, 오브젝트 스토리지에는 업로드되지 않는다.
    • 대신, store-gateway 인스턴스가 필요로 할 때마다 로컬 디스크에서 직접 생성한다.
    • 만약 index-header가 로컬 디스크에 없거나, 롤링 업데이트 후 지속적인 디스크 없이 인스턴스가 재시작되면, 원본 블록의 인덱스에서 index-header를 다시 생성한다.

기본적으로 store gateway는 index-header를 다운로드 한 후 필요할때까지 메모리에 로드하지 않고 있다가, 필요 시 쿼리에 메모리 매핑하고 lazy_loading_idle_timeout(참고) 에 지정한 비활성 시간이 지나면 인덱스 헤더는 스토어 게이트웨이에서 자동으로 해제한다.

인덱스 헤더 지연 로딩을 비활성화 하기 위해 lazy_loading_enabled = false를 제공한다.
비활성화 하면 스토어 게이트웨이가 모든 인덱스 헤더를 메모리 매핑하여 인덱스 헤더의 데이터에 빠르게 액세스 할 수 있다.
하지만 블록수가 많은 클러스터에서는 쿼리 시 사용되는 빈도에 관계없이 각 스토어 게이트웨이어 메모리 매핑된 많은 양의 인덱스 헤더가 있을 수 있다.

 

해시 링 hash-rings

참고링크:
https://grafana.com/docs/mimir/latest/references/architecture/hash-ring/
https://grafana.com/docs/mimir/latest/configure/configure-hash-rings/

해시링은 위에서 Loki에서도 한번 다루었지만, mimir 문서에서 훨씬 자세히 동작하는 방식을 설명하고 있다.

우선 해시링은 작업을 여러 서버가 나눠서 처리할때, 특정 데이터를 찾기위해서 어떤 서버를 찾아가야 하는지 알 수 있는 일종의 맵이라고 볼 수 있다.
Grafana mimir는 fnv32a(32비트 부호없는 정수를 반환하는 해시함수)를 사용하여, 생성된 해시값(토큰)은 0부터 (2^32)-1 사이의 값을 가질 수 있다.

[hashring 동작방법]

Ingester Hash ring 동작 방식을 설명하자면 아래와 같다.

  1. Ingester는 시작할때 임의의 토큰값을 생성하고 이를 링에 등록한다.
    • Ingester #1 은 토큰2와 함께 링에 등록됨
    • Ingester #2 는 토큰4와 함께 링에 등록됨
    • Ingester #3 은 토큰 6과 함께 링에 등록됨
    • Ingester #4 는 토큰 9와 함께 링에 등록됨
  2. 시리즈(데이터)가 Distributer에 입력되면, fvn32a해시함수를 통해 시리즈의 레이블을 해시하여 토큰3이 생성된다.
  3. 토큰3은 시계방향 기준 가장 인접한 Ingester #2 가 소유한다.
  4. 이때, mimir는 기본적으로 시리즈(데이터)를 3개 복제본으로 복사하게 되는데,
    시리즈의 소유자인 Ingester #2를 기준으로 시계방향으로 링을 이동하여 Ingester #3, Ingester #4에 시리즈가 복제된다.

해시링은 일관된 해싱을 보장하며, 특정 링에서 인스턴스(예제에서는 Ingester)가 추가되거나 제거될때 다른 인스턴스로 이동되는 토큰수를 최소화 한다. (이동되는 토큰 개수 = 토큰/링에 등록된 인스턴스 개수)

 

[해시링을 사용하는 구성요소]

해시링을 사용하는 구성요소는 Ingester 뿐만 아니라 아래에 나열된 구성요소에서 해시링이 독립적인 해시링을 구성하게 된다.

 

[그라파나 미미르 인스턴스간 해시링을 공유하는 방법]

해시링은 구성요소 간에 공유 되어야 한다. 
지정된 해시링에 변경사항을 구성요소간에 전파하기 위해 키-값 저장소를 사용한다. 
키-값 저장소는 필수이며 다양한 구성 요소의 해시 링에 대해 독립적으로 구성될 수 있다.

 

[해시링을 사용하여 구축한 기능]

Grafana Mimir는 주로 샤딩 및 복제를 위해 해시 링을 사용하지만, 아래 기능도 해시링으로 구현되었다.

  • Service discovery: 인스턴스는 링에 등록된 인스턴스를 조회하여 서로 검색할 수 있다.
  • Heartbeating: 인스턴스는 주기적으로 링에 하트비트를 전송하여 실행중임을 알린다. 일정기간동안 하트비트를 놓치면 해당 인스턴스는 비정상으로 간주된다.
  • Zone-aware replication: 장애 도메인간의 데이터 복제로, 도메인 중단동안 데이터 손실을 방지하는데 도음을 준다.
    참고:  configuring zone-aware replication.
  • Shuffle sharding: 멀티테넌트 클러스터에서 셔플샤딩을 선택적으로 지원하며, 중단의 폭발반경(blast redius of an outage)을 줄이고 테넌트를 격리할 수있다.
    셔플 샤팅은 서로 다른 테넌트의 워크로드를 격리하고, 공유 클러스터에서 실행되는 경우에도 각 테넌트에 단일 테넌트 환경을 제공하는 기술이다.
    참고: configure shuffle sharding.

 

맴버리스트와 가십프로토콜

참고: https://grafana.com/docs/mimir/latest/references/architecture/memberlist-and-the-gossip-protocol/

기본적으로 미미르는 멤버리스트를 사용하여 인스턴스간에 해시링 데이터 구조를 공유하는 키-값 저장소를 구현한다.
맴버리스트는 Gossip 기반 프로토콜을 사용하여 클러스터 멤버십과 구성원 실패 감지를 관리하는 Go 라이브러리다.

각 미미르 인스턴스는 로컬에서 해시링을 업데이트하고, 멤버리스트를 사용하여 변경사항을 다른 인스턴스에 전파한다.
로컬에서 생성된 업데이트와 다른 인스턴스에서 받은 업데이트는 함께 병합되어 인스턴스에서 링의 현재상태를 형성한다.

 

쿼리 샤딩

참고: https://grafana.com/docs/mimir/latest/references/architecture/query-sharding/

미미르는 하나의 쿼리를 여러 기계에서 동시에 실행할 수 있게 하는 쿼리 샤딩 기능을 포함하고 있다.
쿼리 샤딩 기능은 데이터 처리를 최적화하고, 대규모 데이터셋에 대한 쿼리 응답 시간을 단축시켜, 더 빠르고 효율적인 데이터 쿼리가 가능하게 된다.

쿼리 샤딩은 아래와 같이 동작한다.

  1. 데이터셋 분할: Mimir는 큰 데이터셋을 더 작은 조각들로 나눈다. 이때 이 작은 조각들을 '샤드(shards)'라고 한다.
  2. 부분 쿼리 생성: 각 샤드는 별도의 부분 쿼리(partial query)에 해당하며, 이러한 부분 쿼리들이 다른 쿼리어(queriers)에서 병렬로 실행된다. 이렇게 하면 하나의 큰 쿼리를 여러 개의 작은 쿼리로 나누어 처리할 수 있어, 처리 시간이 단축되고 시스템의 전체적인 성능이 향상된다.
  3. 쿼리 분배: 쿼리-프론트엔드(query-frontend)는 이 부분 쿼리들을 서로 다른 쿼리어에게 분배하여 동시에 실행하도록 한다.
    쿼리어는 Mimir 클러스터 내의 서버로, 쿼리를 실행하고 결과를 반환하는 역할을 한다.
  4. 결과 집계: 각 쿼리어에서 실행된 부분 쿼리의 결과는 다시 쿼리-프론트엔드로 반환되며, 여기에서 이러한 결과들이 집계되어 최종 쿼리 결과를 형성한다. 결과 집계를 통해 사용자는 원래의 하나의 큰 쿼리에 대한 전체적인 결과를 얻게 된다.

쿼리 샤딩은 query와 query_range API에서만 적용된다.

모든 쿼리에서 샤딩이 가능한건 아니다. 전체쿼리는 샤딩할 수 없지만 쿼리 내부 부분은 샤딩할 수 있다.
특히 연관 집계(예 sum: , min, max, count, avg)는 샤딩 가능하지만 일부 쿼리 함수(예 absent: , absent_over_time, histogram_quantile, sort_desc, sort)는 샤딩 가능하지 않다.

쿼리 샤딩의 예시를 확인해보자.

쿼리샤딩 예시 1. 전체쿼리 샤딩

사용자가 아래와 같은 쿼리를 실행한다.

sum(rate(metric[1m]))

쿼리 프론트엔드는 쿼리를 샤딩한다.
이때 __query_shard__ 부분은 병렬로 실행되며, concat은 쿼리 결과가 쿼리 프런트엔드에 의해 연결/병합되는 것을 표현한다.

# 3개 샤드로 분할
sum(
  concat(
    sum(rate(metric{__query_shard__="1_of_3"}[1m]))
    sum(rate(metric{__query_shard__="2_of_3"}[1m]))
    sum(rate(metric{__query_shard__="3_of_3"}[1m]))
  )
)

쿼리샤딩 예시2. 내부 쿼리 샤딩

사용자가 아래와 같은 쿼리를 실행했다.
위에서 언급한데로, histogram_quantile는 쿼리 샤딩이 불가능하다.

histogram_quantile(0.99, sum by(le) (rate(metric[1m])))

이때 전체 쿼리 샤딩은 불가능하더라도 내부 부분(Inner part)는 쿼리를 샤딩한다.

# 3개 샤드로 분할
histogram_quantile(0.99, sum by(le) (
  concat(
    sum by(le) (rate(metric{__query_shard__="1_of_3"}[1m]))
    sum by(le) (rate(metric{__query_shard__="2_of_3"}[1m]))
    sum by(le) (rate(metric{__query_shard__="3_of_3"}[1m]))
  )
))

쿼리샤딩 예시3. 두 개의 샤딩 가능한 부분으로 쿼리

사용자가 아래와 같은 쿼리를 실행한다.

sum(rate(failed[1m])) / sum(rate(total[1m]))

이때, 쿼리 프론트엔드는 각각 두 부분을 샤딩하여 쿼리하고, 쿼리프론트엔드에서 쿼리를 병합하여 반환한다.

sum(
  concat( # 3개 샤드로 분할
    sum (rate(failed{__query_shard__="1_of_3"}[1m]))
    sum (rate(failed{__query_shard__="2_of_3"}[1m]))
    sum (rate(failed{__query_shard__="3_of_3"}[1m]))
  )
)
/
sum(
  concat( # 3개 샤드로 분할
    sum (rate(total{__query_shard__="1_of_3"}[1m]))
    sum (rate(total{__query_shard__="2_of_3"}[1m]))
    sum (rate(total{__query_shard__="3_of_3"}[1m]))
  )
)

 

4.4 템포 추적관리

 

4.4.1 템포 기능

 

Grafana Tempo 아키텍쳐

참고:https://grafana.com/docs/tempo/latest/operations/architecture/

Grafana Tempo는 아래와 같은 특징을 가지고 있다.

  • OpenTelemetry를 포함한 다양한 추적 프로토콜을 지원한다.
  • 특정 스토리지에 종속되지 않고, 객체 스토리지를 사용한다. (S3, GCS, Azure Blob Storage)
  • 메트릭, 로그와 긴밀하게 통합한다.
  • 메트릭생성기(metric generator)를 사용해서 서비스 그래프와 이그잼블러(examplar)를 생성한다.
  • 프로메테우스 기반이며, Loki, Mimir 등과 유사한 구성요소로 운영된다.

https://grafana.com/docs/tempo/latest/operations/architecture/

아키택쳐를 보면 정말 Loki, Mimir와 같은 구성요소로 이루어져 있는걸 알 수 있다.
각 컴포넌트의 특징은 위에 Loki, Mimir에서 다루었으니 간단하게만 알아보고 넘어가도록 하자

  • distributor
    • Jaeger, OpenTelemetry, Zipkin을 포함한 여러 형식의 스팬을 허용한다. 
    • traceID를 해싱하고 일관된 해시 링을 사용하여 스팬을 Ingester로 라우팅 한다.
    • OpenTelemetry Collector 의 receiver layer를 사용한다.
    • 최상의 성능을 위해 OTel Proto 를 사용하는것이 좋다. (성능을 위해 Grafana 에이전트는 otlp exporter/receiver를 사용하여 스팬을 Tempo로 전송한다.)
  • Ingester
    • trace를 블록으로 배치하고, bloom filters 와 indexes를 생성한 다음, 모두 백엔드로 플러시 한다.
    • 백엔드의 블록은 아래와 같이 생성된다.
<bucketname> / <tenantID> / <blockID> / <meta.json>
                                      / <index>
                                      / <data>
                                      / <bloom_0>
                                      / <bloom_1>
                                        ...
                                      / <bloom_n>
  • Query frontend
    • 들어오는 쿼리에 대한 검색 공간을 분할(샤딩)하는 역할을 담당한다.
    • 추척은 HTTP엔드포인트 GET /api/traces/<traceID> 에 노출된다.
    • 내부적으로 쿼리 프론트엔드는 블록ID 공간을 구성 가능한 수의 샤드로 분할하고 이러한 요청을 큐에 넣는다.
      Querier는 gRPC 연결을 통해 쿼리 프론트엔드에 연결하여 이러한 샤드 쿼리를 처리합니다.
  • Querier
    • 쿼리어는 Ingester 또는 백엔드 저장소에서 요청된 추적 ID를 찾는다.
    • 쿼리어는 HTTP 엔드포인트 GET /querier/api/traces/<traceID>에 노출되나, 직접 사용하지는 않는다.
    • 매개변수에 따라 Ingester 및 블룸/인덱스를 가져와서 개체 저장소의 블록을 검색한다.
  • Compactor
    • 블록을 분석하고 테넌트의 여러 블록을 하나의 최적화된 더 큰 블록으로 압축하여 총 블록수를 줄인다.
  • Metrics generator
    • 수집된 트레이스에서 메트릭을 추출하여 메트릭 저장소에 기록하는 선택적 구성 요소이다.
      참고: metrics-generator documentation

 

Tempo 일관된 해시링

참고: https://grafana.com/docs/tempo/latest/operations/consistent_hash_ring/

Tempo는 기본적으로 Cortex의 일관된 해시링을 사용하지만, 필요한 경우  Consul 또는  Etcd 도 사용할 수 있다.
구성된 링 내의 모든 템포 컴포넌트간에는 가십프로토콜을 사용한다.

아래는 Tempo에서 사용하는 4가지 해시링에 대한 설명이다.

  • Distributor
    • 참가자: Distributor
    • 사용대상: Distributor
    • 경로: /distributor/ring
    • 이 링은 글로벌 속도제한(global rate limits)에만 사용된다.
    • Distributor는 이 링을 사용하여 다른 활성 Distributor를 계산한다.
    • 들어오는 트래픽은 모든 Distributor에 균등하게 분산되어있다고 가정하고 global_rate_limit / # of distributors 를 사용하여 로컬 속도 제한을 설정한다.
  • Ingester
    • 참가자: Ingester
    • 사용대상: Distributors, Queriers
    • 경로:  /ingester/ring
    • 이 링은 Distributor가 트래픽을 Ingester로 로드벨런싱하는데 사용한다.
      스팬이 수신되면 TraceID가 해시되고 링의 토큰 소유권에 따라 적절한 Ingester로 전송된다.
    • Querier는 이 링을 사용하여 최근 추적을 쿼리할 Ingester를 찾는다.
  • Metrics-generator
    • 참가자: Metrics-generators
    • 사용대상: Distributors
    • 경로: /metrics-generator/ring
    • 이 링은 Distributors가 트래픽을 Metrics-generators로 로드벨런싱하는데 사용한다.
      스팬이 수신되면 TracdID가 해시되고 링의 토큰 소유권에 따라 적절한 Metrics-generators로 전송된다.
  • Compactor
    • 참가자: Compactor
    • 사용대상: Compactor
    • 경로:  /compactor/ring
    • 이 링은 컴팩터가 압축 작업을 분할하는 데 사용한다.
    • 작업은 링으로 해시되고, 소유한 컴팩터만이 특정 블록세트를 압축할 수 있도록 허용되어 압축시 경쟁조건을 방지한다.

 

Tempo 운영시 권장사항

  1. 읽기에 캐시를 활용해야한다. 참고: Improve performance with caching 
    • 블록스토리지와 WAL에서 읽는것은 캐시보다 느리다.
    • 캐시의 용량을 확보하고 가능하면 캐시에서 결과를 조회하도록 한다.
    • 또한 주기적으로 실행되면서 데이터를 갱신하는 대시보드도 캐시에서 읽도록 변경하는것을 권장한다.
    • 오래된 데이터를 블록스토리지에서 조회하면 속도가 느려진다.
  2. 병렬처리가 잘 이루어지고 있는지 조사한다.
    • 대용량 쿼리를 분할하고 병렬처리를 하면 훨씬 빠르게 읽기를 처리할 수 있다.
  3. 인제스터의 메모리를 충분히 할당한다.
    • 인제스터는 다수의 replication_factor를 메모리에 관리하고, 다른 구성요소와 상호작용을 필요로 하는 무거운 프로세스다.
    • 플러시 주기가 두시간이라면, 인제스터는 내부적으로 메모리에 데이터를 보관하고 두시간마다 디스크에 블록을 생성하기때문이다.
  4. 개별 파라메터 튜닝보다 샤드의 수를 늘려야한다.
    • 오토스케일링을 적용해서 샤드를 늘리고 충분한 리소스를 확보한다.
    • 스케일 인 시에는 데이터가 유실되지 않도록 특히 주의해야한다
    • 데이터가 유실되지 않도록 리벨런싱이 자주 발생하지 않도록 주의하고, 가급적 WAL과 레플리케이션 팩터를 사용해서 복구를 진행한다.

 

4.5 Jeager 추적관리

 

4.5.1 Jeager 구성

 

4.4에서 알아봤던 Tempo는 멀티테넌트, 객체스토리지, 스팬메트릭과 서비스맵의 자동생성, 상관관계구현이 쉽다는 장점이 있었다.
Jeager는 객체스토리지를 제외한 다양한 백엔드 스토리지를 지원하고, 프로파일과의 손쉬운 연계, 스팬 메트릭 자동생성, 근본원인 분석등의 장점이 있다. 

아래의 표는 참고할 수있도록 가져와 봤다.

https://codersociety.com/blog/articles/jaeger-vs-zipkin-vs-tempo

 

Jeager 아키텍쳐 및 주요 컴포넌트

참고:https://grafana.com/docs/tempo/latest/operations/architecture/

https://www.jaegertracing.io/docs/1.55/architecture/

Jeager 역시 읽기와 쓰기가 분리되었다.
Jeager SDKs, Agent(2024년 3월 기준 Agent는 deprecated되었다)와 컬렉터를 통해 데이터베이스에 쓰기를 처리하고, 읽기는 Jeager UI에서 쿼리를 통해 수행한다.
Spark jobs을 통해서 샤딩과 파티셔닝을 구현한다.

Jeager의 구성요소는 아래와 같다.
참고: https://www.jaegertracing.io/docs/1.55/deployment

  • Agent (2024년 3월 기준 Agent는 deprecated되었다)
    • UDP를 통해 추적 데이터를 수신하고 호스트 에이전트/데몬 또는 애플리케이션 사이드카로 각 호스트에서 로컬로 실행되도록 설계되었다. 

Docker로 실행방법 더보기 👇

더보기
docker run \
  --rm \
  -p5775:5775/udp \
  -p6831:6831/udp \
  -p6832:6832/udp \
  -p5778:5778/tcp \
  jaegertracing/jaeger-agent:1.55 \
  --reporter.grpc.host-port=jaeger-collector.jaeger-infra.svc:14250
  • Collector
    • 추적을 수신하고, 처리 파이프라인을 통해 유효성 검사 및 정리/보강을 위해 실행한 다음, 스토리지 백엔드에 저장한다.
    • stateless 컴포넌트로, 병렬로 실행할 수 있다.
    • 여러 스토리지 백엔드에 대한 기본 지원(배포 참조)과 사용자 정의 스토리지 플러그인 구현을 위한 확장 가능한 플러그인 프레임워크가 함께 제공된다. 

Docker로 실행방법 더보기 👇

더보기
docker run \
  --rm \
  -p14268:14268 \
  -p14250:14250 \
  -p9411:9411 \
  jaegertracing/jaeger-collector:1.55 \
  --storage.type=elasticsearch \
  --es.server-urls=http://elasticsearch:9200
  • Query
    • 스토리지에서 추적을 검색하는 컴포넌트이다.

Docker로 실행방법 더보기 👇

더보기
docker run \
  --rm \
  -p16686:16686 \
  jaegertracing/jaeger-query:1.55 \
  --query.service-name=jaeger-query \
  --query.base-path=/jaeger \
  --storage.type=elasticsearch \
  --es.server-urls=http://elasticsearch:9200
  • Ingester
    • 에거는 수집기와 실제 백업 스토리지(ElasticSearch)간의 버퍼로 카프카를 사용할 수 있다.
    • Ingester는 카프카에서 데이터를 읽고 다른 스토리지 백엔드에 쓰는 서비스다.

Docker로 실행방법 더보기 👇

더보기
docker run \
  --rm \
  jaegertracing/jaeger-ingester:1.55 \
  --kafka.consumer.brokers=kafka-broker1:9092,kafka-broker2:9092 \
  --kafka.consumer.topic=jaeger-spans \
  --kafka.consumer.group-id=jaeger-ingester \
  --storage.type=elasticsearch \
  --es.server-urls=http://elasticsearch:9200

 

Jeager helm 설치

참고문서: https://github.com/jaegertracing/helm-charts

# helm 레포지토리 추가
helm repo add jaegertracing https://jaegertracing.github.io/helm-charts

# 레포지토리 확인
helm search repo jaegertracing

# (책에서)jeager 설치
# Hot ROD 라는 예제를 함께 배포 (https://github.com/jaegertracing/jaeger/tree/main/examples/hotrod)
helm install jaegertracing/jaeger --set hotrod.enabled=true --generate-name

# 최근엔 jeager-operator를 설치하는것 같음
helm install jaeger-operator jaegertracing/jaeger-operator

 

 

4.5.2 Jeager 데이터 모델

이 책에서는 데이터 구조에 대해 설명하고 있지만, 전혀 알아들을 수 없는 관계로 (문서도 못찾음 😓) 나름대로 Jeager에 대한 데이터 모델을 이해해 보자.

먼저, Jeager는 OpenTracing Specification 에서 영감을 받은 데이터 모델로 추적 데이터를 표한하고 있으며,
데이터 모델은 논리적으로 OpenTelemetry Traces 와 유사하다.

구조체에 대한 언급을 하고있으니, 코드에서 구조 부분을 복사해와서 이해해보자.

* Jeager의 구조체(struct) 참고링크: https://github.com/jaegertracing/jaeger/blob/main/model/json/model.go

const (
	ChildOf ReferenceType = "CHILD_OF"
	FollowsFrom ReferenceType = "FOLLOWS_FROM"
//..생략..
)

// Trace 구조체
type Trace struct {
	TraceID   TraceID               `json:"traceID"` # 특정 트레이스를 유일하게 식별하는 ID
	Spans     []Span                `json:"spans"`
	Processes map[ProcessID]Process `json:"processes"`
	Warnings  []string              `json:"warnings"`
}

// Span 구조체
type Span struct {
	TraceID       TraceID     `json:"traceID"` # 특정 스팬을 유일하게 식별하는 ID
	SpanID        SpanID      `json:"spanID"`
	ParentSpanID  SpanID      `json:"parentSpanID,omitempty"` // deprecated
	Flags         uint32      `json:"flags,omitempty"`
	OperationName string      `json:"operationName"`
	References    []Reference `json:"references"`
	StartTime     uint64      `json:"startTime"` 
	Duration      uint64      `json:"duration"`  
	Tags          []KeyValue  `json:"tags"` #  트레이스 데이터에 추가적인 정보 부여 (중첩불가)
	Logs          []Log       `json:"logs"`
	ProcessID     ProcessID   `json:"processID,omitempty"`
	Process       *Process    `json:"process,omitempty"`
	Warnings      []string    `json:"warnings"`
}

// Reference 구조체
type Reference struct {
	RefType ReferenceType `json:"refType"` # 스팬간의 참조유형 (CHILD_OF/FOLLOWS_FROM)
	TraceID TraceID       `json:"traceID"`
	SpanID  SpanID        `json:"spanID"`
}

// Log 구조체 (스팬에서 발생한 로그)
// 스팬의 특정 시점 이벤트를 구조화된 형태로 기록
type Log struct {
	Timestamp uint64     `json:"timestamp"`
	Fields    []KeyValue `json:"fields"`
}

// Process 구조체 
// 프로세스는 스팬이 기록된 서비스의 이름과 서비스를 설명하는 태그(키-값 쌍,메타데이터)추가
type Process struct {
	ServiceName string     `json:"serviceName"`
	Tags        []KeyValue `json:"tags"`
}

 

728x90