성균관대 학교 동아리에서 만들고 있는 코딩 채점 플랫폼 Codedang에서 이슈 및 에러 트래킹을 위하여 어플리케이션 수준의 로깅이 필요한 상황이 되었다. 먼저 교내 서버에서 운영되고 있는 Stage서버에 Grafana + Loki 를 도입하기로 하였다.
이번 겨울 방학 (2023.12 ~ 2024.3) 까지 인프라팀에서 반드시 해야할 일 중 하나는 다음과 같다.
어플리케이션 Logging System 구축
인프라 구성 요소마다의 Request Duration Tracing 정보 수집
여기서 의문점이 생길 수도 있겠다. Logging과 Tracing 굉장히 비슷한 말이지만, 뭐가 다르지?
Logging은 어플리케이션 동작 과정에서 일어나는 이벤트와 메시지들을 기록한다. 예를 들어, 특정 Error를 try-catch하면서 찍는 로그들이 그러하다. 개발 과정에서 콘솔창에 찍히는 로그들이 Logging이라고 생각하면 이해가 쉽다.
그래서 우리가 하고자 하는 Logging System 구축은, 운영/개발 환경에서 일어날 수 있는 모든 에러와 이슈들을 실시간으로 모니터링하고, 이를 즉각적으로 반응할 수 있게 하는 것이다. 인프라팀뿐 아니라 프론트엔드, 백엔드팀 모두가 함께해야 하는 작업이다. 프/백엔드 팀은 발생하는 이슈의 원인이 무엇인지 알맞은 코드를 정확하고 자세하게 작성해야 할 것이고, 인프라팀에서는 이러한 로그들을 개발팀들에게 보여주는 시스템을 구축하는 것이 목표이다.
Tracing은 말 그대로 추적이다. 하나의 요청 Flow가 어떤 System을 거치는지 추적하는 것이다. 특히 Codedang 인프라 시스템은 다소 복잡한 시스템이다. ECS기반으로 컨테이너 환경이 구축되어 있고, 코드 채점을 위해서는 Client Container에서 Message Queue로 코드 정보가 담긴 요청을 보내고, 채점을 담당하는 (go언어로 이루어진) Container에서 Queue에 담긴 요청을 Polling하여 이를 채점한다.
교수님께서 요청하신 것은 채점 Request 에 대해 인프라 구성 요소마다 얼마만큼의 시간이 걸리는지 확인하는 것이었다. (교수님이 요청하시지 않았더라도 하긴 해야했다.)
이러한 상황에서 우리가 필요한 것이 Tracing이었고, 이는 Opentelemetry라는 오픈소스를 활용하여 트래킹하는 것으로 계획중에 있다.
Grafana는 단순히 간편한 Tool에 불과하다. Log, Tracing 정보를 시각화 해주고, query를 날려 로그들을 분석하고, 경고 알림을 보내는 역할이다.
우리는 Grafana에 그러한 정보들을 보내주는 시스템을 구축해야 한다.
흔히들 어플리케이션 로깅 시스템을 구축할 때 Sentry / DataDog을 많이 사용한다. 당연히 우리도 이러한 시스템을 분석하고 도입을 검토해보았다.
하지만 우리 동아리는 한정된 비용 안에서 시스템을 구축해야만 했다. Sentry는 어플리케이션 로깅 레벨에만 특화된 플랫폼이었고, DataDog은 무엇보다 너무 비쌌다.
우리가 이루고자 하는 두가지 목표를 이루기 위해서는 Grafana가 제격이었다. 어플리케이션 로깅 뿐만 아니라, Tracing을 위한 연동 시스템이 어느 플랫폼보다 통합적으로 잘 갖추어져있고 비용도 저렴했기 때문이다.
먼저 Logging을 위해서 Grafana + Grafana Loki 오픈소스를 활용하는 것으로 결정하였다.
Tracing은 보류중에 있는데 Opentelemetry와 연동하여 진행할 것 같다. Tracing은 로그 시스템을 구축한 이후에 진행할 것이므로 나중에 분석글을 정리하여 올리겠다.
먼저 이번 포스트에서는 Loki와 관련하여 분석해보려고 한다.
우선 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
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를 쓰는 것이 가장 좋을 것 같다.)
그림의 출처는 Skkuding 인프라팀 김일건씨의 도움을 받았습니다 !
동아리에는 네 대의 물리 서버가 존재한다. 이중 2번 서버에 Stage서버가 가동중이다. 그래서 우리는 Stage서버인 2번 서버에 Promtail로 컨테이너별 로그를 수집하기로 하였다. 그 후, 3번 서버에 Loki를 띄우고, Promtail이 3번서버에 수집된 로그를 보낸다.
현재, 동아리의 서버들은 80포트와 443포트를 제외하고 외부와의 접근이 차단되어 있다. VPC라고 생각하면 된다. 따라서 내부와의 네트워크 전송은 가능하다.
빨간색이 새로 띄운 컨테이너들이다. 그리고, 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번서버에서 처리한다.
아래는 Promtail을 띄운 PR이다. https://github.com/skkuding/codedang/pull/1173/commits/330b1bc7a161f2bbc556444a063674e5f0b7477e#diff-f5c4cb8f8ac757010dbed6fa913574fb6bfa56a3c2ed43ec036ca47c9e92b7f3 해당 코드에서 수정되었습니다. v2로 가주세요
다음은 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
repository로 checkout을 했을 때, 모든 파일을 다 불러올 필요는 없다. 이 중 필요한 파일만 sparse-checkout을 하여 서버로 불러온다. 중요한 파일은 bold체 하겠다.
docker compose로 띄울 docker-compose.yml
deploy 쉘 스크립트를 위한 scripts/deploy.sh (cd-dev에선 쓰이지 않는다)
promtail-config 설정을 위한 shell script이다. Private IP설정 정보를 secret키에서 불러오기 위한 파일이다. 정적으로 yaml파일을 불러오는 것이 아니라 gen-promtail-config.sh 쉘을 실행시켜 github secret을 불러오고 yaml파일에 설정 정보를 덮어 씌운다.
.env.development 생략
Caddyfile 생략
Promtail 컨테이너를 띄울 때, 해당 config 파일을 Promtail의 설정정보로 등록시킨다. 이는 앞서 말했던 gen-promtail-config.sh 파일로 github secret과 함께 덮어 씌워지는 정보이다. 그래서 해당 파일을 만들어 놓고, 컨테이너에 mount시킨다. 사실은 굳이 미리 mount하지 않아도 되지만, mount시켜서 서버에 접속하는 것만으로 config정보를 편하게 보려고 mount 시켰다. grafana-logs/promtail/promtail-config.yml
github secret에 있는 값들을 .env 파일에 등록시킨다. 기존에 존재하는 값을 .env파일에 덮어 씌우고, 추가로 LOKI secret값을 추가하기 위해 >>
로 LOKI server의 URL을 불러왔다.
gen-promtail-config.sh 파일을 실행시키려면 실행 권한이 있어야 한다. 호스트에서 실행권한을 수정하고 이를 컨테이너에 mount해도 권한은 유지되므로, 미리 실행권한을 주었다.
앞에서 container가 띄워져 있지 않았다면 모든 container를 미리 한번 띄운다. 다만, 새로 업데이트 해야하는 image들은 한번 더 image를 pull 받고 다시 띄운다. 굳이 컨테이너마다 if문을 거치는 것은 귀찮은 일이라 이렇게 했다.
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:
update해야할 container들은 profiles에서 'update-target' 정보를 추가하였다. 앞서 github action yaml파일에서 update-target만 pull받아서 컨테이너를 업데이트하는데 사용된다.
env_file로 .env파일을 등록시켰다. github action에서 secret값들을 .env파일에 저장시킨 것을 기억할 것이다.
volumes
log 수집을 위해 /var/run/docker.sock 을 mount시킨다. 이를 mount하지 않으면 log수집이 되지 않는다.
비어있는 promtail-comfing.yml 파일도 mount 시킨다. 어차피 sh파일을 실행시켜서 덮어씌워진다.
script또한 mount시켜서 컨테이너에서 sh파일을 실행시켜 config파일을 설정한다.
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로 등록시킨 것이다.
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값이 적용되지 않는 것으로 보인다. (찾아봐도 보이지 않는다 ㅠ)
#!/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
팀원의 PR review를 통해서 promtail을 컨테이너로 띄울 때, 커맨드 하나만으로 환경변수를 외부에서 주입할 수 있다는 사실을 알게 되었다.
같은 팀원이 아래 공식문서 내용을 제안했다. 꼼꼼히 읽어봤어야 했는데 몸만 고생했다. https://grafana.com/docs/loki/latest/send-data/promtail/configuration/#configuration-file-reference
그래서 shell script 설정을 다 지우고, docker-compose에 커맨드를 수정하였다.
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에 넣어준다.
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'
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