Micrometer และ Prometheus ใน Spring Boot: เมตริก Actuator บนโปรดักชัน

การเชื่อม Micrometer กับ Prometheus บน Spring Boot 4: โมเดลการเข้าถึงของ Actuator, การตั้งค่า scrape, เมตริก HTTP, health probe และกฎแจ้งเตือน

การตรวจสอบ Spring Boot Actuator ด้วย Micrometer และ Prometheus

Spring Boot Actuator เปิด endpoint ด้าน health, เมตริก และการวินิจฉัย โดยมี Micrometer เป็น facade ของเมตริกอยู่เบื้องหลัง การเพิ่ม dependency micrometer-registry-prometheus จะได้ endpoint /actuator/prometheus ที่ Prometheus เข้ามา scrape ตามรอบเวลาที่กำหนด ตัวอย่างในบทความนี้อ้างอิง Spring Boot 4.1 (เวอร์ชัน GA ปัจจุบัน โดย 4.2 อยู่ในขั้น release candidate), Micrometer 1.16 และ Prometheus Java client 1.x

คำตอบแบบสั้น

การเชื่อม Micrometer ของ Spring Boot เข้ากับ Prometheus มีสามขั้นตอน: เพิ่ม spring-boot-starter-actuator และ micrometer-registry-prometheus, เพิ่ม prometheus ลงใน management.endpoints.web.exposure.include จากนั้นตั้ง scrape_config ของ Prometheus ให้ชี้ไปที่ /actuator/prometheus เมตริกของ JVM, HTTP, HikariCP และระบบจะถูกเผยแพร่โดยไม่ต้องเขียนโค้ดเพิ่ม

การตั้งค่า Micrometer ใน Spring Boot: Actuator และ Registry ของ Prometheus

Dependency Maven ที่ต้องใช้

starter ของ Actuator เปิดใช้งาน endpoint และดึง Micrometer เข้ามาด้วย registry ของ Prometheus จัดรูปแบบ meter ทุกตัวตาม exposition format ของ Prometheus ส่วน starter ของ AspectJ จำเป็นเฉพาะเมื่อใช้แอนโนเทชัน @Timed และ @Counted

xml
<!-- pom.xml -->
<!-- Actuator + Micrometer + Prometheus (Spring Boot 4.x) -->
<dependencies>
    <!-- Spring Boot Actuator: monitoring endpoints, includes Micrometer -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <!-- Micrometer Registry Prometheus (Prometheus Java client 1.x) -->
    <!-- Exposes metrics in Prometheus format at /actuator/prometheus -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <!-- AspectJ for @Timed and @Counted -->
    <!-- Spring Boot 4 renamed spring-boot-starter-aop to spring-boot-starter-aspectj -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aspectj</artifactId>
    </dependency>
</dependencies>

บทแนะนำรุ่นเก่ายังมีกับดักเรื่องเวอร์ชันอยู่สองจุด ตั้งแต่ Micrometer 1.13 (Spring Boot 3.3) micrometer-registry-prometheus สร้างอยู่บน Prometheus Java client 1.x ส่วนโค้ด 0.x เดิมย้ายไปอยู่ในโมดูล micrometer-registry-prometheus-simpleclient ซึ่งถูก deprecate แล้ว และเอกสาร Micrometer Prometheus ระบุว่าเป็นเพียงทางออกชั่วคราว อีกจุดคือบน Spring Boot 4 ไฟล์ pom.xml ที่ยังประกาศ spring-boot-starter-aop จะ resolve ไม่ได้อีกต่อไป เพราะคู่มือการย้ายไป Spring Boot 4.0 เปลี่ยนชื่อเป็น spring-boot-starter-aspectj

การตั้งค่า Endpoint ของ Actuator

ค่าเริ่มต้นจะเปิดเฉพาะ endpoint health ผ่าน HTTP เท่านั้น endpoint /actuator/prometheus มีอยู่ทันทีที่ registry อยู่ใน classpath แต่จะตอบกลับ 404 จนกว่าจะถูกเพิ่มเข้าไปในรายการ exposure

Spring Boot 3.4 แทนที่สวิตช์เปิด/ปิด (management.endpoints.enabled-by-default, management.endpoint.<id>.enabled) ด้วยโมเดลการเข้าถึง (access model) และ property เดิมถูก deprecate แล้ว รูปแบบ opt-in คือกำหนดสิทธิ์เริ่มต้นเป็น none แล้วให้สิทธิ์ read-only ทีละ endpoint:

yaml
# application.yml
# Actuator configuration for production (Spring Boot 3.4+ access model)
management:
  endpoints:
    # Every endpoint is inaccessible unless granted below
    access:
      default: none
    web:
      exposure:
        # Endpoints exposed over HTTP
        # health, info, prometheus are the minimum for monitoring
        include: health,info,prometheus,metrics,env,loggers
      base-path: /actuator
  endpoint:
    health:
      access: read-only
      show-details: when-authorized
      show-components: when-authorized
    info:
      access: read-only
    prometheus:
      access: read-only
    metrics:
      access: read-only
    env:
      access: read-only
      # Mask sensitive values for anonymous callers
      show-values: when-authorized
    loggers:
      # unrestricted allows POST to change a log level at runtime
      access: unrestricted

