prometheus中使用python手写webhook完成告警

prometheus 的几种告警方式

prometheus 我们都知道它是最近几年特别火的一个开源的监控工具,原生支持 kubernetes,如果你使用的是 kubernetes 集群,那么使用 prometheus 将会是非常方便的,而且 prometheus 也提供了报警工具alertmanager,实际上在 prometheus 的架构中,告警能力是单独的一部分,主要是通过自定义一堆的rule即告警规则,来周期性的对告警规则进行计算,并且会根据设置的报警触发条件,如果满足,就会进行告警,也就是会向alertmanager发送告警信息,进而由alertmanager进行告警。
那么,alertmanager告警又是通过何种途径呢?其实有很多种方式,例如:

  • 邮件告警
  • 企业微信告警
  • 钉钉告警
  • slack 告警
  • webhook 接口方式告警

其实还有一些,但这些都不重要,这些只是工具,重要的是如何运用,下面就介绍下使用 webhook 的方式来让 alertmanager 调用接口,发送POST请求完成告警消息的推送,而这个推送可以是邮件,也可以是微信,钉钉等。

调用接口以邮件形式告警

大体流程是这样的,首先在我们定义好一堆告警规则之后,如果触发条件,alertmanager 会将报警信息推送给接口,然后我们的这个接口会做一些类似与聚合、汇总、优化的一些操作,然后将处理过的报警信息再以邮件的形式发送给指定的人或者组。也就是下面这个图:
prometheus中使用python手写webhook完成告警

我们这里的重点主要是如何写这个 webhook,以及写 webhook 的时候需要注意什么?下面将一一讲解

假设你有一个 prometheus 监控系统,并且告警规则都已配置完成

配置 alertmanager

首先得先配置 alertmanager,让其可以调用接口,配置方式很简单,只需要指定一下接口地址即可,如下:

receivers:
- webhook_configs:
  url: http://10.127.34.107:5000/webhook
  send_resolved: true

这就完了!当然可以指定多种告警方式
这样配置完成后,alertmanger 就会把告警信息以 POST 请求方式调用接口

编写一个最简单的接口

既然是用 python 来编写一个接口,那么肯定是用 flask 的,代码也非常简单,如下:

import json
from flask import Flask, request
from gevent.pywsgi import WSGIServer

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    prometheus_data = json.loads(request.data)
    print(prometheus_data)
    return "test"

if __name__ == '__main__':
    WSGIServer(('0.0.0.0', 5000), app).serve_forever()

上面导入的一些模块,记得要去下载哦

pip install flask
pip install gevent

这样的话,我们直接运行此段代码,此时机器上会监听 5000 端口,如果此时 prometheus 有告警,那么我们就会看到 prometheus 传过来的数据格式是什么样的了,这里我贴一个示例:

{
	'receiver': 'webhook',
	'status': 'firing',
	'alerts': [{
		'status': 'firing',
		'labels': {
			'alertname': '内存使用率',
			'instance': '10.127.92.100',
			'job': 'sentry',
			'severity': 'warning',
			'team': 'ops'
		},
		'annotations': {
			'description': '内存使用率已超过55%,内存使用率:58%',
			'summary': '内存使用率'
		},
		'startsAt': '2020-12-30T07:20:08.775177336Z',
		'endsAt': '0001-01-01T00:00:00Z',
		'generatorURL': 'http://prometheus-server:9090/graph?g0.expr=round%28%281+-+%28node_memory_MemAvailable_bytes%7Bjob%3D%22sentry%22%7D+%2F+%28node_memory_MemTotal_bytes%7Bjob%3D%22sentry%22%7D%29%29%29+%2A+100%29+%3E+55&g0.tab=1',
		'fingerprint': '09f94bd1aa7da54f'
	}, {
		'status': 'firing',
		'labels': {
			'alertname': '内存使用率',
			'instance': '10.127.92.101',
			'job': 'sentry',
			'severity': 'warning',
			'team': 'ops'
		},
		'annotations': {
			'description': '内存使用率已超过55%,内存使用率:58%',
			'summary': '内存使用率'
		},
		'startsAt': '2020-12-30T07:20:08.775177336Z',
		'endsAt': '0001-01-01T00:00:00Z',
		'generatorURL': 'http://prometheus-server:9090/graph?g0.expr=round%28%281+-+%28node_memory_MemAvailable_bytes%7Bjob%3D%22sentry%22%7D+%2F+%28node_memory_MemTotal_bytes%7Bjob%3D%22sentry%22%7D%29%29%29+%2A+100%29+%3E+55&g0.tab=1',
		'fingerprint': '8a972e4907cf2c60'
	}],
	'groupLabels': {
		'alertname': '内存使用率'
	},
	'commonLabels': {
		'alertname': '内存使用率',
		'job': 'sentry',
		'severity': 'warning',
		'team': 'ops'
	},
	'commonAnnotations': {
		'summary': '内存使用率'
	},
	'externalURL': 'http://alertmanager-server:9093',
	'version': '4',
	'groupKey': '{}:{alertname="内存使用率"}',
	'truncatedAlerts': 0
}

