前提

最近公司在联合运维做一套全方位监控的系统,应用集群的技术栈是SpringCloud体系。虽然本人没有参与具体基础架构的研发,但是从应用引入的包和一些资料的查阅大致推算出具体的实现方案,这里做一次推演,详细记录一下整个搭建过程。

Prometheus是什么

Prometheus(普罗米修斯),是一个开源的系统监控和告警的工具包,其采用Pull方式采集时间序列的度量数据,通过Http协议传输。它的工作方式是被监控的服务需要公开一个Prometheus端点,这端点是一个HTTP接口,该接口公开了度量的列表和当前的值,然后Prometheus应用从此接口定时拉取数据,一般可以存放在时序数据库中,然后通过可视化的Dashboard(例如Promdash或者Grafana)进行数据展示。当然,此文章不打算深入研究这个工具,只做应用层面的展示。这篇文章将会用到下面几个技术栈:

  • SpringCloud体系,主要是注册中心和注册客户端。
  • spring-boot-starter-actuator,主要是提供了Prometheus端点,不用重复造轮子。
  • Prometheus的Java客户端。
  • Prometheus应用。
  • io.micrometer,SpringBoot标准下使用的度量工具包。
  • Grafana,可视化的Dashboard。

这里所有的软件或者依赖全部使用当前的最新版本,如果有坑踩了再填。其实,Prometheus本身也开发了一套Counter、Gauge、Timer等相关接口,不过SpringBoot中使用了io.micrometer中的套件,所以本文不深入分析Prometheus的Java客户端。

io.micrometer的使用

在SpringBoot2.X中,spring-boot-starter-actuator引入了io.micrometer,对1.X中的metrics进行了重构,主要特点是支持tag/label,配合支持tag/label的监控系统,使得我们可以更加方便地对metrics进行多维度的统计查询及监控。io.micrometer目前支持Counter、Gauge、Timer、Summary等多种不同类型的度量方式(不知道有没有遗漏),下面逐个简单分析一下它们的作用和使用方式。 需要在SpringBoot项目下引入下面的依赖:

<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>${micrometer.version}</version>
</dependency>

目前最新的micrometer.version为1.0.5。注意一点的是:io.micrometer支持Tag(标签)的概念,Tag是其metrics是否能够有多维度的支持的基础,Tag必须成对出现,也就是必须配置也就是偶数个Tag,有点类似于K-V的关系。

Counter

Counter(计数器)简单理解就是一种只增不减的计数器。它通常用于记录服务的请求数量、完成的任务数量、错误的发生数量等等。举个例子:

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

/**
* @author throwable
* @version v1.0
* @description
* @since 2018/7/19 23:10
*/
public class CounterSample {

public static void main(String[] args) throws Exception {
//tag必须成对出现,也就是偶数个
Counter counter = Counter.builder("counter")
.tag("counter", "counter")
.description("counter")
.register(new SimpleMeterRegistry());
counter.increment();
counter.increment(2D);
System.out.println(counter.count());
System.out.println(counter.measure());
//全局静态方法
Metrics.addRegistry(new SimpleMeterRegistry());
counter = Metrics.counter("counter", "counter", "counter");
counter.increment(10086D);
counter.increment(10087D);
System.out.println(counter.count());
System.out.println(counter.measure());
}
}

输出:

3.0
[Measurement{statistic='COUNT', value=3.0}]
20173.0
[Measurement{statistic='COUNT', value=20173.0}]

Counter的Measurement的statistic(可以理解为度量的统计角度)只有COUNT,也就是它只具备计数(它只有增量的方法,因此只增不减),这一点从它的接口定义可知:

public interface Counter extends Meter {

default void increment() {
increment(1.0);
}

void increment(double amount);

double count();

//忽略其他方法或者成员
}

Counter还有一个衍生类型FunctionCounter,它是基于函数式接口ToDoubleFunction进行计数统计的,用法差不多。

Gauge

