Micrometer 입문 7편 — 운영 자동화·IaC·OpenTelemetry 연동

2026-05-25Micrometer 입문에서 운영까지

Micrometer 7편. /actuator/prometheus 엔드포인트 보안과 포트 분리, Kubernetes Prometheus Operator의 ServiceMonitor·PodMonitor로 scrape를 코드로 선언하는 방법, OpenTelemetry(OTel) OtlpMeterRegistry와 micrometer-tracing-bridge-otel 연동, MeterBinder로 커스텀 메트릭을 사내 라이브러리화하는 표준화 패턴까지.

📚 Micrometer 입문에서 운영까지 · 7편 — 운영 자동화·IaC·OpenTelemetry 연동

이 글은 Micrometer 입문에서 운영까지 시리즈 7편이에요. 6편에서는 MeterRegistry 구현체 계층과 pull/push 방식의 차이, PushGateway 패턴, step 주기 불일치 함정을 다뤘어요. 1~6편이 "무엇을 어떻게 계측하나"에 집중했다면, 7편부터는 방향이 바뀌어요. production에서 이 스택이 저절로 돌아가도록 만드는 의식 — 보안, 자동 발견, OpenTelemetry(OTel) 연동, 코드 표준화 같은 주제들이에요.

규모가 커지면 "Prometheus scrape 설정 파일에 타깃을 손으로 추가한다"거나 "팀마다 메트릭 이름 규칙이 제각각이다"라는 게 얼마나 고통스러운지 금방 알게 돼요. 거기에 벤더 중립성을 높이겠다고 OpenTelemetry(OTel)를 도입하면 Micrometer와의 경계가 어디인지 또 헷갈리기 시작하죠. 이 글은 그 경계를 정리하고, 자동화의 뼈대를 어디에 두어야 하는지를 짚어요.

이번 글의 범위

이번 글은 Micrometer 운영 자동화 전체를 다뤄요.

자리 내용
엔드포인트 보안 /actuator/prometheus 공개망 노출 위험 · management.server.port 분리 · Spring Security 보호
ServiceMonitor / PodMonitor Kubernetes Prometheus Operator의 CRD · scrape 대상을 코드로 선언
scrape config as code Helm values · relabeling · 수동 타깃 등록 제거
OpenTelemetry 연동 OtlpMeterRegistry · micrometer-tracing-bridge-otel · 언제 OTel로 가나
커스텀 메트릭 코드 표준화 MeterBinder · 네이밍 규약 · 공통 태그 강제
Grafana 대시보드 as code 메트릭 자동 수집 이후의 단계 — Grafana 시리즈 연결

엔드포인트 보안 노출

여기서 실무 함정이 하나 있어요. 많은 팀이 /actuator/prometheus를 아무 보호 없이 앱과 같은 포트(보통 8080)로 열어둬요. 인터널 망에서만 접근 가능하면 괜찮다고 생각하지만, 실제 사고는 방화벽 설정 실수나 ingress 규칙 오류 때문에 이 엔드포인트가 공개 인터넷에 노출되는 케이스에서 발생해요.

/actuator/prometheus 응답에는 JVM 메모리 상태, HikariCP 연결 수, HTTP 요청 latency, 심지어 커스텀 비즈니스 메트릭까지 담겨 있어요. 공격자 입장에서는 시스템 내부 상태를 무료로 스캔하는 창이에요.

management.server.port 분리

가장 확실한 방어는 actuator 포트를 앱 서비스 포트와 분리하는 거예요.

# application.yml
server:
  port: 8080            # 서비스 트래픽 포트 — ingress·LB에 연결됨

management:
  server:
    port: 8081          # actuator 전용 포트 — 내부망에만 열림
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics

Kubernetes 환경에서는 Service를 두 개로 나눠요.

# service-app.yaml — 외부 트래픽용
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  ports:
    - name: http
      port: 80
      targetPort: 8080
  selector:
    app: order-service

---
# service-management.yaml — actuator 내부용
apiVersion: v1
kind: Service
metadata:
  name: order-service-management
  labels:
    app: order-service
    monitoring: "true"    # Prometheus ServiceMonitor가 이 레이블로 발견
spec:
  ports:
    - name: management
      port: 8081
      targetPort: 8081
  selector:
    app: order-service

이렇게 하면 ingress가 8080 Service에만 연결되고, Prometheus는 8081 Management Service만 scrape해요.

Spring Security로 엔드포인트 보호

포트 분리가 어려운 환경(레거시 단일 포트 설정)에서는 Spring Security로 /actuator/prometheus에 IP 기반 접근 제어를 걸 수 있어요.

