Prometheus作為時下最為流行的開源監控系統,其龐大的生態體系:包括針對各種傳統應用的Exporter,完整的二次開發工具鏈,與Kubernetes等主流平台的高度親和以及由此帶來的強大的自發現能力,使得我們通過簡單的配置就能獲取大量的監控指標且包含的維度及其豐富。一方面,如此多樣的指標極大地提高了集群的可觀測性,配合Grafana等Dashboard就能讓我們實時了解集群各個維度的狀態;另一方面,基於監控數據進行實時地告警也是在可觀測性得到滿足之后必然要實現的需求。當然,Prometheus社區已經很好地解決了這個問題,本文也將對Prometheus的告警模型進行詳細的敘述。
1. 概述
如果對Prometheus項目有所了解的話,可以發現,Prometheus一個非常重要的原則就是盡量讓設計保持簡潔並且用簡潔的設計滿足絕大多數場景的需求。同時讓項目保持良好的擴展性,針對極端場景,可以拼接Prometheus生態的一些外圍組件來對已有能力進行增強,從而滿足要求。對於告警也是類似的,基於Prometheus的告警系統的整體架構下圖所示:
告警系統整體被解耦為兩部分:
- Prometheus Server會讀取一系列的告警規則並基於采集的監控數據定期對這些規則進行評估,一旦滿足觸發條件就會生成相應的告警實例發送至AlertManager
- AlertManager是一個獨立於Prometheus Server運行的HTTP Server,它負責接受來自Client端的告警實例並對這些實例進行聚合(aggregation),靜默(silence),抑制(inhibit)等高級操作並且支持Email,Slack等多種通知平台對告警進行通知。對於AlertManager來說,它並不在乎告警實例是否是由Prometheus Server發出的,因此我們只要構造出符合要求的告警實例並發送至Alertmanager,它就能無差別地進行處理。
2. Alert Rules
通常來說,Prometheus的告警規則都會以文件的形式保存在磁盤中,我們需要在配置文件中指定這些規則文件的位置供Prometheus Server啟動時讀取:
rule_files:
- /etc/prometheus/rules/*.yaml
一般一個規則文件的內容如下:
groups:
- name: example rules: - alert: HighRequestLoad expr: rate(http_request_total{pod="p1"}[5m]) > 1000 for: 1m labels: severity: warning annotations: info: High Request Load
在一個規則文件中可以指定若干個group,每個group內可以指定多條告警規則。一般來說,一個group中的告警規則之間會存在某種邏輯上的聯系,但即使它們毫無關聯,對后續的流程也不會有任何影響。而一條告警規則中包含的字段及其含義如下:
alert
: 告警名稱expr
: 告警的觸發條件,本質上是一條promQL查詢表達式,Prometheus Server會定期(一般為15s)對該表達式進行查詢,若能夠得到相應的時間序列,則告警被觸發for
: 告警持續觸發的時間,因為數據可能存在毛刺,Prometheus並不會因為在expr
第一次滿足的時候就生成告警實例發送到AlertManager。比如上面的例子意為名為"p1"的Pod,每秒接受的HTTP請求的數目超過1000時觸發告警且持續時間為一分鍾,若告警規則每15s評估一次,則表示只有在連續四次評估該Pod的負載都超過1000QPS的情況下,才會真正生成告警實例。labels
: 用於附加到告警實例中的標簽,Prometheus會將此處的標簽和評估expr
得到的時間序列的標簽進行合並作為告警實例的標簽。告警實例中的標簽構成了該實例的唯一標識。事實上,告警名稱最后也會包含在告警實例的label中,且key為"alertname"。annotations
: 用於附加到告警實例中的額外信息,Prometheus會將此處的annotations作為告警實例的annotations,一般annotations用於指定告警詳情等較為次要的信息
需要注意的是,一條告警規則並不只會生成一類告警實例,例如對於上面的例子,可能有如下多條時間序列滿足告警的觸發條件,即n1和n2這兩個namespace下名為p1的pod的QPS都持續超過了1000:
http_request_total{namespace="n1", pod="p1"}
http_request_total{namespace="n2", pod="p1"}
最終生成的兩類告警實例為:
# 此處只顯示實例的label
{alertname="HighRequestLoad", severity="warning", namespace="n1", pod="p1"}
{alertname="HighRequestLoad", severity="warning", namespace="n2", pod="p1"}
因此,例如在K8S場景下,由於Pod具有易失性,我們完全可以利用強大的promQL語句,定義一條Deployment層面的告警,只要其中任何的Pod滿足觸發條件,都會產生對應的告警實例。
3. 在Kubernetes下操作Alert Rules
初一看,Prometheus這種將所有告警規則一股腦寫入文件中的方式貌似很簡單,事實上,這的確簡化了Prometheus本身的設計實現難度。但是,真正在生產環境中,尤其是當把Prometheus Server以Pod的形式部署在Kubernetes集群中時,對告警規則的增刪改差操作將變得異常繁瑣。特別地,在Kubernetes環境中,顯然我們只能將若干告警規則文件包含在ConfigMap中並掛載到Prometheus所在Pod的指定目錄中,如果要進行增刪改操作,最直觀的方法就是整體加載該ConfigMap並在修改后重新寫入。
所幸,對此社區早已准備了一套完整的解決方案。我們知道,在Kubernetes體系下,管理復雜有狀態應用最常用的方式就是為其編寫一個專門的Operator。Prometheus Operator作為社區最早實現的Operator之一,大大簡化了Prometheus的配置部署流程。Prometheus Operator將Prometheus相關的概念都抽象為了CRD。與本文相關的主要是Prometheus
和PrometheusRule
這兩個CRD。
apiVersion: monitoring.coreos.com/v1 kind: Prometheus metadata: name: prometheus spec: ruleSelector: matchLabels: role: alert-rules --- apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: labels: role: alert-rules name: spec: groups: - name: example rules: - alert: HighRequestLoad expr: rate(http_request_total{pod="p1"}[5m]) > 1000 for: 1m labels: severity: none annotations: info: High Request Load
上面展示的就是近乎最簡的Prometheus
和PrometheusRule
資源對象。當上述yaml文件被提交至Kubernetes APIServer之后,Prometheus Operator會馬上同步到並根據Prometheus
的配置生成一個StatefulSet用於運行Prometheus Server實例,同時將Prometheus
中的配置寫入Server的配置文件中。對於PrometheusRule
,我們可以發現它的內容與上面的告警規則文件是基本一致的。Prometheus Operator會依據PrometheusRule
的內容生成相應的ConfigMap並將其以Volume的形式掛載到Prometheus Server所在Pod的對應目錄中。最終一個PrometheusRule
資源對象對應一個掛載目錄中的告警規則文件。
那么Operator是如何將Prometheus
和PrometheusRule
關聯在一起的呢?類似於Service通過Selector字段指定關聯的Pod。Prometheus
也通過ruleSelector字段指定了一組label,Operator會將任何包含這些label的PrometheusRule
都整合到一個ConfigMap(若超出單個ConfigMap的限制,則生成多個)並掛載到Prometheus
對應的StatefulSet的各個Pod實例中。因此,在Prometheus Operator的幫助下,對於Prometheus告警規則進行增刪改查的難度已經退化到對Kubernetes資源對象的CRUD操作,整個過程中最為繁瑣的部分已經完全被Operator自動化了。事實上,Prometheus Server的高級配置乃至AlertManager的部署都可以通過Prometheus Operator提供的CRD輕松實現,因為與本文關聯不大,所以不再贅述了。
最后,雖然Operator能保證對於PrometheusRule
的增刪改查能及時反映到相應的ConfigMap中,而Kubernetes本身則保證了ConfigMap的修改也最終能同步到相應Pod的掛載文件中,但是Prometheus Server並不會監聽告警規則文件的變更。因此,我們需要以Sidecar的形式將ConfigMap Reloader部署在Prometheus Server所在的Pod內。由它來監聽告警規則所在ConfigMap的變更,一旦監聽到變化,它就會調用Prometheus Server提供的Reload接口,觸發Prometheus對於配置的重新加載。
4. 告警實例結構
AlertManager本質上是一個HTTP Server用於接受並處理來自Client的告警實例。Client一般都為Prometheus Server,但是任何程序只要能構造出符合標准的告警實例,都能通過POST方法將它們提交至AlertManger進行處理。因此,在生產環境中,對於無法利用Prometheus時序數據生成的告警,例如對於Kubernetes中的Event,我們也可以通過適當的構造,將其發送至AlertManager進行統一處理。告警實例的結構如下:
[
{
"labels": { "alertname": "<requiredAlertName>", "<labelname>": "<labelvalue>", ... }, "annotations": { "<labelname>": "<labelvalue>", }, "startsAt": "<rfc3339>", "endsAt": "<rfc3339>", "generatorURL": "<generator_url>" }, ... ]
labels和annotations字段在前文已經有所提及:labels用於唯一標識一個告警,AlertManger會對labels完全相同的告警實例進行壓縮聚合操作。annotations是一些類似於告警詳情等的附加信息。這里我們重點關注startsAt
和endsAt
這兩個字段,這兩個字段分別表示告警的起始時間和終止時間,不過兩個字段都是可選的。當AlertManager收到告警實例之后,會分以下幾類情況對這兩個字段進行處理:
- 兩者都存在:不做處理
- 兩者都為指定:startsAt指定為當前時間,endsAt為當前時間加上告警持續時間,默認為5分鍾
- 只指定startsAt:endsAt指定為當前時間加上默認的告警持續時間
- 只指定endsAt:將startsAt設置為endsAt
AlertManager一般以當前時間和告警實例的endsAt字段進行比較用以判斷告警的狀態:
- 若當前時間位於endsAt之前,則表示告警仍然處於觸發狀態(firing)
- 若當前時間位於endsAt之后,則表示告警已經消除(resolved)
另外,當Prometheus Server中配置的告警規則被持續滿足時,默認會每隔一分鍾發送一個告警實例。顯然,這些實例除了startsAt和endsAt字段以外都完全相同(其實Prometheus Server會將所有實例的startsAt設置為告警第一次被觸發的時間)。最終,這些實例都會以如下圖所示的方式進行壓縮去重:
三條最終labels相同的告警最終被壓縮聚合為一條告警。當我們進行查詢時,只會得到一條起始時間為t1,結束時間為t4的告警實例。
5. AlertManager架構概述
AlertManager本質上來說是一個強大的告警分發過濾器。所有告警統一存放在Alert Provider中,Dispatcher則會對其中的告警進行訂閱。每當AlertManager接受到新的告警實例就會先在Alert Provider進行存儲,之后立刻轉發到Dispatcher中。Dispatcher則定義了一系列的路由規則將告警發送到預定的接收者。而告警在真正發送到接收者之前,還需要經過一系列的處理,即圖中的Notification Pipeline,例如對相關告警在時間和空間維度進行聚合,對用戶指定的告警進行靜默,檢測當前告警是否被已經發送的告警抑制,甚至在高可用模式下檢測該告警是否已由集群中的其他節點發送。而這一切的操作的最終目的,都為了讓能讓接收者准確接受到其最關心的告警信息,同時避免告警的冗余重復。
6. Alert Provider
所有進入AlertManager的告警實例都會首先存儲在Alert Provider中。Alert Provider本質上是一個內存中的哈希表,用於存放所有的告警實例。因為labels唯一標識了一個告警,因此哈希表的key就是告警實例的label取哈希值,value則為告警實例的具體內容。若新接受到的告警實例在哈希表中已經存在且兩者的[startsAt, endsAt]有重合,則會先將兩者進行合並再刷新哈希表。同時,Alert Provider提供了訂閱接口,每當接收到新的告警實例,它都會在刷新哈希表之后依次發送給各個訂閱者。
值得注意的是,Alert Provider是存在GC機制的。默認每隔30分鍾就會對已經消除的告警(即endsAt早於當前時間)進行清除。顯然,AlertManager從實現上來看並不支持告警的持久化存儲。已經消除的告警會定時清除,由於存儲在內存中,若程序重啟則將丟失所有告警數據。但是如果研讀過AlertManager的代碼,對於Alert Provider的實現是做過良好的封裝的。我們完全可以實現一套底層存儲基於MySQL,ElasticSearch或者Kafka的Alert Provider,從而實現告警信息的持久化(雖然AlertManager並不提供顯式的插件機制,只能通過hack代碼實現)。
7. 告警的路由與分組
將所有告警統一發送給所有人顯然是不合適的。因此AlertManager允許我們按照如下規則定義一系列的接收者並制定路由策略將告警實例分發到對應的目標接收者:
global:
// 所有告警統一從此處進入路由表 route: // 根路由 receiver: ops-mails group_by: ['cluster', 'alertname'] group_wait: 30s group_interval: 5m repeat_interval: 5h routes: // 子路由1 - match_re: service: ^(foo1|foo2|baz)$ receiver: team-X-webhook // 子路由2 - match: service: database receiver: team-DB-pager // 接收者 receivers: - name: 'ops-mails' email_configs: - to: 'ops1@example.org, ops2@example.com' - name: 'team-X-webhook' webhook_configs: - url: 'http://127.0.0.1:8080/webhooks' - name: 'team-DB-pager' pagerduty_configs: - routing_key: <team-DB-key>
上述AlertManager的配置文件中定義了一張路由表以及三個接收者。AlertManager已經內置了Email,Slack,微信等多種通知方式,如果用戶想要將告警發送給內置類型以外的其他信息平台,可以將這些告警通過webhook接口統一發送到webhook server,再由其轉發實現。AlertManager的路由表整體上是一個樹狀結構,所有告警實例進入路由表之后會進行深度優先遍歷,直到最終無法匹配並發送至父節點的Receiver。
需要注意的是路由表的根節點默認匹配所有告警實例,示例中根節點的receiver是ops-mails,表示告警默認都發送給運維團隊。路由表的匹配規則是根據labels的匹配實現的。例如,對於子路由1,若告警包含key為service的label,且label的value為foo1, foo2或者baz,則匹配成功,告警將發送至team X。若告警包含service=database的label,則將其發送至數據庫團隊。
有的時候,作為告警的接收者,我們希望相關的告警能統一通過一封郵件進行發送,一方面能減少同類告警的重復,另一方面也有利於我們對告警進行歸檔。AlertManager通過Group機制對這一點做了很好的支持。每個路由節點都能配置以下四個字段對屬於本節點的告警進行分組(若當前節點未顯式聲明,則繼承父節點的配置):
group_by
:指定一系列的label鍵值作為分組的依據,示例中利用cluster和alertname作為分組依據,則同一集群中,所有名稱相同的告警都將統一通知。若不想對任何告警進行分組,則可以將該字段指定為'...'group_wait
:當相應的Group從創建到第一次發送通知的等待時間,默認為30s,該字段的目的為進行適當的等待從而在一次通知中發送盡量多的告警。在每次通知之后會將已經消除的告警從Group中移除。group_interval
:Group在第一次通知之后會周期性地嘗試發送Group中告警信息,因為Group中可能有新的告警實例加入,本字段為該周期的時間間隔repeat_interval
:在Group沒有發生更新的情況下重新發送通知的時間間隔
綜上,AlertManager的Dispatcher會將新訂閱得到的告警實例根據label進行路由並加入或者創建一個新的Group。而新建的Group經過指定時間間隔會將組中的告警實例統一發送並周期性地檢測組內是否有新的告警加入(或者有告警消除,但需要顯式配置),若是則再次發送通知。另外每隔repeat_interval,即使Group未發生變更也將再次發送通知。
8. Alert Notification Pipeline
通常來說,一個Group中會包含多條告警實例,但是並不是其中的所有告警都是用戶想要看到的。而且已知Group都會周期性地嘗試發送其包含的告警,如果沒有新的告警實例加入,在一定時間內,顯然沒有再重復發送告警通知的必要,另外如果對AlertManager進行高可用部署的話,多個AlertManager之間也需要做好協同,避免重復告警。如上文中AlertManager的整體架構圖所示,當Group嘗試發送告警通知時,總是先要經過一條Notification Pipeline的過濾,最終滿足條件的告警實例才能通過郵件的方式發出。一般過濾分為抑制(Inhibit),靜默(silence)以及去重(dedup)三個步驟。下面我們將逐個進行分析。
8.1 告警抑制
所謂的告警抑制其實是指,當某些告警已經觸發時,則不再發送其他受它抑制的告警。一個典型的使用場景為:如果產生了一條集群不可用的告警,那么任何與該集群相關的告警都應當不再通知給用戶,因為這些告警都是由集群不可用引起的,發送它們只會增加用戶找到問題根因的難度。告警的抑制規則會配置在AlertManager全局的配置文件中,如下所示:
inhibit_rules:
- source_match: alertname: ClusterUnavailable severity: critical target_match: severity: critical equal: - cluster
該配置的含義為,若出現了包含label為{alertname="ClusterUnavailable", severity="critical"}的告警實例A,AlertManager就會對其進行記錄。當后續出現告警實例包含label為{severity="critical"}且"cluster"這個label對應的value和A的“cluster”對應的value相同,則該告警實例被抑制,不再發送。
當Group每次嘗試發送告警實例時,AlertManager都會先用抑制規則篩選掉滿足條件的實例,剩余的實例才能進入Notification Pipeline的下一個步驟,即告警靜默。
8.2 告警靜默
告警靜默指的是用戶可以選擇在一段時間內不接收某些告警。與Inhibit rule不同的是,靜默規則可以由用戶動態配置,AlertManager甚至提供了如下所示的圖形UI:
與告警自身的定義方式類似,靜默規則也是在一個時間段內起作用,需用明確指定開始時間與結束時間。而靜默規則同樣通過指定一組label來匹配作用的告警實例。例如在上圖的例子中,任何包含label為{alertname="clusterUnavailable", severity="critical"}的告警實例都將不再出現在通知中。
顯然,靜默規則又將濾去一部分告警實例,如果此時Group中仍有剩余的實例,則將進入Notification的下一步驟,告警去重。
8.3 告警去重
每當一個Group第一次成功發送告警通知之后,AlertManager就會為其創建一個Notification Log(簡稱nflog),其結構如下:
e := &pb.MeshEntry{ Entry: &pb.Entry{ Receiver; r, GroupKey: []byte(gkey), Timestamp: now, FiringAlerts: firingAlerts, ResolvedAlerts: resolvedAlerts, }, ExpiredAt: now.Add(l.retention) }
可以看到,每個Notification Log中包含:
- 該Group的Key(即該Group用於篩選Alerts的labels的哈希值)
- 該Group對應的Receiver
- 該Notification Log創建的時間
- 該Group中正在觸發的各個告警實例的哈希值
- 該Group中各個已經消除的告警實例的哈希值
- 該Notification Log過期的時間,默認為120小時
當Group再次周期性地嘗試推送通知並經過抑制和靜默的兩層篩選之后,若仍然有告警實例存在,則會進入告警去重階段。首先找到該Group對應的Notification Log並只在以下任一條件滿足的時候發送通知:
- Group剩余的告警實例中,處於觸發狀態的告警實例不是Notification Log中的FiringAlerts的子集,即有新的告警實例被觸發
- Notification Log中FiringAlerts的數目不為零,但是當前Group中處於觸發狀態的告警實例數為0,即Group中的告警全部被消除了
- Group中已消除的告警不是Notification Log中ResolvedAlerts的子集,說明有新的告警被消除,且通知配置中設置對於告警消除進行通知。例如,Email默認不在個別告警實例消除時通知而Webhook則默認會進行通知。
綜上,通過Notification Pipeline通過對告警的抑制,靜默以及去重確保了用戶能夠專注於真正重要的告警而不會被過多無關的或者重復的告警信息所困擾。
9. 高可用
當真正部署到生產環境中,如果只部署單個實例的AlertManager顯然是無法滿足可用性的。因此AlertManager原生支持多實例的部署方式並用Gossip協議來同步實例間的狀態。因為AlertManager並非是無狀態的,它有如下兩個關鍵信息需要同步:
- 告警靜默規則:當存在多個AlertManager實例時,用戶依然只會向其中一個實例發起請求,對靜默規則進行增刪。但是對於靜默規則的應用顯然應當是全局的,因此各個實例應當廣播各自的靜默規則,直到全局一致。
- Notification Log:既然要保證高可用,即確保告警實例不丟失,而AlertManager實例又是將告警保存在各自的內存中的,因此Prometheus顯然不應該在多個AlertManager實例之間做負載均衡而是應該將告警發往所有的AlertManager實例。但是對於同一個Alert Group的通知則只能由一個AlertManager發送,因此我們也應該把Notification Log在全集群范圍內進行同步。
當以集群模式運行AlertManager時,AlertManager的命令行參數配置如下:
--cluster.listen-address=0.0.0.0:9094
--cluster.peer=192.168.1.1:9094
--cluster.peer=192.168.1.2:9094
當AlertManager啟動時,它會首先從cluster.peer
參數指定的地址和端口進行Push/Pull:即首先將本節點的狀態信息(全部的Silence以及Notification Log)發送到對端,再從對端拉取狀態信息並與本節點的狀態信息合並:例如,對於從對端拉取到的靜默規則,如果有本節點不存在的規則則直接添加,若是規則在本節點已存在但是更新時間更晚,則用對端規則覆蓋已有的規則。對於Notification Log的做法類似。最終,集群中的所有AlertManager都會有同樣的靜默規則以及Notification Log。
如果此時用戶在某個AlertManager請求增加新的靜默規則呢?根據Gossip協議,該實例應該從集群中選取幾個實例,將新增的靜默規則發送給它們。而當這些實例收到廣播信息時,一方面它會合並這一新的靜默規則同時再對其進行廣播。最后,整個集群都會接收到這一新添加的靜默規則,實現了最終一致性。
不過,Notification Log的同步並沒有靜默規則這么容易。我們可以假設如下場景:由於高可用的要求,Prometheus會向每個AlertManager發送告警實例。如果該告警實例不屬於任何之前已有的Alert Group,則會新建一個Group並最終創建一個相應的Notification Log。而Notification Log是在通知完成之后創建的,所以在這種情況下,針對同一個告警發送了多次通知。
為了避免這種情況的發生,社區給出的解決方案是錯開各個AlertManager發送通知的時間。如上文的整體架構圖所示,Notification Pipeline在進行去重之前其實還有一個Wait階段。該階段會將對於告警的通知處理暫停一段時間,不同的AlertManager實例等待的時間會因為該實例在整個集群中的位置有所不同。根據實例名進行排序,排名每靠后一位,默認多等待15秒。
假設集群中有兩個AlertManager實例,排名靠前的實例為A0,排名靠后的實例為A1,此時對於上述問題的處理如下:
- 假設兩個AlertManager同時收到告警實例並同時到達Notification Pipeline的Wait階段。在該階段A0無需等待而A1需要等待15秒。
- A0直接發送通知,生成相應的Notification Log並廣播
- A1等待15秒之后進入去重階段,但是由於已經同步到A0廣播的Notification Log,通知不再發送
可以看到,Gossip協議事實上是一個弱一致性的協議,上述的機制能在絕大多數情況下保證AlertManager集群的高可用並且避免實例間同步的不及時對用戶造成的困擾。但是仍然有待在嚴苛生產環境下的進一步驗證,所幸的是,告警數據的強一致性並不是那么敏感。
10. 總結
本文對基於Prometheus的告警系統進行了較為詳盡的分析:包括從告警規則在Prometheus Server的配置,Prometheus Server對告警規則的評估並觸發告警實例發送至AlertManager,AlertManager的整體架構以及AlertManager對於告警實例的處理。可以看到,雖然執行鏈路基本完備,但是與Prometheus的監控模型已經成為事實標准相比,整個Prometheus告警模型的通用性和實用性仍然是存疑的,以筆者經驗來看,如果要真正應用到生產環境中還需要做大量的適配與增強。