프로젝트

Promtail + loki + grafana를 이용한 로그 구축

hanrabong 2025. 3. 17. 22:00

배경

 영화 무대인사 알림 프로젝트 개발을 하고 나서, 무대인사 및 시사회 를 잘 다니고 있었다. 그러던 중 며칠동안 무대인사 알림이 이메일로 전송이 안되고 있는 상황이 있었다. 새로운 시사회나 무대인사가 없을 때는 따로 이메일로 정보가 안오기에 새로운 정보가 없구나라고 생각을 하였다. 그런데 며칠이 지나도 정보가 안 오길래, 영화 사이트에 접속하여 시사회 정보를 살펴 보았는데 새롭게 개봉된 영화 정보를 확인할 수 있었다. 내 코드에 문제가 있었다는 것을 깨닫고, 크롤링 관련 컨테이너 로그들을 살펴보았다. 당연히 에러가 있었고, 크롤링하는 url이 변경이 되어서 특정 사이트에서 크롤링을 못해오는 문제가 있었다.

 해당 서비스를 완벽하게 자동화하기 위해서, 에러 발생 시 알림을 주도록 로그 시스템을 구축하면 편할 것 같다고 생각하였다.

 

해당 프로젝트 개발에 대한 내용은 아래 글에서 확인할 수 있다.

https://hanrabong.tistory.com/entry/movie-preview

 

🎬 영화 무대 인사(시사회) 알림 자동화 사이드 프로젝트 후기

배경 우연히 영화 무대인사를 보러 가게되었는데, 너무 너무 재밌었다. 😄 그 후에도 기회가 될 때 보러 가려고 하였는데, 인기 있는 무대 인사의 경우 자리가 없어 예매를 못하게 되었다.... 어

hanrabong.com

 

 

github 소스 코드의 경우 다음과 같다.

https://github.com/Rabongg/movie_preview

 

GitHub - Rabongg/movie_preview: 영화 시사회 및 무대인사 알림 서비스

영화 시사회 및 무대인사 알림 서비스. Contribute to Rabongg/movie_preview development by creating an account on GitHub.

github.com

 

로그 시스템 구축

 로그 시스템을 구축하려고 했을 때, 어떤 도구를 써야할지 고민을 하였다. 로그 시스템을 구축하는 목적을 생각해 봤을 때  '에러 발생 시 알림 가능'  이유로 인해 사용하려고 했었다. 또한 AWS 서버 비용과 로그의 양이 많지 않다는 점에 가볍고 빠르게 확인할 수 있는 도구를 원했다. 적절한 로그 시스템을 찾던 중 promtail + loki + grafana 구조를 찾게 되었고 해당 구조가 목적에 부합하여 해당 구조로 로그 시스템을 구축하게 되었다.

 

시스템 구조

로그 시스템 구조

구조는 다음과 같다.

EC2 서버는 총 2개가 존재한다. 왼쪽 EC2 서버에는 실제 application이 존재하고, 어플리케이션의 container 로그와 file 로그를 수집하기 위해 promtail이 있다. 다른 EC2 서버에는 Loki, grafana가 존재한다. promtail에서 로그를 수집하고, loki로 전송하면, grafana로 로그를 대시보드 형태로 시각화해서 볼 수 있고, grafana의 alert rule에 기반해서 에러 발생 시 알림을 전송할 수 있다.

 

해당 시스템을 구축하면서 고민했던 부분이나 어려웠던 점을 얘기해보려고 한다.

 

Promtail

promtail은 로그 수집을 해서 loki로 전송하는 역할을 한다. 따라서 어플리케이션이 위치한 서버에 존재하는게 보편적이다. 

promtail 로그 수집

 

 로그 수집을 위해 promtail의 docker-compose.yml 파일에서는 디렉토리를 마운트 해야한다. 컨테이너의 경우 보통 host 서버의  /var/lib/docker/containers 에 로그를 남기고, 해당 값을 promtail에서 사용하기 위해서는 promtail container에 마운트를 해줘야 한다. cron.log의 경우 python web crawling 서비스가 로그를 container 자체에 남기기 때문에 python(container) - host - promtail(container) 이렇게 볼륨을 마운트 해줘야 promtail에서 접근 가능하다.

 

promtail.config의 경우는 다음과 같다.

server:
  http_listen_port: 9080  # Promtail 내부 포트

positions:
  filename: /var/lib/promtail/positions.yaml  # 로그 위치 기억

clients:
  - url: ${LOKI_URL} # Loki로 로그 전송