通过 prometheus 传过来的告警信息,可以看到是一个标准的json,我们在使用python在做处理时,需要先将json字符串转换成python的字典,可以用json这个模块来实现,通过这个json我们可以得到以下信息(非常重要):

  • 每次发出的json数据流中的报警信息是同一个类型的报警,比如这里都是关于内存的
  • status:表示告警的状态,两种:firingresolved
  • alerts:是一个列表,里面的元素是由字典组成,每一个元素都是一条具体的告警信息
  • commonLabels:这里面就是一些公共的信息

剩下的几个 key 都比较好理解,就不一一说了,下面结合 prometheus 的一些 rule 来看下这个告警是凭什么这样发的。

# cat system-rule.yaml  #文件名随意设置,因为prometheus的配置里配置的是: *.yaml
groups:
    - name: sentry
      rules:
      - alert: "Memory Usage"
        expr: round((1-(node_memory_MemAvailable_bytes{job='sentry'} / (node_memory_MemTotal_bytes{job='sentry'})))* 100) > 85
        for: 5m
        labels:
          team: ops
          severity: warning
          cloud: yizhuang
        annotations:
          summary: "Memory usage is too high and over 85% for 5min"
          description: "The current host {{$labels.instance}}' memory usage is {{ $value }}%"

这里就是配置的告警规则,告诉 prometheus 应该按照什么方式进行告警,配置完成后,要在 prometheus 的配置里引用下,如下所示:

# cat prometheus.yml
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets: ['10.10.10.111:9093']

# 就是这里,看这里
rule_files:
  - "/alertmanager/rule/*.yaml"  #文件目录随意设置
...
...
...
此处省略一堆配置

到这里应该就知道告警规则是什么发出来的了吧,然后也应该知道告警内容为什么是这样的了吧,嗯,下面看下最关键的地方

处理原始告警信息并进行邮件告警

原始的告警信息看起来还挺规则的,只需要拼接下就可以了,但是有一个问题就是alerts里面的startsAtendsAt这俩时间格式有些问题,是 UTC 时区的时间,需要转换下。还有一个地方需要注意的,最外层的status如果是firing状态,就不代表alerts中的status就一定都是firing,还有可能是resolved,如下json所示:

{
	'receiver': 'webhook',
	'status': 'firing',
	'alerts': [{
		'status': 'resolved',  # 这里就是resolved状态,所以处理时需要注意下
		'labels': {
			'alertname': 'CPU使用率',
			'instance': '10.127.91.26',
			'severity': 'warning',
			'team': 'ops'
		},
		'annotations': {
			'description': 'CPU使用率已超过35%,CPU使用率:38%',
			'summary': 'CPU使用率'
		},
		'startsAt': '2020-12-30T07:38:38.775177336Z',
		'endsAt': '2020-12-30T07:38:53.775177336Z',
		'generatorURL': 'http://prometheus-server:9090/graph?g0.expr=round%28100+-+%28avg+by%28instance%29+%28irate%28node_cpu_seconds_total%7Bjob%3D%22sentry%22%2Cmode%3D%22idle%22%7D%5B5m%5D%29%29+%2A+100%29%29+%3E+35&g0.tab=1',
		'fingerprint': '58393b2abd2c6987'
	}, {
		'status': 'resolved',
		'labels': {
			'alertname': 'CPU使用率',
			'instance': '10.127.92.101',
			'severity': 'warning',
			'team': 'ops'
		},
		'annotations': {
			'description': 'CPU使用率已超过35%,CPU使用率:38%',
			'summary': 'CPU使用率'
		},
		'startsAt': '2020-12-30T07:42:08.775177336Z',
		'endsAt': '2020-12-30T07:42:38.775177336Z',
		'generatorURL': 'http://prometheus-server:9090/graph?g0.expr=round%28100+-+%28avg+by%28instance%29+%28irate%28node_cpu_seconds_total%7Bjob%3D%22sentry%22%2Cmode%3D%22idle%22%7D%5B5m%5D%29%29+%2A+100%29%29+%3E+35&g0.tab=1',
		'fingerprint': 'eaca600142f9716c'
	}],
	'groupLabels': {
		'alertname': 'CPU使用率'
	},
	'commonLabels': {
		'alertname': 'CPU使用率',
		'severity': 'warning',
		'team': 'ops'
	},
	'commonAnnotations': {
		'summary': 'CPU使用率'
	},
	'externalURL': 'http://alertmanager-server:9093',
	'version': '4',
	'groupKey': '{}:{alertname="CPU使用率"}',
	'truncatedAlerts': 0
}

