Prometheus는 서버와 프로그램의 상태를 숫자로 주기적으로 모아 쌓아 두는 도구이고, 그 숫자 하나하나를 메트릭이라 부른다. PromQL은 이렇게 쌓인 메트릭에 “이 숫자를 보여 달라”고 묻는 쿼리 언어다. 이 글은 리눅스 노드(서버 한 대), Docker 컨테이너, Spring Boot Actuator 세 환경에서 바로 붙여 넣어 쓸 수 있는 PromQL 쿼리만 정리했다. 문법 근거는 Prometheus 공식 문서를 참고한다(PromQL basics, functions, histograms practice).
쿼리를 붙여 넣는 곳은 Prometheus 웹 화면의 입력창이다. Prometheus가 설치되어 돌고 있어야 하고, 화면은 기본 주소 http://<Prometheus 서버>:9090으로 연다. 설치와 설정은 Prometheus 개요 글에 있다. 그 화면의 「Table」 탭은 지금 이 순간의 값을 줄마다 숫자로 보여 주고, 「Graph」 탭은 같은 쿼리를 시간에 따라 그래프로 그린다. Grafana의 패널에 넣어도 같은 쿼리가 그대로 통한다.
쿼리를 읽기 전에 알아 둘 것이 두 가지 있다.
- 메트릭 이름 하나에서 숫자 줄이 여러 개 나온다. 메트릭에는
{mode="idle"}같은 「이름=값」 꼬리표, 즉 라벨이 붙는다. 라벨 값이 다르면 따로 쌓이는 별개의 숫자 줄(시계열)이 된다. 중괄호{...}는 이 줄들 중 라벨이 조건에 맞는 것만 고르고,sum by (라벨)·avg by (라벨)은 괄호 안 라벨이 같은 줄끼리 묶어 하나로 합친다 - 이름이
_total로 끝나는 메트릭은 카운터다. 시작한 뒤로 늘어나기만 하는 누적값이라 그 자체로는 “지금 얼마나 바쁜가”를 못 보여 준다. 그래서 거의 모든 쿼리가rate()로 “최근 몇 분 동안 초당 얼마씩 늘었나”를 먼저 구한다
1. 리눅스 서버(Node Exporter)
node_exporter는 서버 자체의 CPU·메모리·디스크 상태를 재서 Prometheus가 가져갈 수 있는 형태로 내보내는 프로그램이다. 아래에 나오는 node_cpu_seconds_total, node_memory_MemTotal_bytes처럼 node_로 시작하는 이름은 전부 이 node_exporter가 만들어 내보내는 메트릭 이름이다.
CPU 사용률(%)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
한 줄씩 따라가기
node_cpu_seconds_total: CPU가 각 모드에서 보낸 시간(초)을 계속 더해서 쌓아 둔 누적값이다. CPU 하나 안에는 명령을 실행하는 단위인 코어가 여러 개 들어 있다(멀티코어). 이 메트릭에는cpu(몇 번 코어인가)와mode(무엇을 하며 보냈나) 두 라벨이 붙어서, 코어 하나·모드 하나마다 숫자 줄이 하나씩 따로 나온다. 코어가 4개면 idle 줄만 4개다.{mode="idle"}: 여러 모드 중 idle(아무 일도 안 하고 쉰 시간)만 골라낸다. 다른 모드로는user(내 프로그램 코드를 돌린 시간)와system(커널 코드를 돌린 시간, 커널 모드) 등이 있다. 쉰 시간만 알면 나머지는 전부 일한 시간이라, 이 쿼리는 idle 하나만 쓴다.rate(...[5m]): 대괄호에 적은 기간(5m= 최근 5분) 동안 이 값이 초당 얼마씩 늘었는지 평균을 계산한다. 코어 하나는 1초에 최대 1초까지만 쉴 수 있으므로, 결과는 코어마다 0~1 사이 숫자가 된다. 0.8이면 “그 코어는 최근 5분 동안 시간의 80%를 쉬었다”는 뜻이다.avg by (instance) (...): 코어마다 따로 나온 0~1 값을 인스턴스(서버) 단위로 평균 내서 서버 하나에 숫자 하나로 묶는다. Prometheus는 정해진 주기마다 대상에 찾아가 숫자를 가져오는데(스크레이프),instance라벨에는 그 대상의 「주소:포트」(예:10.0.0.5:9100)가 붙는다. 그래서 서버가 여러 대면 서버마다 한 줄씩 나온다.* 100: 0~1 사이 비율을 퍼센트로 바꾼다.100 - (...): 전체(100%)에서 “쉰 비율”을 빼면 “일하고 있던 비율”, 즉 CPU 사용률이 남는다.
메모리 사용률(%)
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100
한 줄씩 따라가기
node_memory_MemTotal_bytes: 이 서버에 꽂혀 있는 전체 메모리 크기(바이트)다.node_memory_MemAvailable_bytes: 지금 당장 새 프로그램에 내줄 수 있는 메모리 크기다.(전체 - 쓸 수 있는 양) / 전체 * 100: 전체에서 쓸 수 있는 양을 빼면 이미 쓰고 있는 양이 남고, 이를 전체로 나눠 퍼센트로 바꾸면 메모리 사용률이 된다. 서버가 여러 대면 두 메트릭 모두 서버마다 줄이 있다. 줄이 여러 개인 결과끼리 빼고 나누면, Prometheus는 라벨이 완전히 같은 줄끼리 짝을 지어 계산하고 짝이 없는 줄은 결과에서 뺀다. 그래서 같은 서버의 전체 메모리와 쓸 수 있는 메모리가 짝이 된다. 두 값 모두 누적값이 아니라 “지금 값”이라rate()를 씌우지 않는다.
디스크 사용률(%)
(max by (instance,device) (node_filesystem_size_bytes{fstype!~"tmpfs|overlay"})
- max by (instance,device) (node_filesystem_free_bytes{fstype!~"tmpfs|overlay"}))
/ max by (instance,device) (node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) * 100
한 줄씩 따라가기
node_filesystem_size_bytes/node_filesystem_free_bytes: 파일 시스템 하나의 전체 크기와 빈 공간(바이트)이다. 디스크에 파일을 늘어놓는 방식이 파일 시스템이라, 디스크가 얼마나 찼는지는 파일 시스템 단위로 잰다. 이 메트릭에는device(어느 디스크 장치인가),mountpoint(어느 폴더에 붙였나),fstype(파일 시스템 종류) 라벨이 붙는다. 리눅스에서는 디스크를 폴더 하나에 연결해 두고(「붙인다」, 마운트), 그 폴더를 열면 그 디스크의 내용이 보인다.mountpoint가 그 폴더다.{fstype!~"tmpfs|overlay"}:=~는 “값이 뒤의 정규식과 일치하는 것만 남겨라”,!~는 “일치하지 않는 것만 남겨라”는 연산자다. 정규식은 글자 모양으로 조건을 적는 방법이고, 그 안의|는 “또는”이라,tmpfs나overlay인 줄을 뺀다. tmpfs는 파일을 메모리에 두는 파일 시스템이고, overlay는 Docker가 컨테이너마다 겹쳐 보여 주는 파일 시스템이다. 둘 다 진짜 디스크 한 칸이 아니어서 디스크 사용률에서 뺀다.max by (instance,device) (...): 장치 하나를 여러 폴더에 붙이면mountpoint만 다른 줄이 여러 개 나온다.by (instance,device)로 묶으면mountpoint라벨이 사라져 같은 장치의 줄들이 한 묶음이 되는데, 이때sum으로 더하면 같은 디스크를 두 번 세게 된다. 그래서 그중 하나의 값만 남기려고max를 쓴다.(전체 - 빈 공간) / 전체 * 100: 메모리 사용률과 같은 계산이다. 세 덩어리 모두by (instance,device)로 묶어서 라벨이instance와device둘만 남으므로, 같은 서버·같은 장치끼리 짝이 맞는다.
네트워크 수신/송신 Bps
sum by (instance) (rate(node_network_receive_bytes_total{device!~"lo"}[5m]))
sum by (instance) (rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))
한 줄씩 따라가기
- 두 줄은 서로 다른 쿼리다. 첫 줄은 받은 양(receive), 둘째 줄은 보낸 양(transmit)이고, 입력창에는 한 줄씩 따로 넣는다.
node_network_receive_bytes_total: 네트워크 장치(device라벨)마다 지금까지 받은 바이트의 누적값이다.{device!~"lo"}:lo는 루프백 인터페이스, 즉 서버가 자기 자신에게 보내는 통신이 지나가는 가상의 장치다. 바깥과 주고받은 양만 보려고 뺀다. PromQL의 정규식은 값 전체와 맞아야 일치로 치기 때문에,!~"lo"는 이름이 정확히lo인 줄만 빼고lo0처럼 lo가 들어간 다른 이름은 빼지 않는다. 결과는!="lo"와 같다.rate(...[5m]): 최근 5분 동안 초당 몇 바이트씩 늘었는지 구한다. 제목의 Bps가 이 “초당 바이트(Bytes per second)”다.sum by (instance): 서버에 네트워크 장치가 여러 개면 장치마다 줄이 따로 나온다. 바이트 양은 장치끼리 더하면 서버 전체 양이 되므로sum으로 합친다. CPU에서avg를 쓴 것은 0~1 비율이라 더하면 의미가 없어서였다.
2. 도커 컨테이너(cAdvisor)
cAdvisor는 컨테이너 하나하나가 CPU·메모리·네트워크를 얼마나 쓰는지 재서 Prometheus가 가져갈 수 있는 형태로 내보내는 도구다. node_exporter가 서버 전체를 본다면, cAdvisor는 그 서버 위에서 도는 컨테이너 하나하나를 본다.
컨테이너 CPU 사용률(%)
sum by (container) (rate(container_cpu_usage_seconds_total{image!=""}[5m])) * 100
한 줄씩 따라가기
container_cpu_usage_seconds_total: 그 컨테이너가 지금까지 CPU를 쓴 시간(초)의 누적값이다.{image!=""}:!=는 “이 값과 다른 것만 남겨라”는 연산자다.image라벨(그 컨테이너를 만든 이미지 이름)이 빈 문자열이 아닌 줄만 남겨서, 이미지로 만든 컨테이너만 본다. image가 빈 줄이 정확히 어떤 대상인지는 확인하지 않았다. 그래서 이 조건을 빼면 무엇이 더 섞여 나오는지도 이 글은 말하지 못한다.rate(...[5m]): 최근 5분 동안 1초에 CPU를 몇 초 썼는지 구한다. 코어 하나를 꽉 채워 쓰면 1, 절반이면 0.5다.sum by (container) (...) * 100:container라벨 값이 같은 줄을 하나로 합친 뒤 100을 곱한다. 컨테이너 하나에서 줄이 몇 개 나오는지(그래서 합치는 일이 실제로 일어나는지)는 확인하지 않았다. 그래서 이 값은 코어 하나를 100%로 본 퍼센트다. 코어 2개를 꽉 채워 쓰는 컨테이너는 200이 나온다.
컨테이너 메모리 사용 바이트
sum by (container) (container_memory_working_set_bytes{image!=""})
한 줄씩 따라가기
container_memory_working_set_bytes: 컨테이너의 워킹 셋, 즉 커널이 당장 거둬 갈 수 있는 쉬는 파일 캐시를 뺀 메모리 사용량(바이트)이다. 누적값이 아니라 “지금 값”(게이지)이라rate()없이 그대로 읽는다.sum by (container): 컨테이너별로 하나의 숫자로 묶는다. CPU 쿼리처럼, 컨테이너 하나에서 줄이 여러 개 나오는지는 확인하지 않았다.
컨테이너 재시작 감지(최근 10분)
increase(container_last_seen{image!=""}[10m]) < 1
한 줄씩 따라가기
container_last_seen: cAdvisor가 그 컨테이너를 마지막으로 본 시각이다. 시각을 초 단위 숫자로 적은 값(타임스탬프)이라, 1초가 지나면 숫자가 1 커진다. 누적값이 아니라 “지금 값”인 게이지이고, 컨테이너가 계속 살아 있으면 이 시각도 계속 앞으로 간다.increase(...[10m]): 최근 10분 동안 이 값이 얼마나 늘었는지 계산한다. 값이 시각이므로, 늘어난 양은 “그 시각이 몇 초 앞으로 갔나”다. 10분 내내 계속 보였다면 600초 가까이 늘어난다. Prometheus 문서는increase()를 카운터에만 쓰라고 적고 있어서, 게이지에 씌운 이 쿼리는 문서가 권하는 쓰임은 아니다.< 1: 쿼리 끝의 비교는 참/거짓을 돌려주지 않고, 조건에 맞는 줄만 남기고 나머지를 결과에서 지운다. 그래서 결과에는 “10분 동안 마지막으로 본 시각이 1초도 앞으로 가지 않은 컨테이너”만 남는다. 최근 10분 사이 cAdvisor가 그 컨테이너를 새로 보지 못했다는 신호다. 컨테이너가 아예 지워졌을 때 그 줄이 결과에 남아 이 조건에 걸리는지, 줄 자체가 사라지는지는 확인하지 않았다.- 이 쿼리만으로는 컨테이너가 멈춘 것과 재시작한 것을 가르지 못한다. 제목의 「재시작 감지」는 “최근 10분 동안 소식이 끊긴 컨테이너를 찾아서 멈췄거나 재시작 중인지 살펴볼 후보로 삼는다”에 가깝다.
컨테이너 네트워크 수신/송신 Bps
sum by (container) (rate(container_network_receive_bytes_total{image!=""}[5m]))
sum by (container) (rate(container_network_transmit_bytes_total{image!=""}[5m]))
한 줄씩 따라가기
- 노드의 네트워크 쿼리와 같은 모양이다. 첫 줄은 받은 바이트(receive), 둘째 줄은 보낸 바이트(transmit)이고 한 줄씩 따로 넣는다.
rate(...[5m])로 초당 바이트를 구하고,sum by (container)로 컨테이너별로 합친다. 노드 쿼리처럼lo를 빼는 조건은 없다.
3. Spring Boot Actuator
Spring Boot Actuator가 내보내는 http_server_requests_seconds_* 계열 메트릭은 이 서버가 받은 HTTP 요청 하나하나의 처리 시간을 잰 히스토그램이다. 이 메트릭이 Prometheus용으로 나오려면 앱에 Prometheus용 의존성을 넣고 /actuator/prometheus 엔드포인트를 공개해야 한다. Spring Boot 문서는 이 엔드포인트가 기본으로는 공개되지 않는다고 적는다. 설정 예시는 Spring Boot Actuator 개요 글에 있다. 이름 하나에서 세 갈래가 나온다.
_count: 지금까지 받은 요청 건수의 누적값_sum: 지금까지 받은 요청들의 처리 시간(초)을 모두 더한 누적값_bucket: 처리 시간을 구간으로 나눠 구간마다 몇 건이었는지 센 누적값
아래 쿼리의 job="spring-app"은 이 메트릭을 내보낸 스프링 앱을 가리키는 라벨이다. job은 같은 일을 하는 인스턴스들을 묶어 부르는 이름이고, Prometheus가 숫자를 가져올 때 설정 파일(prometheus.yml)의 job_name에 적힌 이름을 자동으로 붙인다. 내 job 이름은 그 설정 파일에서 찾는다(Prometheus 개요 글의 설정 예제 참고). 아래 입력창에서 자신의 환경에 맞는 값으로 바꿀 수 있다.
위 값을 수정하면 아래 코드 블록의 부분이 자동으로 변경됩니다.
RPS(초당 요청 수)
sum by (instance) (rate(http_server_requests_seconds_count{job="spring-app"}[1m]))
한 줄씩 따라가기
http_server_requests_seconds_count: 지금까지 받은 요청 건수의 누적값이다. 요청이 하나 끝날 때마다 1씩 는다.rate(...[1m]): 최근 1분 동안 초당 몇 건씩 늘었는지, 즉 초당 요청 수(RPS)를 구한다. 다른 쿼리의5m보다 짧은 1분 평균이라 변화가 더 빨리 보이는 대신 그래프가 더 들쭉날쭉하다.sum by (instance): 같은 앱 안에서도 요청 주소 모양(uri라벨, 예:/api/person/{id})과 상태 코드(status라벨, 예:200)마다 줄이 따로 나오는데, 이를 인스턴스(앱 하나)별로 합친다.
p95 응답시간
histogram_quantile(0.95,
sum by (le) (rate(http_server_requests_seconds_bucket{job="spring-app"}[5m]))
)
한 줄씩 따라가기
http_server_requests_seconds_bucket: 요청 처리 시간을 “0.1초 이하”, “0.3초 이하”처럼 구간(버킷)으로 나눠 센 값이다. 구간의 경계값은le(“less than or equal”, ~이하)라는 라벨에 담긴다. 여기서 “누적”은 두 겹이다. 하나는 구간끼리 누적이라le="0.3"은 0.1초 이하로 끝난 요청까지 함께 센다는 것이고, 다른 하나는 시간으로 누적이라 앱이 뜬 뒤로 계속 쌓이기만 한다는 것이다. Spring Boot에는 이 구간을 내보낼지 정하는management.metrics.distribution.percentiles-histogram설정이 있다. 기본으로 켜져 있는지는 확인하지 않았으므로, 쿼리 결과가 비면 이 설정부터 본다.rate(...[5m]): 시간으로 쌓인 누적값은 앱이 뜬 뒤의 모든 요청을 담고 있어서, 그대로 쓰면 “지금” 응답시간이 아니라 그동안 전체의 응답시간이 된다.rate()를 먼저 씌워 최근 5분 동안 구간마다 초당 몇 건씩 들어왔는지로 바꾼다.sum by (le) (...): 여러 인스턴스에서 올라온 같은le구간의 값을 합쳐서, 서버 전체 기준의 구간별 분포로 만든다.histogram_quantile은le라벨이 있는 줄만 읽어 계산하므로, 묶을 때le를 남겨야 한다.histogram_quantile(0.95, ...): 이 구간별 분포에서 “전체 요청 중 95%가 이 시간 안에 끝났다”고 할 수 있는 경계값(p95)을 계산한다. 구간별 건수만 있으므로, 95% 지점이 들어 있는 구간을 찾은 뒤 그 구간 안에서는 요청이 고르게 퍼져 있다고 가정하고 비례로 값을 정한다(Prometheus 문서의 표현으로 「선형 보간」). 그래서 결과는 구간 경계값 중 하나가 아니라 그 사이 값일 수 있고, 실제 p95와는 조금 다를 수 있는 추정값이다.0.95대신0.5를 넣으면 중앙값(p50),0.99를 넣으면 p99가 된다.
에러율(5xx 비율)
sum(rate(http_server_requests_seconds_count{job="spring-app",status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{job="spring-app"}[5m]))
한 줄씩 따라가기
status=~"5..":=~는 값이 뒤에 오는 정규식과 일치하는지로 걸러내는 연산자다.5..는 “5로 시작하고 그 뒤에 아무 문자나 두 개가 오는 값”을 뜻하는 정규식이라,500·502·503처럼 5xx로 시작하는 상태 코드(서버 쪽 에러)만 걸러낸다.sum(...):by가 없으면 라벨을 하나도 남기지 않고 모든 줄을 더해 숫자 하나로 만든다. 그래서 분자와 분모가 각각 숫자 하나가 된다.- 분자(위)는 5xx 요청의 초당 건수, 분모(아래)는 전체 요청의 초당 건수다. 분자를 분모로 나누면 “전체 요청 중 5xx가 차지하는 비율”, 즉 에러율이 0~1 사이 숫자로 나온다.
JVM 힙 사용량
JVM 힙은 자바 프로그램이 객체를 담아 두는 메모리 영역이다. area="heap"으로 걸러서 힙 메모리만 조회한다.
jvm_memory_used_bytes{job="spring-app",area="heap"}
한 줄씩 따라가기
jvm_memory_used_bytes: JVM이 지금 쓰고 있는 메모리(바이트)다. “지금 값”이라rate()없이 그대로 읽는다.area="heap": 힙 쪽 메모리만 고른다. 이 메트릭은 JVM의 메모리 구역(메모리 풀)마다 따로 등록되어서, 힙 안에서도 구역마다 줄이 따로 나온다. 힙 전체를 숫자 하나로 보려면 앞에서 본sum을 씌워sum(jvm_memory_used_bytes{job="spring-app",area="heap"})으로 쓴다.
GC Pause 합계(5m)
GC가 한 번 일어나는 동안에는 자바 프로그램이 잠깐 멈추는데, 이 멈춘 시간을 Pause라 부른다. GC는 방식이 여러 가지이고, gc 라벨에는 그때 일한 가비지 컬렉터의 이름이 붙는다. 아래 쿼리는 그 이름별로 최근 5분 동안 1초에 몇 초씩 멈췄는지를 보여 준다.
sum by (gc) (rate(jvm_gc_pause_seconds_sum{job="spring-app"}[5m]))
한 줄씩 따라가기
jvm_gc_pause_seconds_sum: 앱이 뜬 뒤로 GC 때문에 멈춘 시간(초)을 모두 더한 누적값이다.rate(...[5m]): 최근 5분 동안 이 합계가 초당 몇 초씩 늘었는지 구한다. 결과는 “5분 동안 멈춘 총 초”가 아니라 “1초당 멈춘 초”다. 0.02면 최근 5분 동안 시간의 2%를 GC로 멈춰 있었다는 뜻이다. 5분 동안 멈춘 총 초가 필요하면 이 값에 300(초)을 곱하면 된다.sum by (gc): 가비지 컬렉터 이름별로 합친다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기