Prometheus
metric을 pull로 긁어 local TSDB에 쌓고 PromQL로 질의하는 모니터링 시스템
Prometheus는 대상에서 metric을 주기적으로 긁어와(pull) 자기 local disk의 TSDB에 시계열로 쌓고, PromQL로 질의하고, 규칙으로 알림을 울리는 모니터링 시스템이다.
Intro
모니터링 시스템의 첫 갈림길은 누가 데이터를 옮기느냐다. 애플리케이션이 metric을 어딘가로 밀어 보내는 push 방식과, 모니터링 서버가 애플리케이션에서 긁어 오는 pull 방식이 있다.
Prometheus는 pull을 택했다. 각 대상이 자기 metric을 HTTP endpoint(/metrics)에 노출해 두면, Prometheus 서버가 정해진 주기(scrape interval)마다 그 endpoint를 긁어 온다.
덕분에 어떤 Target이 살아 있는지를 scrape 성공 여부로 곧장 알 수 있고, 모니터링 서버가 Source of truth가 되며, Target 쪽에는 endpoint 하나만 있으면 된다. Target list는 service discovery(쿠버네티스 API, Consul 등)나 정적 설정으로 채운다.
Prometheus는 이렇게 긁어 온 데이터를 local disk의 TSDB에 시계열로 쌓는다.
Pull Model and the Ecosystem
Prometheus 서버 혼자 모든 걸 하지는 않는다. 생태계는 몇 개의 조각으로 나뉘고, 대부분은 선택 사항이다.
애플리케이션은 client library로 자기 코드에 계측을 심어 /metrics를 노출한다.
직접 고칠 수 없는 ThirdParty 시스템은 exporter가 그 시스템의 상태를 읽어 Prometheus 형식으로 translate해 노출한다.
배치 job처럼 수명이 짧아 scrape 당할 때까지 살아 있지 않는 대상은 pushgateway에 metric을 밀어 두고, Prometheus가 pushgateway를 긁는다.
pull이 원칙이되, 수명이 짧은 job만 이 intermediary로 예외 처리하는 셈이다.
대상이 노출하는 metric은 텍스트 기반 exposition format으로 쓰인다. 한 줄이 하나의 시계열 샘플이다.
# HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="POST", handler="/api/tracks"} 1027
Data Model
Prometheus의 모든 데이터는 시계열, 즉 같은 metric과 같은 label 집합에 속하는 timestamp-값 스트림이다. 하나의 시계열은 metric name과 label의 조합으로 유일하게 식별된다.
- metric name은 측정 대상을 가리킨다. 예를 들어
http_requests_total은 받은 HTTP 요청의 총수다. - label은 같은 metric name 아래의 서로 다른 인스턴스를 가른다.
method="POST",handler="/api/tracks"처럼 key-value로 붙는다.
label이 다르면 다른 시계열이다. http_requests_total{method="POST"}와 http_requests_total{method="GET"}은 별개로 저장된다. 이 다차원 모델이 PromQL의 유연한 질의를 가능케한다. 다만 label 값의 조합이 많아질수록 시계열 수가 폭증하는데, 이 cardinality가 Prometheus 용량의 변수다.
계측하는 metric은 네 가지 타입으로 나뉜다.
| 타입 | 의미 | 예 |
|---|---|---|
| Counter | 단조 증가만 하는 누적값 | 총 요청 수, 총 에러 수 |
| Gauge | 오르내리는 순간값 | 메모리 사용량, 큐 길이 |
| Histogram | 값의 분포를 bucket으로 집계 | 요청 지연 분포 |
| Summary | 분위수를 클라이언트에서 계산 | 요청 지연 p99 |
Local Storage (TSDB)
긁어 온 샘플은 local TSDB에 쌓인다. 저장 구조는 시간을 기준으로 잘린 block의 연속이다.
들어오는 샘플은 기본 2시간 단위 block으로 묶인다. 하나의 block 디렉터리에는 실제 샘플을 담은 chunks, metric name과 label을 그 block 안 시계열로 이어 주는 index, 메타데이터 meta.json이 들어 있다. chunks는 기본 512MB 단위의 segment 파일로 쪼개진다.
지금 들어오는 최신 block은 아직 디스크에 완전히 내려가지 않고 메모리(head)에 있다. 이걸 크래시로부터 지키는 장치가 WAL(Write-Ahead Log)이다. 서버가 재시작하면 WAL을 재생해 메모리 상태를 복구한다. WAL 파일은 wal 디렉터리에 128MB segment로 쌓인다.
메모리에만 두면 유실 위험도 크고 용량도 무한정 늘기 때문에, 2시간이 지난 head는 디스크 block으로 떨어지고, background에서 여러 2시간 block이 더 큰 block으로 compaction된다. compaction으로 만들어지는 block은 retention 기간의 10%나 31일 중 작은 쪽까지 커진다. 오래된 데이터는 retention(기본 15일) 정책에 따라 지워진다.
local storage는 clustered도 replicated도 아니다. 단일 노드 데이터베이스처럼 다뤄야 하고, 디스크나 노드가 통째로 죽으면 그 데이터는 사라진다. Prometheus는 이 문제를 스스로 풀지 않기로 하고, 대신 외부로 내보내는 인터페이스를 열어 뒀다.
Remote Write 에서 계속…
PromQL and Rules
쌓인 시계열은 PromQL로 질의한다. PromQL은 다차원 데이터를 다루도록 설계된 질의 언어로, label로 시계열을 골라내고 시간 축으로 집계한다. 예를 들어 rate(http_requests_total[5m])는 최근 5분간의 초당 요청 증가율을, sum by (handler) (...)는 그것을 handler별로 합친다.
그냥 Prometheus 버전 SQL 이라고 이해하면 된다.
PromQL 표현식은 두 가지 규칙으로도 쓰인다.
recording rule은 자주 쓰거나 무거운 질의를 미리 계산해 새 시계열로 저장해 둔다. 대시보드가 매번 복잡한 질의를 돌리는 대신 미리 구운 결과를 읽게 하는 것이다.
alerting rule은 조건이 참이 되면 alert를 발생시킨다. 다만 Prometheus는 alert를 직접 알리지 않는다. 발생한 alert는 Alertmanager같은 서비스로 넘어가고, Alertmanager가 중복 제거, 그룹화, 라우팅, 억제를 거쳐 실제 채널(이메일, Slack 등)로 통지한다.
규칙을 실제로 적용하는 방식은 이렇다. 두 rule을 rule 파일에 적고, Prometheus 설정의 rule_files로 불러오면, Prometheus가 evaluation_interval마다 표현식을 평가한다. 같은 rate(http_requests_total[5m]) 표현식이라도 record에 넣으면 recording rule, alert에 조건과 함께 넣으면 alerting rule이 된다.
groups:
- name: http
rules:
# recording rule: 표현식을 미리 계산해 새 시계열로 저장
- record: job:http_requests:rate5m
expr: rate(http_requests_total[5m])
# alerting rule: 표현식 + 조건이 5분간 참이면 alert 발생
- alert: HighRequestRate
expr: rate(http_requests_total[5m]) > 100
for: 5m
labels: { severity: warning }
annotations: { summary: "요청률이 높습니다" }
recording rule이 만든 job:http_requests:rate5m는 대시보드가 무거운 원본 대신 읽어 가는 새 metric이 되고, alerting rule의 HighRequestRate는 조건이 참이 되면 Alertmanager로 넘어간다.
Remote Write
local storage의 한계(단일 노드, 장기 보존, 고가용성 부재)를 Prometheus는 스스로 풀지 않는다. 대신 긁어 온 샘플을 외부 시스템으로 실시간 복제하는 remote write를 연다.
remote write가 켜지면 Prometheus는 샘플을 local TSDB에 쓰는 동시에, 그 샘플을 원격 endpoint로 밀어 보낸다. 전송 형식은 snappy로 압축한 Protocol Buffer 메시지를 HTTP로 실어 보내는 것이고, 받는 쪽의 표준 endpoint는 /api/v1/write다. 프로토콜은 1.0이 안정 버전, 2.0이 실험 버전으로 둘 다 서버가 지원한다.
이 remote write가 Prometheus를 장기 저장과 수평 확장을 갖춘 백엔드로 만들어준다. Grafana Mimir나 Thanos 같은 시스템이 이 remote write를 받아, 여러 Prometheus의 metric을 한곳에 모으고 object storage에 장기 보존하며 수평 확장된 질의를 제공한다.
읽기 쪽에도 대응하는 remote read가 있지만 성격이 다르다. remote read는 원격에서 원시 시계열만 가져오고 PromQL 평가는 여전히 질의하는 Prometheus 안에서 이뤄진다. 필요한 데이터를 전부 로컬로 끌어와 처리하는 구조라 확장성에 한계가 있어, 대규모 장기 질의는 보통 백엔드 자체의 질의 계층(Mimir의 query-frontend 같은)에 맡긴다.
Agent Mode
근데 어차피 Query도 Alert도 중앙 Mimir에서 할 거라면, 각 클러스터의 Prometheus는 왜 TSDB와 Query engine까지 통째로 들고 있을 이유가 없다.
그래서 Agent Mode라는걸 지원한다.
Agent Mode를 켜면 scrape, service discovery, remote write만 남고 query, alerting, local storage는 꺼진다.
TSDB는 forwarding에 맞게 손본 WAL이 대신한다. scrape 설정과 service discovery 동작은 서버 모드와 같아서, 쓰던 설정 그대로 forwarding 전용 인스턴스로 갈아 끼울 수 있다.
동작은 단순하게 긁어 온 샘플을 WAL에 적고, remote write 전송에 성공한 데이터는 주기적인 WAL truncation 때 지운다.
원격 endpoint가 죽어 있으면 지우지 않고 디스크에 들고 있다가, 복구되면 이어서 보낸다.
다만 이 버퍼는 무한하지 않아서, --storage.agent.retention.max-time(기본 4시간)을 넘긴 샘플은 WAL truncation 때 강제로 지워지므로, 장애가 그보다 길어지면 유실이 시작된다.
대신 2시간 block도 compaction도 질의용 index도 만들지 않으니, 메모리와 디스크 사용량은 서버 모드보다 크게 준다.
Try It in k3s
지금까지의 개념(pull, /metrics, scrape, PromQL)이 실제로 어떻게 도는지 로컬 k3s 클러스터에서 확인한다. 계측된 Go 백엔드를 하나 띄우고, Prometheus가 그 /metrics를 긁어가 조회하는 데까지 간다.
Instrument a Go backend
백엔드는 client_golang으로 metric을 노출한다. 요청마다 http_requests_total counter를 올리고, /metrics endpoint를 promhttp handler로 연다.
var httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"path", "method"})
func hello(w http.ResponseWriter, r *http.Request) {
httpRequests.WithLabelValues(r.URL.Path, r.Method).Inc()
w.Write([]byte("hello from go backend\n"))
}
func main() {
http.HandleFunc("/", hello)
http.Handle("/metrics", promhttp.Handler()) // Prometheus가 긁어갈 endpoint
http.ListenAndServe(":8080", nil)
}
이 서버를 이미지로 빌드해 k3s에 올리면, /metrics는 exposition format 그대로 counter를 뱉는다.
# HELP http_requests_total Total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/"} 245
http_requests_total{method="GET",path="/api/tracks"} 245
path이 두 개로 갈리는 건 / 핸들러가 catch-all이기 때문이다. Go의 http.HandleFunc("/", ...)는 따로 등록되지 않은 모든 경로를 받으므로, /로 보내든 /api/tracks로 보내든 요청은 전부 hello를 거친다. 그때 WithLabelValues(r.URL.Path, ...)가 실제 경로를 path label로 찍어, 같은 http_requests_total이 경로마다 별개의 시계열로 갈린다. 위 두 줄은 백엔드에 /와 /api/tracks 요청을 섞어 보낸 결과다.
Point Prometheus at it
Prometheus는 scrape config의 target 목록만 있으면 된다. 같은 namespace의 backend Service를 5초마다 긁도록 지정한다.
global:
scrape_interval: 5s
scrape_configs:
- job_name: go-backend
static_configs:
- targets: [backend:8080] # backend Service의 /metrics를 pull
백엔드와 Prometheus를 각각 Deployment와 Service로 k3s에 올리고, Prometheus UI로 port-forward한다.
kubectl apply -f backend.yaml
kubectl apply -f prometheus.yaml
kubectl -n demo port-forward svc/prometheus 9090:9090
Generate some traffic
counter는 요청을 받아야 오른다. 백엔드에 /와 /api/tracks 두 경로로 트래픽을 반복해 흘려 준다(클러스터 안에 요청을 보내는 파드를 하나 두면 된다).
while true; do
curl -s http://backend:8080/ >/dev/null
curl -s http://backend:8080/api/tracks >/dev/null
sleep 0.5
done
이 두 경로가 곧 path="/"와 path="/api/tracks" 두 시계열이 된다.
Watch the pull
Prometheus UI의 Status → Target health를 열면 방금 지정한 target이 잡혀 있다. http://backend:8080/metrics를 5초마다 긁고 State가 UP이면 pull이 도는 것이다.