那既然该注意的都注意了,就开始干吧,首先说下我要实现的一个最终结果:

  • 时区转换
  • 不同类型的告警信息推送给不同的人
  • 告警内容以表格的形式展示,通过 html 实现

时区转换

先看下时区转换,这个比较好解决,代码如下:

import datetime
from dateutil import parser

def time_zone_conversion(utctime):
    format_time = parser.parse(utctime).strftime('%Y-%m-%dT%H:%M:%SZ')
    time_format = datetime.datetime.strptime(format_time, "%Y-%m-%dT%H:%M:%SZ")
    return str(time_format + datetime.timedelta(hours=8))

发送邮件

再来看下邮件发送,也很简单,代码如下:

import smtplib
from email.mime.text import MIMEText

def sendEmail(title, content, receivers=None):
    if receivers is None:
        receivers = ['chenf-o@glodon.com']
    mail_host = "xxx"
    mail_user = "xxx"
    mail_pass = "xxx"
    sender = "xxx"
    msg = MIMEText(content, 'html', 'utf-8')
    msg['From'] = "{}".format(sender)
    msg['To'] = ",".join(receivers)
    msg['Subject'] = title
    try:
        smtpObj = smtplib.SMTP_SSL(mail_host, 465)
        smtpObj.login(mail_user, mail_pass)
        smtpObj.sendmail(sender, receivers, msg.as_string())
        print('mail send successful.')
    except smtplib.SMTPException as e:
        print(e)

告警模板生成

下面就是告警推送的形式了,上面说了,使用表格的形式,如果用 html 来生成表格,还是比较简单的,但是这个表格是不停的变化的,所以为了支持这个动态变化,肯定是得用到模板语言:jinja了,如果是搞运维的肯定知道ansible,ansible 里的 template 用的也是jinja模板语言,所以比较好理解,这里就不再单独说了,后面会详细说一下 python 中如何使用这个jinja模板语言,不明白的可以先看下官方文档,比较简单: http://docs.jinkan.org/docs/jinja2/
那么我这个 html 就长成了这个样子,由于本人对前端一点都不懂,所以能实现我的需求就行了。

<meta http-equiv="Content-Type"content="text/html;charset=utf-8">
<html align='left'>
    <body>
        <h2 style="font-size: x-large;">{{ prometheus_monitor_info['commonLabels']['cloud'] }}--监控告警通知</h2><br/>
        <br>
    <table border="1" width = "70%" cellspacing='0' cellpadding='0' align='left'>
    <tr>
        <!--监控类型:系统层级,业务层级,服务层级等等-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">监控类别</th>
        <!--状态:报警通知还是恢复通知-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">状态</th>
        <!--状态:级别:报警级别-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">级别</th>
        <!--状态:实例:机器地址-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">实例</th>
        <!--状态:描述:报警描述-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">描述</th>
        <!--状态:详细描述:报警详细描述-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">详细描述</th>
        <!--状态:开始时间:报警开始时间-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">开始时间</th>
        <!--状态:开始时间:报警结束时间-->
        <th style="font-size: 20px; padding: 5px; background-color: #F3AE60">结束时间</th>
    </tr>
    {% for items in prometheus_monitor_info['alerts'] %}
    <tr align='center'>
        {% if loop.first %}
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #F3AE60" rowspan="{{ loop.length }}">{{ prometheus_monitor_info['commonLabels']['alertname'] }}</td>
        {% endif %}
        {% if items['status'] == 'firing' %}
        <td style="font-size: 16px; padding: 3px; background-color: red; word-wrap: break-word">告警</td>
        {% else %}
        <td style="font-size: 16px; padding: 3px; background-color: green; word-wrap: break-word">恢复</td>
        {% endif %}
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #EBE4D3">{{ items['labels']['severity'] }}</td>
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #EBE4D3">{{ items['labels']['instance'] }}</td>
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #EBE4D3">{{ items['annotations']['summary'] }}</td>
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #EBE4D3">{{ items['annotations']['description'] }}</td>
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #EBE4D3">{{ items['startsAt'] }}</td>
        {% if items['endsAt'] == '0001-01-01T00:00:00Z' %}
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #EBE4D3">00:00:00:00</td>
        {% else %}
        <td style="font-size: 16px; padding: 3px; word-wrap: break-word; background-color: #3DE869">{{ items['endsAt'] }}</td>
        {% endif %}
    </tr>
    {% endfor %}
    </table>
    </body>
