# PodMonitor와 ServiceMonitor는 무엇이 다른가 - 스크레이프 대상은 Endpoints가 정한다
Prometheus Operator를 쓰면 스크레이프 설정을 직접 쓰지 않고 ServiceMonitor 하나만 만들면 됩니다. 그런데 만들어 놓은 ServiceMonitor가 타깃 목록에 아예 안 나타나거나, 평소에는 잘 수집되다가 하필 장애가 시작된 순간에 시계열이 끊기는 일이 생깁니다.
두 증상 모두 원인은 같습니다. ServiceMonitor는 Service를 스크레이프하지 않습니다. Service가 만들어 낸 엔드포인트를 스크레이프합니다. 이 글에서는 두 CR이 타깃을 찾아가는 경로가 어떻게 다른지, 그 차이가 어떤 상황에서 관측 공백을 만드는지, 그리고 무엇을 기준으로 골라야 하는지 정리합니다.
# 1. 타깃을 찾아가는 경로
두 CR의 필드 설명을 문서에서 그대로 옮기면 차이가 드러납니다.
PodMonitorSpec.selector: defines the label selector to select the Kubernetes
Podobjects to scrape metrics from.
PodMonitor는 파드를 직접 고릅니다. 반면 ServiceMonitor는 Service를 고르고, 그 Service에 딸린 엔드포인트를 타깃으로 삼습니다. 경로를 그림으로 펴면 이렇습니다.
ServiceMonitor → Service (라벨 매칭)
↓ kube-controller-manager가 관리
EndpointSlice (Ready 상태인 파드 주소만)
↓
스크레이프 타깃
PodMonitor → Pod (라벨 매칭)
↓
스크레이프 타깃
중간에 EndpointSlice가 하나 더 끼는 것이 전부지만, 그 한 단계가 동작을 크게 바꿉니다. EndpointSlice에는 기본적으로 Ready 상태인 파드만 들어갑니다. 즉 ServiceMonitor로 수집하는 애플리케이션은 readiness probe가 실패하는 순간 스크레이프 대상에서 빠집니다.
# 2. 장애가 시작되면 메트릭이 먼저 사라진다
이 성질 때문에 무너지는 지점은 뚜렷합니다. 애플리케이션이 느려져 readiness probe가 타임아웃하면 파드는 NotReady가 되고, 엔드포인트에서 빠지고, 타깃 목록에서 사라집니다. 사라진 타깃은 up 메트릭조차 남기지 않습니다. 트래픽을 못 받게 된 파드가 왜 그렇게 됐는지 알려 줄 메트릭이 바로 그 시점에 끊깁니다.
up == 0 알림을 걸어 뒀어도 잡히지 않습니다. up은 "타깃인데 스크레이프 실패"일 때 0이고, 타깃 자체가 없어지면 시계열이 그냥 없어지기 때문입니다. 이 차이 때문에 알림 표현식이 달라집니다.
# 스크레이프 실패는 잡지만, 타깃이 사라진 경우는 못 잡는다
up{job="myapp"} == 0
# 타깃 소멸까지 잡으려면 사라짐 자체를 조건으로 쓴다
absent(up{job="myapp"}) == 1
# 또는 직전에 존재했는데 지금 없는 경우
count(up{job="myapp"}) < count(up{job="myapp"} offset 10m)
대응은 두 가지입니다. 첫째, 메트릭 포트를 별도 헤드리스 Service로 노출하고 publishNotReadyAddresses: true를 켜서 NotReady 파드도 엔드포인트에 남깁니다.
apiVersion: v1
kind: Service
metadata:
name: myapp-metrics
labels:
app.kubernetes.io/name: myapp
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app.kubernetes.io/name: myapp
ports:
- name: metrics # 반드시 이름을 붙인다 (3절 참조)
port: 9090
targetPort: metrics
둘째, 애초에 PodMonitor를 씁니다. 파드를 직접 고르므로 Ready 여부와 무관하게 타깃으로 남습니다. 관측 목적의 수집을 트래픽 라우팅 상태에 연동하지 않는 편이 낫다는 것이 결론입니다.
# 3. 포트를 지정하는 방식이 다르다
ServiceMonitor의 port는 문서상 이렇게 정의됩니다.
port: Port upon a ServiceMonitor's service to target for scraping metrics.
여기서 말하는 Port는 Service 포트의 이름입니다. Service에 name이 없는 포트가 있으면 그 포트는 ServiceMonitor로 지정할 수 없습니다. 포트가 하나뿐인 Service는 이름을 생략해도 동작하기 때문에, 나중에 메트릭 포트를 추가하는 순간 기존 포트에 이름이 없어 매니페스트를 고쳐야 하는 상황이 자주 생깁니다. Service 포트에는 처음부터 이름을 붙이는 편이 안전합니다.
PodMonitor는 파드 컨테이너 포트를 봅니다.
PodMetricsEndpoint.port: Name of the pod port to use for scraping.
PodMetricsEndpoint.portNumber: Numeric port to scrape on the Pod.
이름과 번호 중 하나를 쓸 수 있습니다. 컨테이너 스펙에 ports를 선언하지 않은 파드라도 portNumber로 잡을 수 있다는 뜻입니다. 컨테이너 포트 선언은 문서화 목적일 뿐 실제 리스닝과 무관하므로, 사이드카가 임의 포트에 메트릭을 여는 경우 PodMonitor가 유일한 선택지가 되기도 합니다.
# 4. 라벨을 옮기는 필드가 서로 다르다
수집된 메트릭에 붙는 라벨을 정하는 필드도 두 CR에서 대상이 다릅니다.
| 필드 | ServiceMonitor | PodMonitor | 무엇을 옮기는가 |
|---|---|---|---|
jobLabel | 있음 | 있음 | job 라벨로 쓸 라벨 이름 |
targetLabels | 있음 | 없음 | Service 라벨 → 메트릭 |
podTargetLabels | 있음 | 있음 | Pod 라벨 → 메트릭 |
문서의 설명은 이렇습니다.
targetLabels: Labels to transfer from the associated Kubernetes
Serviceonto ingested metrics.podTargetLabels: Labels which are transferred from the associated Kubernetes
Podobject onto the ingested metrics.
여기서 조용히 어긋나는 지점이 jobLabel입니다. ServiceMonitor의 jobLabel은 Service의 라벨을 찾고, PodMonitor의 jobLabel은 Pod의 라벨을 찾습니다. 같은 이름의 필드지만 조회 대상이 다릅니다. ServiceMonitor를 PodMonitor로 옮겨 적으면서 jobLabel: app.kubernetes.io/name을 그대로 복사했는데, 그 라벨이 Service에만 있고 파드 템플릿에는 없다면 job 라벨이 조용히 기본값(네임스페이스/CR 이름 기반)으로 떨어집니다. 대시보드와 알림이 전부 job으로 필터링돼 있다면 그 순간부터 아무것도 안 보입니다.
CR 종류를 바꿀 때는 jobLabel이 가리키는 라벨이 새 조회 대상에도 있는지 반드시 확인해야 합니다.
# 5. 만든 CR이 아예 안 잡히는 가장 흔한 이유
타깃 목록에 아무것도 안 나올 때, 십중팔구는 CR이 아니라 Prometheus 쪽 셀렉터 문제입니다. 문서 원문에 분명히 적혀 있습니다.
serviceMonitorSelector: serviceMonitors to be selected for target discovery. An empty label selector matches all objects. A null label selector matches no objects.
serviceMonitorNamespaceSelector: namespaces to match for ServiceMonitors discovery. An empty label selector matches all namespaces. A null label selector (default value) matches the current namespace only.
두 문장이 말하는 기본값은 서로 다릅니다.
serviceMonitorSelector를 아예 안 쓰면(null) 아무 ServiceMonitor도 선택되지 않습니다. 빈 객체{}를 명시해야 전부 선택됩니다.serviceMonitorNamespaceSelector를 안 쓰면 Prometheus가 있는 네임스페이스만 봅니다. 다른 네임스페이스에 ServiceMonitor를 만들어 놓고 기다려도 영원히 안 잡힙니다.
{}(빈 셀렉터)와 미지정(null)이 정반대로 동작한다는 점이 핵심입니다. YAML에서 이 둘은 눈으로 구분하기 어렵습니다.
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
metadata:
name: main
spec:
serviceAccountName: prometheus
serviceMonitorSelector: {} # 모든 ServiceMonitor
serviceMonitorNamespaceSelector: {} # 모든 네임스페이스
podMonitorSelector: {}
podMonitorNamespaceSelector: {}
라벨로 범위를 좁히고 싶다면 셀렉터에 조건을 넣되, 네임스페이스 셀렉터를 빼먹지 않는 것이 요령입니다.
serviceMonitorSelector:
matchLabels:
prometheus: main
serviceMonitorNamespaceSelector: {} # 이 줄이 없으면 같은 네임스페이스만 본다
여기에 RBAC이 하나 더 걸립니다. Prometheus의 ServiceAccount는 대상 네임스페이스의 services, endpoints/endpointslices, pods에 대한 get/list/watch 권한이 필요합니다. 권한이 없으면 CR은 선택되는데 타깃이 비는 형태로 나타나 원인을 찾기 까다롭습니다.
# 6. 중복 스크레이프와 카디널리티
한 파드가 두 Service에 속하고 두 Service를 각각 ServiceMonitor가 잡으면, 같은 엔드포인트가 두 번 스크레이프됩니다. 값이 이상해지지는 않지만 시계열이 job 라벨만 다른 상태로 두 벌 생기고, 집계 쿼리에서 값이 두 배가 됩니다. "애플리케이션용 Service"와 "메트릭 전용 Service"를 둘 다 만들어 놓고 ServiceMonitor를 각각 붙일 때 흔히 생깁니다.
폭주를 막는 상한도 CR 단위로 걸 수 있습니다.
sampleLimit: defines a per-scrape limit on the number of scraped samples that will be accepted.
targetLimit: defines a limit on the number of scraped targets that will be accepted.
labelLimit: defines the per-scrape limit on number of labels that will be accepted for a sample.
그런데 이 값은 CR 작성자가 스스로 설정합니다. 애플리케이션 팀이 만든 ServiceMonitor에 상한이 없으면 그대로 들어옵니다. Prometheus CR 쪽에서 강제하는 필드가 따로 있습니다.
enforcedSampleLimit: global limit on the number of scraped samples that will be accepted. This overrides any
spec.sampleLimitset by ServiceMonitor, PodMonitor, Probe objects unlessspec.sampleLimitis greater than zero and less thanspec.enforcedSampleLimit.
읽어 보면 "무조건 덮어쓴다"가 아니라 더 엄격한 쪽이 이깁니다. CR이 더 낮은 값을 스스로 걸면 그 값이 유지됩니다. 플랫폼 쪽에서 상한선을 그어 두고, 팀은 그보다 더 조일 수만 있게 하는 구조입니다. 여러 팀이 CR을 직접 만드는 클러스터라면 enforcedSampleLimit을 켜 두는 것이 사실상 필수입니다.
한편 개별 시계열을 버리려면 상한이 아니라 릴레이블이 필요합니다. 두 릴레이블 필드는 적용 시점이 다릅니다.
relabelings: 스크레이프 전에 타깃을 대상으로 동작합니다. 타깃 자체를 드롭하거나 주소·라벨을 바꿉니다.metricRelabelings: 스크레이프 후 샘플을 대상으로 동작합니다. 이미 긁어온 메트릭을 저장 전에 버립니다.
즉 "이 메트릭 이름만 안 받고 싶다"는 요구는 metricRelabelings로만 해결됩니다. relabelings에 __name__ 조건을 걸어도 그 시점엔 아직 메트릭이 없습니다.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: myapp
labels:
prometheus: main
spec:
selector:
matchLabels:
app.kubernetes.io/name: myapp
namespaceSelector:
matchNames: ["myapp-ns"]
jobLabel: app.kubernetes.io/name # 이 라벨이 Pod에 있어야 한다
podTargetLabels: ["app.kubernetes.io/component"]
sampleLimit: 20000
podMetricsEndpoints:
- port: metrics
path: /metrics
interval: 30s
metricRelabelings:
# 히스토그램 버킷이 카디널리티의 대부분이라면 여기서 버린다
- sourceLabels: [__name__]
regex: "myapp_request_duration_seconds_bucket"
action: drop
# 7. 무엇을 골라야 하는가
| 상황 | 선택 | 이유 |
|---|---|---|
| Ready 상태와 무관하게 항상 수집해야 함 | PodMonitor | 엔드포인트를 거치지 않음 |
| Job/CronJob, 서비스 없는 워크로드 | PodMonitor | 붙일 Service가 없음 |
| 사이드카가 임의 포트에 메트릭 노출 | PodMonitor | portNumber로 직접 지정 |
| Service 라벨을 메트릭에 붙이고 싶음 | ServiceMonitor | targetLabels가 여기에만 있음 |
| 외부 엔드포인트(클러스터 밖 주소) | ServiceMonitor | 셀렉터 없는 Service + 수동 EndpointSlice |
| 차트가 이미 제공하는 CR이 있음 | 그것을 그대로 | 업스트림 라벨 규약과 대시보드가 맞물려 있음 |
기본값을 하나 정하라면 파드 단위 워크로드는 PodMonitor가 무난합니다. 단계가 하나 적어 실패 지점이 적고, 관측이 트래픽 상태에 종속되지 않습니다. ServiceMonitor는 Service 라벨을 메트릭에 실어야 하거나 클러스터 밖 대상을 잡아야 할 때 고르면 됩니다.
# 8. 직접 확인하는 방법
CR을 만든 뒤 어디까지 반영됐는지 단계별로 확인할 수 있습니다.
# 1. Operator가 CR을 선택했는가 - 생성된 스크레이프 설정을 직접 본다
kubectl get secret prometheus-main -o jsonpath='{.data.prometheus\.yaml\.gz}' \
| base64 -d | gunzip | grep -A5 'job_name'
# 2. Service가 실제로 엔드포인트를 만들었는가 (ServiceMonitor일 때)
kubectl get endpointslices -l kubernetes.io/service-name=myapp-metrics -o yaml
# 3. Ready가 아닌 파드가 빠져 있는지 확인
kubectl get endpointslices -l kubernetes.io/service-name=myapp-metrics \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses}{" ready="}{.conditions.ready}{"\n"}{end}'
# 4. Prometheus가 타깃으로 인식했는가
kubectl port-forward svc/prometheus-main 9090:9090
curl -s localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job:.labels.job, health, lastError}'
# 5. 왜 드롭됐는지 - droppedTargets는 릴레이블에 걸린 대상을 보여준다
curl -s 'localhost:9090/api/v1/targets?state=dropped' | jq '.data.droppedTargets[:5]'
1번에서 job이 아예 안 나오면 셀렉터 문제(5절), 1번은 나오는데 2번이 비면 Service 라벨 매칭 문제, 2번은 있는데 ready=false뿐이면 readiness 문제(2절), 4번에서 lastError가 있으면 네트워크·경로 문제입니다. 순서대로 좁히면 원인이 한 단계에서 걸립니다.
# 9. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 타깃 목록에 job 자체가 없음 | serviceMonitorSelector가 null | Prometheus CR에 {} 또는 라벨 조건 명시 |
| 다른 네임스페이스 CR만 안 잡힘 | namespaceSelector 기본값이 현재 네임스페이스 | serviceMonitorNamespaceSelector: {} 추가 |
| job은 있는데 타깃이 0개 | Service 라벨 불일치 또는 엔드포인트 없음 | EndpointSlice 확인, RBAC 확인 |
| 장애 때만 시계열이 끊김 | NotReady 파드가 엔드포인트에서 제외 | PodMonitor 또는 publishNotReadyAddresses |
up == 0 알림이 안 울림 | 타깃 소멸은 up을 남기지 않음 | absent() 기반 표현식 추가 |
job 라벨이 예상과 다름 | jobLabel이 가리키는 라벨이 대상에 없음 | ServiceMonitor는 Service, PodMonitor는 Pod 라벨 확인 |
| 값이 두 배로 집계됨 | 같은 파드가 두 경로로 스크레이프 | 메트릭용 Service를 하나로 정리 |
| 포트를 지정할 수 없음 | Service 포트에 name이 없음 | 포트에 이름 부여, 또는 PodMonitor의 portNumber |
# 10. 마무리
- ServiceMonitor는 Service가 아니라 EndpointSlice를 통해 타깃에 도달합니다. 그래서 NotReady 파드는 조용히 수집 대상에서 빠지고, 장애 시점의 메트릭이 사라집니다.
{}와 미지정은 정반대입니다. 셀렉터는 빈 객체가 전부, null이 아무것도 아님이고, 네임스페이스 셀렉터는 null이 현재 네임스페이스만입니다. 안 잡히는 CR의 대부분이 여기서 나옵니다.jobLabel은 CR 종류에 따라 Service 라벨과 Pod 라벨 중 다른 쪽을 봅니다. CR을 옮겨 적을 때 함께 확인해야 합니다.- 상한은
sampleLimit으로 걸되, 여러 팀이 CR을 만드는 클러스터라면enforcedSampleLimit으로 전역 상한을 걸어 둡니다. 더 엄격한 쪽이 이깁니다.