여기서 앞의 개념이 그대로 눈에 보인다. Prometheus가 target 목록의 source of truth이고, scrape 성공 여부(UP/DOWN)가 곧 그 target의 생사 판정이다.
Query the metric
Query 탭에서 http_requests_total을 조회하면, 긁어와 쌓인 시계열이 label과 함께 나온다. path="/"와 path="/api/tracks"가 각각 별개의 시계열로 저장돼 있다.

raw counter는 단조 증가라 그 자체로는 읽기 어렵다. rate()로 감싸면 초당 증가율이 되고, Graph 탭이 시간 축으로 그려 준다.
rate(http_requests_total[1m])
![rate(http_requests_total[1m]) 그래프. 트래픽이 시작되며 초당 요청률이 올라 약 1.8/s에서 안정되는 곡선](/images/prometheus-demo/query-graph.png)
계측, scrape, 조회로 이어지는 이 한 바퀴가 Prometheus의 기본 동작이다. 여기에 target을 여럿 붙이고, recording rule로 무거운 query를 미리 굽고, remote write로 이 시계열을 Grafana Mimir 같은 백엔드에 넘기면 앞 절들의 그림이 완성된다.
Prometheus Operator and ServiceMonitor
방금 데모는 static_configs에 target을 손으로 적었다. 대상이 backend Service 하나뿐이라 이게 제일 단순했다. 그런데 실제 클러스터에서는 Pod가 수시로 뜨고 지며 긁을 대상이 수십 개로 불어난다. 그때마다 prometheus.yml을 고쳐 reload하는 방식은 오래 버티지 못한다.
그래서 쿠버네티스에서는 보통 Prometheus를 Prometheus Operator로 굴린다. 대개 kube-prometheus-stack 번들로 Operator와 Prometheus, Alertmanager, Grafana를 통째로 깐다. Operator는 사람이 config 파일을 고치던 구조를 뒤집는다. scrape 대상을 ServiceMonitor라는 쿠버네티스 리소스로 선언해 두면, Operator가 그걸 감지해 scrape_configs를 대신 만들어 Prometheus에 밀어 넣는다.
앞서 target list를 service discovery로 채운다고 한 그 자리를, 쿠버네티스에서 CRD로 구체화한 것이 ServiceMonitor인 셈이다.
ServiceMonitor는 Pod를 직접 고르지 않고 Service를 경유해 대상을 찾는다. 데모의 go-backend를 그대로 옮기면 이렇게 된다.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: go-backend
labels:
release: prometheus # 이 SM을 어느 Prometheus가 집을지
spec:
selector:
matchLabels:
app: go-backend # 이 라벨을 가진 Service를 고른다
endpoints:
- port: metrics # Service의 named port
interval: 5s
path: /metrics
연결은 라벨 셀렉터 두 겹으로 이어진다. Prometheus 리소스의 serviceMonitorSelector가 어떤 ServiceMonitor를 채택할지 고르고(위의 release: prometheus), ServiceMonitor의 selector가 감시할 Service를 고르며, 그 Service의 Endpoints가 실제로 긁을 Pod로 이어진다. 데모에서 사람이 targets: [backend:8080]이라 못 박던 자리를, 이 라벨 사슬이 자동으로 메운다.
Service를 붙이기 애매한 대상(수명이 짧은 batch job 등)을 위해서는 형제 CRD인 PodMonitor가 Service 없이 Pod를 직접 겨냥한다. exporter들이 대개 배포 매니페스트에 ServiceMonitor를 함께 담아 두는 것도 이 구조 덕분이다. 노출할 워크로드와 ServiceMonitor를 함께 올려 두면, 나머지 연결은 Operator가 알아서 잇는다.
앞서 본 Agent Mode도 Operator 세계에 대응물이 있다. PrometheusAgent CRD는 Prometheus 리소스와 같은 라벨 사슬로 ServiceMonitor를 채택하되, 실행 모드만 Agent로 바꿔 remote write 전용 인스턴스를 굴린다. edge 클러스터마다 PrometheusAgent를 두고 중앙 클러스터의 Mimir로 모으는 구성이 이 조합으로 만들어진다.
References
- Prometheus — Overview
https://prometheus.io/docs/introduction/overview/ - Prometheus — Data model
https://prometheus.io/docs/concepts/data_model/ - Prometheus — Metric types
https://prometheus.io/docs/concepts/metric_types/ - Prometheus — Storage (local TSDB and remote write/read)
https://prometheus.io/docs/prometheus/latest/storage/ - Prometheus — Remote Write specification
https://prometheus.io/docs/specs/remote_write_spec/ - Prometheus — Introducing Prometheus Agent Mode
https://prometheus.io/blog/2021/11/16/agent/ - Prometheus — Command-line flags (agent mode, storage.agent.*)
https://prometheus.io/docs/prometheus/latest/command-line/prometheus/