Gauge(仪表)是一个表示单个数值的度量,它可以表示任意地上下移动的数值测量。Gauge通常用于变动的测量值,如当前的内存使用情况,同时也可以测量上下移动的”计数”,比如队列中的消息数量。举个例子:

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

import java.util.concurrent.atomic.AtomicInteger;

/**
* @author throwable
* @version v1.0
* @description
* @since 2018/7/19 23:30
*/
public class GaugeSample {

public static void main(String[] args) throws Exception {
AtomicInteger atomicInteger = new AtomicInteger();
Gauge gauge = Gauge.builder("gauge", atomicInteger, AtomicInteger::get)
.tag("gauge", "gauge")
.description("gauge")
.register(new SimpleMeterRegistry());
atomicInteger.addAndGet(5);
System.out.println(gauge.value());
System.out.println(gauge.measure());
atomicInteger.decrementAndGet();
System.out.println(gauge.value());
System.out.println(gauge.measure());
//全局静态方法,返回值竟然是依赖值,有点奇怪,暂时不选用
Metrics.addRegistry(new SimpleMeterRegistry());
AtomicInteger other = Metrics.gauge("gauge", atomicInteger, AtomicInteger::get);
}
}

输出结果:

5.0
[Measurement{statistic='VALUE', value=5.0}]
4.0
[Measurement{statistic='VALUE', value=4.0}]

Gauge关注的度量统计角度是VALUE(值),它的构建方法中依赖于函数式接口ToDoubleFunction的实例(如例子中的实例方法引用AtomicInteger::get)和一个依赖于ToDoubleFunction改变自身值的实例(如例子中的AtomicInteger实例),它的接口方法如下:

public interface Gauge extends Meter {

double value();

//忽略其他方法或者成员
}

Timer

Timer(计时器)同时测量一个特定的代码逻辑块的调用(执行)速度和它的时间分布。简单来说,就是在调用结束的时间点记录整个调用块执行的总时间,适用于测量短时间执行的事件的耗时分布,例如消息队列消息的消费速率。举个例子:

import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

import java.util.concurrent.TimeUnit;

/**
* @author throwable
* @version v1.0
* @description
* @since 2018/7/19 23:44
*/
public class TimerSample {

public static void main(String[] args) throws Exception{
Timer timer = Timer.builder("timer")
.tag("timer","timer")
.description("timer")
.register(new SimpleMeterRegistry());
timer.record(()->{
try {
TimeUnit.SECONDS.sleep(2);
}catch (InterruptedException e){
//ignore
}
});
System.out.println(timer.count());
System.out.println(timer.measure());
System.out.println(timer.totalTime(TimeUnit.SECONDS));
System.out.println(timer.mean(TimeUnit.SECONDS));
System.out.println(timer.max(TimeUnit.SECONDS));
}
}

输出结果:

1
[Measurement{statistic='COUNT', value=1.0}, Measurement{statistic='TOTAL_TIME', value=2.000603975}, Measurement{statistic='MAX', value=2.000603975}]
2.000603975
2.000603975
2.000603975

Timer的度量统计角度主要包括记录执行的最大时间、总时间、平均时间、执行完成的总任务数,它提供多种的统计方法变体:

public interface Timer extends Meter, HistogramSupport {

void record(long amount, TimeUnit unit);

default void record(Duration duration) {
record(duration.toNanos(), TimeUnit.NANOSECONDS);
}

<T> T record(Supplier<T> f);

<T> T recordCallable(Callable<T> f) throws Exception;

void record(Runnable f);

default Runnable wrap(Runnable f) {
return () -> record(f);
}

default <T> Callable<T> wrap(Callable<T> f) {
return () -> recordCallable(f);
}

//忽略其他方法或者成员
}

这些record或者包装方法可以根据需要选择合适的使用,另外,一些度量属性(如下限和上限)或者单位可以自行配置,具体属性的相关内容可以查看DistributionStatisticConfig类,这里不详细展开。

另外,Timer有一个衍生类LongTaskTimer,主要是用来记录正在执行但是尚未完成的任务数,用法差不多。

