Prometheus 配置webhook告警

最近有一个需求是用户需要对实例进行告警,之前使用grafana配置的邮件告警,问题是邮件系统对外部用户的送达不太可靠,经常收不到告警,于是考虑使用prometheus提供的alert manager功能,通过webhook来调用自己的邮件与短信接口实现告警。

安装与运行

alertmanager 是prometheus提供的二进制,直接下载, 解压运行就可以了。可以对其进行一些配置,主要配置一个webhook的地址。一个webhook 的配置如下:

global:
resolve_timeout: 5m

route:
group_by: ['alertname']
group_wait: 10s
group_interval: 10s
repeat_interval: 10m
receiver: 'webhook'
receivers:
- name: 'webhook'
webhook_configs:
- url: 'http://127.0.0.1:18080/api/webhooks/prometheus'
send_resolved: true

直接指定配置文件启动 /data/alertmanager/alertmanager --web.listen-address=":9093" --cluster.listen-address="0.0.0.0:9094"

这里的webhook url在后面会实现一个。

也可以使用cluster 模式启动,在启动命令后面提供一个或者多个 --cluster.peer='ip2:9094' 即可,以集群模式运行需要一个额外的监听端口。

配置prometheus的alert

在prometheus需要配置两个地方,一个是alerting:

alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
rule_files:
- "prometheus.rules.yml"

然后在 prometheus.rules.yml 里面编写告警规则,官网提供的模板比较简单,比如我们的一个告警规则如下:

{% raw %}
groups:
- name: prometheus
rules:
- alert: Server Up
expr: mysql_up * on (instance, job) group_left(db_id, db_ip, db_name) db_info == 0
for: 1m
labels:
serverity: critical
annotations:
summary: "实例 <b>{{ $labels.db_name }} - {{ $labels.db_ip }} - {{ $labels.db_id }} </b> 未运行"
description: ""
{% endraw %}

我们的exporter改造过提供一个特殊的metric叫 db_info 提供这个DB的信息,想要在告警信息中提供实例的信息就需要从这个metric抽出label,一开时对prometheus的表达式不太熟悉,后来发现使用 group_left 的label会自动在alert的 labels中提供,这样可以在webhook中使用了。

还有一个比较复杂的方法是使用go template编写信息,上面的信息可以用一个类似于 `{{ with $info := printf "db_info{instance='%s', job='%s'}" .Labels.instance .Labels.job | query | first}}{{$info | label "db_id" }} - {{$info | label "db_ip" }}{{end}}` 的方式记录到summary中。

编写一个 webhook

通过上面的配置,prometheus在触发alert rule的时候会把一个告警推送到alertmanager,alertmanager则会将告警做一些处理后根据配置发送到receiver,我们这里的receiver就是一个webhook,下面用go编写一个web服务处理告警,可以参考[这里的实现】(https://github.com/bakins/alertmanager-webhook-example),首先要定义alert的信息,这些可以使用 GoDoc 提供的文档和包 :

type (
// HookMessage is the message we receive from Alertmanager
HookMessage struct {
Version string `json:"version"`
GroupKey string `json:"groupKey"`
Status string `json:"status"`
Receiver string `json:"receiver"`
GroupLabels map[string]string `json:"groupLabels"`
CommonLabels map[string]string `json:"commonLabels"`
CommonAnnotations map[string]string `json:"commonAnnotations"`
ExternalURL string `json:"externalURL"`
Alerts []Alert `json:"alerts"`
}

// Alert is a single alert.
Alert struct {
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt string `json:"startsAt,omitempty"`
EndsAt string `json:"EndsAt,omitempty"`
}
)

使用一个配置文件提供不同实例告警的对象(邮件和电话),简单起见没有使用数据库,格式如下:

accounts:
comp1:
emails:
- "xxx@xxx.com"
tels:
- "xxxxx"
comp2:
emails:
- "test@xxxx.cn"
tels:
- "xxxx"

然后我们使用viper读取配置文件,并实时监控配置文件变更,这样增加或者删除告警对象就不用重启服务了:

import (
"log"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
)

viper.SetConfigName("alert-hook")
viper.AddConfigPath(".")
viper.AddConfigPath("/etc")
viper.AddConfigPath("/root")
viper.SetConfigType("yaml")
err := viper.ReadInConfig()
if err != nil {
log.Fatal("Failed to read config file. %v", err)
}
// wathc and reload config
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
log.Printf("log file changed: %s.", e.Name)
})

主要的逻辑是起一个简单的web server:

addr := flag.String("addr", ":18080", "address to listen for webhook")
flag.Parse()

http.HandleFunc("/healthz", healthzHandler)
http.HandleFunc("/api/webhooks/prometheus", alertsHandler)
log.Fatal(http.ListenAndServe(*addr, nil))

func healthzHandler(w http.ResponseWriter, r *http.Request) {
io.WriteString(w,
`# HELP Information about the health.
# TYPE go_info gauge
healthz 1`)
}

func alertsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("recv request.")
switch r.Method {
case http.MethodGet:
getHandler(w, r)
case http.MethodPost:
postHandler(w, r)
default:
http.Error(w, "unsupported HTTP method", 400)
}
}
// 从配置文件中读取邮件列表
func getEmailList(companyID string) []string {
list := viper.GetStringSlice("accounts." + companyID + ".emails")
return list
}
// 从配置中读取短信通知列表
func getTelList(companyID string) []string {
list := viper.GetStringSlice("accounts." + companyID + ".tels")
return list
}

func getHandler(w http.ResponseWriter, r *http.Request) {
// do something here
}

func postHandler(w http.ResponseWriter, r *http.Request) {

dec := json.NewDecoder(r.Body)
defer r.Body.Close()

var m HookMessage
if err := dec.Decode(&m); err != nil {
log.Printf("error decoding message: %v", err)
http.Error(w, "invalid request body", 400)
return
}
// send message to ums
sendMSG(m)
io.WriteString(w, "OK\n")
}
// 读取信息并发送
func sendMSG(msg HookMessage) error {
var msgStr string
companyID := msg.CommonLabels["company"]
log.Printf("try send ums: %v", msg)

// summary 和 description 中已经有足够的信息
for _, alert := range msg.Alerts {
msgStr += alert.Annotations["summary"] + "<br/>" + alert.Annotations["description"] + "<br/>Time:" + alert.StartsAt + "<br/><br/>"
}
emails := getEmailList(companyID)
tels := getTelList(companyID)
if len(emails) == 0 && len(tels) == 0 {
log.Printf("no receivers for %s.", companyID)
return nil
}
zoneID := viper.GetInt("zone_id")
err := SendMSG( emails, tels, "DB 告警", msgStr)
log.Printf("send ums result: %v", err)

return nil
}

这样就实现了一个简单的webhook server,只与发送邮件与短信使用共用的组件即可。