Exposure และ access เป็นการควบคุมสองชั้นที่แยกจากกัน exposure.include กำหนดว่า endpoint จะถูก map ผ่าน HTTP หรือไม่ ส่วน access กำหนดว่า endpoint อนุญาตให้ทำอะไรได้บ้างเมื่อถูก map แล้ว ไฟล์ property ที่ยังใช้ enabled: true จะยังทำงานได้พร้อมคำเตือน deprecation ซึ่งเป็นจังหวะที่ควรย้ายไปใช้รูปแบบใหม่

ตัวเลือก show-details: when-authorized จะแสดงรายละเอียดสุขภาพเฉพาะกับผู้ใช้ที่ผ่านการยืนยันตัวตนและมีบทบาทเหมาะสมเท่านั้น ตัว endpoint เองได้รับการป้องกันด้วย SecurityFilterChain เฉพาะ:

ActuatorSecurityConfig.javajava
// Securing Actuator endpoints (Spring Boot 4, Spring Security 7)
package com.example.monitoring.config;

import static org.springframework.security.web.access.IpAddressAuthorizationManager.hasIpAddress;

import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
public class ActuatorSecurityConfig {

    @Bean
    SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // Health and info public for load balancers
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                // Prometheus accessible from the internal network only
                .requestMatchers(EndpointRequest.to("prometheus")).access(hasIpAddress("10.0.0.0/8"))
                // Other endpoints restricted to admins
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            .httpBasic(basic -> {})
            .build();
    }
}

ใน Spring Boot 4 EndpointRequest ย้ายไปอยู่ที่ org.springframework.boot.security.autoconfigure.actuate.web.servlet ตามที่แสดงในเอกสารอ้างอิง endpoint ของ Actuator DSL แบบ Java AuthorizedUrl ของ authorizeHttpRequests ไม่มีเมธอด hasIpAddress กฎเรื่อง IP จึงต้องผ่าน IpAddressAuthorizationManager.hasIpAddress ซึ่งมีให้ใช้ตั้งแต่ Spring Security 6.3

เมตริกแบบกำหนดเองด้วย Micrometer

Counter, Gauge และ Timer ระดับแอปพลิเคชัน

Micrometer มีเมตริกหลายชนิดให้เลือกใช้ตามสถานการณ์ Counter วัดเหตุการณ์สะสม gauge วัดค่าชั่วขณะ ส่วน timer วัดระยะเวลาของการดำเนินการ

OrderMetricsService.javajava
// Custom business metrics service
package com.example.monitoring.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

@Service
public class OrderMetricsService {

    // Counter for orders created
    private final Counter ordersCreatedCounter;
    // Timer to measure processing duration
    private final Timer orderProcessingTimer;
    // Atomic value for pending orders gauge
    private final AtomicInteger pendingOrdersCount = new AtomicInteger(0);

    public OrderMetricsService(MeterRegistry registry) {
        // Exported as orders_created_total: the _total suffix is added by the registry
        this.ordersCreatedCounter = Counter.builder("orders.created")
            .description("Total number of orders created")
            .tag("channel", "web")
            .register(registry);

        // Timer with histogram buckets: percentiles computed server-side in PromQL
        this.orderProcessingTimer = Timer.builder("orders.processing.duration")
            .description("Order processing duration")
            .publishPercentileHistogram()
            .register(registry);

        // Gauge linked to atomic value
        // Read on each scrape
        Gauge.builder("orders.pending.count", pendingOrdersCount, AtomicInteger::get)
            .description("Number of orders pending processing")
            .register(registry);
    }

    public void recordOrderCreated() {
        ordersCreatedCounter.increment();
        pendingOrdersCount.incrementAndGet();
    }

    public void recordOrderProcessed(Runnable processingLogic) {
        // Measures execution duration
        orderProcessingTimer.record(processingLogic);
        pendingOrdersCount.decrementAndGet();
    }

    public <T> T recordOrderProcessedWithResult(Supplier<T> processingLogic) {
        return orderProcessingTimer.record(processingLogic);
    }
}

เมื่อใช้ registry ของ Prometheus มีรายละเอียดเรื่องการตั้งชื่ออยู่สองข้อ ชื่อ meter ของ Micrometer ใช้จุดคั่นและจะถูกแปลงเป็น snake case (orders.processing.duration กลายเป็น orders_processing_duration_seconds) Counter จะได้รับคำต่อท้าย _total โดยอัตโนมัติ การตั้งชื่อ counter ว่า orders.created.total จึงเพิ่มแต่ความรกโดยไม่จำเป็น

publishPercentiles(0.5, 0.95, 0.99) คำนวณ percentile ภายในแต่ละ JVM และค่าเหล่านี้นำมารวมข้าม instance ไม่ได้ ส่วน publishPercentileHistogram() เผยแพร่ bucket ที่ histogram_quantile() นำมารวมได้ทั้งคลัสเตอร์ จึงเป็นตัวเลือกที่ควรใช้เมื่อทำงานร่วมกับ Prometheus

