현재, 내가 맡은 회사 프로젝트의 모니터링 시스템에는 아래와 같은 단점이 존재한다.
물론, CloudWatch는 정말 잘 만들어진 시스템이지만 점점 메트릭과 로그가 다양해지는 상황에서 개인적으로 불편함을 느꼈던 것 같다.
새롭게 적용할 모니터링 시스템의 목표를 아래와 같이 결정했다.
그리고 이를 위해, 사람들이 많이 사용하는 Grafana + Promtail + Loki + Prometheus
조합을 이용해 구축하고자 했다.
ELK도 많이 사용하긴 하는데, 아직 주니어로서 ElasticSearch는 내 실력에 다루기 쉽지 않을 거라 판단하여 선택하지 않았다.
Grafana + Promtail + Loki + Prometheus
에서 각각의 역할은 다음과 같다.
Grafana
: Loki와 Prometheus를 통해 수집한 데이터를 하나의 대시보드로 시각화 해주는 역할Promtail
: 로컬에서 로그 파일을 실시간으로 감시하여, 새 로그가 감지되면 Loki로 전송하는 역할Loki
: Promtail이 전송한 로그 데이터를 수집 및 저장하는 역할Prometheus
: 메트릭 데이터를 수집 및 저장하는 역할이를 바탕으로 아키텍처를 다음과 같이 설정했다. 애플리케이션 서버가 꺼지더라도 모니터링 서버는 안전할 수 있도록 서버를 독립적으로 구성했다.
xxx.log
파일로 저장한다. (logback을 이용)먼저, 로그 메시지의 형식은 Json 형식의 구조화된 형식을 따르기로 결정하였다.
timestamp
: 로그 생성 시각level
: 로그 레벨 (DEBUG, INFO, WARN, ERROR)threadName
: 스레드 이름loggerName
: 로거 이름message
: 로그 메시지stacktrace
: 스택트레이스 (예외 상황에서만 출력)context
: 로그의 문맥을 파악하도록 도와주는 항목들 (MDC)instanceId
- 서버 인스턴스 별로 구분endpoint
- API Path 별로 구분httpMethod
- HTTP Method 별로 구분memberId
- 사용자 ID 별로 구분예시를 보여주자면 아래와 같다.
{
"timestamp": "2025-01-01T00:00:00.000+0900",
"level": "INFO",
"threadName": "http-nio-8080-exec-1",
"loggerName": "com.example.hello",
"context": {
"instanceId": "a-123456789abcde",
"endpoint": "/api/v1/articles/1",
"httpMethod": "GET",
"memberId": "1"
},
"message": "1번 Article 조회"
}
또한, 팀원끼리 합의하여 로그 레벨에 관한 기준도 결정하였다.
세부 동작 흐름, 내부 객체 상태, 매개변수 값, DB 쿼리, 요청/응답 상세
를 기록한다.시스템 초기화/종료, 환경 설정 값, 주요 비즈니스 처리 과정(시작, 완료 등)
을 기록한다.사용자 입력 오류, try에서 예외가 발생했으나 catch에서 핸들링한 경우
를 기록한다.복구 불가능한 예외, DB 접근 실패, 디스크 공간 부족, OOM, 외부 API 오류로 인한 비즈니스 로직 실패 등
서버 에러를 기록한다.마지막으로, 메트릭과 로그의 임계치 알림 기준은 다음과 같이 결정하였다. 하지만 이는 서비스를 운영하면서 계속 동적으로 변할 예정이다.
cpu
사용률이 80% 이상으로 5분 이상 지속되면 알림 & 95% 이상이면 알림메모리
사용률이 80% 이상으로 5분 이상 지속되면 알림 & 95% 이상이면 알림디스크
사용률이 95% 이상이면 알림HikariCP
활성 커넥션 수가 최대 커넥션 풀의 90% 이상인 경우 알림Tomcat
활성 스레드 수가 최대 스레드 수의 90% 이상인 경우 알림어느정도 설계와 합의를 진행하였으니, 실제로 모니터링 시스템을 구축해보자.
먼저, 각 애플리케이션이 존재하는 서버에 Promtail을 설치하여 애플리케이션 로그를 모니터링 서버로 전송하도록 해야한다.
Promtail은 docker compose를 이용하면 간단하게 띄울 수 있다.
$ vim docker-compose.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
{} 안의 내용은 직접 명시해야 한다.
services:
promtail:
image: grafana/promtail:3.4.1
container_name: promtail
restart: unless-stopped
volumes:
- {애플리케이션 로그의 절대 경로}:/logs:ro
- ./promtail/promtail-config.yaml:/etc/promtail/promtail-config.yaml
- ./promtail/positions:/tmp
command:
- --config.file=/etc/promtail/promtail-config.yaml
/logs
폴더를 읽기 전용으로 마운트한다.promtail-config.yaml
은 Promtail에 대한 환경 설정 파일이고, 직접 만들어서 사용할 예정이다./position
은 로그 파일을 어디까지 읽었는지 그 위치(offset)을 저장하는 폴더이다. 이는 Promtail이 중복으로 로그 수집을 하지 않도록 한다.이후, 동일 경로에 /promtail
폴더를 만들고 그 안에 promtail-config.yaml
까지 작성한다.
$ mkdir promtail
$ cd promtail
$ vim promtail-config.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://{Loki가 있는 EC2의 private ip}:3100/loki/api/v1/push
tenant_id: {서비스 별 로그 구분용 id (생략 가능)}
scrape_configs:
- job_name: system
static_configs:
- targets:
- localhost
labels:
job: varlogs
__path__: /var/log/*.log
- job_name: app
static_configs:
- targets:
- localhost
labels:
job: applogs
__path__: /logs/*.log
pipeline_stages:
- json:
expressions:
timestamp: timestamp
level: level
threadName: threadName
loggerName: loggerName
message: message
stacktrace: stacktrace
endpoint: context.endpoint
instanceId: context.instanceId
httpMethod: context.httpMethod
memberId: context.memberId
- labels:
level: ''
endpoint: ''
instanceId: ''
httpMethod: ''
memberId: ''
- timestamp:
source: timestamp
format: "2006-01-02T15:04:05.000-0700"
url
: Promtail이 수집한 로그를 어디로 보낼지에 대한 엔드포인트이다. Loki가 있는 EC2의 private ip를 이용한다.tenant_id
: 여러 팀 또는 서비스의 로그를 구분할 수 있는 멀티 테넌시(Multi-tenancy) id를 의미한다. 생략 가능하다.job_name
: 로그를 수집하는 job 단위를 의미한다. (우리는 system과 app으로 지정)targets
: 로그를 수집할 호스트를 의미한다.job
: Grafana에서 검색 시 로그 구분에 사용되는 job 단위를 의미한다. (우리는 varlogs와 applogs로 지정)__path__
: 실제 로그 파일이 존재하는 경로를 의미하며, Promtail이 여기서 긁어서 Loki에게 전송한다. (도커 볼륨으로 마운트한 곳)json.expressions
: Json 형식의 로그에서 키를 파싱하여 필드로 추출하기 위한 설정이다. (우리 로그가 Json 형식이라 지정)labels
: 위에서 추출한 필드 중 일부를 Loki에 라벨로 추가하기 위한 설정이다. 이게 있어야 Grafana에서 라벨로 보인다. 원하는 라벨만 선택적으로 지정하면 된다.timestamp
: 로그 내에 기록된 timestamp를 기준으로 Loki에서도 시간 정보를 맞추기 위한 설정이다. 이걸 지정하지 않으면, Loki는 로그가 "기록"된 시간이 아닌, 로그를 "수집"한 시각을 기준으로 로그를 보여준다.모두 작성했다면 docker compose를 이용해 Promtail을 띄우면, 자동으로 환경 설정이 반영되며 컨테이너가 시작한다.
$ docker compose up -d
애플리케이션 서버에서 Promtail을 띄웠으니, 이번에는 모니터링 서버에서 Loki를 띄워 로그를 수집해보자.
Loki도 docker compose를 이용하면 간단하게 띄울 수 있다.
$ vim docker-compose.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
services:
loki:
image: grafana/loki:3.4.1
container_name: loki
restart: unless-stopped
user: root
volumes:
- ./loki/data:/loki
- ./loki/loki-config.yaml:/etc/loki/loki-config.yaml
ports:
- "3100:3100"
command:
- --config.file=/etc/loki/loki-config.yaml
이후, 동일 경로에 /loki
폴더를 만들고 그 안에 loki-config.yaml
까지 작성한다.
$ mkdir loki
$ cd loki
$ vim loki-config.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
auth_enabled: true
server:
http_listen_port: 3100
common:
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
replication_factor: 1
path_prefix: /loki
schema_config:
configs:
- from: 2025-01-01
store: tsdb
object_store: s3
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/index_cache
aws:
s3: s3://ap-northeast-2/{로그를 장기 저장할 S3 버킷 이름}
s3forcepathstyle: true
limits_config:
retention_period: 744h # 로그 보존 기간: 31일
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
delete_request_store: s3
auto_enabled
: true로 설정하면 Loki로 들어온 요청의 X-Scope-OrgID
헤더 값을 검사한다. Promtail에서 작성한 tenant_id가 해당 헤더 값이 된다. 즉 auto_enabled : true
는 멀티 테넌시를 가능하게 하여, Loki가 각 서버의 로그를 구분할 수 있게 해준다.common
: Loki의 클러스터링 관련 설정으로, 여기서는 싱글 인스턴스를 기준으로한다.schema_config
: Loki가 TSDB 기반으로 로그를 저장하며, S3와 같은 스토리지를 사용할 수 있게 설정한다.storage_config
: 인덱스를 임시로 저장할 곳과 장기로 저장할 곳을 설정한다.limits_config
: 여기서는 로그 보존 기간을 744시간(=31일)로 설정했다.compactor
: 로그 압축 및 보존 정책에 따른 삭제 기능을 설정한다. 10분마다 압축을 수행하고, S3에서 위에 설정한 로그 보존 기간이 지난 로그를 삭제한다.모두 작성했다면 docker compose를 이용해 Loki를 띄우면, 자동으로 환경 설정이 반영되며 컨테이너가 시작한다.
$ docker compose up -d
이후 Promtail과 Loki의 로그를 살피면서, 로그를 잘 전송하고 수집하는지 확인한다.
다음은 Prometheus를 이용해 애플리케이션의 메트릭 정보를 수집해보자. Spring actuator과 연동하여 메트릭 정보를 가져올 수 있다.
먼저, 프로젝트의 build.gradle
에 Spring actuator와 Prometheus 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
그리고 application.yaml에서 Spring actuator의 엔드포인트를 열어줘야 한다.
...(생략)
management:
endpoints:
web:
exposure:
include:
- prometheus
이제 모니터링 서버에서 Prometheus를 띄워보자. Prometheus도 docker compose를 이용하면 간단하게 띄울 수 있다.
$ vim docker-compose.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
services:
...(Loki 관련 설정 생략)
prometheus:
image: prom/prometheus:v3.2.1
container_name: prometheus
restart: unless-stopped
user: root
volumes:
- ./prometheus/prometheus-config.yaml:/etc/prometheus/prometheus.yaml
- ./prometheus/data:/prometheus
ports:
- "9090:9090"
command:
- --config.file=/etc/prometheus/prometheus.yaml
이후, 동일 경로에 /prometheus
폴더를 만들고 그 안에 prometheus-config.yaml
까지 작성한다.
$ mkdir prometheus
$ cd prometheus
$ vim prometheus-config.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: [ 'localhost:9090' ]
- job_name: 'loki'
static_configs:
- targets: [ 'loki:3100' ]
- job_name: 'server1_metric'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: [ '{server1 private ip}:8080' ]
labels:
instance: 'server1'
- job_name: 'server2_metric'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: [ '{server2 private ip}:8080' ]
labels:
instance: 'server2'
scrape_interval
: 메트릭을 수집하는 주기를 설정한다. 여기서는 15초마다 메트릭을 수집하도록 하였다.job_name
: 메트릭을 수집하는 job의 이름을 설정한다.metrics_path
: 메트릭을 수집할 엔드포인트를 설정한다. 여기서는 Spring actuator에서 prometheus 관련 엔드포인트로 설정하였다.targets
: 메트릭을 수집할 호스트 및 포트 번호를 설정한다.labels
: grafana와 같은 대시보드에서 메트릭을 식별하는 라벨을 설정한다.모두 작성했다면 docker compose를 이용해 Prometheus를 띄우면, 자동으로 환경 설정이 반영되며 컨테이너가 시작한다.
$ docker compose up -d
지금까지 한 일을 정리해보자.
이로써, 메트릭과 로그 정보를 모두 수집하는 것까지는 완료되었다. 하지만 아직 이들을 우리가 볼 수 있는 형태의 시각화가 되지 않았는데, 이를 Grafana를 통해 대시보드의 형태로 만들어보자.
모니터링 서버에서 Grafana 또한 docker compose를 이용하여 띄울 예정이다.
$ vim docker-compose.yaml
그리고 내부에 아래와 같이 작성하고 저장한다.
services:
...(Loki, Prometheus 관련 설정 생략)
grafana:
image: grafana/grafana:11.6.0
container_name: grafana
restart: unless-stopped
user: root
volumes:
- ./grafana:/var/lib/grafana
ports:
- "3000:3000"
/var/lib/grafana
을 docker volume으로 마운트 해야한다는 것이다.모두 작성했다면 docker compose를 이용해 Grafana를 띄운다.
$ docker compose up -d
모니터링 서버의 3000번 포트로 브라우저에서 접속하면 로그인 화면이 나타난다.
admin
이다. 이후 자유롭게 변경할 수 있다.로그인 후 Home > Connections > Data sources
에 들어가서 Prometheus와 Loki를 데이터소스로 등록한다.
X-Scope-OrgID
헤더를 추가하고 promtail-config.yaml
에 tenant_id
로 작성한 값을 넣어야 한다. (안보여서 좀 불편하다)마찬가지로 Prometheus도 추가해주면 결론적으로는 아래와 같이 데이터소스가 마련된다. (서버 2개에서 로그를 각각 따로 보내므로 Loki도 2개 생성)
이후 Grafana Dashboard를 제공하는 사이트에 접속하여 원하는 대시보드를 템플릿처럼 가져올 수 있다.
개인적으로는 Spring Boot 3.x Statistics
라는 대시보드를 가져와서 Prometheus가 가져온 메트릭과 연결하였다.
로그의 경우, 유튜브 영상을 보면서 따라 만들었는데 나쁘지 않았다. 완성된 대시보드는 아래와 같다.
기존 모니터링 시스템의 한계를 느끼고 이를 개선하기 위해 새로운 모니터링 시스템을 구축해보았다.
이로써 초반에 계획했던 목표를 거의 대부분 만족시킨 것 같다.
Slack으로 임계점 알림도 Grafana Alerting 기능을 이용해 진행하였는데, 단순한 작업이기도 하고 글이 너무 길어질 것 같아서 생략했다.
추가적으로 고려해야 하는 사항에 대해 아래와 같이 생각해보았다.