Administrator
发布于 2021-09-17 / 3522 阅读
92

Prometheus + Grafana 搭建 JVM 监控看板

为什么又搞了一套 Prometheus

我们本来有 SkyWalking 8.6,链路追踪和拓扑都挺好用。但它有两个地方不满足需求:一是数据默认只存 7 天(ES 成本摆在那),二是加自定义业务指标比较麻烦,我更习惯"自己埋点 + 自己配告警"这套。

于是给核心的几个服务加了 Prometheus + Grafana,和 SkyWalking 并存:SkyWalking 看链路和慢调用,Prometheus 看资源水位和趋势告警。

接入:三步

Spring Boot 2.5 接 Micrometer 的 Prometheus 注册表非常简单。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  endpoint:
    health:
      show-details: always
      probes:
        enabled: true            # K8s 的 liveness/readiness
  metrics:
    tags:
      application: ${spring.application.name}    # 全局 tag
    distribution:
      percentiles-histogram:
        http.server.requests: true               # 开直方图,才能算 P99
      slo:
        http.server.requests: 50ms,100ms,200ms,500ms,1s

启动后访问 /actuator/prometheus 就能看到文本格式的指标:

$ curl -s localhost:8080/actuator/prometheus | head -20
# HELP jvm_memory_used_bytes The amount of used memory
# TYPE jvm_memory_used_bytes gauge
jvm_memory_used_bytes{application="order-service",area="heap",id="G1 Old Gen",} 1.2436096E9
jvm_memory_used_bytes{application="order-service",area="heap",id="G1 Survivor Space",} 4.718592E7
jvm_memory_used_bytes{application="order-service",area="nonheap",id="Metaspace",} 1.3824792E8
jvm_threads_live_threads{application="order-service",} 187.0

Prometheus 侧配置抓取(我们是 K8s 环境,用 prometheus.yml 的 kubernetes_sd_configs 也行,这里给静态配置):

scrape_configs:
  - job_name: 'spring-boot'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 15s
    scrape_timeout: 10s
    static_configs:
      - targets:
          - '10.0.2.11:8080'
          - '10.0.2.12:8080'
          - '10.0.2.13:8080'

选了哪些指标

Micrometer 默认会吐出上百个指标,全看一遍不现实。我挑了这些放进看板:

指标用途告警阈值(我们的)
jvm_memory_used_bytes{area="heap"}堆占用持续 5 分钟 > 85% 最大值
jvm_gc_pause_seconds_max单次 GC 最大暂停1 分钟 > 1 秒
rate(jvm_gc_pause_seconds_sum[5m])GC 总耗时占比> 0.1(即 10% 时间在 GC)
jvm_threads_live_threads线程数> 800
process_cpu_usage进程 CPU持续 5 分钟 > 0.8
http_server_requests_seconds接口耗时/量P99 > 1s 持续 3 分钟
hikaricp_connections_pending等连接的线程数> 0 持续 1 分钟
tomcat_threads_busy繁忙线程占比 > 90%
logback_events_total{level="error"}错误日志速率rate 5m > 10

Grafana 上直接导入现成的看板更快,我用的是 JVM (Micrometer) - dashboard ID 4701,改了几个面板适配我们的 tag。另外 Spring Boot 官方的 12900 也不错,偏 HTTP 维度。

P99 怎么算

这是新手最容易搞错的地方。Micrometer 默认只暴露 _count_sum,没有分位数,所以直接查 histogram_quantile 是查不出来的——这就是上面配置里 percentiles-histogram: true 的作用,它让 Micrometer 生成 http_server_requests_seconds_bucket 这一组直方图桶。

有了桶之后:

histogram_quantile(0.99,
  sum by (le, uri) (
    rate(http_server_requests_seconds_bucket{application="order-service"}[5m])
  )
)

如果用 slo: 50ms,100ms,200ms,500ms,1s 指定自定义桶,桶数量会少很多(Prometheus 侧存储压力小),精度也够日常用。两种方案二选一,不要同时开,否则桶会翻倍。

自定义埋点

默认的 JVM / HTTP 指标只能反映"机器健不健康",业务上想知道的东西得自己埋。Micrometer 四个基本类型:

@Service
public class OrderMetrics {

    private final Counter createCounter;
    private final Timer payTimer;
    private final DistributionSummary amountSummary;
    private final AtomicInteger pendingQueue = new AtomicInteger();

