Go中的Elasticsearch到Prometheus导出器

476 阅读6分钟

这篇文章描述了如何创建一个小型的Go HTTP服务器,它能够在Prometheus /metrics 端点上公开Elasticsearch的数据。

例如,如果你使用ELK堆栈收集网络应用程序的日志,在这种情况下,日志将被保存在Elasticsearch中,这可能是有用的。

一个例子是分析收集到的日志中返回的响应代码或单个请求的响应时间。

在这篇文章中,我假设读者至少对Elasticsearch和Prometheus有基本的了解,知道它们是什么,以及这些工具的用途,因为我不会对这些主题进行任何详细介绍。

在下面的示例代码中,我们将看看如何使用elasticsearch集群进行交互,以及如何使用官方的prometheus go客户端为prometheus暴露指标。

这个例子背后的想法是,我们在Elasticsearch上有一个索引some_logging_index ,里面有结构化的日志数据,包括日志来自的服务器environment ,请求的processing_time ,以及它们的status_code

我们的目标是将这些数据点提供给Prometheus,这样我们就可以分析数据和/或基于它创建警报。(例如,如果响应时间的第99个百分点超过了某个阈值)

这个例子不一定要用你自己的数据来复制和运行,因为这需要一些设置和ES及Prometheus的知识,而是要看看如何用Go来做这样的事情。

让我们开始吧!

实施

首先,我们为我们的结构化日志定义数据结构,如上所述。

type GatewayLog struct {
    Timestamp              string  `json:"@timestamp"`
    Env                    string  `json:"env"`
    StatusCode             int     `json:"backend_status_code"`
    BackendProcessingTime  float64 `json:"backend_processing_time"`
}

下一步是定义一些初始值,比如获取数据的更新时间间隔和Elasticsearch主机,以及使用elastic 来设置与Elasticsearch的连接。

func main() {
    ESHost := "http://127.0.0.1:9200"
    GatewayLogIndex := "some_logging_index" 
    UpdateIntervalEnv := 30 * time.Second 
    ctx := context.Background()
    log.Info("Connecting to ElasticSearch..")
    var client *elastic.Client
    for {
        esClient, err := elastic.NewClient(elastic.SetURL(ESHost), elastic.SetSniff(false))
        if err != nil {
            log.Errorf("Could not connect to ElasticSearch: %v\n", err)
            time.Sleep(1 * time.Second)
            continue
        }
        client = esClient
        break
    }

    info, _, err := client.Ping(ESHost).Do(ctx)
    if err != nil {
        log.Fatalf("Could not ping ElasticSearch %v", err)
    }
    log.Infof("Connected to ElasticSearch with version %s\n", info.Version.Number)

这里并没有发生什么--如果我们不能连接,我们就重试,否则我们就继续。

在准备好Elasticsearch的设置后,我们还需要初始化我们的Prometheus指标。

    statusCodeCollector := prometheus.NewCounterVec(prometheus.CounterOpts{
        Name: "gateway_status_code",
        Help: "Status Code of Gateway",
    }, []string{"env", "statuscode", "type"})

    responseTimeCollector := prometheus.NewSummaryVec(prometheus.SummaryOpts{
        Name: "gateway_response_time",
        Help: "Response Time of Gateway",
    }, []string{"env"})

    if err := prometheus.Register(statusCodeCollector); err != nil {
        log.Fatal(err, "could not register status code 500 collector")
    }
    if err := prometheus.Register(responseTimeCollector); err != nil {
        log.Fatal(err, "could not register response time collector")
    }

我们在这里使用两种不同的指标,CounterSummary 。毫不奇怪,计数器只是一个简单的数字指标,它把发生率往上数。这对请求的status_code ,因为它给我们提供了总体分布,以及从Prometheus查询计数器在一个时间段内的差异的可能性。我们通过environment ,以及type (如2xx,5xx...)来对status_code 进行分类。

第二个指标是Summary ,我们将把它用于response_time 。摘要是一种基于时间序列的方法,它自动把数值放入量化桶(默认:0,5 0,9和0,99)。这正是我们想要的,因为我们感兴趣的是查询例如响应时间的第95个百分点是否低于某个阈值。

在定义完prometheus指标后,我们注册它们,在/metrics ,并在我们的路由器上注册指标的端点,然后继续。

    r := chi.NewRouter()
    r.Use(render.SetContentType(render.ContentTypeJSON))
    r.Handle("/metrics", promhttp.Handler())
    log.Infof("ElasticSearch-Exporter started on localhost:8092")
    log.Fatal(http.ListenAndServe(":8092", r))
}

这个小程序的下一步,也是最复杂的一步,是实际从Elasticsearch获取数据,并将其添加到我们的度量中。

