Prometheus告警帶圖完美解決方案

哈喽哈喽111111發表於2024-11-27

轉載自: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}")

相關文章