Summary

Summary(摘要)用于跟踪事件的分布。它类似于一个计时器,但更一般的情况是,它的大小并不一定是一段时间的测量值。在micrometer中,对应的类是DistributionSummary,它的用法有点像Timer,但是记录的值是需要直接指定,而不是通过测量一个任务的执行时间。举个例子:


import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

/**
* @author throwable
* @version v1.0
* @description
* @since 2018/7/19 23:55
*/
public class SummarySample {

public static void main(String[] args) throws Exception {
DistributionSummary summary = DistributionSummary.builder("summary")
.tag("summary", "summary")
.description("summary")
.register(new SimpleMeterRegistry());
summary.record(2D);
summary.record(3D);
summary.record(4D);
System.out.println(summary.measure());
System.out.println(summary.count());
System.out.println(summary.max());
System.out.println(summary.mean());
System.out.println(summary.totalAmount());
}
}

输出结果:

[Measurement{statistic='COUNT', value=3.0}, Measurement{statistic='TOTAL', value=9.0}, Measurement{statistic='MAX', value=4.0}]
3
4.0
3.0
9.0

Summary的度量统计角度主要包括记录过的数据中的最大值、总数值、平均值和总次数。另外,一些度量属性(如下限和上限)或者单位可以自行配置,具体属性的相关内容可以查看DistributionStatisticConfig类,这里不详细展开。

小结

一般情况下,上面的Counter、Gauge、Timer、DistributionSummary例子可以满足日常开发需要,但是有些高级的特性这里没有展开,具体可以参考micrometer-spring-legacy这个依赖包,毕竟源码是老师,源码不会骗人。

spring-boot-starter-actuator的使用

spring-boot-starter-actuator在2.X版本中不仅升级了metrics为io.micrometer,很多配置方式也和1.X完全不同,鉴于前段时间没有维护SpringBoot技术栈的项目,现在重新看了下官网复习一下。引入依赖:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>${springboot.version}</version>
</dependency>

目前最新的springboot.version为2.0.3.RELEASE。在spring-boot-starter-actuator中,最大的变化就是配置的变化,原来在1.X版本是通过management.security.enabled控制是否可以忽略权限访问所有的监控端点,在2.X版本中,必须显式配置不需要权限验证对外开放的端点:

management.endpoints.web.exposure.include=*
management.endpoints.web.exposure.exclude=env,beans
management.endpoints.jmx.exposure.include=
management.endpoints.jmx.exposure.include=*

例如上面的配置,访问非/env和非/beans的端点,可以不受权限控制,也就是所有人都可以访问非/env和非/beans的端点。例如,如果我只想暴露/health端点,只需配置:

management.endpoints.web.exposure.include=health

这一点需要特别注意,其他使用和1.X差不多。还有一点是,2.X中所有监控端点的访问url的默认路径前缀为:http://${host}/${port}/actuator,也就是想访问health端点就要访问http://${host}/${port}/actuator/health,当然也可以修改/actuator这个路径前缀。其他细节区别没有深入研究,可以参考文档

搭建SpringCloud应用

接着先搭建一个SpringCloud应用群,主要包括注册中心(registry-center)和一个简单的服务节点(cloud-prometheus-sample),其中注册中心只引入eureka-server的依赖,而服务节点用于对接Prometheus,引入eureka-client、spring-boot-starter-actuator、prometheus等依赖。

registry-center

registry-center是一个单纯的服务注册中心,只需要引入eureka-server的依赖,添加一个启动类即可,添加的依赖如下:

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>

添加一个启动类:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

/**
* @author throwable
* @version v1.0
* @description
* @since 2018/7/21 9:06
*/
@SpringBootApplication
@EnableEurekaServer
public class RegistryCenterApplication {

public static void main(String[] args) {
SpringApplication.run(RegistryCenterApplication.class, args);
}
}

配置文件application.yaml如下:

server:
port: 9091
spring:
application:
name: registry-center
eureka:
instance:
hostname: localhost
client:
enabled: true
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