为了这个目的,我们将使用下面的函数。

func fetchDataFromElasticSearch(
    ctx context.Context,
    UpdateInterval time.Duration,
    GatewayLogIndex string,
    client *elastic.Client,
    statusCodeCollector *prometheus.CounterVec,
    responseTimeCollector *prometheus.SummaryVec,
) {
    ticker := time.NewTicker(UpdateInterval)
    go func() {
        for range ticker.C {
            now := time.Now()
            lastUpdate := now.Add(-UpdateInterval)

            rangeQuery := elastic.NewRangeQuery("@timestamp").
                Gte(lastUpdate).
                Lte(now)

            log.Info("Fetching from ElasticSearch...")
            scroll := client.Scroll(GatewayLogIndex).
                Query(rangeQuery).
                Size(5000)

            scrollIdx := 0
            for {
                res, err := scroll.Do(ctx)
                if err == io.EOF {
                    break
                }
                if err != nil {
                    log.Errorf("Error while fetching from ElasticSearch: %v", err)
                    break
                }
                scrollIdx++
                log.Infof("Query Executed, Hits: %d TookInMillis: %d ScrollIdx: %d", res.TotalHits(), res.TookInMillis, scrollIdx)
                var typ GatewayLog
                for _, item := range res.Each(reflect.TypeOf(typ)) {
                    if l, ok := item.(GatewayLog); ok {
                        handleLogResult(l, statusCodeCollector, responseTimeCollector)
                    }
                }
            }
        }
    }()
}

好吧,所以它毕竟没有那么复杂。我们创建一个定时器,使用time.NewTicker ,在给定的时间间隔内滴答。然后我们用Go的漂亮的range 语法对这个定时器进行迭代。

对于每一个滴答,我们计算我们最后一次更新数据的时间,并创建一个Elasticsearch查询,从而提供我们从那个时间点到现在的日志数据。请记住,在执行这个过程中,我们有可能会丢掉一些日志,尽管这只是几毫秒。这是这个实现的一个小的权衡,它比试图跟踪每一个日志条目更简单。

我们在这里对大量的数据进行分析,所以在这里或那里丢掉几个日志可能不会有太大的区别,但是记住这一点还是很好的。

因为在生产环境中,这个查询可以返回相当多的数据,所以我们必须通过数据scroll 。在这种情况下,我们每次滚动5000个条目,并记录每批数据的进度。

现在有趣的部分来了。我们对数据进行迭代,但只使用GatewayLog 类型的结果--即具有上述定义字段的数据,并为每条日志调用handleLogResult

func handleLogResult(l GatewayLog, statusCodeCollector *prometheus.CounterVec, responseTimeCollector *prometheus.SummaryVec) {
    responseTimeCollector.WithLabelValues(l.Env).Observe(l.BackendProcessingTime)
    trackStatusCodes(statusCodeCollector, l.StatusCode, l.Env)
}

这就是它的全部内容。对于response_time ,我们用给定的BackendProcessingTime ,调用Observe ,将数据放入我们的摘要。对于status_codes ,我们必须做得更多。

func trackStatusCodes(statusCodeCollector *prometheus.CounterVec, statusCode int, env string) {
    if statusCode >= 500 && statusCode <= 599 {
        statusCodeCollector.WithLabelValues(env, strconv.Itoa(statusCode), "500").Inc()
    } else if statusCode >= 200 && statusCode <= 299 {
        statusCodeCollector.WithLabelValues(env, strconv.Itoa(statusCode), "200").Inc()
    } else if statusCode >= 300 && statusCode <= 399 {
        statusCodeCollector.WithLabelValues(env, strconv.Itoa(statusCode), "300").Inc()
    } else if statusCode >= 400 && statusCode <= 499 {
        statusCodeCollector.WithLabelValues(env, strconv.Itoa(statusCode), "400").Inc()
    }
}

在这里,我们将status_code 归入HTTP statusCode类别(5xx,4xx...),并为每条日志调用.Inc() ,增加计数器。我们还用environment 、实际状态码和状态码的type 来标记条目,这使我们能够查询来自特定服务器的5xx错误,例如。

这就是了。这里有一个完整代码的链接

总结

难怪Go在Ops人群中拥有这么多粉丝。有了无缝交叉编译和Go的简单性,创建像这样的小工具来简化和改善你的监控和操作工具链是非常令人高兴的。

使用的库,elastic 和 prometheus 客户端都有很好的 API 和很棒的文档 - 我完全没有问题。

我希望这篇文章是有用的,即使它本身不是一个真正的可运行的例子,需要一些先前的知识,但最近建立了类似的东西,我想这是一个好主意,与大家分享。)

资源