@Configuration
@EnableWebSecurity
public class ActuatorSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // prometheus scrape는 내부 서버 IP에서만
                .requestMatchers("/actuator/prometheus")
                    .access(new IpAddressAuthorizationManager("10.0.0.0/8", "172.16.0.0/12"))
                // health check는 LB가 쓰니까 열어둠
                .requestMatchers("/actuator/health")
                    .permitAll()
                // 그 외 actuator는 관리자 권한
                .requestMatchers("/actuator/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            )
            .build();
    }
}

네트워크 격리가 가장 강력하지만, 코드 레벨 보호도 함께 두면 심층 방어(defense in depth)가 돼요.

⚠️ 엔드포인트 보안 체크

운영 배포 전 필수 확인 — curl -s https://your-service.com/actuator/prometheus | head -5 로 응답이 오면 이미 노출된 상태예요.

management.server.port 분리 + 네트워크 정책(NetworkPolicy) + Spring Security IP 필터 세 겹이 있으면 실수 한 겹이 뚫려도 나머지 두 겹이 막아요.

Prometheus ServiceMonitor / PodMonitor

Kubernetes에서 Prometheus를 운영할 때 가장 흔한 수동 작업이 prometheus.ymlstatic_configs에 Pod IP를 직접 추가하는 거예요. Pod가 재시작되거나 HPA로 스케일아웃되면 IP가 바뀌는데, 그때마다 사람이 설정을 바꿔야 해요. 이게 반복되다 보면 scrape 대상이 맞지 않아 메트릭이 뚝뚝 끊기는 사고가 생겨요.

이 문제를 해결하는 게 Prometheus Operator의 ServiceMonitor와 PodMonitor예요. 둘 다 Kubernetes CRD(Custom Resource Definition)이고, Prometheus가 어떤 Service 또는 Pod에서 메트릭을 긁어올지를 YAML 선언으로 관리해요.

ServiceMonitor

ServiceMonitor는 Kubernetes Service를 scrape 대상으로 선언해요. 레이블 셀렉터로 Service를 발견하고, 그 Service가 가리키는 Pod 목록을 Prometheus가 자동으로 추적해요.

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: order-service-monitor
  namespace: monitoring          # Prometheus Operator가 감시하는 네임스페이스
  labels:
    release: prometheus          # Prometheus Operator의 serviceMonitorSelector와 매칭
spec:
  selector:
    matchLabels:
      monitoring: "true"         # 이 레이블을 가진 Service를 발견
  namespaceSelector:
    matchNames:
      - production               # production 네임스페이스의 Service만
  endpoints:
    - port: management           # Service의 port name — 8081 management 포트
      path: /actuator/prometheus
      interval: 15s
      scrapeTimeout: 10s
      honorLabels: false

selector.matchLabelsmonitoring: "true"를 걸면, 위에서 만든 order-service-management Service가 자동으로 발견돼요. 새 마이크로서비스를 배포할 때 Service에 레이블 하나만 붙이면 자동으로 scrape 대상이 되는 거예요.

PodMonitor

PodMonitor는 Service 없이 Pod를 직접 scrape 대상으로 삼아요. Headless Service나 DaemonSet 같은 Service가 없거나 우회해야 할 때 쓰기 좋아요.

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: order-worker-monitor
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: order-worker          # Pod 레이블로 직접 발견
  podMetricsEndpoints:
    - port: management
      path: /actuator/prometheus
      interval: 30s
💡 ServiceMonitor vs PodMonitor 선택 기준

일반적인 Deployment + ClusterIP Service 조합이면 ServiceMonitor가 기본이에요. Service의 레이블 셀렉터가 Pod 목록을 이미 관리하고 있으니, 그 위에 ServiceMonitor를 한 층 올리는 게 자연스러워요. PodMonitor는 Service 없는 리소스(DaemonSet, Job 등)나 Service를 bypas해 각 Pod를 독립적으로 scrape해야 할 때만 쓰는 게 좋아요.

scrape config as code

ServiceMonitor·PodMonitor로 scrape 대상 발견을 자동화했다면, 그 다음은 레이블 정제예요. Kubernetes 환경에서는 scrape된 메트릭에 Pod 이름, 네임스페이스, 노드 IP 같은 레이블이 자동으로 붙는데, 이 중 불필요한 것들이 많아요. 그냥 두면 카디널리티가 올라가서 저장 비용이 늘어요.

Helm values로 ServiceMonitor 관리