แอนโนเทชัน @Timed และ @Counted

เพื่อหลีกเลี่ยงโค้ดซ้ำซาก Micrometer มีแอนโนเทชันที่ทำการ instrument เมธอดผ่าน AspectJ

PaymentService.javajava
// Automatic instrumentation with annotations
package com.example.monitoring.service;

import io.micrometer.core.annotation.Counted;
import io.micrometer.core.annotation.Timed;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final PaymentValidator paymentValidator;

    public PaymentService(PaymentValidator paymentValidator) {
        this.paymentValidator = paymentValidator;
    }

    // @Timed creates a Timer
    // Measures each call and publishes count, sum, max and histogram buckets
    @Timed(
        value = "payment.process.duration",
        description = "Payment processing duration",
        histogram = true
    )
    public PaymentResult processPayment(PaymentRequest request) {
        // Call through another bean: the proxy intercepts it
        paymentValidator.validate(request);
        return executePayment(request);
    }

    // @Counted increments a counter on each call
    // Useful for discrete events
    @Counted(
        value = "payment.refunds",
        description = "Total number of refunds"
    )
    public void refundPayment(String transactionId) {
        // Refund logic
    }
}
yaml
# application.yml
# Enables TimedAspect, CountedAspect and the other Micrometer annotation aspects
management:
  observations:
    annotations:
      enabled: true

ตั้งแต่ Spring Boot 3.2 ไม่จำเป็นต้องประกาศ bean TimedAspect และ CountedAspect เองอีกต่อไป การตั้ง management.observations.annotations.enabled=true พร้อมมี spring-boot-starter-aspectj อยู่ใน classpath จะ auto-configure ให้ทันที ค่าเริ่มต้นของ property นี้คือ false ซึ่งเป็นสาเหตุของรายงานส่วนใหญ่ประเภท "@Timed ไม่ทำงาน"

ข้อจำกัดของ AOP

แอนโนเทชัน @Timed และ @Counted ใช้ได้เฉพาะกับเมธอด public ของ bean ของ Spring ที่ถูกเรียกผ่าน proxy การเรียกจากเมธอดหนึ่งไปอีกเมธอดในคลาสเดียวกันจะข้าม proxy จึงไม่ถูก instrument และแอนโนเทชันบนเมธอด private จะถูกละเลย การใส่แอนโนเทชันบน controller ของ Spring MVC ที่ถูก instrument อยู่แล้วจะทำให้เกิด observation ซ้ำซ้อน

พร้อมที่จะพิชิตการสัมภาษณ์ Spring Boot แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

Endpoint สุขภาพแบบกำหนดเอง

Health Indicator เชิงธุรกิจ

Health Indicator ตรวจสอบสถานะของบริการภายนอกและองค์ประกอบทางธุรกิจที่สำคัญ Spring Boot มี indicator มาตรฐานสำหรับฐานข้อมูล Redis และบริการที่พบบ่อยอื่น ๆ ใน Spring Boot 4 API ด้าน health แยกไปอยู่ในโมดูล spring-boot-health ของตัวเอง และ Health กับ HealthIndicator ย้ายจาก org.springframework.boot.actuate.health ไปที่ org.springframework.boot.health.contributor

PaymentGatewayHealthIndicator.javajava
// Health indicator for payment gateway (Spring Boot 4 packages)
package com.example.monitoring.health;

import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.time.Duration;
import java.time.Instant;

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final RestClient restClient;
    private final String gatewayHealthUrl;

    public PaymentGatewayHealthIndicator(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
        this.gatewayHealthUrl = "https://api.payment-gateway.com/health";
    }

    @Override
    public Health health() {
        Instant start = Instant.now();

        try {
            // Call gateway health endpoint
            var response = restClient.get()
                .uri(gatewayHealthUrl)
                .retrieve()
                .toBodilessEntity();

            Duration responseTime = Duration.between(start, Instant.now());

            if (response.getStatusCode().is2xxSuccessful()) {
                return Health.up()
                    .withDetail("responseTime", responseTime.toMillis() + "ms")
                    .withDetail("statusCode", response.getStatusCode().value())
                    .build();
            } else {
                return Health.down()
                    .withDetail("statusCode", response.getStatusCode().value())
                    .withDetail("reason", "Unexpected status code")
                    .build();
            }
        } catch (Exception e) {
            Duration responseTime = Duration.between(start, Instant.now());

            return Health.down()
                .withDetail("error", e.getClass().getSimpleName())
                .withDetail("message", e.getMessage())
                .withDetail("responseTime", responseTime.toMillis() + "ms")
                .build();
        }
    }
}

Indicator นี้จะปรากฏใน /actuator/health ภายใต้ชื่อ paymentGateway ซึ่งก็คือชื่อ bean ที่ตัดคำต่อท้าย HealthIndicator ออก

กลุ่มสุขภาพสำหรับ Kubernetes

