Skip to content

19 应用监控:如何使用埋点方式对应用监控?

你好,我是秦晓辉。

前面几讲我们主要是介绍了常见的中间件、数据库的监控,统称为组件监控,根据 第 9 讲 中我们对监控做的分类,还有应用和业务层面的监控没有介绍,接下来我会用两讲来介绍这部分内容。

💡 因为业务指标的生成也需要应用程序侧来实现,所以这两个层面的监控可以统称为应用监控。

在指标监控的世界里,应用和业务层面的监控有两种典型手段,一种是在应用程序里埋点,另一种是分析日志,从日志中提取指标。埋点的方式性能更好,也更灵活,只是对应用程序有一定侵入性,而分析日志的话对应用程序侵入性较小,但由于链路较长、需要做文本分析处理,性能较差,需要更多算力支持。这一讲我们先来介绍第一种方式,使用埋点的方式做应用和业务监控。

埋点方式介绍

所谓的埋点,就是指应用程序内嵌了埋点的 SDK(一个 lib 库),然后在一些关键代码流程里调用 SDK 提供的方法,记录下各种关键指标。比如某个 HTTP 服务,提供了 10 个接口,每个接口的处理花费了多少毫秒,就可以作为指标记录下来。

你可能会疑惑,我的监控系统已经提供了 PUSH 接口了,比如 Prometheus 的 Pushgateway 组件,在应用程序里直接调用 Pushgateway 接口推数据不就行了吗?为什么还需要 SDK 呢?

核心原因是 SDK 可以帮我们封装一些通用的计算逻辑。比如有个指标是 Summary 类型( 第 2 讲 介绍过这个数据类型),可以提供某个接口99分位的延迟数据。如果没有 SDK,我们需要怎么做呢?每当这个接口响应一次请求,就记录一个延迟时间,然后存入一个内存数据结构,等到一个时间间隔,比如10秒钟,就把这段时间内所有的延迟数据排个序,然后取99分位的值,最后调用 Pushgateway 的接口推过去。很麻烦是不是?

我们需要准备这个数据结构,这个数据结构还需要线程安全。如果请求非常频繁,耗时数据很多,还得进行限制,比如这个数据结构只保存1000个耗时数据。然后到了时间间隔之后需要排序,还要组织成监控系统需要的数据格式,还需要调用 HTTP 接口推送数据。

而 SDK 就是做这些通用逻辑的,应用程序要做的,就是拿到延迟数据之后,调用 SDK 的方法告知 SDK,说又有一次新的接口调用,延迟数据是多少,哥们后续就交给你了,SDK 就能完成剩余所有的事情。

业界比较知名的跨语言埋点工具是 StatsDPrometheus。当然,有些语言有自己生态的常用工具,比如 Java 生态的 Micrometer,不过一般公司都会使用多种语言,这些跨语言的埋点方案通常使用频率会更高。所以,这一讲我会分别介绍这两个方式,让你有个全面的认识,我们先从 StatsD 开始。

StatsD

StatsD 开始被熟知是因为 《Measure Anything, Measure Everything》 这篇文章,Etsy 的工程师使用 NodeJS 开源了这个 项目

StatsD 有个很大的特点是使用 UDP 传输协议,大部分计算逻辑都挪到了 StatsD 的 Server,SDK 层面的工作非常轻量。

StatsD SDK 与 StatsD Server 之间通信使用的是UDP 协议,UDP 协议是 fire-and-forget,无需建立连接,即使 StatsD Server 挂了,也不影响应用程序,而对于延迟分布区间这样的计算逻辑,是在 StatsD Server 里计算的,也不会影响应用程序,所以整个 StatsD 的设计是非常轻量的,对应用程序基本没有影响。

由于 StatsD 相当知名,所以很多采集器都实现了 StatsD 的协议,比如 Telegraf、Datadog-agent,也就是说,图上的 StatsD Server 是可以换成 Telegraf 或 Datadog-agent 的。这样就不用部署太多进程,一个采集器包打天下,就拿 Telegraf 来说吧,替换后架构就变成了这样。

下面我来演示一下,用一个小程序做个埋点,让你有个感性的认识。我们就把 Telegraf 当做 StatsD Server 来接收埋点数据,Telegraf 是 InfluxData 主导开源的监控采集器,我之前做过一次调研,调研笔记我也放在 这里,供你参考。