scrape_configs:
  # 1️⃣ 컨테이너 로그 수집 설정
  - job_name: docker-logs
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
    relabel_configs:
    - source_labels: [ '__meta_docker_container_name' ]
      action: keep
      regex: '.*\/(mysql.*|redis.*|spring.*)'
    - source_labels: ['__meta_docker_container_name']
      target_label: 'container_name'

  # 2️⃣ 파일 로그 수집 설정
  - job_name: file-logs
    static_configs:
      - targets:
          - localhost
        labels:
          app: "cron-job"
          __path__: "/var/log/_data/cron.log"

 

간략하게 promtail.conf에 대해서 살펴보자.

clients에는 loki가 존재하는 서버의 url을 명시해주면된다. 따로 ec2에 url을 명시하지 않았을 경우 ip와 port를 명시해주면된다. 이 때 당연히 전송을 위해 loki가 위치한 ec2의 security group 인 바운드는 해당 port로 열어줘야 한다. 

 

scrape_configs는 로그 수집 대상을 정의하는 부분이다. docker container log와 file-log(cron.log)를 수집하는 설정을 확인할 수 있다. container-log의 경우 mysql, redis, spring으로 시작된 컨테이너의 로그만 수집하게 설정한 것을 확인할 수 있다.

밑에서 loki에 대해서 얘기할껀데, label만 index하는 구조라서, labels를 잘 정의해줘야 한다. 해당 서비스의 경우 크롤링이고 로그가 별로 없어서 상관이 없긴하지만, container_name, app 이라는 라벨로 정의를 하였다. 흔히 아는 RDB의 index처럼 loki에서도 label을 무분별하게 남용하면 인덱스 비용과 성능에 문제가 생길 수 있다.

 

loki

loki는 promtail에서 수집해서 보낸 로그를 저장하는 역할을 한다. 다른 저장소인 Opensearch와 차이점이 있다면 라벨 정보만 인덱싱을 한다는 차이점이 있다. 라벨만 인덱싱 하기 때문에 저장 공간에 이점이 있지만, 본문 검색 속도는 느리다는 단점이 있다. 내가 모니터링 하는 서비스의 경우 로그가 많지 않기에, loki를 사용하기에 적당했다.

 

공식 문서에 나오는 loki architecture 그림은 다음과 같다. loki 구조에 대한 자세한 설명은 다른 블로그에도 많아서 내가 작성한 loki 설정 파일과 비교하며 간략하게 적으려고 한다.

 

loki 설정 파일은 다음과 같이 작성하였다.

server:
  http_listen_port: 3100

# distributor 설정

distributor:
  ring:
    kvstore:
      store: inmemory  # 키-값 저장소를 메모리에 유지

limits_config:
  reject_old_samples: true
  reject_old_samples_max_age: 168h
  retention_period: 744h # 31 days
  allow_structured_metadata: false

# ingester 설정
ingester:
  lifecycler:
    address: 127.0.0.1
    ring:
      kvstore:
        store: inmemory
      replication_factor: 1
    final_sleep: 30s
  chunk_target_size: 1048576         # 압축 후 1MB 목표
  max_chunk_age: 1h                  # 최대 1시간 유지 후 flush
  max_chunk_idle: 5m                # 5분 이상 로그 없으면 flush

schema_config:
  configs:
    - from: 2025-01-01
      store: tsdb
      object_store: s3
      schema: v12
      index:
        prefix: index_
        period: 24h

# compactor 설정
compactor:
  working_directory: /var/loki/compactor
  shared_store: s3
  compaction_interval: 6h
  retention_enabled: true

# querier 설정
querier:
  query_store_max_look_back_period: 744h   # 31일 조회 허용
  engine:
    timeout: 1m

storage_config:
  aws:
    endpoint: s3.${S3_REGION}.amazonaws.com
    bucketnames: ${S3_BUCKET_NAME}
    region: ${S3_REGION}
    access_key_id: ${S3_ACCESS_KEY}
    secret_access_key: ${S3_SECRET_KEY}

common:
  path_prefix: /loki

 

Distributor

 처음 로그가 들어오면 distributor를 거쳐서 ingestor 에게 전달이 되는데, distributor는 클라이언트(promtail 등)로 부터 받은 로그를 처리하는 역할을 한다. 여기서 처리라고 하면, validation, 전처리 등을 말한다. 