Spring Boot 4 เปิดใช้ probe liveness และ readiness เป็นค่าเริ่มต้นบนทุกแพลตฟอร์ม ไม่ใช่เฉพาะเมื่อตรวจพบ Kubernetes กลุ่ม /actuator/health/liveness และ /actuator/health/readiness มีให้ใช้โดยไม่ต้องตั้งค่าใด ๆ และไม่จำเป็นต้องประกาศ bean LivenessStateHealthIndicator หรือ ReadinessStateHealthIndicator เอง การตั้งค่าจำเป็นเฉพาะเมื่อต้องการเพิ่มองค์ประกอบเข้าไปในกลุ่ม หรือกำหนดกลุ่มขึ้นเอง:

yaml
# application.yml
# Health groups configuration for Kubernetes
management:
  endpoint:
    health:
      probes:
        # true by default in Spring Boot 4
        enabled: true
        # Also serve the groups at /livez and /readyz on the main server port
        add-additional-paths: true
      group:
        # Readiness: can the application receive traffic?
        readiness:
          include: readinessState,db
        # Custom group for critical dependencies
        critical:
          include: db,paymentGateway
          show-details: when-authorized

กลุ่ม liveness ควรจำกัดไว้ที่ livenessState เท่านั้น การใส่ฐานข้อมูลหรือ gateway ภายนอกเข้าไปใน liveness จะทำให้ Kubernetes รีสตาร์ททุก pod เมื่อ dependency นั้นล่ม ทำให้ปัญหาที่กระทบบางส่วนกลายเป็นระบบล่มทั้งหมด dependency ภายนอกควรอยู่ใน readiness และเฉพาะกรณีที่แอปพลิเคชันไม่สามารถให้บริการคำขอใดได้เลยหากขาดมัน

จากนั้น probe ของ Kubernetes ก็ชี้ไปยัง endpoint เฉพาะได้:

yaml
# kubernetes-deployment.yml
# Kubernetes probes configuration
spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /livez
          port: 8080
        initialDelaySeconds: 30
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        initialDelaySeconds: 10
        periodSeconds: 5
        failureThreshold: 3

เมื่อตั้ง add-additional-paths: true probe จะยังอยู่บนพอร์ตของแอปพลิเคชันแม้ Actuator จะรันบนพอร์ต management แยก ทำให้ probe ตรวจพบได้เมื่อเซิร์ฟเวอร์หลักมีปัญหา

การเชื่อม Prometheus กับ Spring Boot และ Grafana

การตั้งค่า Scraping ของ Prometheus

Prometheus เก็บเมตริกโดยเรียกดู endpoint /actuator/prometheus เป็นระยะ การตั้งค่า scrape จะระบุเป้าหมายและ path ของเมตริก เนื่องจากค่าเริ่มต้นของ Prometheus คือ /metrics

yaml
# prometheus.yml
# Prometheus configuration for Spring Boot
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-apps'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 10s
    static_configs:
      - targets:
          - 'order-service:9090'
          - 'payment-service:9090'
          - 'inventory-service:9090'
    # Relabeling to strip the port from the instance label
    relabel_configs:
      - source_labels: [__address__]
        target_label: instance
        regex: '([^:]+):\d+'
        replacement: '${1}'

  # Kubernetes service discovery
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      # Only scrape pods with annotation
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
        action: replace
        target_label: __metrics_path__
        regex: (.+)

เป้าหมายใช้พอร์ต 9090 เพราะการตั้งค่าโปรดักชันในส่วนถัดไปย้าย Actuator ไปไว้บนพอร์ต management แยก หากไม่มี management.server.port เป้าหมายของการ scrape คือพอร์ตของแอปพลิเคชัน

เมตริกของ Spring Boot Actuator ที่เผยแพร่เป็นค่าเริ่มต้น

Actuator ที่ทำงานคู่กับ Micrometer เปิดเผยเมตริกของ JVM, process, HikariCP และ HTTP โดยไม่ต้องเขียนโค้ด คิวรี PromQL ต่อไปนี้ครอบคลุมเมตริกที่มักถูกเฝ้าดูเป็นอันดับแรกบนโปรดักชัน:

promql
# PromQL queries for JVM monitoring

# Heap memory usage, summed across heap pools
sum by (instance) (jvm_memory_used_bytes{area="heap"})

# Heap usage percentage
sum by (instance) (jvm_memory_used_bytes{area="heap"})
  / sum by (instance) (jvm_memory_max_bytes{area="heap"}) * 100

# Live threads
jvm_threads_live_threads

# Garbage collection: seconds spent paused per second
rate(jvm_gc_pause_seconds_sum[5m])

# GC pauses per minute
rate(jvm_gc_pause_seconds_count[1m]) * 60

# CPU used by the JVM process
process_cpu_usage

# Active database connections
hikaricp_connections_active

# Connection pool utilization
hikaricp_connections_active / hikaricp_connections_max * 100

jvm_memory_used_bytes{area="heap"} คืนค่าหนึ่ง series ต่อหนึ่ง memory pool (Eden, Survivor, Old Gen เมื่อใช้ G1) การหารด้วยค่า max โดยไม่มี sum by (instance) จะได้อัตราส่วนแยกตาม pool ซึ่งดูผิดเพี้ยนบนแดชบอร์ด

