로깅을 위한 Grafana (+ Loki) 도입기 (추후 Tracing까지) <교내 서버>

김태훈·2024년 1월 10일
0

성균관대 Skkuding

목록 보기
7/15
post-thumbnail

성균관대 학교 동아리에서 만들고 있는 코딩 채점 플랫폼 Codedang에서 이슈 및 에러 트래킹을 위하여 어플리케이션 수준의 로깅이 필요한 상황이 되었다.
먼저 학교 교내 서버에서 운영되고 있는 Stage서버에 Grafana + Loki 를 도입하기로 하였다.

1. Grafana란 & 그리고 왜 Grafana인가

이번 겨울 방학 (2023.12 ~ 2024.3) 까지 인프라팀에서 반드시 해야할 일 중 하나는 다음과 같다.

  1. 어플리케이션 Logging System 구축
  2. 인프라 구성 요소마다의 Request Duration Tracing 정보 수집

여기서 의문점이 생길 수도 있겠다. Logging과 Tracing 굉장히 비슷한 말이지만, 뭐가 다르지?

(1) Logging

Logging은 어플리케이션 동작 과정에서 일어나는 이벤트와 메시지들을 기록한다. 예를 들어, 특정 Error를 try-catch하면서 찍는 로그들이 그러하다. 개발 과정에서 콘솔창에 찍히는 로그들이 Logging이라고 생각하면 이해가 쉽다.

그래서 우리가 하고자 하는 Logging System 구축은, 운영/개발 환경에서 일어날 수 있는 모든 에러와 이슈들을 실시간으로 모니터링하고, 이를 즉각적으로 반응할 수 있게 하는 것이다. 인프라팀뿐 아니라 프론트엔드, 백엔드팀 모두가 함께해야 하는 작업이다. 프/백엔드 팀은 발생하는 이슈의 원인이 무엇인지 알맞은 코드를 정확하고 자세하게 작성해야 할 것이고, 인프라팀에서는 이러한 로그들을 개발팀들에게 보여주는 시스템을 구축하는 것이 목표이다.

(2) Tracing

Tracing은 말 그대로 추적이다. 하나의 요청 Flow가 어떤 System을 거치는지 추적하는 것이다. 특히 Codedang 인프라 시스템은 다소 복잡한 시스템이다. ECS기반으로 컨테이너 환경이 구축되어 있고, 코드 채점을 위해서는 Client Container에서 Message Queue로 코드 정보가 담긴 요청을 보내고, 채점을 담당하는 (go언어로 이루어진) Container에서 Queue에 담긴 요청을 Polling하여 이를 채점한다.

교수님께서 요청하신 것은 채점 Request 에 대해 인프라 구성 요소마다 얼마만큼의 시간이 걸리는지 확인하는 것이었다. (교수님이 요청하시지 않았더라도 하긴 해야했다.)

이러한 상황에서 우리가 필요한 것이 Tracing이었고, 이는 Opentelemetry라는 오픈소스를 활용하여 트래킹하는 것으로 계획중에 있다.

(3) 그래서 왜 Grafana인가?

Grafana는 단순히 간편한 Tool에 불과하다. Log, Tracing 정보를 시각화 해주고, query를 날려 로그들을 분석하고, 경고 알림을 보내는 역할이다.

우리는 Grafana에 그러한 정보들을 보내주는 시스템을 구축해야 한다.

흔히들 어플리케이션 로깅 시스템을 구축할 때 Sentry / DataDog을 많이 사용한다. 당연히 우리도 이러한 시스템을 분석하고 도입을 검토해보았다.

하지만 우리 동아리는 한정된 비용 안에서 시스템을 구축해야만 했다.
Sentry는 어플리케이션 로깅 레벨에만 특화된 플랫폼이었고, DataDog은 무엇보다 너무 비쌌다.

우리가 이루고자 하는 두가지 목표를 이루기 위해서는 Grafana가 제격이었다.
어플리케이션 로깅 뿐만 아니라, Tracing을 위한 연동 시스템이 어느 플랫폼보다 통합적으로 잘 갖추어져있고 비용도 저렴했기 때문이다.