</html>

en。。。。仔细一看好像也挺简单的,就是一堆 for 循环,if 判断啥的,比较不好弄的就是这个表格的合并单元格,对我来说有点费劲,我就简单把监控类别给合并成一个单元格了,其他的就没再归类了

<tr>...</tr>这里设置的是表格的表头信息,我这里都有详细的注释,就不介绍了。

<td>...</td>里是一行一行的告警信息,里面有一个判断,是判断这一条告警信息里到底是报警还是已恢复,然后根据不同来设置一个不同的颜色展示,这样的话领导看了肯定会觉着真贴心。

然后我就说一个比较重要的地方

{% for items in prometheus_monitor_info['alerts'] %}
这里面是最关键的告警信息,其中prometheus_monitor_info这个是一个变量吧,代表的是把prometheus推过来的json字符串转换成python的一个字典,注意这是一个字典,然后这个字典做了一个时区转换的操作。

嗯,那prometheus_monitor_info['alerts']这里就是取得alerts这个列表了,然后用for循环迭代这个列表,items这里就是每一条具体的告警信息,它是一个字典,嗯,然后就是把字典里的value取出来了,嗯。仔细想想也很简单。
{% endfor %}

这样的话,我这个 html 的模板就写好了,然后我怎么使用这个模板呢?这里我又写了一个方法来解析这个模板,并传入对应的参数

from jinja2 import Environment, FileSystemLoader

class ParseingTemplate:
    def __init__(self, templatefile):
        self.templatefile = templatefile

    def template(self, **kwargs):
        try:
            env = Environment(loader=FileSystemLoader('templates'))
            template = env.get_template(self.templatefile)
            template_content = template.render(kwargs)
            return template_content
        except Exception as error:
            raise error

简单说下这个类的作用,就是为了传入告警信息,然后再读取 html 模板,最后把解析好的 html 内容返回出来,最后通过邮件,把这个内容发出去,就完事了。

精准告警,对应到具体的人

这里其实比较简单,只需要解析原始 json 里的commonLabels下的team,如果你仔细看我上面贴的那个 rule 报警规则的话,你肯定注意到里面有一个自定义的 key-value:

groups:
    - name: sentry  # 这个名字可以理解为一个分类,做一个区分
      rules:
      - alert: "Memory Usage"
        expr: round((1-(node_memory_MemAvailable_bytes{job='sentry'} / (node_memory_MemTotal_bytes{job='sentry'})))* 100) > 85
        for: 5m
        labels:
          team: ops   # 就是这里,我定义了一个组,用来给这个组发消息
          severity: warning
          cloud: yizhuang
......
......

然后我再解析原始 json 的时候,我把这个team的值获取出来,根据这个值,去取这个组里的具体邮件地址,最后发给这些人就好了。
具体的邮件地址,我是取出来了,但是我怎么知道区分这些人应该对应哪个环境或者哪个应用呢,那就是下面这个:

groups:
    - name: sentry
......
......

这里的 name 肯定和 prometheus 中指定的 job_name 对应,那么 prometheus 中相应的配置就是:

# cat prometheus.yml
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets: ['10.127.92.105:9093']

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  - "/alertmanager/rule/*.yaml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: 'prometheus'
    static_configs:
      - targets: ['10.127.92.105:9090']

  - job_name: 'cadvisor-app'
    file_sd_configs:
    - refresh_interval: 1m
      files:
      - /etc/prometheus/file-sd-configs/cadvisor-metrics.json

  - job_name: 'sentry'
    file_sd_configs:
    - refresh_interval: 1m
      files:
      - /etc/prometheus/file-sd-configs/system-metrics.json

  - job_name: 'kafka-monitor'
    file_sd_configs:
    - refresh_interval: 1m
      files:
      - /etc/prometheus/file-sd-configs/kafka-metrics.json

是不是串起来了呢?可以回想下,然后再参考我最终完整的代码

完整代码参考

代码参考

from flask import Flask, request
from dateutil import parser
import json
import yaml
import datetime
import smtplib
from email.mime.text import MIMEText
from jinja2 import Environment, FileSystemLoader
from gevent.pywsgi import WSGIServer