bean MeterBinder ใช้เพิ่ม gauge ระดับแอปพลิเคชันที่เมตริกเริ่มต้นไม่ได้ครอบคลุม:

CustomJvmMetrics.javajava
// Additional JVM metrics
package com.example.monitoring.metrics;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.stereotype.Component;

import java.lang.management.ManagementFactory;
import java.lang.management.OperatingSystemMXBean;

@Component
public class CustomJvmMetrics implements MeterBinder {

    @Override
    public void bindTo(MeterRegistry registry) {
        OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();

        // System load average
        Gauge.builder("system.load.average", osBean, OperatingSystemMXBean::getSystemLoadAverage)
            .description("System load average over 1 minute")
            .register(registry);

        // Available processors count
        Gauge.builder("system.cpu.count", osBean, OperatingSystemMXBean::getAvailableProcessors)
            .description("Number of available processors")
            .register(registry);

        // Application uptime
        Gauge.builder("application.uptime.seconds",
                ManagementFactory.getRuntimeMXBean(),
                bean -> bean.getUptime() / 1000.0)
            .description("Application uptime in seconds")
            .register(registry);
    }
}

system.cpu.count และ process.uptime ถูกเผยแพร่โดย binder ที่มีมาในตัวของ Micrometer อยู่แล้ว ตัวอย่างข้างต้นแสดงรูปแบบการเขียน ส่วน binder ที่ใช้งานจริงควรลงทะเบียนเฉพาะสิ่งที่ค่าเริ่มต้นยังไม่มี

แดชบอร์ด Grafana ที่พร้อมใช้งาน

Grafana มีแดชบอร์ดจากชุมชนสำหรับ Spring Boot ที่ทำงานกับเมตริกมาตรฐานของ Actuator แดชบอร์ดที่เขียนเองใช้ panel ชนิด timeseries และ stat:

json
{
  "annotations": {
    "list": []
  },
  "panels": [
    {
      "title": "Request Rate",
      "type": "timeseries",
      "targets": [
        {
          "expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{application=\"$application\"}[5m]))",
          "legendFormat": "{{method}} {{uri}} - {{status}}"
        }
      ]
    },
    {
      "title": "Response Time P99",
      "type": "timeseries",
      "targets": [
        {
          "expr": "histogram_quantile(0.99, sum by (le, method, uri) (rate(http_server_requests_seconds_bucket{application=\"$application\"}[5m])))",
          "legendFormat": "{{method}} {{uri}}"
        }
      ]
    },
    {
      "title": "Error Rate",
      "type": "stat",
      "targets": [
        {
          "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{application=\"$application\"}[5m])) * 100"
        }
      ]
    }
  ]
}

แดชบอร์ดจำนวนมากที่คัดลอกมาจากบทแนะนำรุ่นเก่ายังใช้ panel ชนิด graph และ singlestat Singlestat ถูกถอดออกใน Grafana 8 และGrafana 12 ยกเลิกการรองรับ AngularJS ทั้งหมด panel เหล่านั้นจึงแสดงผลได้ก็ต่อเมื่อย้ายไปใช้ timeseries และ stat แล้ว

การนำเข้า Grafana

วิธีนำเข้าแดชบอร์ดจากชุมชน: Grafana > Dashboards > New > Import แล้วใส่ ID 4701 (JVM Micrometer) หรือ 12900 (SpringBoot APM) ควรตรวจสอบชนิดของ panel หลังนำเข้า เพราะแดชบอร์ดชุมชนรุ่นเก่าอาจยังมี panel แบบ Angular ที่ Grafana 12 ต้องแปลงให้

การแจ้งเตือนด้วย Prometheus

กฎแจ้งเตือนที่จำเป็น

กฎแจ้งเตือนของ Prometheus จะทริกเกอร์การแจ้งเตือนเมื่อเมตริกเกินเกณฑ์วิกฤต

yaml
# alerting-rules.yml
# Alert rules for Spring Boot applications
groups:
  - name: spring-boot-alerts
    rules:
      # Alert if application is down
      - alert: ApplicationDown
        expr: up{job="spring-boot-apps"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Application {{ $labels.instance }} is down"
          description: "{{ $labels.instance }} has been down for more than 1 minute"

      # Alert on HTTP error rate
      - alert: HighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (application)
          /
          sum(rate(http_server_requests_seconds_count[5m])) by (application)
          > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High error rate on {{ $labels.application }}"
          description: "Error rate is {{ $value | humanizePercentage }}"

      # Alert on P99 latency, aggregated per application
      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum by (le, application) (rate(http_server_requests_seconds_bucket[5m]))
          ) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High latency on {{ $labels.application }}"
          description: "P99 latency is {{ $value | humanizeDuration }}"

      # Heap memory alert
      - alert: HighHeapUsage
        expr: |
          sum by (instance) (jvm_memory_used_bytes{area="heap"})
          / sum by (instance) (jvm_memory_max_bytes{area="heap"})
          > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High heap memory usage on {{ $labels.instance }}"
          description: "Heap usage is at {{ $value | humanizePercentage }}"

      # Database connection pool exhausted alert
      - alert: DatabaseConnectionPoolExhausted
        expr: |
          hikaricp_connections_active
          / hikaricp_connections_max
          > 0.9
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Database connection pool nearly exhausted"
          description: "{{ $value | humanizePercentage }} of connections in use"

      # Excessive GC alert
      - alert: HighGCPause
        expr: |
          rate(jvm_gc_pause_seconds_sum[5m])
          / rate(jvm_gc_pause_seconds_count[5m])
          > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High GC pause time"
          description: "Average GC pause is {{ $value | humanizeDuration }}"