distributor 설정을 보면 ring의 kvstore를 inmemory로 즉 로컬 메모리로 설정을 한다고 했는데, 단일 인스턴스만 사용하기에 저렇게 설정을 해두었다. 보통 사내 프로젝트에서는 로그도 분산 운영을 하기에 distributor, ingestor도 여러 개가 있을 수 있는데, 이 때는 ring kvstore를 이용해 distributor가 어느 ingestor에게 로그를 분산하면 되는지 판단한다. 이 때는 kvstore 값이 inmemory가 아닌 memberlist 등으로 분산되어 있는 ingestor를 명시해줘야 한다.

limits_config 하위의 reject_old_samples, reject_old_samples_max_age 설정을 이용해서 168h(7일)이 넘은 로그는 수신 거부하게 설정을 하였다.

 

Ingestor

ingestor는 distributor로부터 로그를 받으면 저장하는 역할을 한다. 처음 distributor로 로그 데이터를 전송받으면 메모리에 label과 본문(line, body)을 나눠서 저장을 한다. 이 후에 설정에 의해서 flush를 하게 되는데, 이 때 label은 인덱싱을 하여 chunk(본문)와 같이 설정한 저장소(fs, S3 등)에 저장한다. flush를 유발하는 설정을 보면 다음과 같다.

chunk_target_size: 1048576         # 압축 후 1MB 목표
max_chunk_age: 1h                  # 최대 1시간 유지 후 flush
max_chunk_idle: 5m                # 5분 이상 로그 없으면 flush

 

 여기서도 chunk라는 단어가 나오는데, ingestor가 로그를 받으면 메모리에 [ 시리즈(label) - chunk(본문) ] 형태로 저장을 한다. 같은 label의 경우 같은 chunk에 저장이 되고 위 설정에 따르면 chunk가 1MB가 되면 flush가 일어난다. 또한 chunk가 1MB가 되지 않았더라도 1시간 동안 로그를 받으면 flush가 일어나게 된다. 마지막 조건은 5분 이상 로그가 없으면 flush가 일어나게 된다. 내 서비스에서 유효한 설정은 마지막 설정밖에 없다. 오전 9시 오후 4시에 크롤링이 돌고, 로그 양이 작기 때문에 1MB에 도달할 일도 없고, 1시간동안 로그가 발생하는 경우도 없다. 5m 이라는 idle 시간은 보통 서비스의 경우 이렇게 설정을 하곤하는데, 아직까지 문제가 없어 해당 설정을 유지하려고 한다.

 

schema_config에서는 Loki 데이터 저장 구조를 정의하고 있다. store는 tsdb로 설정을 하였는데, promethus와 비슷한 tsdb 형식으로 저장하게 설정했다. 이 방식말고 boltdb-shipper 방식도 존재하는데 해당 방식은 구버전에서만 사용하고 최신 버전에서는 tsdb 형식으로 저장하는게 일반적이다. object_store의 경우 chunk나 label이 저장되는 스토리지를 명시하면 된다. aws 에서 서버를 배포하고 있기에 s3에 저장하는 것으로 설정했다.

 

Compactor

 Loki에서 tsdb를 사용하는 경우 꼭 필요한 구성 요소이다. 말 그대로 compact하는 역할을 하는데, chunk와 index 블록을 병합을 해준다. 병합을 하게 되면 block수가 감소하기에 S3에서 구조도 더 깔끔하고 쿼리 성능과 저장비용에서도 이점이 존재한다. 병합을 어느 주기로 할지가 가장 관건인데, compaction_interval을 6h으로 설정해주었다. 보통 10m 단위로 설정을 한다고 하는데, 서비스 특성 상 하루 2번, 오전 9시, 16시에 로그가 찍히기 때문에 10m은 너무 과도하다고 생각을 했다. 10m 간격으로 설정을하면 S3에 API를 10m 간격으로 요청을 하게되는데 요청량에 따라 S3 비용도 증가하기 때문에 6h으로 설정을 했다. 

retention_enabled: true 값의 경우 limit_config 하위의 retention_period를 가능하게 해준다. retention_period 주기로 s3에서 로그를 삭제하게 되는데, 해당 서비스에서는 한 달 전의 로그들은 삭제하게 설정해두었다.

 

Querier

 위에 distributor, ingestor, compactor 는 로그 저장을 담당했다면, querier는 로그 조회할 때 사용하는 컴포넌트이다. 사용자가 보낸 LogQL을 분석해서 index 를 조회하여 chunk 위치를 찾아 사용자에게 결과를 반환하는 역할을 한다. chunk가 메모리에 없고 S3에 있을 때는 S3에서 가져오기도 한다.