def time_zone_conversion(utctime):
    format_time = parser.parse(utctime).strftime('%Y-%m-%dT%H:%M:%SZ')
    time_format = datetime.datetime.strptime(format_time, "%Y-%m-%dT%H:%M:%SZ")
    return str(time_format + datetime.timedelta(hours=8))


def get_email_conf(file, email_name=None, action=0):
    """
    :param file: yaml格式的文件类型
    :param email_name: 发送的邮件列表名
    :param action: 操作类型,0: 查询收件人的邮件地址列表, 1: 查询收件人的列表名称, 2: 获取邮件账号信息
    :return: 根据action的值,返回不通的数据结构
    """
    try:
        with open(file, 'r', encoding='utf-8') as fr:
            read_conf = yaml.safe_load(fr)
            if action == 0:
                for email in read_conf['email']:
                    if email['name'] == email_name:
                        return email['receive_addr']
                    else:
                        print("%s does not match for %s" % (email_name, file))
                else:
                    print("No recipient address configured")
            elif action == 1:
                return [items['name'] for items in read_conf['email']]
            elif action == 2:
                return read_conf['send']
    except KeyError:
        print("%s not exist" % email_name)
        exit(-1)
    except FileNotFoundError:
        print("%s file not found" % file)
        exit(-2)
    except Exception as e:
        raise e


def sendEmail(title, content, receivers=None):
    if receivers is None:
        receivers = ['chenf-o@glodon.com']
    send_dict = get_email_conf('email.yaml', action=2)
    mail_host = send_dict['smtp_host']
    mail_user = send_dict['send_user']
    mail_pass = send_dict['send_pass']
    sender = send_dict['send_addr']
    msg = MIMEText(content, 'html', 'utf-8')
    msg['From'] = "{}".format(sender)
    msg['To'] = ",".join(receivers)
    msg['Subject'] = title
    try:
        smtpObj = smtplib.SMTP_SSL(mail_host, 465)
        smtpObj.login(mail_user, mail_pass)
        smtpObj.sendmail(sender, receivers, msg.as_string())
        print('mail send successful.')
    except smtplib.SMTPException as e:
        print(e)


class ParseingTemplate:
    def __init__(self, templatefile):
        self.templatefile = templatefile

    def template(self, **kwargs):
        try:
            env = Environment(loader=FileSystemLoader('templates'))
            template = env.get_template(self.templatefile)
            template_content = template.render(kwargs)
            return template_content
        except Exception as error:
            raise error


app = Flask(__name__)


@app.route('/webhook', methods=['POST'])
def webhook():
    try:
        prometheus_data = json.loads(request.data)
        # 时间转换,转换成东八区时间
        for k, v in prometheus_data.items():
            if k == 'alerts':
                for items in v:
                    if items['status'] == 'firing':
                        items['startsAt'] = time_zone_conversion(items['startsAt'])
                    else:
                        items['startsAt'] = time_zone_conversion(items['startsAt'])
                        items['endsAt'] = time_zone_conversion(items['endsAt'])
        team_name = prometheus_data["commonLabels"]["team"]
        generate_html_template_subj = ParseingTemplate('email_template_firing.html')
        html_template_content = generate_html_template_subj.template(
            prometheus_monitor_info=prometheus_data
        )
        # 获取收件人邮件列表
        email_list = get_email_conf('email.yaml', email_name=team_name, action=0)
        sendEmail(
            'Prometheus Monitor',
            html_template_content,
            receivers=email_list
        )
        return "prometheus monitor"
    except Exception as e:
        raise e


if __name__ == '__main__':
    WSGIServer(('0.0.0.0', 5000), app).serve_forever()

配置文件参考

send:
  smtp_host: smtp.163.com
  send_user: warxxxxgs@163.com
  send_addr: warxxxs@163.com
  send_pass: BRxxxxxxxZPUZEK
email:
  - name: kafka-monitor   # 要和team对应
    receive_addr:
      - 邮件地址1
      - 邮件地址2
      - 邮件地址3
  - name: ops
    receive_addr:
      - 邮件地址1
      - 邮件地址2

最终效果图

1)全是告警的

prometheus中使用python手写webhook完成告警

prometheus中使用python手写webhook完成告警

2)既有告警又有恢复的

prometheus中使用python手写webhook完成告警

3)都是恢复的

prometheus中使用python手写webhook完成告警

prometheus中使用python手写webhook完成告警


欢迎各位朋友关注我的公众号,来一起学习进步哦
prometheus中使用python手写webhook完成告警

上一篇:Table '.\mysql\proc' is marked as crashed and should be repaired 报错


下一篇:prometheus中使用python手写webhook完成告警