การแจ้งเตือนเหล่านี้ครอบคลุมปัญหาที่พบบ่อยที่สุดในโปรดักชัน ทั้งความพร้อมใช้งาน ประสิทธิภาพ และทรัพยากร กฎ HighLatency ต้องมี sum by (le, ...) เพราะหากไม่มี histogram_quantile จะคำนวณ P99 แยกต่อ series (ต่อ URI, method และ status) และ endpoint ที่ช้าเพียงตัวเดียวซึ่งถูกเรียกแค่สามครั้งก็ทำให้การแจ้งเตือนทำงานได้

เมตริก HTTP และเมตริกฐานข้อมูลของ Spring Boot

การ Instrument คำขอ HTTP โดยอัตโนมัติ

Spring Boot ทำการ instrument คำขอ Spring MVC และ WebFlux ทุกรายการที่เข้ามาผ่าน Observation API และเผยแพร่ timer http.server.requests พร้อม tag method, uri, status, outcome และ exception

yaml
# application.yml
# HTTP metrics configuration
management:
  metrics:
    distribution:
      # Histogram buckets for server-side percentiles
      percentiles-histogram:
        http.server.requests: true
      # SLO buckets: count requests faster than each boundary
      slo:
        http.server.requests: 100ms, 500ms, 1s, 2s
    tags:
      # Common tags added to every meter
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}

interface WebMvcTagsContributor และ WebMvcTagsProvider ที่ปรากฏในตัวอย่างจำนวนมากถูกถอดออกไปตั้งแต่ Spring Boot 3.0 ปัจจุบันการเพิ่ม tag แบบกำหนดเองทำได้ด้วยการ extend DefaultServerRequestObservationConvention ตามที่เอกสารอ้างอิงเมตริกของ Spring Boot อธิบายไว้:

TenantObservationConvention.javajava
// Adds custom tags to http.server.requests (Spring Boot 3.0+)
package com.example.monitoring.config;

import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
import org.springframework.http.server.observation.ServerRequestObservationContext;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;

@Component
public class TenantObservationConvention extends DefaultServerRequestObservationConvention {

    @Override
    public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
        HttpServletRequest request = context.getCarrier();
        String userId = request.getHeader("X-User-Id");
        String tenantId = request.getHeader("X-Tenant-Id");

        // Keep the default tags and add two bounded ones
        return super.getLowCardinalityKeyValues(context).and(
            KeyValue.of("user.type", userId != null ? "authenticated" : "anonymous"),
            KeyValue.of("tenant", tenantId != null ? tenantId : "default")
        );
    }
}

เฉพาะ key-value ที่มี cardinality ต่ำเท่านั้นที่กลายเป็น label ของ Prometheus tag tenant ยังรับได้เมื่อมี tenant หลักสิบ แต่จะกลายเป็นปัญหา cardinality เมื่อมีหลักพัน ค่าที่มี cardinality สูง (ID ผู้ใช้, ID คำสั่งซื้อ) ควรอยู่ใน getHighCardinalityKeyValues ซึ่งส่งไปยัง trace เท่านั้น ฝั่ง tracing ของ Observation API อธิบายไว้อย่างละเอียดใน observability ของ Spring Boot ด้วย OpenTelemetry

เมตริก HikariCP และคิวรี SQL

เมตริกของ pool HikariCP ถูกเปิดเผยให้โดยอัตโนมัติด้วย prefix hikaricp ติด tag ตามชื่อ pool และอยู่คู่กับ gauge ทั่วไป jdbc.connections การตั้งชื่อ pool ทำให้ tag อ่านเข้าใจง่าย:

yaml
# application.yml
# HikariCP pool configuration
spring:
  datasource:
    hikari:
      # Becomes the "pool" tag on hikaricp_* metrics
      pool-name: OrderServicePool
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

Spring Boot ไม่ได้วัดเวลาในระดับคิวรี โปรเจกต์ datasource-micrometer ครอบ DataSource และสร้าง observation หนึ่งรายการต่อการเชื่อมต่อ คิวรี และ result set ซึ่งให้ทั้งเมตริกและ span ของ trace สาย 2.x รองรับ Spring Boot 4 ส่วนสาย 1.x รองรับ Spring Boot 3:

xml
<!-- pom.xml -->
<!-- JDBC observations: 2.x for Spring Boot 4, 1.x for Spring Boot 3 -->
<dependency>
    <groupId>net.ttddyy.observation</groupId>
    <artifactId>datasource-micrometer-spring-boot</artifactId>
    <version>2.0.1</version>
</dependency>