Telegraf 提供 StatsD 的 input 插件,在配置文件中搜索 inputs.statsd 就能找到相关的配置段了,里边有详尽的注释,我就不再赘述了,直接上配置样例。

[[inputs.statsd]]
protocol = "udp"
service_address = ":8125"
percentiles = [50.0, 90.0, 99.0, 99.9, 99.95, 100.0]
metric_separator = "_"

Telegraf重启之后就会在 8125 开启 UDP 监听,我们写的程序就可以往这个地址推送监控数据了。因为我对 Go 语言比较熟悉,所以这里我给你提供一个 Go 埋点的例子。

package main
import (
    "fmt"
    "math/rand"
    "net/http"
    "time"
    "github.com/smira/go-statsd"
)
var client *statsd.Client
func homeHandler(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    // random sleep
    num := rand.Int31n(100)
    time.Sleep(time.Duration(num) * time.Millisecond)
    fmt.Fprintf(w, "duration: %d", num)
    client.Incr("requests.counter,page=home", 1)
    client.PrecisionTiming("requests.latency,page=home", time.Since(start))
}
func main() {
    // init client
    client = statsd.NewClient("localhost:8125",
        statsd.TagStyle(statsd.TagFormatInfluxDB),
        statsd.MaxPacketSize(1400),
        statsd.MetricPrefix("http."),
        statsd.DefaultTags(statsd.StringTag("service", "n9e-webapi"), statsd.StringTag("region", "bj")),
    )
    defer client.Close()
    http.HandleFunc("/", homeHandler)
    http.ListenAndServe(":8000", nil)
}

这个 Web 服务只有一个根路径,逻辑也很简单,就是随机 sleep 几十个毫秒当做业务处理时间。整体逻辑是这样的:首先,我们要通过 statsd.NewClient 初始化一个 StatsD 的客户端,参数中指定了 StatsD 的 Server 地址(在这个例子里就是 Telegraf 的 8125),指定了所有监控指标的前缀是 http.,还指定了两个全局 Tag,一个是 service=n9e-webapi,另一个是 region=bj。通过 TagStyle 指定要发送的是 InfluxDB 样式的标签。

然后,在请求的具体处理逻辑里上报了两个监控指标,一个是 requests.counter,另一个是 requests.latency,并为它们指定了一个指标级别的标签 page=home,整体看起来还是比较简单的。

Telegraf 支持多种 output 插件,可以使用比较快捷的 outputs.file 插件,把生成的指标写到 stdout,来验证效果。

[[outputs.file]]
files = ["stdout"]

请求数量和请求延时都是典型的应用层面的监控指标,如果我们想要做业务指标监控,比如订单量监控,应该怎么做呢?其实逻辑是完全一样的,仿照 requests.counter 的写法,做一个订单量的指标,每次创建一个新订单的时候,就给订单量指标 +1。一般一个服务会部署多个实例,每个实例都统计了自己的订单量,要计算最近一分钟的订单数量的话,只需要在服务端使用 PromQL 做二次汇总计算。

StatsD 的埋点方式我们就介绍这么多,下面我们介绍一下 Prometheus 的埋点方式。

Prometheus

Prometheus 的埋点方式跟 StatsD 很像,对于请求数量和延迟这样的监控指标,也是在请求处理完成之后,调用 SDK 的方法进行记录的。不过,如果每个方法都要加这么几行代码就显得太冗余了,最好还是通过 AOP 的方式做一些切面逻辑,Nightingale 的 Webapi 模块就是这么干的,我直接用这个 代码 给你讲解。

Webapi 的职能是提供一系列 HTTP 接口给 JavaScript 调用,我们需要监控这些接口的调用量、成功率、延迟数据。埋点之前我们先规划一下标签,我们给每个 HTTP 接口规划 4 个标签。

  • service:服务名称,要求全局唯一,可以和其他服务区分开。
  • code:HTTP 返回状态码,可以根据这个信息得知 4xx 的比例是多少,5xx 的比例是多少,计算成功率。
  • path:接口路径,比如 /api/v1/users ,有时候我们会在接口路径中放置 URL 参数,比如 /api/v1/user/23/api/v1/user/12 是请求 id 为 23 和 12 的用户信息。这个时候不能直接把这个 URL 作为接口路径的标签值,否则这个指标颗粒度就太细了,应该把接口路径的标签值设置成 /api/v1/user/:id
  • method:HTTP 方法,GET、POST、DELETE、PUT 等。