Helm Chart 안에 ServiceMonitor를 같이 패키징하면, 서비스 배포와 scrape 설정을 한 번에 관리할 수 있어요.

# values.yaml
serviceMonitor:
  enabled: true
  interval: 15s
  scrapeTimeout: 10s
  labels:
    release: prometheus
  relabelings:
    # Pod 이름에서 랜덤 suffix 제거 (order-service-7d4b9f-xk2p → order-service)
    - sourceLabels: [__meta_kubernetes_pod_name]
      regex: '(.*)-[a-z0-9]+-[a-z0-9]+'
      targetLabel: pod_name
      replacement: '$1'
  metricRelabelings:
    # 불필요한 JVM 내부 메트릭 드롭 (카디널리티 절감)
    - sourceLabels: [__name__]
      regex: 'jvm_gc_pause_seconds_bucket'
      action: drop
# templates/servicemonitor.yaml
{{- if .Values.serviceMonitor.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: {{ include "order-service.fullname" . }}
  labels:
    {{- toYaml .Values.serviceMonitor.labels | nindent 4 }}
spec:
  endpoints:
    - port: management
      path: /actuator/prometheus
      interval: {{ .Values.serviceMonitor.interval }}
      scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }}
      relabelings: {{- toYaml .Values.serviceMonitor.relabelings | nindent 8 }}
      metricRelabelings: {{- toYaml .Values.serviceMonitor.metricRelabelings | nindent 8 }}
  selector:
    matchLabels:
      {{- include "order-service.selectorLabels" . | nindent 6 }}
{{- end }}

이렇게 하면 helm install이나 helm upgrade 한 번으로 서비스 배포와 Prometheus scrape 설정이 동시에 적용돼요. PR에서 scrape 설정 변경 이력이 남고, 롤백도 helm rollback으로 한 번에 돼요.

relabeling 핵심 패턴

relabelings:
  # 네임스페이스를 환경 레이블로 매핑
  - sourceLabels: [__meta_kubernetes_namespace]
    targetLabel: environment
    regex: '(production|staging|development)'
    replacement: '$1'

  # 불필요한 내부 레이블 제거
  - regex: '__meta_kubernetes_pod_annotation_.*'
    action: labeldrop

metricRelabelings:
  # 버킷 수가 많은 histogram — le 레이블 절감
  - sourceLabels: [__name__, le]
    regex: 'http_server_requests_seconds_bucket;(0\.005|0\.01|0\.025|0\.05|0\.1|0\.25|0\.5|1\.0|2\.5|5\.0|10\.0|.*)'
    action: keep

relabelings은 scrape 전에 적용돼서 어떤 대상을 긁어올지를 제어해요. metricRelabelings은 scrape 후에 적용돼서 가져온 메트릭을 정제·드롭해요. 순서가 중요해요.

OpenTelemetry 연동

여기서 개념 정리가 필요해요. Micrometer와 OpenTelemetry(OTel)는 같은 레이어가 아니에요. Micrometer는 앱에서 메트릭·트레이스를 생성하는 계측 계층이고, OpenTelemetry는 벤더 중립 표준 wire format(OTLP) + 수집 컴포넌트(OTel Collector) 생태계예요. 그러다 보니 "Micrometer를 쓰는데 OpenTelemetry도 써야 하나?"는 팀마다 고민이 달라요.

OtlpMeterRegistry — 메트릭을 OTLP로 push

Micrometer는 micrometer-registry-otlp 모듈로 OTLP 프로토콜로 메트릭을 push하는 OtlpMeterRegistry를 제공해요. 이걸 쓰면 Prometheus scrape 없이도 OTel Collector나 Grafana OTEL endpoint로 메트릭을 보낼 수 있어요.

<!-- pom.xml -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-otlp</artifactId>
</dependency>
# application.yml
management:
  otlp:
    metrics:
      export:
        url: http://otel-collector.internal:4318/v1/metrics
        step: 30s
        resourceAttributes:
          service.name: order-service
          service.version: "1.0.0"
          deployment.environment: production

resourceAttributes는 OpenTelemetry의 Resource 개념이에요. Prometheus의 common tags와 비슷하지만, OTLP 스펙에 정의된 표준 키(service.name, service.version 등)를 쓰면 OTel 생태계 전체에서 일관되게 처리돼요.

OTel Collector 설정 쪽에서는 이렇게 받아요.

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
  resource:
    attributes:
      - key: environment
        from_attribute: deployment.environment
        action: upsert