วิธีนี้แทนที่แนวทางแบบเขียนเองที่ครอบ DataSource ด้วย ProxyDataSourceBuilder และ listener ที่เขียนขึ้นเอง ซึ่งข้าม auto-configuration ของ DataSource ใน Spring Boot และต้องคอยปรับตามทุกครั้งที่อัปเกรด Boot คิวรีที่ช้ามักบ่งบอกถึงรูปแบบ N+1 ซึ่งอธิบายไว้ใน วิธีแก้ปัญหาคิวรี N+1 ของ Spring Data JPA

แนวทางปฏิบัติที่ดีบนโปรดักชัน

Cardinality ของเมตริก

Cardinality ที่มากเกินไปจะลดประสิทธิภาพของ Prometheus เพราะแต่ละการรวม tag ที่ไม่ซ้ำกันจะสร้าง time series ใหม่หนึ่งชุด

AntiPatternHighCardinality.javajava
// AVOID: unbounded cardinality
package com.example.monitoring.antipattern;

@Service
public class AntiPatternHighCardinality {

    private final MeterRegistry registry;

    // BAD: userId creates one series per user
    public void trackUserAction(String userId, String action) {
        Counter.builder("user.actions")
            .tag("userId", userId)  // Millions of possible values
            .tag("action", action)
            .register(registry)
            .increment();
    }
}
GoodPracticeCardinality.javajava
// Controlled cardinality
package com.example.monitoring.bestpractice;

@Service
public class GoodPracticeCardinality {

    private final MeterRegistry registry;

    // GOOD: user category instead of ID
    public void trackUserAction(User user, String action) {
        Counter.builder("user.actions")
            .tag("userType", user.getSubscriptionType())  // FREE, PREMIUM, ENTERPRISE
            .tag("action", action)
            .register(registry)
            .increment();
    }

    // GOOD: let a timer histogram bucket durations
    public void trackResponseTime(long responseTimeMs) {
        Timer.builder("response.time")
            .publishPercentileHistogram()
            .register(registry)
            .record(Duration.ofMillis(responseTimeMs));
    }
}

Timer ที่มี bucket แบบ histogram ตอบคำถามเรื่อง latency ได้ดีกว่า counter ที่ใส่ tag bucket ขึ้นเอง เพราะ PromQL คำนวณ percentile ใดก็ได้จาก bucket และขอบเขตของ bucket ก็สอดคล้องกันในทุกเซอร์วิส

การตั้งค่าที่พร้อมใช้งานบนโปรดักชัน

yaml
# application-production.yml
# Production configuration (Spring Boot 4)
management:
  server:
    # Separate port for management endpoints, not exposed publicly
    port: 9090
  endpoints:
    access:
      default: none
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      access: read-only
      show-details: when-authorized
      probes:
        add-additional-paths: true
    info:
      access: read-only
    prometheus:
      access: read-only
  observations:
    annotations:
      enabled: true
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      minimum-expected-value:
        http.server.requests: 1ms
      maximum-expected-value:
        http.server.requests: 30s
    tags:
      application: ${spring.application.name}
      environment: production
      version: ${app.version:unknown}

เมื่อใช้ access.default: none endpoint อย่าง env, beans, configprops และ mappings จะเข้าถึงไม่ได้โดยไม่ต้องไล่ระบุทีละตัว management.prometheus.metrics.export.enabled มีค่าเริ่มต้นเป็น true และการตั้งค่า step ไม่มีผลกับ Prometheus เพราะเป็น registry แบบ pull ซึ่งรอบการ scrape กำหนดไว้ใน prometheus.yml

ข้อผิดพลาดที่พบบ่อยกับ Actuator และ Prometheus

อาการสาเหตุวิธีแก้
/actuator/prometheus ตอบกลับ 404endpoint ไม่อยู่ในรายการ exposure หรือไม่มี registryเพิ่ม prometheus ลงใน management.endpoints.web.exposure.include และตรวจสอบว่า micrometer-registry-prometheus อยู่ใน classpath
เป้าหมายใน Prometheus แสดง "server returned HTTP status 401"Spring Security ป้องกันทุก endpoint ยกเว้น healthเปิด prometheus ให้เครือข่ายของ scraper หรือตั้งค่า basic auth ใน scrape config
@Timed ไม่สร้างเมตริกmanagement.observations.annotations.enabled เป็น false โดยค่าเริ่มต้น หรือไม่มี AspectJตั้ง property เป็น true และเพิ่ม spring-boot-starter-aspectj
หา spring-boot-starter-aop ไม่พบหลังอัปเกรดถูกเปลี่ยนชื่อใน Spring Boot 4ใช้ spring-boot-starter-aspectj
import Health หรือ HealthIndicator ล้มเหลวบน Boot 4package ย้ายไปที่ org.springframework.boot.health.contributorแก้ไข import
คำเตือน deprecation บน management.endpoint.*.enabledโมเดลเปิด/ปิดถูกแทนที่ใน Spring Boot 3.4ใช้ management.endpoints.access.default และ management.endpoint.<id>.access
percentile บนแดชบอร์ดต่างจากความเป็นจริงมากค่า publishPercentiles ถูกนำมาเฉลี่ยข้าม podใช้ publishPercentileHistogram() และ histogram_quantile()

