Prometheus作為一套完整的開源監控接近方案,因為其諸多強大的特性以及生態的開放性,儼然已經成為了監控領域的事實標准並在全球范圍內得到了廣泛的部署應用。那么應該如何利用Prometheus對我們的應用形成有效的監控呢?事實上,作為應用我們僅僅需要以符合Prometheus標准的方式暴露監控數據即可,后續對於監控數據的采集,處理,保存都將由Prometheus自動完成。
一般來說,Prometheus監控對象有兩種:如果用戶對應用的代碼有定制化能力,Prometheus提供了各種語言的SDK,用戶能夠方便地將其集成至應用中,從而對應用的內部狀態進行有效監控並將數據以符合Prometheus標准的格式對外暴露;對於MySQL,Nginx等應用,一方面定制化代碼難度頗大,另一方面它們已經以某種格式對外暴露了監控數據,對於此類應用,我們需要一個中間組件,利用此類應用的接口獲取原始監控數據並轉化成符合Prometheus標准的格式對外暴露,此類中間組件,我們稱之為Exporter,社區中已經有大量現成的Exporter可以直接使用。
本文將以一個由Golang編寫的HTTP Server為例,借此說明如何利用Prometheus的Golang SDK,逐步添加各種監控指標並以標准的方式暴露應用的內部狀態。后續的內容將分為基礎、進階兩個部分,通常第一部分的內容就足以滿足大多數需求,但是若想要獲得更多的定制化能力,那么第二部分的內容可以提供很好的參考。
1. 基礎
Promethues提供的各種語言的SDK包其實已經非常完善了,如果希望某個Golang程序能夠被Prometheus監控,我們需要做的僅僅是引入client_golang
這個包並添加幾行代碼而已,代碼實例如下:
package main
import ( "net/http" "github.com/prometheus/client_golang/prometheus/promhttp" ) func main() { http.Handle("/metrics", promhttp.Handler()) http.ListenAndServe(":8080", nil) }
可以看到,上述代碼中,我們僅僅啟動了HTTP Server並將client_golang
提供的一個默認的HTTP Handler注冊到了路徑/metrics
上。我們可以試着運行該程序並對接口進行訪問,結果如下:
$ curl http://127.0.0.1:8080/metrics
...
# HELP go_goroutines Number of goroutines that currently exist. # TYPE go_goroutines gauge go_goroutines 7 # HELP go_info Information about the Go environment. # TYPE go_info gauge go_info{version="go1.12.1"} 1 # HELP go_memstats_alloc_bytes Number of bytes allocated and still in use. # TYPE go_memstats_alloc_bytes gauge go_memstats_alloc_bytes 418912 ...
Prometheus Golang SDK提供的默認Handler會自動注冊一系列用於監控Golang運行時以及應用進程相關信息的監控指標。所以,在我們未注冊任何自定義指標的情況下,依然暴露了一系列的指標。指標暴露的格式也非常統一,首行以# HELP
開頭用於說明該指標的用途,次行以# TYPE
開頭用於說明指標的類型,后續幾行則是指標的具體內容。對於Prometheus來說,只要提供抓取對象的地址以及訪問路徑(一般為/metrics
),它就能對如上所示的監控數據進行抓取。而對於用戶來說,則只需要自定義指標並按照程序的運行情況對相應的指標值進行修改即可。中間對於監控數據的聚合暴露,Prometheus的SDK會自動幫你處理。下面,我們將結合具體的需求試着添加各種類型的自定義監控指標。
對於一個HTTP Server來說,了解當前請求的接收速率是非常重要的。Prometheus支持一種稱為Counter的數據類型,這一類型本質上就是一個只能單調遞增的計數器。如果我們定義一個Counter表示累積接收的HTTP請求的數目,那么最近一段時間內該Counter的增長量其實就是接收速率。另外,Prometheus中定義了一種自定義查詢語句PromQL,能夠方便地對樣本的監控數據進行統計分析,包括對於Counter類型的數據求速率。因此,經過修改后的程序如下:
package main
import ( "net/http" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" ) var ( http_request_total = promauto.NewCounter( prometheus.CounterOpts{ Name: "http_request_total", Help: "The total number of processed http requests", }, ) ) func main() { http.HandleFunc("/", func(http.ResponseWriter, *http.Request){ http_request_total.Inc() }) http.Handle("/metrics", promhttp.Handler()) http.ListenAndServe(":8080", nil) }
我們利用promauto
包提供的NewCounter
方法定義了一個Counter類型的監控指標,只需要填充名字以及幫助信息,該指標就創建完成了。需要注意的是,Counter類型數據的名字要盡量以_total
作為后綴。否則當Prometheus與其他系統集成時,可能會出現指標無法識別的問題。每當有請求訪問根目錄時,該指標就會調用Inc()
方法加一,當然,我們也可以調用Add()
方法累加任意的非負數。
再次運行修改后的程序,先對根路徑進行多次訪問,再對/metrics
路徑進行訪問,可以看到新定義的指標已經成功暴露了:
$ curl http://127.0.0.1:8080/metrics | grep http_request_total # HELP http_request_total The total number of processed http requests # TYPE http_request_total counter http_request_total 5
監控累積的請求處理顯然還是不夠的,通常我們還想知道當前正在處理的請求的數量。Prometheus中的Gauge類型數據,與Counter不同,它既能增大也能變小。將正在處理的請求數量定義為Gauge類型是合適的。因此,我們新增的代碼塊如下:
...
var (
...
http_request_in_flight = promauto.NewGauge( prometheus.GaugeOpts{ Name: "http_request_in_flight", Help: "Current number of http requests in flight", }, ) ) ... http.HandleFunc("/", func(http.ResponseWriter, *http.Request){ http_request_in_flight.Inc() defer http_request_in_flight.Dec() http_request_total.Inc() }) ...
Gauge和Counter類型的數據操作起來的差別並不大,唯一的區別是Gauge支持Dec()
或者Sub()
方法減小指標的值。
對於一個網絡服務來說,能夠知道它的平均時延是重要的,不過很多時候我們更想知道響應時間的分布狀況。Prometheus中的Histogram類型就對此類需求提供了很好的支持。具體到需要新增的代碼如下:
...
var (
...
http_request_duration_seconds = promauto.NewHistogram( prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "Histogram of lantencies for HTTP requests", // Buckets: []float64{.1, .2, .4, 1, 3, 8, 20, 60, 120}, }, ) ) ... http.HandleFunc("/", func(http.ResponseWriter, *http.Request){ now := time.Now() http_request_in_flight.Inc() defer http_request_in_flight.Dec() http_request_total.Inc() time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) http_request_duration_seconds.Observe(time.Since(now).Seconds()) }) ...
在訪問了若干次上述HTTP Server的根路徑之后,從/metrics
路徑得到的響應如下:
# HELP http_request_duration_seconds Histogram of lantencies for HTTP requests # TYPE http_request_duration_seconds histogram http_request_duration_seconds_bucket{le="0.005"} 0 http_request_duration_seconds_bucket{le="0.01"} 0 http_request_duration_seconds_bucket{le="0.025"} 0 http_request_duration_seconds_bucket{le="0.05"} 0 http_request_duration_seconds_bucket{le="0.1"} 3 http_request_duration_seconds_bucket{le="0.25"} 3 http_request_duration_seconds_bucket{le="0.5"} 5 http_request_duration_seconds_bucket{le="1"} 8 http_request_duration_seconds_bucket{le="2.5"} 8 http_request_duration_seconds_bucket{le="5"} 8 http_request_duration_seconds_bucket{le="10"} 8 http_request_duration_seconds_bucket{le="+Inf"} 8 http_request_duration_seconds_sum 3.238809838 http_request_duration_seconds_count 8
Histogram類型暴露的監控數據要比Counter和Gauge復雜得多,最后以_sum
和_count
開頭的指標分別表示總的響應時間以及對於響應時間的計數。而它們之上的若干行表示:時延在0.005秒內的響應數目,0.01秒內的響應次數,0.025秒內的響應次數...最后的+Inf
表示響應時間無窮大的響應次數,它的值和_count
的值是相等的。顯然,Histogram類型的監控數據很好地呈現了數據的分布狀態。當然,Histogram默認的邊界設置,例如0.005,0.01這類數值一般是用來衡量一個網絡服務的時延的。對於具體的應用場景,我們也可以對它們進行自定義,類似於上述代碼中被注釋掉的那一行(最后的+Inf
會自動添加)。
與Histogram類似,Prometheus中定義了一種類型Summary,從另一個角度描繪了數據的分布狀況。對於響應時延,我們可能想知道它們的中位數是多少?九分位數又是多少?對於Summary類型數據的定義及使用如下:
...
var (
...
http_request_summary_seconds = promauto.NewSummary( prometheus.SummaryOpts{ Name: "http_request_summary_seconds", Help: "Summary of lantencies for HTTP requests", // Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001, 0.999, 0.0001}, }, ) ) ... http.HandleFunc("/", func(http.ResponseWriter, *http.Request){ now := time.Now() http_request_in_flight.Inc() defer http_request_in_flight.Dec() http_request_total.Inc() time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) http_request_duration_seconds.Observe(time.Since(now).Seconds()) http_request_summary_seconds.Observe(time.Since(now).Seconds()) }) ...
Summary的定義和使用與Histogram是類似的,最終我們得到的結果如下:
$ curl http://127.0.0.1:8080/metrics | grep http_request_summary
# HELP http_request_summary_seconds Summary of lantencies for HTTP requests # TYPE http_request_summary_seconds summary http_request_summary_seconds{quantile="0.5"} 0.31810446 http_request_summary_seconds{quantile="0.9"} 0.887116164 http_request_summary_seconds{quantile="0.99"} 0.887116164 http_request_summary_seconds_sum 3.2388269649999994 http_request_summary_seconds_count 8
同樣,_sum
和_count
分別表示請求的總時延以及請求的數目,與Histogram不同的是,Summary其余的部分分別表示,響應時間的中位數是0.31810446秒,九分位數位0.887116164等等。我們也可以根據具體的需求對Summary呈現的分位數進行自定義,如上述程序中被注釋的Objectives字段。令人疑惑的是,它是一個map類型,其中的key表示的是分位數,而value表示的則是誤差。例如,上述的0.31810446秒是分布在響應數據的0.45~0.55之間的,而並非完美地落在0.5。
事實上,上述的Counter,Gauge,Histogram,Summary就是Prometheus能夠支持的全部監控數據類型了(其實還有一種類型Untyped,表示未知類型)。一般使用最多的是Counter和Gauge這兩種基本類型,結合PromQL對基礎監控數據強大的分析處理能力,我們就能獲取極其豐富的監控信息。
不過,有的時候,我們可能希望從更多的特征維度去衡量一個指標。例如,對於接收到的HTTP請求的數目,我們可能希望知道具體到每個路徑接收到的請求數目。假設當前能夠訪問/
和/foo
目錄,顯然定義兩個不同的Counter,比如http_request_root_total和http_request_foo_total,並不是一個很好的方法。一方面擴展性比較差:如果定義更多的訪問路徑就需要創建更多新的監控指標,同時,我們定義的特征維度往往不止一個,可能我們想知道某個路徑且返回碼為XXX的請求數目是多少,這種方法就無能為力了;另一方面,PromQL也無法很好地對這些指標進行聚合分析。
Prometheus對於此類問題的方法是為指標的每個特征維度定義一個label,一個label本質上就是一組鍵值對。一個指標可以和多個label相關聯,而一個指標和一組具體的label可以唯一確定一條時間序列。對於上述分別統計每條路徑的請求數目的問題,標准的Prometheus的解決方法如下:
...
var (
http_request_total = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "http_request_total", Help: "The total number of processed http requests", }, []string{"path"}, ) ... http.HandleFunc("/", func(http.ResponseWriter, *http.Request){ ... http_request_total.WithLabelValues("root").Inc() ... }) http.HandleFunc("/foo", func(http.ResponseWriter, *http.Request){ ... http_request_total.WithLabelValues("foo").Inc() ... }) )
此處以Counter類型的數據舉例,對於其他另外三種數據類型的操作是完全相同的。此處我們在調用NewCounterVec
方法定義指標時,我們定義了一個名為path
的label,在/
和/foo
的Handler中,WithLabelValues
方法分別指定了label的值為root
和foo
,如果該值對應的時間序列不存在,則該方法會新建一個,之后的操作和普通的Counter指標沒有任何不同。而最終通過/metrics
暴露的結果如下:
$ curl http://127.0.0.1:8080/metrics | grep http_request_total
# HELP http_request_total The total number of processed http requests # TYPE http_request_total counter http_request_total{path="foo"} 9 http_request_total{path="root"} 5
可以看到,此時指標http_request_total
對應兩條時間序列,分別表示path為foo
和root
時的請求數目。那么如果我們反過來想統計,各個路徑的請求總和呢?我們是否需要定義個path的值為total
,用來表示總體的計數情況?顯然是不必的,PromQL能夠輕松地對一個指標的各個維度的數據進行聚合,通過如下語句查詢Prometheus就能獲得請求總和:
sum(http_request_total)
label在Prometheus中是一個簡單而強大的工具,理論上,Prometheus沒有限制一個指標能夠關聯的label的數目。但是,label的數目也並不是越多越好,因為每增加一個label,用戶在使用PromQL的時候就需要額外考慮一個label的配置。一般來說,我們要求添加了一個label之后,對於指標的求和以及求均值都是有意義的。
2. 進階
基於上文所描述的內容,我們就能很好地在自己的應用程序里面定義各種監控指標並且保證它能被Prometheus接收處理了。但是有的時候我們可能需要更強的定制化能力,盡管使用高度封裝的API確實很方便,不過它附加的一些東西可能不是我們想要的,比如默認的Handler提供的Golang運行時相關以及進程相關的一些監控指標。另外,當我們自己編寫Exporter的時候,該如何利用已有的組件,將應用原生的監控指標轉化為符合Prometheus標准的指標。為了解決上述問題,我們有必要對Prometheus SDK內部的實現機理了解地更為深刻一些。
在Prometheus SDK中,Register和Collector是兩個核心對象。Collector里面可以包含一個或者多個Metric,它事實上是一個Golang中的interface,提供如下兩個方法:
type Collector interface { Describe(chan<- *Desc) Collect(chan<- Metric) }
簡單地說,Describe方法通過channel能夠提供該Collector中每個Metric的描述信息,Collect方法則通過channel提供了其中每個Metric的具體數據。單單定義Collector還是不夠的,我們還需要將其注冊到某個Registry中,Registry會調用它的Describe方法保證新添加的Metric和之前已經存在的Metric並不沖突。而Registry則需要和具體的Handler相關聯,這樣當用戶訪問/metrics
路徑時,Handler中的Registry會調用已經注冊的各個Collector的Collect方法,獲取指標數據並返回。
在上文中,我們定義一個指標如此方便,根本原因是promauto
為我們做了大量的封裝,例如,對於我們使用的promauto.NewCounter
方法,其具體實現如下:
http_request_total = promauto.NewCounterVec(
prometheus.CounterOpts{ Name: "http_request_total", Help: "The total number of processed http requests", }, []string{"path"}, ) --- // client_golang/prometheus/promauto/auto.go func NewCounterVec(opts prometheus.CounterOpts, labelNames []string) *prometheus.CounterVec { c := prometheus.NewCounterVec(opts, labelNames) prometheus.MustRegister(c) return c } --- // client_golang/prometheus/counter.go func NewCounterVec(opts CounterOpts, labelNames []string) *CounterVec { desc := NewDesc( BuildFQName(opts.Namespace, opts.Subsystem, opts.Name), opts.Help, labelNames, opts.ConstLabels, ) return &CounterVec{ metricVec: newMetricVec(desc, func(lvs ...string) Metric { if len(lvs) != len(desc.variableLabels) { panic(makeInconsistentCardinalityError(desc.fqName, desc.variableLabels, lvs)) } result := &counter{desc: desc, labelPairs: makeLabelPairs(desc, lvs)} result.init(result) // Init self-collection. return result }), } }
一個Counter(或者CounterVec,即包含label的Counter)其實就是一個Collector的具體實現,它的Describe方法提供的描述信息,無非就是指標的名字,幫助信息以及定義的Label的名字。promauto
在對它完成定義之后,還調用prometheus.MustRegister(c)
進行了注冊。事實上,prometheus默認提供了一個Default Registry,prometheus.MustRegister
會將Collector直接注冊到Default Registry中。如果我們直接使用了promhttp.Handler()
來處理/metrics
路徑的請求,它會直接將Default Registry和Handler相關聯並且向Default Registry注冊Golang Collector和Process Collector。所以,假設我們不需要這些自動注入的監控指標,只要構造自己的Handler就可以。
當然,Registry和Collector也都是能自定義的,特別在編寫Exporter的時候,我們往往會將所有的指標定義在一個Collector中,根據訪問應用原生監控接口的結果對所需的指標進行填充並返回結果。基於上述對於Prometheus SDK的實現機制的理解,我們可以實現一個最簡單的Exporter框架如下所示:
package main
import ( "net/http" "math/rand" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) type Exporter struct { up *prometheus.Desc } func NewExporter() *Exporter { namespace := "exporter" up := prometheus.NewDesc(prometheus.BuildFQName(namespace, "", "up"), "If scrape target is healthy", nil, nil) return &Exporter{ up: up, } } func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { ch <- e.up } func (e *Exporter) Scrape() (up float64) { // Scrape raw monitoring data from target, may need to do some data format conversion here rand.Seed(time.Now().UnixNano()) return float64(rand.Intn(2)) } func (e *Exporter) Collect(ch chan<- prometheus.Metric) { up := e.Scrape() ch <- prometheus.MustNewConstMetric(e.up, prometheus.GaugeValue, up) } func main() { registry := prometheus.NewRegistry() exporter := NewExporter() registry.Register(exporter) http.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) http.ListenAndServe(":8080", nil) }
在這個Exporter的最簡實現中,我們創建了新的Registry,手動對exporter這個Collector完成了注冊並且基於這個Registry自己構建了一個Handler並且與/metrics
相關聯。在初始exporter的時候,我們僅僅需要調用NewDesc()
方法填充需要監控的指標的描述信息。當用戶訪問/metrics
路徑時,經過完整的調用鏈,最后在進行Collect的時候,我們才會對應用的原生監控接口進行訪問,獲取監控數據。在真實的Exporter實現中,該步驟應該在Scrape()
方法中完成。最后,根據返回的原生監控數據,利用MustNewConstMetric()
構造出我們所需的Metric,返回給channel即可。訪問該Exporter的/metrics
得到的結果如下:
$ curl http://127.0.0.1:8080/metrics
# HELP exporter_up If scrape target is healthy # TYPE exporter_up gauge exporter_up 1
3. 總結
經過本文的分析,可以發現,利用Prometheus SDK將應用程序進行簡單的二次開發,它就能被Prometheus有效地監控,從而享受整個Prometheus監控生態帶來的便利。同時,Prometheus SDK也提供了多層次的抽象,通常情況下,高度封裝的API就能快速地滿足我們的需求。至於更多的定制化需求,Prometheus SDK也有很多底層的,更為靈活的API可供使用。
本文中的示例代碼以及如何對應用進行Prometheus化二次開發和編寫Exporter的詳細規范,參見參考文獻中的相關內容。