Prometheus 埋点需要提前把指标变量定义出来,我们使用一个单独的包来放置,你可以看一下 相关代码

package stat

import (
    "time"

    "github.com/prometheus/client_golang/prometheus"
)

const Service = "n9e-webapi"

var (
    labels = []string{"service", "code", "path", "method"}

    uptime = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "uptime",
            Help: "HTTP service uptime.",
        }, []string{"service"},
    )

    RequestCounter = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_request_count_total",
            Help: "Total number of HTTP requests made.",
        }, labels,
    )

    RequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Buckets: []float64{.01, .1, 1, 10},
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request latencies in seconds.",
        }, labels,
    )
)

func Init() {
    // Register the summary and the histogram with Prometheus's default registry.
    prometheus.MustRegister(
        uptime,
        RequestCounter,
        RequestDuration,
    )

    go recordUptime()
}

// recordUptime increases service uptime per second.
func recordUptime() {
    for range time.Tick(time.Second) {
        uptime.WithLabelValues(Service).Inc()
    }
}

uptime 变量是顺手为之,统计进程启动了多长时间,不用太关注,RequestCounter 和 RequestDuration,分别统计请求流量和请求延迟。Init 方法是在 Webapi 模块进程初始化的时候调用,所以进程一起,就会自动注册好。

然后我们写一个 middleware,在请求进来的时候拦截一下,省得每个请求都要去统计,你可以看一下 middleware 方法的 代码

func stat() gin.HandlerFunc {
        return func(c *gin.Context) {
            start := time.Now()
            c.Next()

            code := fmt.Sprintf("%d", c.Writer.Status())
            method := c.Request.Method
            labels := []string{promstat.Service, code, c.FullPath(), method}

            promstat.RequestCounter.WithLabelValues(labels...).Inc()
            promstat.RequestDuration.WithLabelValues(labels...).Observe(float64(time.Since(start).Seconds()))
        }
    }

有了这个 middleware 之后,new 出 gin 的 engine 的时候,就立马 Use 一下。

...
r := gin.New()
r.Use(stat())
...

最后,监控数据要通过/metrics接口暴露出去,我们要暴露这个请求端点。

import (
    ...
    "github.com/prometheus/client_golang/prometheus/promhttp"
)
func configRoute(r *gin.Engine, version string) {
    ...
    r.GET("/metrics", gin.WrapH(promhttp.Handler()))
}

这样,每个 Webapi 接口的流量和成功率都可以监控到了。如果你也部署了 Nightingale,请求 Webapi(默认是18000)的 /metrics 接口看看返回的内容吧。

无论是StatsD,还是使用Prometheus,总之通过埋点我们采集到了监控指标。接下来这些数据如何进入监控服务端呢?我们看一下这个通路典型的构建方式。

数据传输通路

使用 StatsD 的埋点方式,数据通过 UDP 推给 Telegraf,Telegraf 推给后端监控系统。如果是通过 Prometheus 的方式来埋点,就是暴露 /metrics 接口,等待监控系统来拉。如果应用是部署在物理机或虚拟机上,直接通过本地的监控 agent 来拉取即可。如果应用是部署在 Kubernetes 的 Pod 里,则有两种办法来拉取数据,一个是 Sidecar 模式,一个是中心端服务发现的模式。下面这个示意图展示的是 Sidecar 模式。

左侧 Pod1 里有两个容器,App 通过 StatsD 埋点,然后通过 UDP 推给 Telegraf,Telegraf 接收到数据之后做二次计算,把结果推给监控服务端;右侧 Pod2 里也有两个容器,App 通过 Prometheus SDK 埋点,暴露 /metrics 接口,Categraf 通过这个接口拉取数据,然后推给监控服务端。