    public OrderMetrics(MeterRegistry registry) {
        this.createCounter = Counter.builder("order.create.total")
                .description("下单总数")
                .tag("type", "create")
                .register(registry);

        this.payTimer = Timer.builder("order.pay.duration")
                .description("支付耗时")
                .publishPercentileHistogram()
                .register(registry);

        this.amountSummary = DistributionSummary.builder("order.amount")
                .description("订单金额分布")
                .baseUnit("yuan")
                .register(registry);

        // Gauge 是"测一次返回一个值",注册时给个 Supplier
        Gauge.builder("order.pending.size", pendingQueue, AtomicInteger::get)
                .description("待处理订单数")
                .register(registry);
    }

    public void onCreate(String channel) {
        createCounter.increment();
    }

    public void recordPay(long millis) {
        payTimer.record(millis, TimeUnit.MILLISECONDS);
    }

    // 或者用注解,更省事
    @Timed(value = "order.query.duration", percentiles = {0.5, 0.95, 0.99})
    public OrderVO queryOrder(Long id) { ... }
}

几个注意点:

  • Gauge 持有一个对象的弱引用,注册之后如果 pendingQueue 被 GC 掉,这个指标就变成 NaN。所以要保证被观测的对象是长生命周期的(通常是 static 或单例 Bean 的字段)。
  • tag 的取值必须有限。我第一次埋点用了 .tag("userId", userId.toString()),上线 4 小时 Prometheus 内存从 2 GB 涨到 14 GB,series 数从 12 万暴涨到 340 万,直接把 Prometheus 压垮了。这是指标基数(cardinality)爆炸,血泪教训。
  • 同理,HTTP 指标的 uri 标签一定要用路径模板(/order/{id}),Spring Boot 2.x 默认已经处理了,但如果你自己写了拦截器埋点,务必用模板而不是真实 URI。

告警规则

Prometheus 侧的 rules 文件:

groups:
  - name: jvm-alerts
    rules:
      - alert: HeapUsageTooHigh
        expr: |
          sum by (application, instance) (jvm_memory_used_bytes{area="heap"})
          / sum by (application, instance) (jvm_memory_max_bytes{area="heap"}) > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.application }} 堆内存使用率 {{ $value | humanizePercentage }}"
          description: "实例 {{ $labels.instance }} 持续 5 分钟超过 85%,可能需要排查内存泄漏"

      - alert: GCPauseTooLong
        expr: |
          rate(jvm_gc_pause_seconds_sum{action="end of major GC"}[5m])
          / rate(jvm_gc_pause_seconds_count{action="end of major GC"}[5m]) > 1
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.application }} Full GC 平均耗时超过 1 秒"

      - alert: HttpP99TooHigh
        expr: |
          histogram_quantile(0.99,
            sum by (le, application) (rate(http_server_requests_seconds_bucket[5m]))
          ) > 1
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.application }} 接口 P99 超过 1 秒"

      - alert: InstanceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.instance }} 抓取失败,服务可能已下线"

几个经验:

  • for 一定要设。瞬时抖动就告警会让人麻木,我们统一设 2 到 5 分钟。
  • 告警的 expr 里用 sum by (...) 而不是 sum,否则多实例聚合后丢失实例维度,不知道该去哪台机器上看。
  • InstanceDown。我们之前漏了这个,有次一个实例 OOM 重启了三次才从别的告警里发现。
  • 告警走 Alertmanager 分组发送,按 alertname + applicationgroup_by,避免一次故障刷屏几十条。

三个容易写错的 PromQL

rateincreasecounter 类型的指标(GC 次数、请求数、错误数)是单调递增的,直接查原值没有意义,要查变化率:

# 错误:直接查累计值,看不出波动
logback_events_total{level="error"}

# 正确:查每秒速率
rate(logback_events_total{level="error"}[5m])

# 或者查一段时间内的增量
increase(logback_events_total{level="error"}[1h])

方括号里的 [5m] 是时间窗口。窗口不能小于抓取间隔的 2 倍,我们抓取间隔 15 秒,窗口至少给 [1m],一般用 [5m] 平滑一些。

分母要对齐。算 GC 平均耗时这类"总时间 / 总次数"的指标时,sum by 的维度必须包含所有用到的标签,否则会把不同实例的数据混在一起:

# 正确:sum by 里带上 instance
sum by (instance) (rate(jvm_gc_pause_seconds_sum[5m]))
  / sum by (instance) (rate(jvm_gc_pause_seconds_count[5m]))

# 错误:两边维度不一致,Prometheus 会报 "vector cannot contain metrics with the same labelset"

up 指标别忘。up{job="spring-boot"} 是 Prometheus 自己生成的,抓取失败时为 0,这是最基础也最容易被忽略的告警。我们一开始只配了业务指标告警,有个实例连续重启了三次,因为没有配 InstanceDown 所以谁都不知道。

下篇预告

这篇先把《Prometheus + Grafana 搭建 JVM 监控看板》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考