exporters:
  prometheusremotewrite:
    endpoint: http://prometheus.internal:9090/api/v1/write
  otlp/grafana:
    endpoint: https://otlp-gateway-prod-us-east-0.grafana.net:443
    headers:
      authorization: "Basic ${GRAFANA_TOKEN}"

service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [prometheusremotewrite, otlp/grafana]

이 구조의 장점은 앱이 단일 엔드포인트(OTel Collector)로만 push하면, Collector가 Prometheus RemoteWrite·Grafana Cloud·Datadog 등 여러 백엔드로 팬아웃해준다는 점이에요. 백엔드를 추가하거나 변경할 때 앱 재배포가 필요 없어요.

Micrometer Tracing bridge — OpenTelemetry와 trace 연동

메트릭뿐 아니라 trace도 OpenTelemetry(OTel)로 내보내려면 micrometer-tracing-bridge-otel을 써요. 이 모듈이 Micrometer Tracing API와 OTel SDK를 연결하는 bridge 역할을 해요.

<!-- pom.xml -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
# application.yml
management:
  tracing:
    sampling:
      probability: 0.1    # 10% 샘플링
  otlp:
    tracing:
      endpoint: http://otel-collector.internal:4317    # gRPC OTLP

Spring Boot 3.x에서는 spring-boot-starter-actuator + micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp 세 가지 의존성만 추가하면 자동 설정이 돼요.

💡 언제 OpenTelemetry로 가나

Prometheus scrape만으로 충분한 팀 — 단일 Kubernetes 클러스터, 메트릭만 필요, Prometheus+Grafana 스택이 이미 안정적이면 굳이 OTel Collector를 도입할 이유가 없어요.

OpenTelemetry(OTel)를 고려할 타이밍 — (1) 메트릭·트레이스·로그를 하나의 파이프라인으로 통합하고 싶을 때, (2) 멀티 클러스터·멀티 리전에서 여러 백엔드로 팬아웃이 필요할 때, (3) 벤더 lock-in을 줄이고 싶을 때. "OpenTelemetry는 메트릭은 Micrometer, 표준 와이어 포맷은 OTLP"라는 구도로 쓰는 게 현실적이에요.

OpenTelemetry 공식 docs에서 OTLP 프로토콜 스펙과 Collector 설정 레퍼런스를 확인할 수 있어요.

OTel Java agent와 Micrometer 병행

OTel Java agent를 JVM에 attach해서 자동 계측을 하는 팀도 있어요. 이 경우 Micrometer와 OTel Java agent를 병행하는 게 가능해요.

OTel Java agent (JVM attach)
  ├─ HTTP 요청, DB 쿼리, 메시지 소비 등 자동 trace
  └─ Micrometer MeterRegistry → OpenTelemetry bridge로 메트릭도 export

Micrometer (코드 레벨)
  ├─ 비즈니스 커스텀 메트릭 (orders.created, payment.latency 등)
  └─ micrometer-tracing-bridge-otel → OTel Collector

OTel Java agent가 표준 라이브러리 계측을 담당하고, Micrometer가 도메인 특화 커스텀 메트릭을 담당하는 분리예요. 두 경로 모두 OTel Collector 하나로 모이면 운영 부담이 줄어요.

커스텀 메트릭 코드 표준화

팀이 세 개 이상으로 늘어나면 메트릭 이름이 제각각이 되는 게 어렵지 않게 일어나요. A팀은 order.created.count, B팀은 orders_created_total, C팀은 orderCreated로 쓰기 시작하면 Grafana 대시보드가 팀마다 달라지고, 장애 때 한 번에 전사 현황을 볼 수가 없어요.

MeterBinder — 재사용 가능한 계측 컴포넌트

MeterBinder는 커스텀 메트릭 묶음을 재사용 가능한 컴포넌트로 만드는 Micrometer 인터페이스예요. 사내 공통 라이브러리 jar에 넣어두면 각 서비스가 의존성 한 줄로 표준 메트릭을 얻을 수 있어요.

// 사내 공통 라이브러리: company-metrics-commons.jar
public class OrderMetrics implements MeterBinder {

    private Counter createdCounter;
    private Counter failedCounter;
    private Timer processingTimer;
    private DistributionSummary orderValueSummary;

    @Override
    public void bindTo(MeterRegistry registry) {
        this.createdCounter = Counter.builder("company.orders.created.total")
            .description("총 주문 생성 횟수 (성공)")
            .register(registry);

        this.failedCounter = Counter.builder("company.orders.failed.total")
            .description("총 주문 실패 횟수")
            .register(registry);

        this.processingTimer = Timer.builder("company.orders.processing.seconds")
            .description("주문 처리 latency (초)")
            .publishPercentiles(0.5, 0.95, 0.99)
            .publishPercentileHistogram()
            .register(registry);

        this.orderValueSummary = DistributionSummary.builder("company.orders.value.krw")
            .description("주문 금액 분포 (원)")
            .scale(0.001)    // 원 → 천원 단위 스케일링
            .register(registry);
    }

