轉載自:https://mp.weixin.qq.com/s/dDmZaJ66tdEScCJyansyJA
需求背景
告警分析處理流程
通常我們收到 Prometheus 告警事件通知後,往往都需要登入 Alertmanager 頁面檢視當前啟用的告警,如果需要分析告警歷史資料資訊,還需要登入 Prometheus 頁面的在 Alerts 中查詢告警 promQL 表示式,然後複製到 Graph 中查詢資料。
這樣做無疑大大降低了故障分析和處理流程,此時有些聰明的小夥伴就在想,能不能在告警事件推送的時候,將這些歷史資料繪製成圖表一塊推送過來,方便我們第一時間瞭解指標變化趨勢,便於更快的處理問題。
功能需求分析
1.可靠性要求:無論告警圖表資料是否能獲取到,都不能影響告警資訊的及時推送。(這是 webhook 最核心的功能,告警帶圖只是錦上添花,不可本末倒置,不能因為獲取圖表失敗而導致告警事件無法正常推送。)
2.資料折線圖顯示:希望可以查詢指定時間範圍內資料變化趨勢,便於分析排查問題,這是最核心的功能。
3.只顯示異常 instance 資料:每個 job 都會對應很多 targets,我們只希望顯示異常 instance 相關的資料,避免整個圖表因資料過多導致顯示混亂。
4.最大值最小值最近值顯示:這三個值是我們最關注的資訊,尤其是最近值,在告警觸發時還是告警恢復後,我們都需要知道這個指標當前值是多少。
5.時間範圍靈活:有些告警比較靈敏,for 時間會較短,通常不到 1 分鐘(例如網路探針檢測 http 狀態碼)。而有些告警比較遲鈍,for 時間會設定的比較長,通常以為小時為單位(例如證書有效期監控)。而此時告警圖表時間範圍不應該統一設定為某個指定的值,而是根據 for 配置的時間靈活變化。
實現思路與效果
實現效果
廢話不多說,先展示一下最終的告警效果。
grafana 渲染指標圖:
方案對比分析
針對這個需求,目前主流的實現方案主要有以下兩種。
方案 1:開發程式,查詢 prometheus 指標資料,然後使用圖表庫渲染出圖片,以 python 為例,可以使用 Matplotlib 或 Pandas 實現 。
優點:實現起來較為靈活,可以很好的滿足各種功能需求。
缺點:圖表樣式單一,如果需要修改圖表樣式需要變更程式碼。
方案 2:使用 grafana-image-renderer外掛渲染圖片。
優點:圖表樣式美觀,圖表配置靈活方便。
缺點:需要指定 dashboard 和 panel 的 id 才能渲染,也就意味著每個告警規則都需要建立一個圖表,工作量巨大。
實現思路
既然兩種方案都各有優缺點,能否將兩種方案的優點整合起來,既要配置實現起來靈活方便又要圖表樣式美觀且易於配置呢?畢竟只有小孩子才做選擇題,大人都是全都要。
具體實現思路如下:
1.收到 alertmanager 推送告警資料
2.根據告警資料,提取告警標題和異常的 instance 。
3.根據告警標題請求 prometheus 的 rules 介面 ,獲取 for 和表示式配置。介面文件:https://prometheus.ac.cn/docs/prometheus/latest/querying/api/#rules
4.正則處理表示式,去除條件判斷值,並新增 instance 標籤選擇,例如原始的告警規則表示式為,處理後的查詢語句為
5.請求 grafana 的 api 介面,獲取 grafana dashboard 配置。介面文件:https://grafana.com/docs/grafana/latest/developers/http_api/dashboard/
6.根據獲取到的 dashboard 配置資料,替換表示式,時間範圍, 告警標題。
7.再次 post 請求 grafana 的 api 介面,更新 dashboard 配置。
8.呼叫grafana-image-renderer外掛,傳入 dashboard 和 panels 引數,渲染圖片並下載到本地。
9.將本地圖片推送至企業微信、釘釘或 teams(也可上傳至公有云物件儲存,直接返回圖片公網 url 地址)。
流程圖如下:
核心程式碼與配置
grafana 配置
1.建立 api key 用於呼叫 api 介面請求 grafana。
2.建立 dashboard 和圖表,並配置圖表樣式。
表示式、標題、時間範圍可以隨便寫,主要是為了除錯圖表配置。
3.安裝grafana-image-renderer 外掛。參考文件:
https://grafana.com/grafana/plugins/grafana-image-renderer/。
安裝完成後點選渲染影像驗證是否可以正常渲染。
webhook 程式
核心程式碼如下,需要注意的是為了確保可以正常獲取圖片和推送訊息,建議新增重試和異常處理邏輯,增加程式可靠性。
import base64
import hashlib
import json
import re
import time
import os
import httpx
from log import logger
from config import vx_conf, grafana_conf, alertmanager_conf, prometheus_conf, aliyun_conf
from oss2 import Auth, Bucket, exceptions
class VxRobot:
"""
企業微信推送
"""
def __init__(self, team, max_retries=3, backoff_factor=2):
self.url = vx_conf[team]
self.headers = {'Content-Type': 'application/json'}
self.params = {}
self.max_retries = max_retries
self.backoff_factor = backoff_factor
def __send_data(self):
"""
推送企業微信資料
:return:
"""
retries = 0
with httpx.Client(verify=False) as client:
while retries < self.max_retries:
try:
# 傳送 POST 請求
response = client.post(self.url, headers=self.headers, json=self.params, timeout=10)
response.raise_for_status() # 如果狀態碼不是 2xx,會丟擲異常
if response.json()['errcode'] == 0:
logger.info("企業微信告警推送完成")
return
else:
logger.error(f"企業微信告警推送內容有誤: {response.json()['errmsg']}")
break
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
retries += 1
logger.error(f"企業微信告警推送失敗 (嘗試 {retries}/{self.max_retries}): {exc}")
if retries < self.max_retries:
sleep_time = self.backoff_factor * retries
logger.info(f"等待 {sleep_time} 秒後重試...")
time.sleep(sleep_time) # 實現退避策略
else:
logger.error("達到最大重試次數,企業微信告警推送失敗")
raise Exception(f"無法使用企業微信告警推送: {exc}")
def send_img(self, image_path):
"""
推送圖片資訊
:param image_path:圖片路徑
:return: None
"""
try:
# 讀取圖片檔案內容
with open(image_path, "rb") as f:
image_data = f.read()
# 計算圖片內容的 MD5 值
md5_hash = hashlib.md5(image_data).hexdigest()
# 生成圖片內容的 Base64 編碼
base64_encoded = base64.b64encode(image_data).decode("utf-8")
except FileNotFoundError:
raise FileNotFoundError(f"圖片檔案 {image_path} 未找到")
except Exception as e:
raise RuntimeError(f"處理圖片時發生錯誤: {str(e)}")
params = {
"msgtype": "image",
"image": {
"base64": "DATA",
"md5": "MD5"
}
}
# 資料替換
params["image"]["base64"] = base64_encoded
params["image"]["md5"] = md5_hash
logger.info("推送企業微信圖片內容,圖片地址為%s" % image_path)
self.params = params
self.__send_data()
def get_panel(alert_name, instance):
"""
獲取指標圖表資訊
:return:
"""
prometheus = PrometheusTools()
config = prometheus.get_alert_config(alert_name)
config['instance'] = instance
config['time_range'] = 'now-' + str(2 * config['duration']) + 's'
if 2 * config['duration'] > 60:
config['alert_name'] = config['alert_name'] + '(最近' + str(round(2 * config['duration'] / 60)) + '分鐘)'
else:
config['alert_name'] = config['alert_name'] + '(最近' + str(round(2 * config['duration'])) + '秒)'
grafana = GrafanaTools()
dashboard_data = grafana.fetch_dashboard()
dashboard_conf = grafana.update_panel_query(dashboard_data, config)
grafana.update_dashboard(dashboard_conf)
local_img_path = grafana.render_image(config['alert_name'])
return local_img_path
# aliyun_oss = AliyunTools()
# oss_path = local_img_path.replace("img/", "alert_img/")
# aliyun_oss.upload_image(local_img_path, oss_path)
class PrometheusTools:
def __init__(self, max_retries=3, backoff_factor=2):
"""
初始化 Prometheus 客戶端
"""
self.url = prometheus_conf['url']
self.username = prometheus_conf['username']
self.password = prometheus_conf['password']
self.max_retries = max_retries
self.backoff_factor = backoff_factor
# 生成認證頭
auth_str = f"{self.username}:{self.password}"
auth_bytes = base64.b64encode(auth_str.encode("utf-8")).decode("utf-8")
self.headers = {"Authorization": f"Basic {auth_bytes}"}
def get_alert_config(self, alert_name):
"""
獲取告警配置
:param alert_name: 告警名稱
:return: 告警配置
"""
url = f"{self.url}/api/v1/rules"
params = {"rule_name[]": alert_name}
retries = 0
with httpx.Client(verify=False) as client:
while retries < self.max_retries:
try:
# 發起請求
response = client.get(url, params=params, headers=self.headers, timeout=10)
response.raise_for_status() # 如果狀態碼不是 2xx,會丟擲異常
logger.info("成功獲取 Prometheus 告警資料")
logger.debug(json.dumps(response.json()))
# 提取關鍵資料
config = {
'alert_name': response.json()['data']['groups'][0]['rules'][0]['name'],
'query': response.json()['data']['groups'][0]['rules'][0]['query'],
'duration': response.json()['data']['groups'][0]['rules'][0]['duration']
}
logger.debug(config)
return config
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
logger.error(f"Attempt {retries + 1} failed: {exc}")
retries += 1
if retries < self.max_retries:
time.sleep(self.backoff_factor * retries) # 退避策略
else:
raise Exception(f"Failed to fetch alert config after {self.max_retries} retries.")
class GrafanaTools:
"""
grafana渲染圖表工具
"""
def __init__(self, max_retries=3, backoff_factor=2):
self.grafana_url = grafana_conf['url']
self.api_key = grafana_conf['api_key']
self.dashboard_uid = grafana_conf['dashboard_id']
self.panel_id = grafana_conf['panel_id']
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
self.max_retries = max_retries
self.backoff_factor = backoff_factor
def fetch_dashboard(self):
"""
獲取儀表盤的完整配置
:return: 儀表盤的完整配置
"""
url = f"{self.grafana_url}/api/dashboards/uid/{self.dashboard_uid}"
logger.info("開始獲取dashboard配置, url: %s" % url)
retries = 0
with httpx.Client(verify=False) as client:
while retries < self.max_retries:
try:
# 傳送請求
response = client.get(url, headers=self.headers, timeout=10)
response.raise_for_status() # 如果狀態碼不是 2xx,會丟擲異常
logger.info("成功獲取dashboard配置")
logger.debug(json.dumps(response.json(), indent=2)) # 列印詳細配置
return response.json()
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
retries += 1
logger.error(f"請求失敗 (嘗試 {retries}/{self.max_retries}): {exc}")
if retries < self.max_retries:
sleep_time = self.backoff_factor * retries
logger.info(f"等待 {sleep_time} 秒後重試...")
time.sleep(sleep_time) # 實現退避策略
else:
logger.error("達到最大重試次數,獲取dashboard配置失敗")
raise Exception(f"無法獲取儀表盤配置: {exc}")
def update_panel_query(self, dashboard_data, config):
"""
修改grafana dashboard配置內容
:param dashboard_data: dashboard配置
:param config: 告警配置
:return: 新的grafana dashboard配置
"""
# logger.error(config)
for panel in dashboard_data["dashboard"]["panels"]:
if panel["id"] == self.panel_id:
# 修改皮膚查詢,動態新增 instance 變數
panel["title"] = config['alert_name']
instance = config['instance']
query = config['query']
# 去除規則條件
query_prom = re.sub(r'\s*(==|!=|>=|<=|>|<)\s*([0-9]+(?:\.[0-9]+)?)$', '', query)
# 正規表示式,匹配 Prometheus 查詢語句中的指標名稱
pattern = r'([a-z_][a-z0-9_]*)(?=\s*(\{|\[|$))'
# 查詢匹配的指標名稱
match = re.search(pattern, query_prom)
# 如果找到了匹配項
if match and instance != "none":
# 獲取指標名稱的結束位置
end_pos = match.end(1) # end(1) 獲取捕獲組1(指標名稱)的結束位置
# 判斷原始指標是否存在標籤選擇
if '{' in query_prom:
query_cleaned = query_prom[:end_pos + 1] + 'instance="' + instance + '",' + query_prom[
end_pos + 1:]
else:
query_cleaned = query_prom[:end_pos] + '{instance="' + instance + '"}' + query_prom[end_pos:]
else:
logger.error("指標匹配失敗,使用預設指標%s" % query_prom)
query_cleaned = query_prom
# logger.error(query_cleaned)
panel["targets"][0]["expr"] = query_cleaned
break
logger.info("dashboard配置替換完成")
logger.debug(json.dumps(dashboard_data))
return dashboard_data
def update_dashboard(self, dashboard):
"""
更新儀表盤配置到 Grafana
:param dashboard: 要更新的儀表盤配置 (dict)
:return: None
"""
url = f"{self.grafana_url}/api/dashboards/db"
logger.info("開始更新Dashboard配置, url: %s" % url)
retries = 0
with httpx.Client(verify=False) as client:
while retries < self.max_retries:
try:
# 傳送 POST 請求
response = client.post(url, headers=self.headers, json=dashboard, timeout=10)
response.raise_for_status() # 如果狀態碼不是 2xx,會丟擲異常
logger.info("Dashboard更新完成")
return
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
retries += 1
logger.error(f"更新Dashboard失敗 (嘗試 {retries}/{self.max_retries}): {exc}")
if retries < self.max_retries:
sleep_time = self.backoff_factor * retries
logger.info(f"等待 {sleep_time} 秒後重試...")
time.sleep(sleep_time) # 實現退避策略
else:
logger.error("達到最大重試次數,Dashboard更新失敗")
raise Exception(f"無法更新儀表盤配置: {exc}")
def render_image(self, alert_name):
"""
呼叫 Grafana 渲染圖片 API
:param alert_name: 告警標題
:return: 圖片儲存路徑
"""
output_path = "img/" + alert_name + ".png"
render_url = (
f"{self.grafana_url}/render/d-solo/{self.dashboard_uid}"
f"?orgId=1&panelId={self.panel_id}&width=2000&height=1000"
)
logger.info("開始渲染圖片, render_url: %s" % render_url)
retries = 0
with httpx.Client(verify=False) as client:
while retries < self.max_retries:
try:
# 發起請求
response = client.get(render_url, headers=self.headers, timeout=20)
response.raise_for_status() # 如果狀態碼不是 2xx,會丟擲異常
# 確保輸出目錄存在
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "wb") as f:
f.write(response.content)
logger.info(f"告警圖片下載至 {output_path}")
return output_path
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
retries += 1
logger.error(f"渲染圖片失敗 (嘗試 {retries}/{self.max_retries}): {exc}")
if retries < self.max_retries:
sleep_time = self.backoff_factor * retries
logger.info(f"等待 {sleep_time} 秒後重試...")
time.sleep(sleep_time) # 實現退避策略
else:
logger.error("達到最大重試次數,渲染圖片失敗")
raise Exception(f"無法渲染圖片: {exc}")
class AliyunTools:
def __init__(self, max_retries=3, backoff_factor=2):
self.auth = Auth(aliyun_conf['access_key_id'], aliyun_conf['access_key_secret'])
self.bucket = Bucket(self.auth, aliyun_conf['endpoint'], aliyun_conf['bucket_name'])
self.bucket_name = aliyun_conf['bucket_name']
self.endpoint = aliyun_conf['endpoint']
self.max_retries = max_retries
self.backoff_factor = backoff_factor
def upload_image(self, local_image_path, oss_object_name):
"""
上傳圖片到阿里雲 OSS
:param local_image_path: 本地圖片路徑
:param oss_object_name: OSS 中的物件名 (路徑/檔名)
:return: 圖片的公開訪問地址
"""
if not os.path.exists(local_image_path):
raise FileNotFoundError(f"本地檔案 {local_image_path} 不存在")
retries = 0
while retries < self.max_retries:
try:
logger.info(f"開始上傳圖片 {local_image_path} 到 OSS,目標物件名: {oss_object_name}")
# 上傳圖片到 OSS
self.bucket.put_object_from_file(oss_object_name, local_image_path)
# 獲取圖片訪問地址
image_url = self.bucket.sign_url('GET', oss_object_name, 60 * 60 * 24)
logger.info(f"圖片成功上傳至 OSS,訪問地址: {image_url}")
return image_url
except (exceptions.RequestError, exceptions.ServerError) as exc:
retries += 1
logger.error(f"上傳圖片到 OSS 失敗 (嘗試 {retries}/{self.max_retries}): {exc}")
if retries < self.max_retries:
sleep_time = self.backoff_factor * retries
logger.info(f"等待 {sleep_time} 秒後重試...")
time.sleep(sleep_time)
else:
logger.error("達到最大重試次數,上傳圖片失敗")
raise Exception(f"無法上傳圖片到 OSS: {exc}")