就是这么简单,启动入口类即可,启动的端口为9091。

cloud-prometheus-sample

cloud-prometheus-sample主要作为eureka-client,接入spring-boot-starter-actuator和prometheus依赖,引入依赖如下:

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

这里引入的是micrometer-registry-prometheus而不是micrometer-spring-legacy是因为micrometer-spring-legacyspring-integration(spring系统集成)的依赖,这里没有用到,但是里面很多实现可以参考。micrometer-registry-prometheus提供了基于actuator的端点,路径是../prometheus。启动类如下:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;

/**
* @author throwable
* @version v1.0
* @description
* @since 2018/7/21 9:13
*/
@SpringBootApplication
@EnableEurekaClient
public class SampleApplication {

public static void main(String[] args) {
SpringApplication.run(SampleApplication.class, args);
}
}

配置文件application.yaml如下:

server:
port: 9092
spring:
application:
name: cloud-prometheus-sample
eureka:
instance:
hostname: localhost
client:
service-url: http://localhost:9091/eureka/

启动端口为9092,eureka的服务注册地址为:http://localhost:9091/eureka/,也就是`registry-center`中指定的默认数据区(defaultZone)的注册地址,先启动`registry-center`,再启动`cloud-prometheus-sample`,然后访问http://localhost:9091/:

sp-p-1

访问http://localhost:9092/actuator/prometheus:

sp-p-2

这些数据就是实时的度量数据,Prometheus(软件)配置好任务并且启动执行后,就是通过定时拉取/prometheus这个端点返回的数据进行数据聚合和展示的。

接着,我们先定制一个功能,统计cloud-prometheus-sample所有入站的Http请求数量(包括成功、失败和非法的),添加如下代码:

//请求拦截器
@Component
public class SampleMvcInterceptor extends HandlerInterceptorAdapter {

private static final Counter COUNTER = Counter.builder("Http请求统计")
.tag("HttpCount", "HttpCount")
.description("Http请求统计")
.register(Metrics.globalRegistry);

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
COUNTER.increment();
}
}
//自定义Mvc配置
@Component
public class SampleWebMvcConfigurer implements WebMvcConfigurer {

@Autowired
private SampleMvcInterceptor sampleMvcInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(sampleMvcInterceptor);
}
}

重启cloud-prometheus-sample,直接访问几次不存在的根节点路径http://localhost:9092,再查看端点统计数据:

sp-p-3

安装和使用Prometheus

先从Prometheus官方下载地址下载软件,这里用Windows10平台演示,直接下载prometheus-2.3.2.windows-amd64.tar.gz,个人有软件洁癖,用软件或者依赖喜欢最高版本,出现坑了自己填。解压后目录如下:

sp-p-4

启动的话,直接运行prometheus.exe即可,这里先配置一下prometheus.yml:

global:
scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
alerting:
alertmanagers:
- static_configs:
- targets:
# - alertmanager:9093
scrape_configs:
- job_name: 'prometheus'
metrics_path: /actuator/prometheus
static_configs:
- targets: ['localhost:9092']

我们主要修改的是scrape_configs节点下的配置,这个节点时配置同步任务的,这里配置一个任务为’prometheus’,拉取数据的路径为/actuator/prometheus,目标host-port为’localhost:9092’,也就是cloud-prometheus-sample暴露的prometheus端点,Prometheus(软件)的默认启动端口为9090。启动后,同级目录下会生成一个data目录,实际上起到”时序数据库”的类似作用。访问Prometheus(软件)的控制台http://localhost:9090/targets:

sp-p-5

sp-p-6

Prometheus度量统计的所有监控项可以在http://localhost:9090/graph中查看到。这里可以观察到HttpCount的统计,但是界面不够炫酷,配置项也少,因此需要引入Grafana。

安装和接入Grafana

Grafana的安装也十分简单,它也是开箱即用的,就是配置的时候需要熟悉它的语法。先到Grafana官网下载页面下载一个适合系统的版本,这里选择Windows版本。解压之后,直接运行bin目录下的grafana-server.exe即可,默认的启动端口是3000,访问http://localhost:3000/,初始化账号密码是admin/admin,首次登陆需要修改密码,接着添加一个数据源:

sp-p-7

接着添加一个新的命名为’sample’的Dashboard,添加一个Graph类型的Panel,配置其属性:

sp-p-8

sp-p-9

A记录(查询命令)就是对应http://localhost:9090/graph中的查询命令的目标:

sp-p-10

很简单,配置完毕之后,就可以看到高大上的统计图:

sp-p-11

这里只是介绍了Grafana使用的冰山一角,更多配置和使用命令可以自行查阅它的官方文档。

原理和扩展

原理

下面是Prometheus的工作原理流程图,来源于其官网:

sp-p-12

在SpringBoot项目中,它的工作原理如下:

sp-p-13

这就是为什么能够使用Metrics的静态方法直接进行数据统计,因为Spring内部用MeterRegistryPostProcessor对Metrics内部持有的全局的CompositeMeterRegistry进行了合成操作,也就是所有MeterRegistry类型的Bean都会添加到Metrics内部持有的静态globalRegistry。

扩展

下面来个相对有生产意义的扩展实现,这篇文章提到SpringCloud体系的监控,我们需要扩展一个功能,记录一下每个有效的请求的执行时间。添加下面几个类或者方法:

//注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MethodMetric {

String name() default "";

String description() default "";

String[] tags() default {};
}
//切面类
@Aspect
@Component
public class HttpMethodCostAspect {

@Autowired
private MeterRegistry meterRegistry;

@Pointcut("@annotation(club.throwable.sample.aspect.MethodMetric)")
public void pointcut() {
}

@Around(value = "pointcut()")
public Object process(ProceedingJoinPoint joinPoint) throws Throwable {
Method targetMethod = ((MethodSignature) joinPoint.getSignature()).getMethod();
//这里是为了拿到实现类的注解
Method currentMethod = ClassUtils.getUserClass(joinPoint.getTarget().getClass())
.getDeclaredMethod(targetMethod.getName(), targetMethod.getParameterTypes());
if (currentMethod.isAnnotationPresent(MethodMetric.class)) {
MethodMetric methodMetric = currentMethod.getAnnotation(MethodMetric.class);
return processMetric(joinPoint, currentMethod, methodMetric);
} else {
return joinPoint.proceed();
}
}

private Object processMetric(ProceedingJoinPoint joinPoint, Method currentMethod,
MethodMetric methodMetric) throws Throwable {
String name = methodMetric.name();
if (!StringUtils.hasText(name)) {
name = currentMethod.getName();
}
String desc = methodMetric.description();
if (!StringUtils.hasText(desc)) {
desc = name;
}
String[] tags = methodMetric.tags();
if (tags.length == 0) {
tags = new String[2];
tags[0] = name;
tags[1] = name;
}
Timer timer = Timer.builder(name).tags(tags)
.description(desc)
.register(meterRegistry);
return timer.record(() -> {
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new IllegalStateException(throwable);
}
});
}
}
//启动类里面添加方法
@SpringBootApplication
@EnableEurekaClient
@RestController
public class SampleApplication {

public static void main(String[] args) {
SpringApplication.run(SampleApplication.class, args);
}

@MethodMetric
@GetMapping(value = "/hello")
public String hello(@RequestParam(name = "name", required = false, defaultValue = "doge") String name) {
return String.format("%s say hello!", name);
}
}

配置好Grafana的面板,重启项目,多次调用/hello接口:

sp-p-14

后记

如果想把监控界面做得更炫酷、更直观、更详细,可以先熟悉一下Prometheus的查询语法和Grafana的面板配置,此外,Grafana或者Prometheus都支持预警功能,可以接入钉钉机器人等以便及时发现问题作出预警。

参考资料:

本文Demo项目仓库:https://github.com/zjcscut/spring-cloud-prometheus-sample

(本文完 c-5-d e-a-20180721 r-a-201918)