    public void recordCreated(long valueKrw, Duration latency) {
        createdCounter.increment();
        processingTimer.record(latency);
        orderValueSummary.record(valueKrw);
    }

    public void recordFailed() {
        failedCounter.increment();
    }
}
// 각 서비스에서 사용 — Spring Bean으로 등록하면 자동으로 bindTo 호출됨
@Configuration
public class MetricsConfig {

    @Bean
    public OrderMetrics orderMetrics() {
        return new OrderMetrics();  // MeterBinder는 Spring이 자동으로 MeterRegistry에 bind
    }
}

Spring Boot는 MeterBinder를 구현한 Bean을 감지하면 자동으로 bindTo(meterRegistry)를 호출해요. 직접 registry를 주입받아 등록할 필요가 없어요.

네이밍 규약 강제

MeterFilter로 조직 전체에 이름 규칙을 강제할 수 있어요.

@Bean
public MeterRegistryCustomizer<MeterRegistry> namingConventionFilter(
        @Value("${spring.application.name}") String appName) {
    return registry -> registry.config()
        // 메트릭 이름 prefix 강제: company.<service>.<metric>
        .meterFilter(new MeterFilter() {
            @Override
            public Meter.Id map(Meter.Id id) {
                if (!id.getName().startsWith("company.") &&
                    !id.getName().startsWith("jvm.") &&
                    !id.getName().startsWith("http.") &&
                    !id.getName().startsWith("system.")) {
                    return id.withName("company." + appName + "." + id.getName());
                }
                return id;
            }
        })
        // 공통 태그 강제
        .commonTags(
            "service", appName,
            "team", System.getenv().getOrDefault("TEAM_NAME", "unknown"),
            "environment", System.getenv().getOrDefault("ENV", "local")
        );
}

이 Bean 하나를 공통 라이브러리에 넣어두면 어느 서비스에서 만든 메트릭이든 company. prefix와 공통 태그가 자동으로 붙어요. 팀이 커스텀 메트릭을 어떻게 이름 짓든 Grafana 쿼리는 company.*로 통일돼요.

카디널리티 제어 필터

4편에서 다룬 cardinality 폭발을 조직 레벨에서 방어하는 필터도 공통 라이브러리에 박을 수 있어요.

// 태그 값 수가 100개를 초과하는 메트릭은 자동 차단
@Bean
public MeterRegistryCustomizer<MeterRegistry> cardinalityGuard() {
    return registry -> registry.config()
        .meterFilter(MeterFilter.maximumAllowableTags(
            "company.",    // 이 prefix의 메트릭에 적용
            "userId",      // userId 태그 값이
            100,           // 100개 초과하면
            MeterFilter.deny()  // 해당 메트릭 등록 거부
        ));
}

Grafana 대시보드 as code 연결

메트릭이 ServiceMonitor + OTel Collector + Prometheus RemoteWrite 조합으로 자동 수집되기 시작하면, 그 다음 단계는 대시보드도 코드로 관리하는 거예요. Grafana 대시보드를 UI에서 클릭으로 만들면 Git에 이력이 없고, 실수로 패널을 지우면 복구가 어려워요.

대시보드·알림을 Terraform이나 Grafonnet으로 코드화하는 건 Grafana 시리즈 7편에서 다뤘어요. Micrometer로 메트릭 이름을 표준화했다면, 그 이름에 맞춰 Grafana 대시보드 JSON을 코드로 선언하는 흐름이 자연스럽게 이어져요.

# terraform/grafana-dashboard.tf
resource "grafana_dashboard" "order_service" {
  config_json = templatefile("${path.module}/dashboards/order-service.json", {
    datasource = var.prometheus_datasource_uid
    service    = "order-service"
  })
  folder = grafana_folder.microservices.id
}
// dashboards/order-service.json (일부)
{
  "panels": [
    {
      "title": "주문 생성 rate (5분)",
      "targets": [{
        "expr": "rate(company_orders_created_total{service=\"${service}\"}[5m])",
        "datasource": "${datasource}"
      }]
    },
    {
      "title": "주문 처리 p99 latency",
      "targets": [{
        "expr": "histogram_quantile(0.99, rate(company_orders_processing_seconds_bucket{service=\"${service}\"}[5m]))",
        "datasource": "${datasource}"
      }]
    }
  ]
}

