教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志

第28讲:使用 Prometheu 和 Grafana 处理性能指标数据
用户对于应用的性能总是有着苛刻的要求。在目前的市场上,每一个服务都有着不少的替代选项。如果你的网页打开速度不够快,或者你的 App 在每次刷新时总是长时间显示加载中的图标,那么你多半会失去这个用户。提升性能的前提是了解应用的性能,知道整个系统的瓶颈在哪里。这就需要收集足够多的性能指标数据,并以可视化的形式展现出来。本课时介绍性能指标数据的捕获、收集、分析和展示,用到的工具包括 Micrometer、Prometheus 和 Grafana 等。
捕获数据
第一步是捕获需要收集的性能指标数据,每个应用有自己感兴趣的性能指标,性能指标通常可以分成两类,即业务无关和业务相关。业务无关的性能指标所提供的信息比较底层,比如数据库操作的时间、API 请求的响应时间、API 请求的总数等。业务相关的性能指标的抽象层次较高,比如订单的总数、总的交易金额、某个业务端到端的处理时间等。
Micrometer 为了捕获性能指标数据,我们需要在应用中特定的地方添加代码,不同的编程语言有各自不同的库。Java 平台目前流行的是 Micrometer。Spring Boot 提供了对 Micrometer 的自动配置,只需要添加相关的依赖即可。
Micrometer 为 Java 平台上的性能数据收集提供了一个通用的 API,类似于 SLF4J 在日志记录上的作用。应用程序只需要使用 Micrometer 的通用 API 来收集性能指标数据即可。Micrometer 会负责完成与不同监控系统的适配工作,使得切换监控系统变得很容易,避免了供应商锁定的问题。Micrometer 还支持同时推送数据到多个不同的监控系统。
Micrometer 中有两个最核心的概念,分别是计量器(Meter)和计量器注册表(Meter Registry)。计量器表示的是需要收集的性能指标数据,而计量器注册表负责创建和维护计量器,每个监控系统有自己独有的计量器注册表实现。
Spring Boot Actuator 提供了对 Micrometer 的自动配置,会自动创建一个组合注册表对象,并把 CLASSPATH 上找到的所有支持的注册表实现都添加进来。只需要在 CLASSPATH 上添加相应的第三方库,Spring Boot 会完成所需的配置。如果需要对该注册表进行配置,添加类型为 MeterRegistryCustomizer 的 bean 即可,如下面的代码所示。在需要使用注册表的地方,可以通过依赖注入的方式来使用 MeterRegistry 对象。

@Configuration public class ApplicationConfig { @Bean public MeterRegistryCustomizer meterRegistryCustomizer() { return registry -> registry.config().commonTags("service", "address"); } }

每个计量器都有自己的名称。由于不同的监控系统有自己独有的推荐命名规则,Micrometer 使用英文句点 “.” 分隔计量器名称中的不同部分,比如 a.b.c。Micrometer 会负责完成所需的转换,以满足不同监控系统的需求。
每个计量器在创建时都可以指定一系列标签,标签以名值对的形式出现,监控系统使用标签对数据进行过滤。除了每个计量器独有的标签之外,每个计量器注册表还可以添加通用标签,所有该注册表导出的数据都会带上这些通用标签。上面代码中的 MeterRegistryCustomizer 添加了通用标签 service,对应的值是 address。
计量器用来收集不同类型的性能指标信息。Micrometer 提供了不同类型的计量器实现。
计数器 计数器(Counter) 表示的是单个只允许增加的值。通过 MeterRegistry 的 counter 方法来创建表示计数器的 Counter 对象;还可以使用 Counter.builder 方法来创建 Counter 对象的构建器。Counter 所表示的计数值是 double 类型,其 increment 方法可以指定增加的值,默认情况下增加的值是 1.0。如果已经有一个方法返回计数值,可以直接从该方法中创建类型为 FunctionCounter 的计数器。
下面的代码展示了计数器的创建和使用。
@Service public class CounterService { private final Counter counter; public CounterService( final MeterRegistry meterRegistry) { this.counter = Counter.builder("simple.counter1") .description("A simple counter") .tag("type", "counter") .register(meterRegistry); } public void count() { this.counter.increment(); } }

计量仪 计量仪(Gauge) 表示的是单个变化的值。与计数器的不同之处在于,计量仪的值并不总是增加的。与创建 Counter 对象类似,Gauge 对象可以从计量器注册表中创建,也可以使用 Gauge.builder 方法返回的构建器来创建。
下面的代码展示了计量仪的创建。当 Gauge 对象创建之后,每次捕获数据时,会自动调用创建时提供的方法来获取最新值。
@Service public class GaugeService { public GaugeService(final MeterRegistry meterRegistry) { Gauge.builder("simple.gauge1", this, GaugeService::getValue) .description("A simple gauge") .tag("type", "gauge") .register(meterRegistry); } private double getValue() { return ThreadLocalRandom.current().nextDouble(); } }

