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 개요 글의 설정 예제 참고). 아래 입력창에서 자신의 환경에 맞는 값으로 바꿀 수 있다.

위 값을 수정하면 아래 코드 블록의 spring-app 부분이 자동으로 변경됩니다.

연동 코드: 계산 중...

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): 가비지 컬렉터 이름별로 합친다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기