메트릭 이름이 company.orders.created.total로 표준화돼 있으면, 대시보드 템플릿의 company_orders_created_total 쿼리가 어느 서비스에서도 그대로 동작해요. 서비스 이름만 파라미터로 바꾸면 새 서비스 대시보드가 자동 생성돼요.

함정 정리

사고 1: /actuator/prometheus가 공개 인터넷에 노출됨

서비스 배포 후 ingress 설정 실수나 /* 와일드카드 경로 때문에 actuator 엔드포인트가 공개망에 열리는 경우예요. 공격자가 JVM 상태, DB 커넥션 수, 비즈니스 메트릭을 무료로 수집할 수 있게 돼요.

해결management.server.port를 앱 포트와 분리하고, Kubernetes NetworkPolicy로 Prometheus scraper IP만 8081 포트에 접근 가능하게 해요. ingress는 8080만 바라보도록.

사고 2: ServiceMonitor의 release 레이블이 Prometheus Operator와 매칭 안 됨

ServiceMonitor를 배포했는데 Prometheus가 새 타깃을 발견하지 못하는 경우예요. Prometheus Operator는 자신의 serviceMonitorSelector에 지정된 레이블을 가진 ServiceMonitor만 처리해요.

해결kubectl get prometheus -n monitoring -o yaml | grep -A5 serviceMonitorSelector로 Operator의 selector를 확인하고, ServiceMonitor의 metadata.labels를 맞춰요. 보통 release: prometheus가 기본값이에요.

사고 3: PodMonitor와 ServiceMonitor 중복 설정으로 메트릭 두 배 수집

같은 Pod를 ServiceMonitor 하나, PodMonitor 하나, 두 가지로 동시에 scrape 대상으로 설정하면 Prometheus에 같은 메트릭이 두 배로 쌓여요. rate()increase() 계산이 두 배로 나와서 대시보드가 틀리게 돼요.

해결 — ServiceMonitor와 PodMonitor 중 하나만 써요. 일반적으로 ServiceMonitor가 우선이에요. PodMonitor는 Service가 없는 리소스에만 써요.

사고 4: OtlpMeterRegistry와 PrometheusMeterRegistry를 동시에 켜서 메트릭이 이중으로 전송됨

둘 다 enabled: true면 같은 메트릭이 Prometheus scrape + OTLP push 두 경로로 나가요. OTel Collector가 다시 Prometheus RemoteWrite로 쓰면 Prometheus에 메트릭이 두 번 들어올 수 있어요.

해결 — OTLP 경로로 통합하기로 결정했으면 Prometheus enabled: false로 명시해요. 아직 Prometheus scrape를 유지하면서 OTel을 추가하는 마이그레이션 중이라면 CompositeMeterRegistry 동작을 정확히 이해하고 의도적으로 두 경로를 운영하는 거예요.

사고 5: OpenTelemetry(OTel) Java agent와 micrometer-tracing-bridge-otel 충돌

OTel Java agent를 JVM에 attach하면서 동시에 micrometer-tracing-bridge-otel 의존성도 추가하면, trace exporter가 이중으로 활성화돼서 span이 중복으로 전송돼요.

해결 — 둘 중 하나만 써요. OTel Java agent를 쓰면 micrometer-tracing-bridge-otel의 trace export 기능이 필요 없어요. Micrometer tracing bridge를 쓰면 agent 없이도 trace가 나가요.

사고 6: MeterBinder의 bindTo 호출 시점 이전에 메트릭을 기록하려고 함

MeterBinder 구현체에서 bindTo가 호출되기 전에 Counter 등의 필드가 초기화되지 않아 NullPointerException이 발생하는 경우예요.

해결MeterBinder.bindTo(MeterRegistry) 안에서 모든 Meter 필드를 초기화해요. bindTo 이전에는 Meter 필드를 null로 두거나, lazy initialization 패턴으로 처음 사용 시 registry에서 조회해요.

사고 7: relabeling 규칙 순서 착각 — relabelings가 수집 후에 적용된다고 오해

relabelings는 scrape 전 타깃 메타데이터 처리이고, metricRelabelings는 scrape 후 메트릭 레이블 처리예요. 둘의 순서를 헷갈려서 scrape 대상 자체를 필터링하고 싶었는데 메트릭 레이블을 드롭하거나 반대로 하는 경우예요.

해결 — 타깃 레이블 정제(어떤 Pod를 긁을지 결정, 타깃 레이블 변환)는 relabelings. 수집된 메트릭 이름·레이블 드롭·변환은 metricRelabelings. ServiceMonitor YAML에서 두 필드 이름으로 구분해요.

사고 8: metricRelabelings로 중요 메트릭을 action: drop으로 실수 삭제

카디널리티를 줄이겠다고 regex가 의도보다 넓게 걸려서 http_server_requests_seconds_* 전체를 드롭해버리는 경우예요. 알람이 없어지고 대시보드가 공백이 되어서야 발견해요.

해결metricRelabelings 규칙 적용 전 Prometheus promtool 또는 --dry-run으로 검증해요. staging에서 먼저 적용하고 24시간 메트릭이 정상적으로 수집되는지 확인한 뒤 production에 반영해요.

사고 9: OTel Collector가 단일 장애 지점이 됨

앱이 OTel Collector 하나로만 OTLP push를 하는데, Collector Pod가 재시작되면 그 사이 메트릭이 통째로 유실돼요.

해결 — OTel Collector를 DaemonSet으로 배포해서 각 노드에 하나씩 두거나, Deployment에 여러 replica를 두고 OtlpMeterRegistry의 URL에 Service를 가리키게 해요. Collector exporter에 retry_on_failuresending_queue도 설정해요.

사고 10: 공통 MeterFilter로 prefix 강제했는데 표준 JVM·HTTP 메트릭 이름도 변환됨

company. prefix 강제 필터가 jvm.memory.used, http.server.requests 같은 Spring Boot 자동 메트릭에도 적용돼서 company.jvm.memory.used가 돼버리는 경우예요. Grafana 기본 대시보드가 동작을 안 해요.

해결 — MeterFilter의 map 메서드 안에서 Micrometer/Spring Boot 자동 메트릭 prefix(jvm., http., system., process., tomcat., hikaricp., spring.)는 제외하고 커스텀 메트릭에만 prefix를 붙여요.

운영 권장 패턴

Pattern 1: 3-레이어 보안

레이어 1 — 네트워크: management.server.port=8081 분리 + Kubernetes NetworkPolicy
레이어 2 — 앱: Spring Security IP 필터
레이어 3 — 모니터링: actuator 엔드포인트 접근 로그 + 이상 접근 알람

세 레이어 중 하나가 설정 실수로 뚫려도 나머지 두 개가 막아요. 레이어 3은 뚫렸을 때 탐지하는 용도예요.

Pattern 2: ServiceMonitor를 Helm Chart에 포함

# Chart.yaml
name: order-service
version: 1.0.0
order-service/
  templates/
    deployment.yaml
    service.yaml
    service-management.yaml
    servicemonitor.yaml        ← 여기 같이
  values.yaml

서비스 배포와 scrape 설정을 같은 PR에서 리뷰·배포해요. 서비스가 늘어날 때마다 Prometheus scrape 설정을 별도로 수정하는 작업이 사라져요.

Pattern 3: OpenTelemetry(OTel) Collector를 팬아웃 허브로

# 앱은 OTel Collector 하나에만 push
management:
  otlp:
    metrics:
      export:
        url: http://otel-collector:4318/v1/metrics

# Collector 설정에서 여러 백엔드로 팬아웃
exporters:
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write
  datadog:
    api:
      key: ${DD_API_KEY}
  logging:
    loglevel: info

새 백엔드를 추가할 때 앱 재배포 없이 Collector ConfigMap만 바꾸면 돼요.

Pattern 4: MeterBinder로 팀 표준 메트릭 라이브러리화

company-metrics-commons/
  src/main/java/com/company/metrics/
    OrderMetrics.java          implements MeterBinder
    PaymentMetrics.java        implements MeterBinder
    UserMetrics.java           implements MeterBinder
    MetricsNamingFilter.java   MeterFilter — prefix 강제
    CardinalityGuardFilter.java MeterFilter — 카디널리티 방어

각 서비스는 company-metrics-commons를 의존성으로 추가하고 필요한 MeterBinder Bean만 등록해요. 메트릭 이름·공통 태그·카디널리티 방어가 사내 표준으로 통일돼요.

Pattern 5: Grafana 대시보드 as code와 메트릭 표준화 연계

메트릭 이름이 표준화되면 대시보드 JSON 템플릿을 하나 만들어서 서비스 이름만 파라미터로 바꿔 재사용해요.

# 새 서비스 대시보드 배포
terraform apply \
  -var="service_name=payment-service" \
  -var="team_name=payments"

company_<service>_* 패턴으로 메트릭이 표준화돼 있으면 Terraform이 변수만 바꿔서 대시보드를 자동 생성해요.

Pattern 6: relabeling dry-run 검증 파이프라인

# CI에서 ServiceMonitor 변경 시 relabeling 검증
promtool check config prometheus-config-with-new-relabeling.yaml

# 로컬에서 특정 메트릭에 relabeling이 어떻게 적용되는지 확인
# (Prometheus UI의 /api/v1/targets 엔드포인트로 확인)
curl -s http://prometheus:9090/api/v1/targets | \
  jq '.data.activeTargets[] | select(.labels.job=="order-service") | .labels'

PR 단계에서 relabeling 규칙 오류를 잡아서 production에서 메트릭이 갑자기 사라지는 사고를 예방해요.

시험 직전 한 번 더 — IaC/연동 압축 노트

엔드포인트 보안

  • /actuator/prometheus를 공개망에 열면 JVM·비즈니스 메트릭 전체가 노출됨
  • management.server.port=8081로 앱 포트와 분리 + Kubernetes Service 두 개로 나눔
  • Spring Security IP 필터로 코드 레벨 방어까지 — 심층 방어 3겹

ServiceMonitor / PodMonitor

  • Prometheus Operator의 CRD — Kubernetes에서 scrape 대상을 YAML로 선언
  • ServiceMonitor = Service를 대상으로 Pod 자동 발견, PodMonitor = Pod 직접
  • selector.matchLabels로 레이블 붙은 Service 자동 발견 — 수동 IP 등록 불필요
  • ServiceMonitor의 metadata.labels이 Prometheus Operator의 serviceMonitorSelector와 일치해야 함
  • endpoints.path=/actuator/prometheus, port(port name), interval 지정

scrape config as code

  • Helm Chart에 ServiceMonitor 포함 → 서비스 배포와 scrape 설정을 같은 PR로
  • relabelings — scrape 전, 타깃 메타데이터 처리 (어떤 Pod를 긁을지)
  • metricRelabelings — scrape 후, 수집된 메트릭 이름·레이블 정제·드롭
  • 카디널리티 제어: 불필요한 레이블·메트릭 드롭으로 저장 비용 절감

OpenTelemetry(OTel) 연동

  • micrometer-registry-otlpOtlpMeterRegistry → OTLP HTTP push
  • OTel Collector가 팬아웃 허브 — 앱 재배포 없이 백엔드 추가
  • micrometer-tracing-bridge-otel = Micrometer Tracing API ↔ OTel SDK 브릿지
  • "메트릭은 Micrometer, 표준 wire format은 OTLP" 구도가 현실적
  • OTel Java agent와 micrometer-tracing-bridge-otel 동시 사용 시 span 중복 주의
  • OpenTelemetry 고려 시점 — 멀티 백엔드 팬아웃, trace 통합, 벤더 중립성 강화
  • resourceAttributes.service.name/version/environment = OTel Resource 표준 키

MeterBinder 표준화

  • MeterBinder.bindTo(MeterRegistry) — 재사용 가능한 계측 컴포넌트
  • Spring Boot가 MeterBinder Bean을 자동 감지 → bindTo 자동 호출
  • 사내 공통 라이브러리로 패키징 → 팀 표준 메트릭 이름·구조 강제
  • MeterFilter.map() — 메트릭 이름 prefix 강제 (단, 자동 메트릭 prefix 제외 조건 필수)
  • commonTags()service, team, environment 공통 태그 조직 표준화
  • MeterFilter.maximumAllowableTags() — 카디널리티 방어 조직 레벨 강제

Grafana as code 연결

  • 메트릭 이름 표준화 → 대시보드 JSON 템플릿 재사용 가능
  • Terraform grafana_dashboard 리소스 + JSON 템플릿으로 서비스 이름만 파라미터화
  • 새 서비스 대시보드 = Terraform apply 한 번

흔한 사고

  • actuator 엔드포인트 공개 노출
  • ServiceMonitor의 release 레이블 불일치로 Prometheus가 발견 못함
  • ServiceMonitor + PodMonitor 중복으로 메트릭 2배 수집
  • OtlpMeterRegistry + PrometheusMeterRegistry 동시 활성화 → 이중 경로
  • OTel Java agent + micrometer-tracing-bridge-otel 충돌 → span 중복
  • relabelings vs metricRelabelings 순서 혼동
  • 공통 MeterFilter가 JVM·HTTP 자동 메트릭 이름까지 변환

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

답글 남기기

error: Content is protected !!