먼저 Logging을 위해서 Grafana + Grafana Loki 오픈소스를 활용하는 것으로 결정하였다.

Tracing은 보류중에 있는데 Opentelemetry와 연동하여 진행할 것 같다.
Tracing은 로그 시스템을 구축한 이후에 진행할 것이므로 나중에 분석글을 정리하여 올리겠다.

먼저 이번 포스트에서는 Loki와 관련하여 분석해보려고 한다.

2. Grafana Loki

(1) Storage

우선 Loki가 로그를 어떻게 저장하고 어디에 저장하는지 부터 살펴보자
Grafana Loki는 로그의 metadata를 indexing한 후 압축하여 AWS S3나 GCS, 혹은 local file system에 저장한다.
이를 통해 빠르게 검색하고, 대용량의 로그파일을 효율적으로 저장하게 한다.

Loki 2.0 버전 이전까지는 압축된 chunk data와 index 데이터들을 각각 Object Storage(S3,GCS) 그리고 NoSQL기반의 Key-Value로 저장했었다.

Loki 2.0버전에서 'boltdb-shipper' 라는 Single Store를 도입하였다.
Single Store란 Object Storage로 Chunk Data정보와 Index 정보를 한 데 저장하는 기능이다.

현재 2.8버전이 나와있고, 역시 Single Store기반으로 데이터를 저장하며 TSDB index store라고 한다. 가장 추천되는 방법이라고 공식문서에 적혀있다.
https://grafana.com/docs/loki/latest/storage/#single-store

(2) Send Data

Loki에게 log를 어떻게 보낼 것인가?
크게 세가지 방법들이 있다. 이러한 방법들을 통틀어 Grafana Client라고 칭하자.

  • Grafana Agent
    공식문서에서 가장 추천하는 방식이었다. log, trace정보들을 수집할 수도 있게, 향후에 도입할 tracing정보에 opentelemetry와도 호환이 된다고 한다.
    https://grafana.com/docs/agent/latest/
  • Promtail
    쿠버네티스환경에서 사용하는 것이 좋다고 한다.
    다만, 쿠버네티스에서 쓰이는 API가 있어서 유연하게 설계할 수 있다는 것 뿐이지, 단독으로도 사용이 가능하다.
    정확히 말하면 static, 쿠버네티스 서비스 환경에서 로그를 수집할 수 있다고 한다. 이 때, static이랑 수동으로 설정해야 하는 서비스 정보들로, 우리의 stage서버로 사용하기 적합해 보인다.
    https://grafana.com/docs/loki/latest/send-data/promtail/
  • xk6-loki extension
    loki의 load test를 담당한다고 하는데, 알아볼 필요가 아직은 없었다.

이 외의 Third-Party Client들이 많은데, Grafana가 해당 client들의 지원을 하지 않는다고 해서 생략한다. (끊긴건지, 도움을 줄 수 없다는 뜻인지 정확하게 문맥상으로 파악되기 힘들다. 어쨌거나 추천하는 Client를 쓰는 것이 가장 좋을 것 같다.)

3. 교내 서버 적용 방법

그림의 출처는 Skkuding 인프라팀 김일건씨의 도움을 받았습니다 !

(1) 적용 이전의 교내 서버


동아리에는 네 대의 물리 서버가 존재한다. 이중 2번 서버에 Stage서버가 가동중이다.
그래서 우리는 Stage서버인 2번 서버에 Promtail로 컨테이너별 로그를 수집하기로 하였다.
그 후, 3번 서버에 Loki를 띄우고, Promtail이 3번서버에 수집된 로그를 보낸다.

현재, 동아리의 서버들은 80포트와 443포트를 제외하고 외부와의 접근이 차단되어 있다. VPC라고 생각하면 된다. 따라서 내부와의 네트워크 전송은 가능하다.

(2) 적용 이후의 서버들