计时器 计时器(Timer)通常用来记录事件的持续时间。计时器会记录两类数据:事件的数量和总的持续时间。在使用计时器之后,就不再需要单独创建一个计数器,计时器可以从注册表中创建,或者使用 Timer.builder 方法返回的构建器来创建。Timer 提供了不同的方式来记录持续时间,第一种方式是使用 record 方法来记录 Runnable 和 Callable 对象的运行时间;第二种是使用 Timer.Sample 来手动启动和停止计时。
如果一个任务的耗时很长,直接使用 Timer 对象并不是一个好的选择,因为 Timer 对象只有在任务完成之后才会记录时间。更好的选择是使用 LongTaskTimer 对象。LongTaskTimer 对象可以在任务进行中记录已经耗费的时间,它通过注册表的 more().longTaskTimer 方法来创建。
下面代码展示了计时器的创建和使用,其中的 record 方法记录 Runnable 对象的执行时间,start 方法用来启动计时,stop 方法用来停止计时。
@Service public class TimerService { private final MeterRegistry meterRegistry; private final Timer timer1; private Timer.Sample sample; public TimerService( final MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.timer1 = Timer.builder("simple.timer1") .description("A simple timer 1") .register(meterRegistry); } public void record() { this.timer1.record(() -> { try { Thread.sleep(ThreadLocalRandom.current().nextLong(1000)); } catch (final InterruptedException e) { // ignore } }); } public void start() { this.sample = Timer.start(this.meterRegistry); } public void stop() { this.sample.stop(Timer.builder("simple.timer2") .description("A simple timer 2") .register(this.meterRegistry)); } }

大多数时候,需要计时的是一个方法的执行时间,此时更简单的做法是使用 @Timed 注解。下面代码中的 @Timed 注解添加在 search 方法上,指定了性能指标的名称和发布的百分比数值。
@Timed(value = "https://www.it610.com/article/happyride.address.search", percentiles = {0.5, 0.75, 0.9}) public List search(final Long areaCode, final String query) { }

为了 @Timed 注解可以生效,需要添加 Spring AOP 和 AspectJ 的 aspectjweaver 依赖,同时使用 @EnableAspectJAutoProxy 注解来启用 AspectJ 的支持,并创建 TimedAspect 对象,如下面的代码所示。
@Configuration @EnableAspectJAutoProxy public class ApplicationConfig { @Bean public TimedAspect timedAspect(final MeterRegistry meterRegistry) { return new TimedAspect(meterRegistry); } }

分布概要 分布概要(Distribution Summary)用来记录事件的分布情况。计时器本质上也是一种分布概要,表示分布概要的 DistributionSummary 对象可以从注册表中创建,也可以使用 DistributionSummary.builder 方法提供的构建器来创建。分布概要根据每个事件所对应的值,把事件分配到对应的桶(Bucket)中。Micrometer 默认的桶值从 1 到最大的 long 值,可以通过 minimumExpectedValue 和 maximumExpectedValue 来控制值的范围。
如果事件所对应的值较小,可以通过 scale 来设置一个值来对数值进行放大。与分布概要密切相关的是直方图和百分比(percentile)。大多数时候,我们并不关注具体的数值,而是数值的分布区间,比如在查看 HTTP 服务响应时间的性能指标时,选择几个重要的百分比,如 50%、75% 和 90% 等,关注的是这些百分比数量的请求都在多少时间内完成。
下面的代码展示了分布概要的创建和使用。
@Service public class DistributionSummaryService { private final DistributionSummary summary; public DistributionSummaryService(final MeterRegistry meterRegistry) { this.summary = DistributionSummary.builder("simple.summary") .description("A simple distribution summary") .tag("type", "distribution_summary") .minimumExpectedValue(1.0) .maximumExpectedValue(10.0) .publishPercentiles(0.5, 0.75, 0.9) .register(meterRegistry); } public void record(final double value) { this.summary.record(value); } }

发布数据
在捕获了性能指标数据之后,下一步是把这些数据发布到后台的处理系统。有很多开源和商用的系统可供选择,Micrometer 都可以提供集成,示例应用使用的是 Prometheus,与其他监控系统的不同在于,Prometheus 采取的是主动抽取数据的方式,也就是拉模式。因此客户端需要暴露 HTTP 服务,并由 Prometheus 定期来访问以获取数据。
Prometheus 数据抓取 对于 Prometheus 来说,Spring Boot Actuator 会自动配置一个 URL 为 /actuator/prometheus 的 HTTP 服务来供 Prometheus 抓取数据。不过该 Actuator 服务默认是关闭的,需要通过 Spring Boot 的配置打开,如下面的代码所示。
management: endpoints: enabled-by-default: false web: exposure: include: prometheus endpoint: prometheus: enabled: true

接下来需要配置 Prometheus 来抓取应用提供的数据。下面的代码是 Prometheus 的配置文件 prometheus.yml 的内容,其中 scrape_interval 设置抓取数据的时间间隔,scrape_configs 设置需要抓取的目标,这里使用的是静态的服务器地址。Prometheus 支持抓取目标的自动发现,具体请查看官方文档。
global: scrape_interval: 10s scrape_configs: - job_name: 'simple' metrics_path: '/actuator/prometheus' static_configs: - targets: - "localhost:8080"

使用推送网关 Prometheus 的一般工作模式是拉模式,也就是由 Prometheus 主动的定期获取数据,对于一些运行时间较短的任务来说,拉模式不太适用。这些任务的运行时间可能短于 Prometheus 的数据抓取间隔,导致数据无法被收集,此时应该使用推送网关(Push Gateway)来主动推送数据,这是一个独立的应用,作为应用和 Prometheus 服务器之间的中介。应用推送数据到推送网关,Prometheus 从推送网关中拉取数据。
Spring Boot Actuator 提供了对推送网关的自动配置,可以定期推送数据。应用的代码只需要通过正常的方式使用 Micrometer 发布性能指标数据即可。下面的代码给出了推送网关的相关配置。
management.metrics.export.prometheus.pushgateway: enabled: true base-url: http://localhost:9091 job: batch-task shutdown-operation: push grouping-key: instance: ${random.value}

下表是推送网关的配置项说明。
配置项 说明
base-url 推送网关的地址
job 任务的名称
shutdown-operation 应用关闭时的行为,push 的含义是在关闭时推送一次数据
grouping-key 分组名称和值
【教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志】在每次进行推送时,如果性能指标的名称,以及分组名称和值都相同,推送的数据会替换之前的值。Prometheus 在抓取推送网关的数据时,使用的是 /metrics 路径。
节点导出工具 Prometheus 提供了节点导出工具(Node exporter)来收集硬件相关的性能指标数据,包括 CPU、内存、文件系统和网络等。
Grafana
Prometheus 提供了界面来查询性能指标的值,并绘制简单的图形。如果需要更强大的展示方式,可以使用 Grafana,其可以用 Prometheus 作为数据源,并提供了不同类型的图表和表格作为展示方式,同时还提供了图形化界面来对图表进行配置。当以 Prometheus 作为数据源时,则需要了解基本的 Prometheus 查询语法。
最基本的查询方式是使用性能指标的名称,这样可以查询到收集的原始数据,比如 http_request_total 表示所有 HTTP 请求的计数器。可以使用标签进行过滤,比如查询 http_request_total{handler="/search/"} 使用标签 handler 来进行过滤。Prometheus 还支持操作符和函数,典型的操作符如 sum、min、max、avg 和 topk 等,函数包括计算增加速率的 rate()、进行排序的 sort()、计算绝对值的 abs() 等。
Grafana 的界面简单易用,通过 Prometheus 查询得到数据之后,选择不同的图表,再进行配置即可。
Prometheus Operator
性能指标数据的分析需要一个完整的技术栈,包括 Prometheus 服务器、推送网关、节点导出工具和 Grafana 等。在 Kubernetes 上手动安装和配置这些不同的工具是一件非常耗时的任务。更好的做法是使用 Prometheus Operator。
使用 Helm 3 来安装 Prometheus Operator,安装使用的名称空间是 monitoring。
helm install prom-o -n monitoring stable/prometheus-operator

安装完成之后,所有的相关服务都会启动。Prometheus Operator 会自动收集 Kubernetes 自身的性能指标数据。对于应用的性能指标数据,需要添加服务监控器(Service Monitor)对象,服务监控器是 Prometheus Operator 提供的 Kubernetes 上的自定义资源定义。下面代码给了地址管理服务的服务监控器对象,指定了 Kubernetes 服务的选择器,以及抓取数据的路径。
apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: address-service labels: release: prom-o spec: selector: matchLabels: app.kubernetes.io/name: address namespaceSelector: matchNames: - default endpoints: - port: api interval: 10s path: "/actuator/prometheus" honorLabels: true

在开发中,可以通过 kubectl 提供的端口转发功能来访问 Prometheus 和 Grafana 服务。通过运行下面的命令,可以在本地机器的 9090 端口访问 Prometheus 的界面。
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090

在下图所展示的 Prometheus 的抓取目标界面中,可以看到新添加的地址管理服务。

通过 Grafana 的界面可以创建出各种类型的图表。下图是地址管理服务的搜索操作的请求处理速度。
教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志
文章图片

总结
提升系统性能的首要前提是了解系统的性能,只有收集足够多的性能指标数据,才能找到性能优化的正确方向,并及时处理性能相关的问题。通过本课时的学习,你应该掌握如何使用 Micrometer 来在代码中捕获性能指标数据,以及如何用 Prometheus 来抓取数据。对于 Prometheus 中的数据,可以使用 Grafana 来进行展示,你还应该了解如何在 Kubernetes 上使用 Prometheus Operator,从而构建自己的性能监控的技术栈。
第29讲:使用 Fluentd、ElaticSearch 和 Kibana 管理日志
当系统在运行出现问题时,进行错误排查的首要目标是系统的日志,日志在系统维护中的重要性不言而喻。与单体应用相比,微服务架构应用的每个服务都独立运行,会产生各自的日志。这就要求把来自不同服务的日志记录聚合起来,形成统一的查询视图。云原生应用运行在 Kubernetes 上,对日志记录有不同的要求。本课时将介绍微服务架构的云原生应用,如何使用 Fluentd、ElasticSearch 和 Kibana 来管理日志。
记录日志
日志记录是开发中的重要组成部分,这离不开日志库的支持。
日志库 在 Java 平台上,直到 JDK 1.4 版本才在标准库中增加了日志记录的 API,也就是 java.util.logging 包(JUL)。在那之前已经有一些开源日志实现流行起来,如 Apache Log4j,这就造成了在目前的 Java 日志实现中,Java 标准库的 JUL 包的使用者较少,而Log4j 和 Logback 这样的开源实现反而比较流行。
几乎所有的应用和第三方库都需要用到日志的功能,而且可以自由选择所使用的日志实现库,每个日志库都有自己特定的配置方式。当不同的日志实现同时使用时,它们的配置没办法统一起来,还可能产生冲突,这就产生了 Java 平台上特殊的日志 API 抽象层。
日志 API 抽象层(Facade)提供了一个抽象的接口来访问日志相关的功能,不同的日志库都实现该抽象层的接口,从而允许在运行时切换不同的具体日志实现。对于共享库的代码,推荐使用日志抽象层的 API,这就保证了共享库的使用者在选择日志实现时的灵活性。
常用的抽象层库包括早期流行的 Apache Commons Logging 和目前最常用的 SLF4J。日志实现库负责完成实际的日志记录,常用的库包括 Java 标准库提供的 JUL、Log4j 和 Logback 等。在一般的应用开发中,通常使用日志抽象层加上具体日志实现库的方式。
如果使用 Log4j 2 作为具体的日志实现,那么通常需要用到下表中给出的 3 个 Maven 库。
分组 Artifact 名称 作用
org.slf4j slf4j-api SLF4J 提供的日志 API
org.apache.logging.log4j log4j-slf4j-impl Log4j 2 与 SLF4J API 的适配器
org.apache.logging.log4j log4j-core Log4j 2 的具体实现
对于 Spring Boot 应用来说,只需要选择添加下面列表中给出的依赖即可。
Spring Boot 依赖名称 日志实现
spring-boot-starter-log4j2 Log4j 2
spring-boot-starter-logging Logback
在应用开发中,可以选择使用 SLF4J 的 API 来记录日志,也可以直接使用某个具体日志实现的 API。使用 SLF4J API 的好处是避免了供应商锁定的问题,与其他第三方库一块使用时不容易产生冲突,不足之处是 SLF4J 的 API 为了保证更广泛的兼容性,其 API 只是提供了最通用的功能,无法使用具体日志实现特有的功能。
在开发共享库时,建议使用 SLF4J 的 API 以提高兼容性;在应用的开发中,一般很少会出现替换日志实现的情况,因此可以选择直接使用日志实现的 API。以 Log4j 2 为例,它提供了对 SLF4J 等其他日志 API 的适配器。即便直接使用 Log4j 2 的 API,也可以通过适配器与其他日志实现库进行交互。
日志记录器 日志 API 的使用者通过记录器(Logger)来发出日志记录请求,并提供日志的内容。在记录日志时,需要指定日志的严重性级别,日志记录 API 都提供了相应的工厂方法来创建记录器对象,每个记录器对象都有名称。一般的做法是使用当前 Java 类的名称或所在包的名称来作为记录器对象的名称。记录器的名称通常是具有层次结构的,与 Java 包的层次结构相对应。
在通过日志记录器对象记录日志时,需要指定日志的严重性级别。根据每个记录器对象的不同配置,低于某个级别的日志消息可能不会被记录下来,该级别是日志 API 的使用者根据日志记录中所包含的信息来自行决定的。当通过记录器对象来记录日志时,只是发出一个日志记录请求,该请求是否会完成取决于请求和记录器对象的严重性级别。记录器使用者产生的低于记录器对象严重性级别的日志消息不会被记录下来,这样的记录请求会被忽略。一般来说,对于 DEBUG 及其以下级别的日志消息,首先需要使用类似 isDebugEnabled 这样的方法来检查日志消息是否会被记录,如下面的代码所示。
if (LOGGER.isDebugEnabled()) { LOGGER.debug("This is a debug message."); }

日志记录在产生之后以事件的形式来表示。输出源(Appender)负责把日志事件传递到不同的目的地,常用的日志目的地包括文件、控制台、数据库、HTTP 服务和 syslog 等。其中控制台和文件是最常用的两种,控制台输出用在开发中,滚动文件(Rolling File)在生产环境中用来保存历史日志记录。
在输出日志事件到目的地之前,通常需要对事件进行格式化,这是通过布局(Layout)来完成的。布局负责把事件转换成输出源所需要的格式,常用的布局格式包括字符串、JSON、XML、CSV、HTML 和 YAML 等。
过滤器(Filter)的作用是对日志事件进行过滤,以确定日志事件是否需要被发布。过滤器可以添加在日志记录器或输出源上。
MDC 和 NDC 在多线程和多用户的应用中,同样的代码会处理不同用户的请求。在记录日志时,应该包含与用户相关的信息,当某个用户出现问题时,可以通过用户的标识符在日志中快速查找相关的记录,更方便定位问题。在日志记录中,映射调试上下文(Mapped Diagnostic Context,MDC)和嵌套调试上下文(Nested Diagnostic Context,NDC)解决了这个问题。正如名字里面所指出的一样,MDC 和 NDC 最早是为了错误调试的需要而引入的,不过现在一般作为通用的数据存储方式。MDC 和 NDC 在实现和作用上是相似,只不过 MDC 用的是哈希表,而 NDC 用的是栈,因此 NDC 中只能包含一个值。MDC 和 NDC 使用 ThreadLocal 来实现,与当前线程绑定。
由于 MDC 比 NDC 更灵活,实际中一般使用 MDC 较多,SLF4J 的 API 提供了对 MDC 和 NDC 的支持。同一个线程中运行的不同代码,可以通过 MDC 来共享数据。以 REST API 为例,当用户通过认证之后,可以在 Spring Security 过滤器的实现中把已认证用户的标识符保存在 MDC 中,后续的代码都可以从 MDC 中获取用户的标识符,而不用通过方法调用时的参数来传递。
MDC 类中包含了对哈希表进行操作的静态方法,如 get、put、remove 和 clear 等。大部分时候把 MDC 当成一个哈希表来使用即可,如下面的代码所示。
MDC.put("value", "hello"); LOGGER.info("MDC value : {}", MDC.get("value"));

由于 MDC 保存在 ThreadLocal 中,如果当前线程通过 Java 中的 ExecutorService 来提交任务,任务的代码由工作线程来运行,有可能无法获取到 MDC 的值。这个时候就需要手动传递 MDC 中的值。
在下面的代码中,首先使用 MDC.getCopyOfContextMap 方法获取到当前线程的 MDC 中数据的拷贝,在任务的代码中使用 MDC.setContextMap 方法来设置 MDC 的值。通过这种方式,可以在不同线程之间传递 MDC。
final ExecutorService executor = Executors.newSingleThreadExecutor(); final Map contextMap = MDC.getCopyOfContextMap(); try { executor.submit(() -> { MDC.setContextMap(contextMap); new MDCGetter().display(); }).get(); } catch (final InterruptedException | ExecutionException e) { e.printStackTrace(); } executor.shutdown();

NDC 在使用时更加简单一些,只有 push 和 pop 两个方法,分别进行进栈和出栈操作。NDC 的 API 在 slf4j-ext 库中,其内部实现时实际上使用的是 MDC。
MDC 和 NDC 中的值,除了直接在代码中使用之外,还可以在模式布局中使用,从而出现在日志记录中。在 Log4j 2 中,模式布局支持不同的参数来引用 MDC 和 NDC 的值,如下表所示。
参数 说明
%X MDC 中的全部值
%X{key} MDC 中特定键对应的值
%x NDC 中的值
需要注意的是,由于 SLF4J 中的 NDC 实际上通过 MDC 来实现,在直接使用 SLF4J 的 API 时,%x 并不能获取到 NDC 中的值。
如果以 Log4j 2 作为日志实现,推荐的做法是直接使用 ThreadContext 类,该类同时提供了对 MDC 和 NDC 的支持。下面的代码展示了 ThreadContext 中 NDC 功能的使用方式。
public class Log4jThreadContext { private static final Logger LOGGER = LogManager.getLogger("ThreadContext"); public void display() { ThreadContext.push("user1"); LOGGER.info("message 1"); LOGGER.info("message 2"); ThreadContext.pop(); LOGGER.info("message 3"); // NDC中已经没有值 } }

MDC 通常作为任务执行时的上下文。当退出当前的执行上下文之后,MDC 中的内容应该被恢复。Log4j 2 提供了 CloseableThreadContext 类来方便对 ThreadContext 的管理。当 CloseableThreadContext 对象关闭时,对 ThreadContext 所做的修改会被自动恢复。下面代码中 ThreadContextHelper 类的 withContext 方法,可以在指定的上下文对象中,执行 Runnable 表示的代码。
public class ThreadContextHelper { public static void withContext(final Map context, final Runnable action) { try (final Instance ignored = CloseableThreadContext.putAll(context)) { action.run(); } } }

在下面的代码中,withContext 方法中的两条日志记录可以访问 userId 的值,而最后一条日志记录无法访问。
ThreadContextHelper.withContext( ImmutableMap.of("userId", "12345"), () -> { LOGGER.info("message 1"); LOGGER.info("message 2"); }); LOGGER.info("message 3");

SLF4J 中的 MDC.MDCCloseable 类的作用与 CloseableThreadContext 类似,通过 MDC 的 putCloseable 方法来使用,如下面的代码所示。
try (final MDC.MDCCloseable ignored = MDC.putCloseable("userId", "12345")) { LOGGER.info("message 1"); }

日志聚合
在单体应用中,日志通常被写入到文件中。当出现问题时,最直接的做法是在日志文件中根据错误产生的时间和错误消息进行查找,这种做法的效率很低。如果应用同时运行在多个虚拟机之上,需要对多个应用实例产生的日志记录进行聚合,并提供统一的查询视图。有很多的开源和商用解决方案提供了对日志聚合的支持,典型的是 ELK 技术栈,即 Elasticsearch、Logstash 和 Kibana 的集成。这 3 个组成部分代表了日志管理系统的 3 个重要功能,分别是日志的收集、保存与索引、查询。
对于微服务架构的云原生应用来说,日志管理的要求更高,应用被拆分成多个微服务,每个微服务在运行时的实例数量可能很多。在 Kubernetes 上,需要收集的是 Pod 中产生的日志。
在单体应用中,日志消息的主要消费者是开发人员,因此日志消息侧重的是可读性,一般是半结构化的字符串形式。通过模式布局,从日志事件中提取出感兴趣的属性,并格式化成日志消息。日志消息是半结构化的,通过正则表达式可以从中提取相关的信息。
当需要进行日志的聚合时,半结构化的日志消息变得不再适用,因为日志消息的消费者变成了日志收集程序,JSON 这样的结构化日志成了更好的选择。如果可以完全控制日志的格式,推荐使用 JSON。对于来自外部应用的日志消息,如果是纯文本格式的,仍然需要通过工具来解析并转换成 JSON。
当应用在容器中运行时,日志并不需要写到文件中,而是直接写入到标准输出流。Kubernetes 会把容器中产生的输出保存在节点的文件中,可以由工具进行收集。
Fluentd
Fluentd 是一个开源的数据收集器,可以提供统一的日志管理;还可以通过灵活的插件架构,与不同的日志数据源和目的地进行集成。
Fluentd 使用 JSON 作为数据格式,同时以事件来表示每条日志记录。事件由下表中给出的 3 个部分组成。
属性 说明
标签 事件源的标识
时间戳 事件的产生时间
记录 JSON 格式的日志记录
Fluentd 采用插件化的架构来方便扩展,其插件分为输入、解析、过滤、输出、格式化、存储、服务发现和缓冲等 8 个类别。下表给出了这 8 个类别的说明和插件示例。
类别 说明 插件
输入 从外部源中获取事件日志 文件、UDP、TCP、HTTP、syslog 等
解析 解析事件日志的内容 正则表达式、Apache 2、Nginx、CSV、JSON
过滤 对事件进行修改,包括提取字段、添加新字段、删除字段 grep、记录转换器
输出 事件日志的输出目的地 文件、HTTP、Elasticsearch、Kafka、MongoDB、Amazon S3
格式化 对事件输出进行格式化 JSON、CSV、单个值
存储 保存插件的内部状态 本地文件
服务发现 发现输出的目的地 静态目标、文件
缓冲 输出插件的缓冲 文件、内存
Fluentd 以流水线的方式来处理日志事件,流水线由 Fluentd 的配置文件来定义。流水线最基本的组成元素是输入、过滤器和输出,分别用下表中的 指令来声明。事件的标签在流水线中很重要,用来选择不同的处理方式。
指令 说明
事件的输入源
对事件进行处理,与事件的标签匹配
对事件进行处理和输出,与事件的标签匹配
除了上表中的 3 个基本指令之外,下表还给出了两个内嵌指令的说明。
指令 说明 可能的父指令
使用解析插件
使用格式化插件
在配置插件时,通过 @type 参数来指定插件的名称。下面的代码是 Fluentd 的配置文件的示例,其中定义了一个从 HTTP 输入到文件输出的处理流水线。输入源是运行在 8280 端口的 HTTP 服务;过滤操作匹配标签为 app.log 的事件,并添加 hostname 字段;输出目的地是文件,并通过格式化插件 json 转换为 JSON 格式。发送 HTTP POST 请求到 URL http://localhost:8280/app.log 可以发布新的事件,POST 请求的路径 app.log 是事件的标签。
@type http port 8280 bind 0.0.0.0 @type record_transformer hostname "#{Socket.gethostname}" @type file path /opt/app/log @type json

除了 Fluentd 之外,还可以使用 Filebeat 或 Logstash 来收集日志。
Elasticsearch 和 Kibana
当收集到来自不同源的日志事件之后,还需要进行存储和搜索。在流行的日志处理技术栈中,Elasticsearch 和 Kibana 是两个常用的选择,前者提供了日志事件的存储和搜索,而后者则提供了日志查询和结果的展示。
在 Kubernetes 上,可以使用 Helm 来安装 Elasticsearch 和 Kibana。不过更推荐的做法是使用 Elastic Cloud on Kubernetes(ECK)。ECK 基于 Kubernetes 上的操作员模式来实现,提供了更好的可伸缩性和可维护性,类似第 28 课时介绍的 Prometheus Operator。
首先使用下面的命令安装 ECK 的自定义资源定义。
kubectl apply -f https://download.elastic.co/downloads/eck/1.1.2/all-in-one.yaml

接着可以使用 ECK 提供的自定义资源定义来创建 Elasticsearch 集群。下面的代码创建了一个名为 default 包含一个节点的 Elasticsearch 集群。
apiVersion: elasticsearch.k8s.elastic.co/v1 kind: Elasticsearch metadata: name: default spec: version: 7.8.0 nodeSets: - name: default count: 1 config: node.master: true node.data: true node.ingest: true node.ml: false xpack.ml.enabled: false node.store.allow_mmap: false

在 Elasticsearch 集群创建之后,可以使用 kubectl get elasticsearch 命令来查看集群的状态,输出结果如下面的代码所示。
NAMEHEALTHNODESVERSIONPHASEAGE defaultgreen17.8.0Ready21m

Kibana 的部署方式类似于 Elasticsearch,如下面的代码所示。属性 elasticsearchRef 的值用来配置 Kibana,引用之前创建的 Elasticsearch 集群。
apiVersion: kibana.k8s.elastic.co/v1 kind: Kibana metadata: name: default spec: version: 7.8.0 count: 1 elasticsearchRef: name: default

当 Kibana 部署完成之后,可以在本地机器上使用 kubectl port-forward 来访问 Kibana 界面,如下面的代码所示:
kubectl port-forward svc/default-kb-http 5601

使用浏览器访问 https://localhost:5601 即可。需要注意的是,Kibana 服务器默认使用了自签名的 SSL 证书,浏览器会给出警告,在开发环境中可以忽略。Kibana 的登录用户名是 elastic,而密码需要从 Kubernetes 的 Secret 中获取,使用下面的代码可以获取到密码。
kubectl get secret default-es-elastic-user -o go-template='{{.data.elastic | base64decode}}'

接着需要在 Kubernetes 上运行 Fluentd。Fluentd 以守护进程集(DaemonSet)的形式来运行,确保在每个节点上都可以运行;同时它会收集容器中产生的日志,并发送到 Elasticsearch。
下面的代码是创建 Fluentd 的守护进程集的 YAML 文件。通过卷的绑定,Fluentd 可以读取节点上 /var/log 和 /var/lib/docker/containers 目录下的日志文件。
apiVersion: apps/v1 kind: DaemonSet metadata: name: fluentd namespace: default labels: app.kubernetes.io/name: fluentd-logging spec: selector: matchLabels: app.kubernetes.io/name: fluentd-logging template: metadata: labels: app.kubernetes.io/name: fluentd-logging spec: containers: - name: fluentd image: fluent/fluentd-kubernetes-daemonset:v1-debian-elasticsearch env: - name: FLUENTD_SYSTEMD_CONF value: "disabled" - name:FLUENT_ELASTICSEARCH_HOST value: "default-es-http" - name:FLUENT_ELASTICSEARCH_PORT value: "9200" - name: FLUENT_ELASTICSEARCH_SCHEME value: "https" - name: FLUENT_ELASTICSEARCH_SSL_VERIFY value: "false" - name: FLUENT_ELASTICSEARCH_USER value: "elastic" - name: FLUENT_ELASTICSEARCH_PASSWORD valueFrom: secretKeyRef: name: "default-es-elastic-user" key: "elastic" resources: limits: memory: 200Mi requests: cpu: 100m memory: 200Mi volumeMounts: - name: varlog mountPath: /var/log - name: varlibdockercontainers mountPath: /var/lib/docker/containers readOnly: true terminationGracePeriodSeconds: 30 volumes: - name: varlog hostPath: path: /var/log - name: varlibdockercontainers hostPath: path: /var/lib/docker/containers

在首次使用 Kibana 时,需要配置索引的模式,使用 logstash-* 作为模式即可。下图是 Kibana 查询日志的界面,可以通过标签来快速对日志消息进行过滤。
教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志
文章图片

总结
应用的开发和维护都离不开日志的支持,对于微服务架构的云原生应用来说,完整的日志聚合、分析和查询的技术栈是必不可少的。通过本课时的学习,你可以掌握 Java 应用中记录日志的方式和最佳实践,还可以了解如何基于 Fluentd、Elasticsearch 和 Kibana,在 Kubernetes 上构建自己的日志聚合、分析和查询的完整技术栈。
第30讲:使用 Sentry 处理异常
异常是 Java 应用中处理错误的标准方式。在捕获异常时,通常的做法是以日志的方式记录下来,可以使用第 29 课时介绍的日志聚合技术栈来处理异常。
但是异常中包含了很多与代码相关的信息,尤其是异常的堆栈信息,对错误调试很有帮助。如果只是把这些异常消息当成普通的日志消息,则没办法将其充分利用,更好的做法应该是对异常进行特殊的处理,也就是本课时会主要讲解的内容。
Java 中的异常
在 Java 开发中,总是免不了与异常打交道,异常表示的是错误的情况。
1.异常的类型 Java 中的异常可以分成 3 类,分别是检查异常(Checked Exception)、非检查异常(Unchecked Exception)和错误(Error)。这三种异常都是 Throwable 的子类型,类层次结构如下图所示。
教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志
文章图片

只有 Throwable 类或者其子类的实例,才能被 Java 虚拟机以错误来抛出,或是作为 throw 语句的对象。同样的,只有 Throwable 类及其子类才能作为 catch 子句的类型。
Error 类表示的是严重的系统错误,应用程序不应该试图去捕获和处理这样的错误。这样的错误通常表示异常的情况,应该由虚拟机来处理,只能终止程序的执行。常见的错误包括表示内存不足的 OutOfMemoryError、表示类链接错误的 LinkageError 及其子类、表示 IO 错误的 IOError等。Error 类型是保留给虚拟机使用的,应用程序不应该创建自定义的 Error 类的子类。
与 Error 相对应的 Exception 类,表示的是程序可以捕获和处理的异常情况,异常可分成检查异常和非检查异常两类。这两者的区别在于,检查异常的使用由编译器来检查,而非检查异常则不需要。Throwable 类及其子类,如果不是 Error 或 RuntimeException 及它们的子类型,则被视为检查异常,否则是非检查异常。当检查异常出现在方法声明的 throws 子句中时,该方法的调用者必须对声明的异常进行处理,要么使用 catch 子句来捕获并处理该异常,要么把异常往上传递。非检查异常则没有这样的限制,可以自由地抛出和捕获。
2.异常对象 Throwable 对象在创建时有两个基本的参数,其中一个是 String 类型的 message,表示概要性的描述;另外一个是 Throwable 类型的 cause,表示导致当前 Throwable 对象产生的原因。由于每个 Throwable 对象都可以有自己的原因。多个 Throwable 对象可以通过这种关系串联起来,形成异常链。
在出现异常时,异常对象是获取错误信息的最重要的渠道。因此,异常类中应该包含足够多的信息来描述错误出现时的情况。这一点与日志记录是相似的,其目的都是为了方便开发人员查找错误的根源。异常类除了必须继承自 Throwable 类之外,与其他的 Java 类并没有区别。异常类也可以添加不同的属性和方法。
下面代码中的 OrderNotFoundException 类表示找不到指定标识符对应的订单,其构造参数orderId 表示订单的标识符。当 OrderNotFoundException 异常被抛出时,可以利用异常消息中包含的订单标识符快速查找问题。
public class OrderNotFoundException extends Exception { private final String orderId; public OrderNotFoundException(final String orderId) { super(String.format("Order %s not found", orderId)); this.orderId = orderId; } public String getOrderId() { return this.orderId; } }

3.异常翻译 当一个方法中使用 throws 子句声明了它所能抛出的异常之后,这些异常就成了这个方法的公开 API 的一部分。从抽象的层次来说,一个方法抛出的异常的抽象层次,应该与该方法的抽象层次互相匹配。举例来说,服务层的方法在实现中需要调用数据访问层的代码,数据访问层的代码可能抛出相应的异常,服务层的方法需要捕获这些异常,并翻译成服务层所对应的异常。这就需要用到上面提到的异常链。
在下面的代码中,MyDataAccess 类的 getData 方法会抛出 DataAccessException 异常。在 MyService 类的 service 方法中,DataAccessException 异常被捕获,并翻译成服务层的MyServiceException 异常。DataAccessException 异常对象则作为 MyServiceException 异常的原因。
@Service public class MyService { @Autowired MyDataAccess dataAccess; public void service() throws MyServiceException { try { this.dataAccess.getData(); } catch (final DataAccessException e) { throw new MyServiceException("Failed to get data", e); } } }

通过异常翻译,既可以保证异常的抽象层次,又可以保留错误产生的追踪信息。
4.检查异常与非检查异常 在设计 Java 的 API 时,一个常见的讨论是检查异常和非检查异常应该在什么时候使用。关于这一点,社区中有很多不同的观点,不同的开发团队也可能选择不同的策略。检查异常的特点在于它们的使用由编译器来强制保证。如果不使用 try-catch 来捕获异常,或是重新抛出异常,代码无法通过编译。
检查异常的这种特点,如果应用得当,会是代码调用者的一大助力,可以让调用者清楚地了解到可能出现的错误情况,并加以处理。而如果使用不当,则会让调用者感觉到很困扰。设计不好的检查异常可能会产生不好的使用模式。
一种常见的情况是,调用者除了忽略异常之外,没有别的处理方法。一个典型的例子是 Java 标准库中的 java.net.URLEncoder 类的 encode 方法。这个方法的一个参数是进行编码的字符集名称,而这个方法会抛出检查异常 UnsupportedEncodingException,也是因为可能找不到指定的字符集。实际上,绝大多数情况下都会使用 UTF-8 作为字符集,而 UTF-8 属于必须支持的字符集,因此这个 UnsupportedEncodingException 不可能出现。
对于这样的情况,一种做法是对原始方法进行封装,去掉检查异常,如下面的代码所示。另外一种做法是把原来的方法拆分成两个方法,其中一个方法用来判断是否可以进行编码,另外一个方法进行编码,但是不抛出检查异常。
public static String encode(final String input) { try { return URLEncoder.encode(input, "UTF-8"); } catch (final UnsupportedEncodingException ignored) { // 不会出现的情况 } return input; }

使用检查异常的目的是希望调用者可以从错误中恢复。比如,当从文件中读取系统的配置时,如果出现 IOException,则可以使用默认配置。非检查异常用来表示程序运行中的错误。由于非检查异常并不强制进行处理,在使用时会方便一些。比如,Integer.parseInt 会抛出非检查异常NumberFormatException。如果输入的字符串来自内部,则可以忽略对该异常的处理;如果来自外部的用户输入,则需要处理该异常。由于非检查异常的这种灵活性,一般的观点是认为应该优先使用非检查异常。在 Kotlin 中,所有的异常都是非检查的。检查异常的另外一个劣势在于不能与 Java 流 API 一同使用。
5.异常处理的原则 首先是不要忽略异常。使用 try-catch 捕获异常之后,不做任何处理是一种危险的做法,会造成意想不到的问题。如果确定异常不可能产生,应该在 catch 子句中添加注释来说明忽略该异常的理由,如上面代码中对 UnsupportedEncodingException 异常的处理。另外一种更加安全的做法是添加日志记录,至少可以保留异常的相关信息。
另外一个原则是避免多长的异常链。当异常链过长时,在输出堆栈信息或记录日志时,会占用过多的空间。在很多时候,异常链的根本原因就已经足够了。只需要通过 Throwable 的 getCause方法来遍历异常链,并找到作为根的 Throwable 对象即可。更简单的做法是使用 Guava 中Throwables 类的 getRootCause 方法。
使用 Sentry
Sentry 是开源的记录应用错误的服务。对于应用来说,既可以使用 Sentry.io 提供的在线服务,也可以在自己的服务器上部署。Sentry 提供了容器镜像,在 Kubernetes 上运行部署也很简单。
1.配置 如果使用 Sentry.io 提供的在线服务,在使用之前,首先需要注册账号和创建新的项目。在项目的配置界面中,可以找到客户端秘钥(DSN)。这个秘钥是 Sentry SDK 发送数据到服务器所必需的。复制该 DSN 的值,并以系统属性 sentry.dsn 或环境变量 SENTRY_DSN 传递给应用。
除了 DSN 之外,Sentry 还提供了很多配置项。这些配置项可以添加在 CLASSPATH 上的sentry.properties 文件中,也可以通过 Java 系统属性或环境变量来传递。所有的系统属性都以 sentry. 开头,而环境变量都以 SENTRY_ 开头。下表给出了常用的配置项。当需要提供配置项 environment 的值时,可以使用系统属性 sentry.environment 或环境变量SENTRY_ENVIRONMENT。
配置项 说明
release 应用的版本号
environment 当前的运行环境,如开发、测试、交付准备或生产环境
servername 服务器主机名
tags 附加的标签名值对,以 tag1:value1,tag2:value2 的形式传递
extra 附加的数据,以 key1:value1,key2:value2 的形式传递
stacktrace.app.packages 异常堆栈信息中,属于应用代码的包的名称
stacktrace.hidecommon 异常堆栈信息中,隐藏异常链中非应用代码的帧
uncaught.handler.enabled 处理并发送未捕获的异常
Sentry 的大部分配置项都与运行环境有关,因此不适合放在 sentry.properties 文件中,而是在运行时由底层平台来提供。
在使用 Sentry 之前,需要在 Java 应用的 main 方法中调用 Sentry.init 方法来进行初始化。
2.集成日志实现 Sentry 提供了对日志实现框架的集成。在记录日志时,通常都会记录下相关的异常对象。通过与 Sentry 的集成,日志事件中包含的异常会被自动发送到 Sentry。以 Log4j 2 来说,只需要配置使用 Sentry 的日志输出源即可,如下面的代码所示。

3.手动记录事件 除了与日志实现集成之外,还可以通过 Sentry 的 Java 客户端来手动记录事件。使用Sentry.capture 方法可以记录不同类型的事件,包括 String、Throwable 和 Sentry 的 Event对象。Event 是 Sentry 提供的事件 POJO 类,包含了事件所具备的属性,可以从 EventBuilder 构建器中创建。
在下面的代码中,通过 EventBuilder 创建出新的 Event 对象,并由 Sentry.capture 方法来记录。
Sentry.capture(new EventBuilder() .withMessage("Test message") .withLevel(Level.WARNING) .withTag("tag1", "value1") .withExtra("key1", "value1") );

除了直接使用 Event 对象之外,事件中包含的数据,还可以来自当前的上下文。默认情况下,Sentry 使用 ThreadLocal 来保存上下文对象,与当前的线程关联起来,这一点与日志实现中的MDC 是相同的。在发送事件到 Sentry 时,事件会自动包含当前上下文中的内容。
在下面的代码中,通过 Sentry.getContext 方法可以获取到当前的上下文对象,并对其中的数据进行修改。
Sentry.getContext().setUser( new UserBuilder().setUsername("test").setEmail("test@test.com").build() ); Sentry.getContext().addTag("tag1", "value1"); Sentry.getContext().addExtra("key1", "value1"); Sentry.capture("A new message");

Sentry 还支持收集一种名为面包屑(Breadcrumb)的数据,表示一些相关的事件。面包屑中可以包含下表中的属性。
属性 说明
message 事件的消息
data 以哈希表表示的元数据
category 事件的类别
level 事件的严重性级别
type 事件的类型
在下面的代码中,通过上下文对象的 recordBreadcrumb 方法可以记录 Breadcrumb 对象。
Sentry.getContext().recordBreadcrumb( new BreadcrumbBuilder() .setMessage("Event 123") .setData(ImmutableMap.of("key1", "value1")) .setCategory("test") .setLevel(Breadcrumb.Level.INFO) .setType(Type.DEFAULT) .build()); Sentry.capture("A message with breadcrumb");

4.用户界面 捕获的事件可以通过 Sentry 的界面来查看,下图给出了 Sentry 中查看问题列表的界面。
教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志
文章图片

对于每个问题,可以查看详细信息,如下图所示。
教程|云原生微服务架构实战精讲第九节 Prometheus/Grafana处理指标和Fluentd、Elasticsearch、 Kibana管理日志
文章图片

Sentry 界面所提供的功能很强大,可以帮助开发人员快速获取相关信息。
总结
通过记录 Java 应用运行中产生的异常,可以方便开发人员查找问题的根源。通过本课时的学习,你可以掌握 Java 中使用异常的基本知识和相关实践细节,包括检查异常和非检查异常的使用和异常处理的原则等,还可以了解到如何使用 Sentry 来记录异常和发布相关的事件。

    推荐阅读