这种方式的优点是比较灵活,Pod 内怎么做应用自己说了算。即使给 /metrics 接口增加一些认证鉴权、指标过滤、扩展标签的逻辑,都不影响其他的 Pod。数据是推给监控服务端的,监控服务端接收数据的组件可以做成无状态集群,前面架设负载均衡,整个架构非常简单、扩展性也很好。当然缺点也很明显,每个 Pod 里都伴生 Sidecar agent,浪费资源。

StatsD 埋点的方式只能使用 Sidecar模式传输数据,而Prometheus SDK 埋点还有第二种数据抓取方式:中心端服务发现模式。

图片

每个应用容器都统一暴露 /metrics 接口,监控抓取器通过 Kubernetes 服务发现机制,找到所有的 Pod,分别抓取即可。不过,并不是所有的 Pod 都暴露指标数据,即使暴露了,接口路径也未必一定是 /metrics,这就需要有一个机制和规范,通过这个机制告诉指标抓取器,选择哪个类型的 Pod 抓数据,抓数据的时候使用哪个端口、哪个接口路径来抓。

一般我们使用 Pod 注解来承接这个规范,比如我们制定标准:所有想要被抓取监控数据的 Pod 都统一暴露如下注解。

prometheus.io/scrape=true
prometheus.io/metric_path=<path>
prometheus.io/scrape_port=<port>

然后我们就可以写抓取 Job 来抓取这类数据了,下面我给出一个样例。

- job_name: "kubernetes-pods"
  kubernetes_sd_configs:
    - role: pod
  relabel_configs:
    # "prometheus.io/scrape = true" annotation.
    - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
      action: keep
      regex: true
    # "prometheus.io/metric_path = <metric path>" annotation.
    - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_metric_path]
      action: replace
      target_label: __metrics_path__
      regex: (.+)
    # "prometheus.io/scrape_port = <port>" annotation.
    - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_scrape_port]
      action: replace
      regex: ([^:]+)(?::\d+)?;(\d+)
      replacement: $1:$2
      target_label: __address__
    - action: labelmap
      regex: __meta_kubernetes_pod_label_(.+)
    - source_labels: [__meta_kubernetes_namespace]
      action: replace
      target_label: namespace
    - source_labels: [__meta_kubernetes_pod_name]
      action: replace
      target_label: pod

我们分析一下中心端服务发现机制的优缺点。优点很明显,不需要 sidecar 的 agent 了,大幅节省资源,缺点是所有的抓取规则都是统一的,不好为每个 Pod 单独指定一些认证机制、过滤规则等。不是说不能干,而是要做的话就得修改中心端这个统一的抓取规则,非常不方便,稍有不慎还容易改错,影响到别的Pod。

当然了,一般来说不提供这个灵活性也不是太大的问题,大家遵照一个统一的规范也挺好的,如果真的有某个 Pod 需要额外做一些灵活配置,大不了就不开那些注解,仍使用 sidecar 模式就好。这两种方式并不一定要二选一,同时并用也没有任何问题。

到这里,埋点监控的两种手段就都介绍完了,下面我们做一个小结。

小结

这一讲我们介绍了两种埋点监控应用的方式,一个是 StatsD,一个是 Prometheus,这两种方式都是跨语言的埋点方案,业界应用广泛。StatsD 是推模式,采用 UDP 协议,各类计算逻辑挪到了 StatsD Server 中,对应用本身不会造成影响。Prometheus 是拉模式,SDK 的逻辑跑在应用进程里,可能对应用造成一定影响,不过已经有很多公司做了生产验证,Prometheus 的 SDK 还是很稳定的,大可放心使用。

对于 Prometheus SDK 的埋点方案,有两种数据抓取方式,一个是 Sidecar 模式,灵活但是占用资源多;一个是中心端服务发现模式,不那么灵活但是资源占用得少,技术上没有非黑即白,建议你根据具体场景灵活选择。

互动时刻

对于 Prometheus SDK 埋点机制,在使用中心端抓取方式的时候,如果目标 Pod 量很大,单个抓取器就抓不过来了,这个时候就需要有个分片机制,用多个抓取器来抓,每个抓取器抓一部分指标。我们应该按照哪些维度做分片呢?具体配置样例是什么样子的?欢迎你留言分享,也欢迎你把今天的内容分享给你身边的朋友,邀他一起学习。我们下一讲再见!