query_store_max_loop_back_period 설정은 말 그대로 며칠 전까지 조회를 하는지 설정하는 옵션이다. 설정을 안할 경우 무제한으로 확인이 가능하다. 로그의 경우 장기보관할 필요가 없어서 retention도 744h로 맞춰두어서, 해당 옵션의 값도 744h로 맞췄다.

 

grafana

grafana를 통해서 loki에 저장되어 있는 로그를 대시보드 형태로 시각화해서 볼 수 있다. 또한 모니터링 시스템을 구축한 가장 큰 이유인 알림을 보낼 수가 있다.

 

대시보드는 이렇게 만들어 둔 상태이다.

grafana dashboard

가장 첫 번째 대시보드는 정상 로그가 남는지 확인하는 대시보드이다. 두 번째 대시보드는 에러 로그를 보여주고, 세 번째 대시보드는 에러 로그 내용을 보여준다.

 

알림의 경우는 이메일로 알림 전송이 오게 했다. 이러한 형식으로 알림이 오고 있다.

에러 발생 시 grafana 알림

 

alert rule을 설정할 때 주의해야할 점을 몇 가지 얘기해 보겠다.

 LogQL을 만들었을 때, data가 없어서 grafana에서 알림이 오는 경우도 있다. 에러 로그에 대한 알림일 경우 에러 로그가 발생하지 않는 경우가 더 많기에 해당 LogQL로 조회되는 데이터가 없는 경우가 많다. 이 때 알림이 오지 않게 하려면  "Alert state if no data or all values are null" 해당 값을 Normal로 줘야 한다. 처음에 alert rule을 만들었을 때 며칠 동안 계속 알림이 와서... 당황했던 적이 있다.

alert rule을 설정할 때 evaluated time과 pending period 시간과 alert 조건의 시간의 관계를 잘 생각해야한다. 로그 초기에 alert 조건은 2h으로 설정을 하고 evaluated time은 1h, pending period는 1h 30m으로 설정을 한 적이 있었다. 분명 에러로 인해서 에러 로그가 찍혔는데, pending period를 기다리다보니 (alarm을 보내야 하는 시간 -  2h) 전에는 에러가 없다고 인식하여 에러가 안 오는 경우가 있었다. 이러한 점을 고려하여 evaluated time과 pending period 시간을 설정했다.

 

 

마치며...

 로그 시스템 구축 후에 좀 더 서비스를 안정적으로 운영할 수 있었다. 에러가 발생하면 grafana를 통해서 메일로 알림을 받을 수 있어 매번 서비스가 잘 동작하는지 확인하지 않아도 된다는 점이 편했다. 또한 에러 발생 시 즉각 메일이 오다보니, 어떤 코드에서 문제가 있다는 점을 빠르게 파악하고 수정할 수 있었다.

 로그 시스템 구축 전에 어플리케이션 레벨에서 log level에 맞게 로그를 남겨야만 해당 시스템의 효과가 더 좋은 것 같다. 보통 운영 단에서 log level은 팀마다 규칙이 다르지만, 확인을 잘 못한다고 해서 info나 debug 레벨의 로그를 error로 두게되면 의미가 없어진다. 또한 내 서비스에서는 에러 로그로 찍히면 알림이 오기 때문에 무분별한 알림이 되어서 의미가 없어진다는 것을 깨달았다. 알림이 오지 않더라도 적당한 로그 레벨로 로그를 남겨야만 검색 시에도 편하다.

에러가 발생하지 않는 100% 완벽한 소프트웨어는 없기때문에 에러를 쉽게 확인할 수 있는 로그 시스템 구축이 중요한 것 같다. 추후에 다른 서비스를 개발하면서 더 다양한 로그 시스템을 개발하려고 한다.

 

참고자료

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

 

Loki architecture | Grafana Loki documentation

Loki architecture Grafana Loki has a microservices-based architecture and is designed to run as a horizontally scalable, distributed system. The system has multiple components that can run separately and in parallel. The Grafana Loki design compiles the co

grafana.com

 

https://grafana.com/blog/2018/12/12/loki-prometheus-inspired-open-source-logging-for-cloud-natives/#loki

 

Loki: Prometheus-inspired, open source logging for cloud natives | Grafana Labs

Loki: Prometheus-inspired, open source logging for cloud natives Introduction This blog post is a companion piece for my talk at https://devopsdaysindia.org. I will discuss the motivations, architecture, and the future of logging in Grafana! Let’s get ri

grafana.com