빨간색이 새로 띄운 컨테이너들이다. 그리고, Loki의 서버 정보를 Grafana와 연동시켜, Loki에 저장된 로그들을 Visualization하기 위해 Grafana에 등록하였다. 그 후 별도의 도메인을 등록시켜서 grafana.codedang.com으로 외부에서 접근이 가능하게 하였다.

위와 같이 Route53에 해당 도메인을 등록시켰다.
해당 도메인이 곧 DNS server와 유사한 역할을 한다. 즉, 해당 도메인으로 온 요청은 115.~~ 의 도착지 IP정보로 요청을 보내고 해당 IP는 교내 물리 서버의 public IP이다. 이 때, Caddyfile에서

grafana.codedang.com {
	handle /loki/* {
		reverse_proxy 127.0.0.1:3200
	}

	handle {
		reverse_proxy 127.0.0.1:3000
	}
}

다음과 같이 설정하여 IP가 아닌 도메인네임으로 온 요청만 3번서버에서 처리한다.

(3) 정리

4. 적용 코드 v1

아래는 Promtail을 띄운 PR이다.
https://github.com/skkuding/codedang/pull/1173/commits/330b1bc7a161f2bbc556444a063674e5f0b7477e#diff-f5c4cb8f8ac757010dbed6fa913574fb6bfa56a3c2ed43ec036ca47c9e92b7f3
해당 코드에서 수정되었습니다. v2로 가주세요

(1) github action yaml

다음은 cd-dev를 위한 cd-dev.yaml파일이다.
서비스별 job들은 생략하겠다.

name: CD - Development

on:
  push:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-frontend:
    # 생략

  build-client-api:
    # 생략

  build-admin-api:
    # 생략

  build-iris:
    # 생략
  run-server:
    name: Run development server
    runs-on: self-hosted
    needs: [build-frontend, build-client-api, build-admin-api, build-iris]
    environment: development
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          sparse-checkout: |
            docker-compose.yml
            scripts/deploy.sh
            scripts/gen-promtail-config.sh
            .env.development
            Caddyfile
            grafana-logs/promtail/promtail-config.yml

      - name: Load dotenv from secret
        run: |
          echo "${{ secrets.ENV_DEVELOPMENT }}" > .env
          echo "${{ secrets.LOKI_SERVER_URL }}" >> .env

      - name: Load frontend static bundle
        uses: actions/download-artifact@v4
        with:
          name: frontend-bundle
          path: ./dist

      - name: Check if containers are running
        id: check-container
        run: |
          {
            echo 'stdout<<EOF'
            docker compose --profile deploy ps -q
            echo EOF
          } >> "$GITHUB_OUTPUT"

      - name: Initialize containers
        if: steps.check-container.outputs.stdout == ''
        run: docker compose --profile deploy up -d --no-recreate

      - name: Pull docker images
        run: docker compose --profile update-target pull

      - name: Set promtail script execution permission
        run: chmod +x ./scripts/gen-promtail-config.sh

      - name: Run docker compose up
        run: |
          docker compose --profile deploy up backend-client -d --no-deps
          docker compose --profile deploy up backend-admin -d --no-deps
          docker compose --profile deploy up iris -d --no-deps
          docker compose --profile deploy up promtail -d --no-deps

      - name: Copy Caddyfile into Caddy Container
        run: docker cp ./Caddyfile caddy:/etc/caddy/Caddyfile

      - name: Graceful reload Caddy
        run: docker exec -w /etc/caddy caddy caddy reload

      - name: Make Directory into Caddy & Copy static files to Caddy
        run: |
          docker exec -w / caddy mkdir -p /var/www/html
          docker cp ./dist/. caddy:/var/www/html

      - name: Remove unused docker storages
        run: docker system prune -a -f --volumes

1. sparse-checkout

repository로 checkout을 했을 때, 모든 파일을 다 불러올 필요는 없다.
이 중 필요한 파일만 sparse-checkout을 하여 서버에 불러온다.
중요한 파일은 bold체 하겠다.

  1. docker compose로 띄울 docker-compose.yml
  2. deploy 쉘 스크립트를 위한 scripts/deploy.sh (cd-dev에선 쓰이지 않는다)
  3. promtail-config 설정을 위한 shell script이다. Private IP설정 정보를 secret키에서 불러오기 위한 파일이다. 정적으로 yaml파일을 불러오는 것이 아니라 gen-promtail-config.sh 쉘을 실행시켜 github secret을 불러오고 yaml파일에 설정 정보를 덮어 씌운다.
  4. .env.development 생략
  5. Caddyfile 생략
  6. Promtail 컨테이너를 띄울 때, 해당 config 파일을 Promtail의 설정정보로 등록시킨다. 이는 앞서 말했던 gen-promtail-config.sh 파일로 github secret과 함께 덮어 씌워지는 정보이다. 그래서 해당 파일을 만들어 놓고, 컨테이너에 mount시킨다. 사실은 굳이 미리 mount하지 않아도 되지만, mount시켜서 서버에 접속하는 것만으로 config정보를 편하게 보려고 mount 시켰다. grafana-logs/promtail/promtail-config.yml

2. Load dotenv

github secret에 있는 값들을 .env 파일에 등록시킨다.
기존에 존재하는 값을 .env파일에 덮어 씌우고, 추가로 LOKI secret값을 추가하기 위해 >>로 LOKI server의 URL을 불러왔다.

3. Set promtail script execution permission

gen-promtail-config.sh 파일을 실행시키려면 실행 권한이 있어야 한다. 호스트에서 실행권한을 수정하고 이를 컨테이너에 mount해도 권한은 유지되므로, 미리 실행권한을 주었다.

4. Run docker compose up

앞에서 container가 띄워져 있지 않았다면 모든 container를 미리 한번 띄운다.
다만, 새로 업데이트 해야하는 image들은 한번 더 image를 pull 받고 다시 띄운다.
굳이 컨테이너마다 if문을 거치는 것은 귀찮은 일이라 이렇게 했다.

(2) docker-compose.yaml

version: '3'

services:
  app:
    profiles: ['devcontainer']
    container_name: codedang-dev
    # 생략

  testcase:
    container_name: codedang-testcase
    # 생략

  database:
    container_name: codedang-database
    # 생략

  cache:
    container_name: codedang-cache
    # 생략

  rabbitmq:
    container_name: codedang-rabbitmq
    # 생략
    
  caddy:
    profiles: ['deploy']
    container_name: caddy
    # 생략


  backend-client:
    profiles: ['deploy', 'update-target']
    container_name: backend-client
    restart: always
    depends_on:
      setup:
        condition: service_completed_successfully
    env_file:
      - .env.development
      - .env
    network_mode: host

  backend-admin:
    profiles: ['deploy', 'update-target']
    image: ghcr.io/skkuding/codedang-admin-api:latest
    container_name: backend-admin
    restart: always
    depends_on:
      setup:
        condition: service_completed_successfully
    env_file:
      - .env.development
      - .env
    network_mode: host

  iris:
    profiles: ['deploy', 'update-target']
    image: ghcr.io/skkuding/codedang-iris:latest
    container_name: iris
    restart: always
    read_only: true
    depends_on:
      setup:
        condition: service_completed_successfully
    env_file: .env.development
    network_mode: host

  promtail:
    profiles: ['deploy']
    image: grafana/promtail
    container_name: grafana-promtail
    restart: on-failure
    env_file: .env
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./grafana-logs/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml
      - ./scripts/gen-promtail-config.sh:/usr/local/bin/generate-config.sh
    entrypoint: /bin/sh
    command:
      - -c
      - '/usr/local/bin/generate-config.sh && /usr/bin/promtail -config.file=/etc/promtail/promtail-config.yml'
    network_mode: host

  dozzle:
    profiles: ['deploy']
    image: amir20/dozzle:latest
    container_name: dozzle
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - 9999:8080
    environment:
      DOZZLE_BASE: /logs

  setup:
    profiles: ['deploy']
    image: python:3-bullseye # Python for rabbitmqadmin
    container_name: setup
    depends_on:
      - rabbitmq
      - database
      - testcase
    volumes:
      - $PWD/scripts:/etc/scripts
      # - $PWD/dist:/etc/dist:z
    network_mode: host
    command: ['/bin/bash', '/etc/scripts/deploy.sh']

volumes:
  codedang-testcase:
  codedang-database:
  codedang-rabbitmq:

1. update-target

update해야할 container들은 profiles에서 'update-target' 정보를 추가하였다.
앞서 github action yaml파일에서 update-target만 pull받아서 컨테이너를 업데이트하는데 사용된다.

2. promtail

  1. env_file로 .env파일을 등록시켰다.
    github action에서 secret값들을 .env파일에 저장시킨 것을 기억할 것이다.
  2. volumes
    • log 수집을 위해 /var/run/docker.sock 을 mount시킨다. 이를 mount하지 않으면 log수집이 되지 않는다.
    • 비어있는 promtail-comfing.yml 파일도 mount 시킨다. 어차피 sh파일을 실행시켜서 덮어씌워진다.
    • script또한 mount시켜서 컨테이너에서 sh파일을 실행시켜 config파일을 설정한다.
  3. entrypoint
    grafana/promtail image는 entrypoint가
    /usr/bin/promtail config.file=/etc/promtail/config.yaml 로 설정이 되어있다.
    docker ps로 실행시켜보면 command 정보가 다 보이지 않아
    docker ps --no-trunc 로 살펴보면 다음과 같다.

    우리는 이러한 yaml파일을 새롭게 정의내리기 위해서 sh파일을 등록시켰고, sh파일을 실행하여 config파일을 수정하여만 한다.
    따라서 command를 덮어 씌우는 것이 먼저이다.
    먼저 shell script를 실행시키기 위하여 /bin/sh를 entry point로 등록시킨 것이다.
  4. command
    그 후, command로 -c로 후의 인자들을 command로 인식 시킨다.
    그 다음 mount된 경로에 있는 generate-config.sh파일을 실행시켜서 yaml파일을 LOKI server URL이 등록된 config파일로 바꾼다.
    그 후 promtail을 해당 config 파일을 적용시켜 실행시킨다.

주의할점

promtail:
    profiles: ['deploy']
    image: grafana/promtail
    container_name: grafana-promtail
    restart: on-failure
    env_file: .env
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./grafana-logs/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml
      - ./scripts/gen-promtail-config.sh:/usr/local/bin/generate-config.sh
    entrypoint: /bin/sh -c `/usr/local/bin/generate-config.sh`
    command:
      - /usr/bin/promtail -config.file=/etc/promtail/promtail-config.yml'
    network_mode: host

이렇게 적용하면 env값을 불러오지 못한다. 정확한 이유는 모르겠지만, entrypoint에서는 env값이 적용되지 않는 것으로 보인다. (찾아봐도 보이지 않는다 ㅠ)

(3) gen-promtail-config.sh

#!/bin/bash

# .env 파일에서 환경변수 읽기
. ../.env

# promtail-config.yml 파일 생성
cat << EOF > /etc/promtail/promtail-config.yml
server:
  http_listen_port: 9080
  grpc_listen_port: 0
positions:
  filename: /var/log/positions.yaml
clients:
  - url: http://${LOKI_SERVER_URL}/loki/api/v1/push
scrape_configs:
  - job_name: codedang-dev
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: 'container'
EOF

env파일을 sh파일에서 실행시켜서 env값들을 key-value로 다 불러온다.
여기서도 많은 삽질을 했는데 .env파일이 컨테이너 내에서 불러오는게 아니라, 호스트에서 불러오는 것 같다. (.env로도 혹시모르니 다시 해봐야겠다.)

해당 sh파일을 통해서 log를 scrape하는 config정보를 설정할 수 있다.

<정적 매핑 config>
아래 것은 정적으로 모든 컨테이너의 log들을 수집하는 설정 값인데, 이렇게 적용해봤더니 로깅이 되지 않았다. 이유는 모르겠지만, 일단 docs에 나와있는 동적 매핑으로 설정하자.

server:
  http_listen_port: 9080
  grpc_listen_port: 0
positions:
  filename: /var/log/positions.yaml
clients:
  - url: http://${LOKI_SERVER_URL}/loki/api/v1/push
scrape_configs:
  - job_name: codedang-dev
    decompression:
      enabled: true
      initial_delay: 10s
      format: gz
    static_configs:
      - targets:
          - localhost
        labels:
          job: containerlogs
          __path__: /var/lib/docker/containers/*/*.log

5. 적용 코드 v2

팀원의 PR review를 통해서 promtail을 컨테이너로 띄울 때, 커맨드 하나만으로 환경변수를 외부에서 주입할 수 있다는 사실을 알게 되었다.

같은 팀원이 아래 공식문서 내용을 제안했다. 꼼꼼히 읽어봤어야 했는데 몸만 고생했다.
https://grafana.com/docs/loki/latest/send-data/promtail/configuration/#configuration-file-reference

그래서 shell script 설정을 다 지우고, docker-compose에 커맨드를 수정하였다.

(1) docker-compose.yaml

promtail정보만 기입하겠다.

promtail:
    profiles: ['deploy']
    image: grafana/promtail
    container_name: grafana-promtail
    restart: on-failure
    env_file: .env
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./grafana-logs/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml
    command:
      - -config.file=/etc/promtail/promtail-config.yml
      - -config.expand-env=true
    network_mode: host

위와 같이 sh로 실행시키는 커맨드를 삭제하였고
이미지의 entrypoint인 /usr/bin/promtail에 command를 추가하여 config 파일을 지정, 그리고 config파일에 환경변수를 주입시키는 command -config.expand-env=true 를 추가하였다.
그러면, .env파일에서 key값을 가져와, config에 넣어준다.

(2) promtail-config.yaml

server:
  http_listen_port: 9080
  grpc_listen_port: 0
positions:
  filename: /var/log/positions.yaml
clients:
  - url: http://${LOKI_SERVER_URL}/loki/api/v1/push
scrape_configs:
  - job_name: codedang-dev
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: 'container'

(3) cd-dev.yaml (gitflow)

run-server:
    name: Run development server
    runs-on: self-hosted
    needs: [build-frontend, build-client-api, build-admin-api, build-iris]
    environment: development
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          sparse-checkout: |
            docker-compose.yml
            scripts/deploy.sh
            .env.development
            Caddyfile
            grafana-logs/promtail/promtail-config.yml

      - name: Load dotenv from secret
        run: |
          echo "${{ secrets.ENV_DEVELOPMENT }}" > .env
          echo "${{ secrets.LOKI_SERVER_URL }}" >> .env

      - name: Load frontend static bundle
        uses: actions/download-artifact@v4
        with:
          name: frontend-bundle
          path: ./dist

      - name: Check if containers are running
        id: check-container
        run: |
          {
            echo 'stdout<<EOF'
            docker compose --profile deploy ps -q
            echo EOF
          } >> "$GITHUB_OUTPUT"

      - name: Initialize containers
        if: steps.check-container.outputs.stdout == ''
        run: docker compose --profile deploy up -d --no-recreate

      - name: Pull docker images
        run: docker compose --profile update-target pull

      - name: Run docker compose up
        run: |
          docker compose --profile deploy up backend-client -d --no-deps
          docker compose --profile deploy up backend-admin -d --no-deps
          docker compose --profile deploy up iris -d --no-deps
          docker compose --profile deploy up promtail -d --no-deps

      - name: Copy Caddyfile into Caddy Container
        run: docker cp ./Caddyfile caddy:/etc/caddy/Caddyfile

      - name: Graceful reload Caddy
        run: docker exec -w /etc/caddy caddy caddy reload

      - name: Make Directory into Caddy & Copy static files to Caddy
        run: |
          docker exec -w / caddy mkdir -p /var/www/html
          docker cp ./dist/. caddy:/var/www/html

      - name: Remove unused docker storages
        run: docker system prune -a -f --volumes
profile
기록하고, 공유합시다

0개의 댓글