คำถามสัมภาษณ์งานเกี่ยวกับ Micrometer ใน Spring Boot

Actuator ถูกถามในการสัมภาษณ์ Spring Boot ส่วนใหญ่สำหรับตำแหน่งระดับกลางและอาวุโส คำตอบที่แยกผู้มีประสบการณ์ออกจากระดับจูเนียร์มีดังนี้:

  • ความต่างระหว่าง liveness และ readiness: liveness บอกให้ Kubernetes รีสตาร์ท pod ส่วน readiness บอกให้หยุดส่ง traffic เข้ามา ผู้สมัครที่ใส่ฐานข้อมูลไว้ในกลุ่ม liveness คือผู้ที่ยังไม่เคยผ่านเหตุการณ์ระบบล่มกับการตั้งค่านั้นจริง
  • ทำไมใช้ Micrometer แทนการใช้ client ของ Prometheus โดยตรง: Micrometer เป็น facade โค้ด Counter และ Timer ชุดเดียวกันส่งออกไปยัง Prometheus, Datadog หรือ OTLP ได้เพียงเปลี่ยน dependency และการ instrument ที่มีมากับ Spring เอง (HTTP, pool ของ JDBC, cache) ก็เขียนอยู่บน Micrometer
  • percentile ฝั่ง client เทียบกับ histogram: percentile ฝั่ง client นำมารวมข้าม instance ไม่ได้ แต่ histogram ทำได้ นี่คือคำถามหลอกที่พบบ่อยที่สุดในหัวข้อนี้
  • cardinality คืออะไรและทำให้ Prometheus พังได้อย่างไร: หนึ่ง time series ต่อหนึ่งการรวม label ที่ไม่ซ้ำกัน tag ที่เป็น ID ผู้ใช้จะทำให้การใช้หน่วยความจำเพิ่มขึ้นเป็นทวีคูณตามจำนวนผู้ใช้
  • วิธีรักษาความปลอดภัยของ Actuator: จำกัด exposure, กำหนดระดับการเข้าถึง, รัน management บนพอร์ตแยก และป้องกันด้วย SecurityFilterChain เฉพาะ

คำถามในลักษณะเดียวกันรวบรวมไว้ในโมดูลคำถามสัมภาษณ์ Spring Boot Actuator

แหล่งอ้างอิง

สิ่งที่ควรจำเกี่ยวกับ Micrometer ของ Spring Boot และ Prometheus

  • สามขั้นตอนสู่การ scrape ที่ใช้งานได้: starter ของ Actuator, micrometer-registry-prometheus, prometheus ในรายการ exposure
  • โมเดลการเข้าถึงตั้งแต่ 3.4: management.endpoints.access.default: none ร่วมกับ read-only ราย endpoint แทนที่แฟล็ก enabled ที่ถูก deprecate
  • การเปลี่ยนแปลงใน Spring Boot 4: spring-boot-starter-aspectj, API ด้าน health ใน org.springframework.boot.health.contributor, EndpointRequest ใน org.springframework.boot.security.autoconfigure.actuate.web.servlet, probe เปิดเป็นค่าเริ่มต้น
  • แอนโนเทชันต้องเปิดสวิตช์: management.observations.annotations.enabled=true โดยไม่ต้องสร้าง bean TimedAspect เอง
  • tag ของ HTTP ผ่าน Observation API: extend DefaultServerRequestObservationConvention และรักษา label ให้มี cardinality ต่ำ
  • ใช้ histogram แทน percentile ฝั่ง client: publishPercentileHistogram() และ sum by (le) ในทุก histogram_quantile
  • liveness ตรวจเฉพาะภายในตัวเอง: dependency ภายนอกอยู่ใน readiness หรือกลุ่มที่กำหนดเอง ไม่ใช่ใน liveness

ค่าเริ่มต้นเหล่านี้ทำให้เซอร์วิส Spring Boot 4 มีเมตริกที่รวมข้าม pod ได้ถูกต้อง มี endpoint ที่ไม่รั่วไหลข้อมูลออกสู่เครือข่ายสาธารณะ และมีการแจ้งเตือนที่ทำงานเมื่อเกิดเหตุจริง ไม่ใช่เพราะ URI ที่ช้าเพียงตัวเดียว

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

ชาเลนจ์ประจำวัน

คุณหาบั๊กใน Spring Boot เจอไหม

โค้ดจริงหนึ่งชิ้น บั๊กที่ซ่อนอยู่หนึ่งจุด วันละหนึ่งครั้ง ลองได้โดยไม่ต้องมีบัญชี

Anthony Fillion-Maillet

เขียนโดย

Anthony Fillion-Maillet

ผู้ก่อตั้ง SharpSkill

เป็นนักพัฒนาฟูลสแตกมากว่า 10 ปี ดูแล SharpSkill และรับผิดชอบทุกสิ่งที่เผยแพร่ที่นี่

อัปเดตเมื่อ 26 ตุลาคม 2569

แท็ก

#spring boot actuator
#micrometer
#prometheus
#monitoring
#observability

แชร์

บทความที่เกี่ยวข้อง