ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Gin 构建告警 Webhook 转发服务:协议转换与多通道分发

Gin 构建告警 Webhook 转发服务:协议转换与多通道分发

做监控告警的同学多半都遇到过这种场面:Prometheus 那边 Alertmanager 刚配好,Falco 的运行时告警也接上了,结果一到“往哪儿发”这个环节就卡住。钉钉机器人要的是msgtype加markdown的固定结构,企业微信要msgtype加markdown的content,飞书那边要么走post要么走interactive卡片,三家的签名算法还各不相同。而 Alertmanager 的 webhook 只负责把一组告警以它自己的 JSON 结构 POST 出来,Falco 的输出格式又是另一套字段。中间这层“翻译加分发”,就是这套练手路线第 33 关要解决的东西——用 Gin 写一个 webhook 转发服务,把 Prometheus 和 Falco 两路告警统一收进来,再按配置分发到钉钉、企业微信、飞书三个通道。

这篇文章写给正在搭告警链路的中初级运维和 Go 后端同学,也写给那些被“告警发不出去”“发出去一堆重复消息”“恢复通知永远收不到”折腾过的人。我会把整个服务的设计取舍、报文结构、平台签名细节、并发与限流策略、以及我实际部署时踩过的坑,尽量摊开讲清楚。代码是可以直接拿去改的,思路是可以直接复用的,不必照抄某个现成项目的目录结构。

1. 为什么中间必须插一层转发服务

1.1 三个告警源乘三个接收端,硬连是死路

先把组合关系摆出来。告警源主要是两路:一路是 Prometheus 生态,Alertmanager 通过webhook_configs把告警组推出去;另一路是 Falco,它可以把运行时安全事件的 JSON 直接 POST 到某个 HTTP 端点。接收端有三个:钉钉自定义机器人、企业微信群机器人、飞书自定义机器人。

如果选择硬连,就是让 Alertmanager 直接配钉钉的 webhook 地址。这条路走不通,原因很直接:Alertmanager 发出去的 body 长这样——

{ "version": "4", "groupKey": "{}/{alertname=~\".*\"}:{alertname=\"NodeDown\"}", "status": "firing", "receiver": "webhook", "groupLabels": {"alertname": "NodeDown"}, "commonLabels": {"severity": "critical"}, "commonAnnotations": {"summary": "节点失联"}, "externalURL": "http://alertmanager:9093", "alerts": [ { "status": "firing", "labels": {"alertname": "NodeDown", "instance": "10.0.0.11:9100"}, "annotations": {"description": "实例 3 分钟不可达"}, "startsAt": "2024-05-11T08:12:33.421Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "http://prometheus:9090/graph", "fingerprint": "6c9f2b1a7d3e4f50" } ] }

钉钉那边要的是{"msgtype":"markdown","markdown":{"title":"...","text":"..."}}。字段名完全不同,结构层级也不同,直接把 Alertmanager 的 body 甩给钉钉,返回的必然是errcode 400013之类的参数错误。同理,Falco 的输出字段是priority、rule、output、output_fields,跟 Prometheus 又不一样。

所以中间这层的第一个价值就是协议转换。但这只是最表层的原因,真正让我决定自己写的,是后面几个更麻烦的问题。

格式编排的自由度。告警发出去是给人看的,不是给机器看的。我希望 critical 的告警标题带红色标记、带实例 IP、带一键跳转 Prometheus 的链接;我希望同一批告警聚合成一条消息而不是刷屏十条;我希望 Falco 的高危规则(比如容器内起了一个 shell)单独走一个更醒目的通道。这些诉求在“Alertmanager 直连”的模型里几乎没有施展空间。

多通道冗余。生产环境里,钉钉的机器人偶尔会因为限流返回失败,企业微信的 key 轮换过之后旧地址就废了。如果我只有一个出口,消息丢了就是丢了。有了转发层,我可以一条告警同时打三个通道,任何一个成功就算成功,全部失败才记录待重试。

去重与状态管理。Alertmanager 本身有 group 和 repeat_interval,但它的“恢复通知”语义很弱——endsAt有值且status变成resolved才会发。实际跑下来,经常出现“告警发了、恢复了没发”的情况。自己在转发层存一份 fingerprint 到状态机的映射,才能在告警恢复时补一条“已恢复”的消息,让值班的人心里有底。

审计与回放。出了事故要复盘,光看监控截图不够,我需要知道“当时到底发出了哪些告警、内容是什么、有没有发成功”。把每一条入站和出站的报文落到日志甚至对象存储里,价值极高。这部分我后面会专门讲。

1.2 这一层该做什么、坚决不做什么

自己写服务最容易犯的错是越写越大,最后变成一个四不像的告警平台。我在动手之前给自己划了三条边界,实践下来挺管用。

只做无状态转换,不做告警计算。阈值判断、聚合规则、抑制逻辑,这些是 Prometheus 和 Alertmanager 的活。转发层收到什么就发什么,不参与“这条告警该不该发”的判断。唯一的例外是去重——同一个 fingerprint 在短时间内重复进来,我只发一次。这是传输层语义,不是业务语义。

只做尽力而为,不做强一致。告警消息不是订单,丢一条不会资损。所以我的重试策略是有限次、指数退避、失败落日志,绝不引入消息队列做持久化。引入 Kafka 或者 RabbitMQ,运维成本立刻翻几倍,收益却微乎其微。真到了“一条都不能丢”的级别,那说明告警体系本身设计有问题,应该去做多级冗余而不是给转发层加队列。

配置驱动,代码里不写死任何通道。通道的地址、密钥、启用的告警级别、消息模板,全部走配置文件加热加载。加一个新通道是改 YAML,不是改代码重新发版。这条边界让这个服务在半年里从三个通道扩到七个通道(后来又加了两个内部系统),一次业务代码都没动。

下面这张表是我当时对比的三种方案,最后选了自建 Gin 服务:

方案优点硬伤结论
Alertmanager 直连各平台零开发量报文格式不兼容,Falco 接不进来不可行
用现成的 webhook-adapter 类工具上手快,社区维护多平台支持有限,定制模板困难,Falco 侧要额外适配小规模可用
自建 Gin 转发服务完全可控,多源多目标,可加去重审计需要自己维护选它

2. 先定数据契约,再写一行代码

2.1 把 Prometheus 的载荷结构吃干净

写这类服务,最忌讳上来就写router.POST。我一般会先把三类输入的真实报文抓下来存成文件,对着字段一个一个看。抓法很简单,在 Alertmanager 的配置里临时把 webhook 指向nc -l 8080或者一个临时的 Python HTTP 服务,触发一次告警就能拿到完整 body。

Prometheus 这边的关键字段有这么几个,必须理解它们的语义差别:

status有两个层级。顶层的status表示这个告警组整体的状态,取值为firing或resolved;alerts[]里每一项还有自己的status。这两个不一定一致——一批告警里可能有几条恢复了、几条还在 firing,此时顶层是firing,组内有resolved的项。如果你只用顶层状态判断,就会漏掉组内的恢复事件。我的处理是遍历alerts,逐条判断。

endsAt是零值时间0001-01-01T00:00:00Z表示“还没恢复”。Go 的标准库time.Time能直接解析这个值,判断用alerts[i].EndsAt.IsZero()会返回 false(因为它不是 Go 的零值),正确写法是比较年份小于 2,或者更稳妥地判断time.Since(EndsAt) > 0。我踩过这个坑,一开始用IsZero(),结果所有 firing 的告警都被当成已恢复。

fingerprint是 Alertmanager 算好的稳定哈希,同一个告警在 firing 和 resolved 两次推送里保持不变。这是做去重和状态追踪的最佳主键,比自己拼alertname + instance靠谱得多。

commonLabels和commonAnnotations是组内公共部分,单条告警的labels里也有完整信息。渲染模板的时候优先用单条的,缺失的再从 common 里兜底。

还有一点容易忽略:truncatedAlerts字段。当一次推送的告警数量超过max_alerts时,超出部分会被截断,这个字段告诉你截了多少。如果它是非零值,说明你正在丢告警,需要调大max_alerts或者调整分组策略。

2.2 Falco 的输出走 HTTP,字段是另一套

Falco 侧我推荐两种接入姿势,看版本和运维习惯。较新的 Falco 在falco.yaml里可以直接开http_output:

http_output: enabled: true url: "http://gin-webhook:8080/webhook/falco" insecure: false user_agent: "falcosecurity/falco"

如果你的环境里已经在用 falcosidekick 做统一出口,那就让 falcosidekick 把事件转发过来,它 POST 的 body 结构更规整。不管走哪条路,落到我们服务里的核心字段是这些:

{ "uuid": "a1b2c3d4-0000-1111-2222-333344445555", "output": "08:12:33.421: Warning Sensitive file opened for reading by non-trusted program (user=root file=/etc/shadow command=cat)", "priority": "Warning", "rule": "Read sensitive file untrusted", "time": "2024-05-11T08:12:33.421Z", "source": "syscall", "hostname": "node-03", "tags": ["filesystem", "mitre_credential_access"], "output_fields": { "evt.time": 1715415153421000000, "fd.name": "/etc/shadow", "proc.name": "cat", "user.name": "root", "container.id": "9f8e7d6c" } }

这里有两个必须注意的细节。第一,output_fields是一个动态 map,key 随规则变化,值是interface{}——可能是字符串、数字、布尔。用map[string]interface{}接收最省事,要强类型就在二次解析时做类型断言。第二,priority是字符串,取值从Emergency到Debug共八级,顺序是 Emergency > Alert > Critical > Error > Warning > Notice > Informational > Debug。这个顺序和很多人直觉里的“Critical 最高”是反的,映射到内部级别时千万别写错。

我用的映射表是这样:

Falco priority内部级别是否推送消息样式
Emergency / Alert / CriticalP0是红色加粗,@值班
ErrorP1是橙色
WarningP2是默认色
Notice / InformationalP3可选默认色,可配置关闭
DebugP4否丢弃

2.3 统一内部模型:一个 Alert 结构打通两路

不管来源是 Prometheus 还是 Falco,进了服务之后都要归一化成同一个结构。这一步是整个服务的地基,设计得好后面所有代码都顺,设计得差就要到处打补丁。

type Level string const ( LevelP0 Level = "P0" LevelP1 Level = "P1" LevelP2 Level = "P2" LevelP3 Level = "P3" ) type Alert struct { Source string `json:"source"` // prometheus | falco Fingerprint string `json:"fingerprint"` // 去重主键 Status string `json:"status"` // firing | resolved Level Level `json:"level"` Title string `json:"title"` Summary string `json:"summary"` Detail string `json:"detail"` Host string `json:"host"` Labels map[string]string `json:"labels"` StartsAt time.Time `json:"starts_at"` EndsAt time.Time `json:"ends_at"` Link string `json:"link"` Raw json.RawMessage `json:"raw"` }

Raw字段保留原始报文,用于审计和问题回放。Fingerprint对 Falco 来说没有现成的,我用uuid,如果没有uuid就退化成rule + hostname + 秒级时间戳的 SHA1。Link是生成跳转链接用的,Prometheus 侧指向generatorURL,Falco 侧指向内部的事件检索页。

这里有个设计上的取舍值得说一下:为什么不把这些字段塞进Labels里省事?因为Labels是透传的、不稳定的、可能为空的,而Level、Status是要参与逻辑判断的。混在一起写,半年后你自己都分不清哪个 key 是业务给的、哪个是你自己加的。宁可多定义几个字段,也不要让Labels承担语义。

3. Gin 服务骨架与核心实现

3.1 路由、中间件和启动参数

目录结构我用了很朴素的四层:cmd/放入口,internal/handler放入站处理,internal/notify放各平台的 Sender 实现,internal/model放数据结构。没有引入依赖注入框架,也没有搞 DDD 分层,这个体量的服务搞那一套纯属自我感动。

路由只有四个:

func SetupRouter(cfg *config.Config, dispatcher *notify.Dispatcher) *gin.Engine { gin.SetMode(cfg.Server.Mode) r := gin.New() r.Use(gin.Recovery(), middleware.RequestID(), middleware.AccessLog()) r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) r.POST("/webhook/prometheus", handler.PrometheusHandler(cfg, dispatcher)) r.POST("/webhook/falco", handler.FalcoHandler(cfg, dispatcher)) // 手动触发测试消息,只在非生产模式注册 if cfg.Server.Mode != gin.ReleaseMode { r.POST("/webhook/test", handler.TestHandler(cfg, dispatcher)) } return r }

中间件里有两个是必须的,一个是我强烈建议加的。

RequestID中间件给每个请求生成一个短 ID,写进X-Request-Id响应头,同时注入日志上下文。这个 ID 会一路透传到 Sender 的日志里。线上排查“这条告警到底发没发出去”的时候,从 Alertmanager 的日志里捞一个时间点,交叉比对 RequestID,几分钟就能定位,不用大海捞针。

AccessLog中间件记录方法、路径、状态码、耗时、客户端 IP。这里有个坑:Gin 默认只信任127.0.0.1和::1的代理头,如果服务前面挂了反向代理,c.ClientIP()拿到的永远是代理的 IP,日志里全是同一个地址,毫无价值。解决办法是显式设置r.SetTrustedProxies([]string{"10.0.0.0/8", "172.16.0.0/12"}),只信任内网段。

还有个体积极小的坑:Gin 的c.Request.Body是一次性读取的。如果你在中间件里读了 body 做审计,后面的 handler 再ShouldBindJSON就会拿到空内容。我的做法是用c.ShouldBindBodyWith(&obj, binding.JSON),Gin 内部会把读过的 body 缓存在 context 里,后续还能复用。或者在最外层io.ReadAll之后用c.Request.Body = io.NopCloser(bytes.NewReader(body))重置。

3.2 入站处理:解析、归一化、异步扇出

Prometheus 的 handler 是核心,大概长这样:

func PrometheusHandler(cfg *config.Config, d *notify.Dispatcher) gin.HandlerFunc { return func(c *gin.Context) { var payload model.AlertmanagerPayload if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()}) return } alerts := make([]model.Alert, 0, len(payload.Alerts)) for _, a := range payload.Alerts { lv := model.MapSeverity(a.Labels["severity"]) if lv == "" { lv = model.LevelP2 } alerts = append(alerts, model.Alert{ Source: "prometheus", Fingerprint: a.Fingerprint, Status: a.Status, Level: lv, Title: firstNonEmpty(a.Labels["alertname"], payload.CommonLabels["alertname"]), Summary: firstNonEmpty(a.Annotations["summary"], payload.CommonAnnotations["summary"]), Detail: a.Annotations["description"], Host: a.Labels["instance"], Labels: a.Labels, StartsAt: a.StartsAt, EndsAt: a.EndsAt, Link: a.GeneratorURL, }) } // 立即返回 200,避免 Alertmanager 超时重试 c.JSON(http.StatusOK, gin.H{"accepted": len(alerts)}) // 异步分发 go d.Dispatch(context.Background(), alerts) } }

为什么要异步?Alertmanager 的 webhook 默认超时是 10 秒,超时它会认为发送失败,然后进入重试。如果我在 handler 里同步等三个平台的 HTTP 调用完成,只要其中一个平台慢(钉钉限流时响应能到 3 秒以上),整条链路就会拖长。一旦触发超时重试,同一条告警会被发两次甚至更多次。异步之后,handler 的响应时间稳定在毫秒级,永远不超时。代价是丢了“发送结果”的同步反馈,但告警场景不需要这个,日志里能看到就够了。

Dispatch里做的是扇出。每个通道一个 goroutine,用sync.WaitGroup等待,整体有个兜底超时:

func (d *Dispatcher) Dispatch(ctx context.Context, alerts []model.Alert) { if len(alerts) == 0 { return } ctx, cancel := context.WithTimeout(ctx, d.cfg.Notify.TotalTimeout) defer cancel() var wg sync.WaitGroup for _, ch := range d.enabledChannels() { if !ch.ShouldSend(alerts) { continue } wg.Add(1) go func(ch notify.Channel) { defer wg.Done() msg := ch.Render(alerts) if err := ch.Send(ctx, msg); err != nil { log.Error("send failed", "channel", ch.Name(), "err", err) metrics.SendFailed.WithLabelValues(ch.Name()).Inc() return } metrics.SendOK.WithLabelValues(ch.Name()).Inc() }(ch) } wg.Wait() }

ShouldSend这个方法看着多余,其实是配置能力的落点。它让每个通道可以自己声明“我只关心 P0 和 P1”“我只关心 source=falco”“我可以接收 resolved”。加通道的时候只改 YAML,判断逻辑通用。

3.3 三个平台的签名与消息体,细节全在这

这部分是整个项目里最容易反复返工的地方,我按平台拆开说。

钉钉的机器人有两种安全设置:关键词和加签。关键词模式最简单,但要求消息内容里必须包含预设词,很容易踩到“我的告警文案改了,结果全发不出去”。我推荐加签模式,虽然要多写二十行代码,但文案自由度完全打开。

加签的算法是:取当前毫秒时间戳ts,把ts + "\n" + secret拼成字符串,用 secret 作为 HMAC-SHA256 的密钥对它做摘要,结果 base64 后再 URL encode,最后拼到 webhook 地址后面。注意这里的方向——待签数据是 ts 加换行加 secret,密钥是 secret,这一点和飞书是反的,我一开始把两个搞混了,调了半小时。

func signDingTalk(secret string, ts int64) string { strToSign := fmt.Sprintf("%d\n%s", ts, secret) h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(strToSign)) return url.QueryEscape(base64.StdEncoding.EncodeToString(h.Sum(nil))) }

消息体用 markdown 类型,标题和正文分开:

body := map[string]interface{}{ "msgtype": "markdown", "markdown": map[string]string{ "title": title, "text": text, }, "at": map[string]interface{}{ "atMobiles": mobiles, "isAtAll": false, }, }

企业微信走群机器人,鉴权全靠 URL 上的key参数,没有签名环节,简单。但有两个硬限制必须知道:一是markdown类型的content最长 4096 字节,超了会直接报错而不是截断;二是它支持的 markdown 语法是阉割版,只有font color的info、comment、warning三种颜色,没有表格、没有代码块、没有多级标题。

body := map[string]interface{}{ "msgtype": "markdown", "markdown": map[string]string{ "content": truncateBytes(content, 4000), }, }

truncateBytes必须按字节截断而不是按 rune,且要保证截断后不出现半个中文字符。我的实现是从 4000 往下退,直到utf8.ValidString返回 true。这个细节不做,偶尔就会收到企业微信返回的 40058 参数错误。

飞书是最麻烦的。它的签名算法是:取时间戳ts,把ts + "\n" + secret作为HMAC-SHA256 的密钥,对空字符串做摘要,再 base64。看清楚,待签数据是空的,密钥是那串拼接结果。和钉钉完全相反。

func signFeishu(secret string, ts int64) string { key := fmt.Sprintf("%d\n%s", ts, secret) h := hmac.New(sha256.New, []byte(key)) h.Write([]byte{}) // 空内容 return base64.StdEncoding.EncodeToString(h.Sum(nil)) }

签名放在请求体的timestamp和sign字段里,不是 header,也不是 URL。飞书的消息类型我推荐用interactive卡片,因为只有卡片能把不同级别的告警渲染成不同颜色的侧边条,值班的人扫一眼就知道严重程度。卡片 JSON 层级比较深:

{ "timestamp": "1715415153", "sign": "xxxx", "msg_type": "interactive", "card": { "config": {"wide_screen_mode": true}, "header": { "template": "red", "title": {"tag": "plain_text", "content": "[P0] 节点失联"} }, "elements": [ {"tag": "div", "text": {"tag": "lark_md", "content": "**实例**:10.0.0.11:9100\n**时间**:2024-05-11 08:12:33"}}, {"tag": "hr"}, {"tag": "action", "actions": [{"tag": "button", "text": {"tag": "plain_text", "content": "查看详情"}, "url": "http://prometheus:9090/graph", "type": "primary"}]} ] } }

header.template的取值对应颜色:red给 P0,orange给 P1,blue给 P2,grey给恢复通知。这套映射我写死在配置的模板层里,不放在代码里,方便以后调。

三个平台的频率限制也得记住,超了不是报错就是静默丢弃:

平台限制超限表现应对
钉钉每分钟 20 条返回 errcode 130101服务端令牌桶限速
企业微信每分钟 20 条返回 errcode 45009同上
飞书每分钟 100 次、每秒 5 次返回 code 11232同上,注意秒级限制

我在每个 Sender 内部挂了一个golang.org/x/time/rate的限流器,钉钉和企业微信设成rate.NewLimiter(rate.Every(3*time.Second), 1),也就是平均 3 秒一条,突发 1 条。告警风暴来的时候会被排队而不是被打回,队列长度设个上限(比如 100),超了就丢弃并记一条 error 日志——总比因为限流把所有消息都搞丢强。

3.4 去重、状态机与重试策略

去重我用的是进程内的 LRU,key 是fingerprint,value 是一个小结构体记录上次发送的status和level。规则是:status变了必须发,level变了必须发,同一个status在 30 分钟内不重复发。30 分钟这个值是从 Alertmanager 的repeat_interval倒推的——它设的是 4 小时,那我在 30 分钟窗口内去重就够了,不会误伤正常的重复告警。

Redis 我也考虑过,但评估下来不值得。这个服务是无状态的、可以多副本部署的,用 Redis 做跨副本去重确实更精确,但引入一个外部依赖意味着多一个故障点。而且多副本本身就是个伪需求——单副本扛每秒几十条告警绰绰有余,真到了要扩副本的量级,那说明监控规则写得有问题,该去治理告警风暴而不是给转发层扩容。

重试策略我定得很保守:

  • 只对网络错误、5xx、429 重试,4xx 一律不重试(参数错了重试一万次也是错)
  • 最多重试 3 次,间隔 1s、2s、4s
  • 重试在单个通道内部进行,不阻塞其他通道
  • 全部失败后写一条结构化 error 日志,带上完整报文,供事后补发
func (s *baseSender) doWithRetry(ctx context.Context, req *http.Request, build func() (*http.Request, error)) error { var lastErr error for i := 0; i < 3; i++ { if i > 0 { select { case <-time.After(time.Duration(1<<uint(i-1)) * time.Second): case <-ctx.Done(): return ctx.Err() } } r, err := build() if err != nil { return err } resp, err := s.client.Do(r.WithContext(ctx)) if err != nil { lastErr = err continue } body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { // 注意:HTTP 200 不代表业务成功,还要看平台返回的 errcode if err := s.checkBizCode(body); err != nil { lastErr = err continue } return nil } if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { return fmt.Errorf("client error %d: %s", resp.StatusCode, string(body)) } lastErr = fmt.Errorf("server error %d: %s", resp.StatusCode, string(body)) } return lastErr }

checkBizCode这一步很多人会漏。钉钉和企业微信在参数错误、限流、关键词不匹配的时候,HTTP 状态码依然是 200,错误信息藏在 body 的errcode里。如果不解析 body 就直接看状态码,你会以为发送成功了,实际上一条消息都没发出去,而且日志里干干净净,查都没法查。

4. 部署联调与问题排查实录

4.1 容器化与配置管理

Dockerfile 用两阶段构建,最终镜像基于alpine加 ca-certificates(访问 HTTPS 必须)。不用scratch是因为我得进容器里nslookup排查 DNS 问题,多这 5MB 换来的是排障便利,很划算。

FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/webhook ./cmd/server FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata ENV TZ=Asia/Shanghai COPY --from=builder /out/webhook /usr/local/bin/webhook EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/webhook", "-config", "/etc/webhook/config.yaml"]

TZ=Asia/Shanghai这行必须加。不加的话容器内是 UTC,告警消息里的时间会早 8 小时,值班的人看到“凌晨 0 点”的告警,实际上是早上 8 点,这种信息错位在应急时非常要命。

配置用 YAML,密钥字段支持环境变量覆盖:

server: addr: ":8080" mode: "release" dedup: ttl: "30m" max_size: 10000 channels: - name: "dingtalk-ops" type: "dingtalk" enabled: true webhook: "${DINGTALK_OPS_WEBHOOK}" secret: "${DINGTALK_OPS_SECRET}" accept_levels: ["P0", "P1", "P2"] accept_sources: ["prometheus", "falco"] send_resolved: true - name: "wecom-security" type: "wecom" enabled: true webhook: "${WECOM_SEC_WEBHOOK}" accept_levels: ["P0", "P1"] accept_sources: ["falco"] send_resolved: false - name: "feishu-all" type: "feishu" enabled: true webhook: "${FEISHU_ALL_WEBHOOK}" secret: "${FEISHU_ALL_SECRET}" accept_levels: ["P0", "P1", "P2", "P3"] accept_sources: ["prometheus", "falco"] send_resolved: true

用${VAR}占位符加os.ExpandEnv展开,好处是敏感信息不进 Git,坏处是启动时如果变量没配就会展开成空字符串,然后服务带着空密钥跑起来,第一次发消息才报错。所以我加了一个启动时的校验:所有enabled: true的通道,如果webhook为空直接log.Fatal,让它在启动阶段就崩掉,别等到半夜告警发不出去才发现。

4.2 分阶段联调,别一次性全接

我的联调顺序是:先打通单个平台,再打通单条链路,最后全量接。这个顺序看着笨,但能省掉大量“到底是哪一层出问题”的排查时间。

第一步,平台侧裸测。用 curl 直接调三家机器人,确认地址和签名是对的。这一步不写任何 Go 代码。

# 钉钉,先测无签名的关键词模式,确认地址通 curl -X POST "$DINGTALK_WEBHOOK" \ -H 'Content-Type: application/json' \ -d '{"msgtype":"text","text":{"content":"联通性测试"}}' # 飞书 curl -X POST "$FEISHU_WEBHOOK" \ -H 'Content-Type: application/json' \ -d '{"msg_type":"text","content":{"text":"联通性测试"}}'

第二步,服务侧回放。把之前用nc抓到的真实 Alertmanager 报文存成prom.json,直接往本地服务打:

go run ./cmd/server -config ./config.local.yaml & curl -X POST http://127.0.0.1:8080/webhook/prometheus \ -H 'Content-Type: application/json' \ --data-binary @testdata/prom.json

这一步把协议转换和渲染的逻辑跑通,不涉及真实的告警源。报文回放的好处是可重复——改一次模板,重放一次,立刻能看到效果,不用等真实的告警触发。

第三步,接真实 Alertmanager。改alertmanager.yml:

route: receiver: 'gin-webhook' group_by: ['alertname', 'severity'] group_wait: 30s group_interval: 5m repeat_interval: 4h receivers: - name: 'gin-webhook' webhook_configs: - url: 'http://gin-webhook.monitoring.svc:8080/webhook/prometheus' send_resolved: true max_alerts: 20

send_resolved: true千万别漏,不配的话恢复通知根本不会推过来,你在转发层做的状态机全是空转。max_alerts: 20是为了和去重窗口配合,一次推太多条既没意义也容易触发平台限流。

第四步,接 Falco。先手动造一条事件,别等真实的安全事件:falco -A -r test_rule.yaml或者直接用 curl 往/webhook/falco打一条构造的 JSON,确认字段能对上。

4.3 常见问题速查表

下面这些是我和同事在这套链路上真实撞到过的问题,按出现频率排序:

现象根因解决
钉钉返回 errcode 310000加签失败或关键词不匹配检查时间戳是否为毫秒、签名字符串拼接方向是否正确;确认机器人安全设置模式
钉钉 HTTP 200 但群里没消息机器人被移出群或 token 失效用 curl 单独验证 webhook 地址
企业微信 40058markdown content 超过 4096 字节按字节截断并保证 UTF-8 合法
企业微信 45009触发每分钟 20 条限流服务端加令牌桶,队列溢出时丢弃并告警
飞书返回 code 19021sign 为空或算法错误确认真空数据、拼接串作密钥的写法;时间戳单位是秒
飞书返回 code 11232触发频率限制加秒级限流器
告警只发一次,恢复没通知Alertmanager 未开 send_resolved,或服务端去重把 resolved 也过滤了两处都检查;状态变更必须绕过去重
明明在 firing 却显示“已恢复”用time.IsZero()判断 endsAt改为判断年份小于 2 或EndsAt.After(time.Now())
消息里的时间是 UTC容器未设 TZDockerfile 加ENV TZ=Asia/Shanghai并装 tzdata
日志里客户端 IP 全是同一个Gin 默认不信任反代头SetTrustedProxies配置内网段
中文显示成问号Content-Type 缺 charset设为application/json; charset=utf-8
告警风暴时服务 CPU 打满同步发送 + 无并发上限改异步扇出,加信号量限制同时在途的请求数
Falco 事件解析 panicoutput_fields 值类型不定,强类型断言用interface{}接收,二次断言带 ok 判断

关于告警风暴,单独说一句。有一次上游一条错误的 Prometheus 规则导致 300 多个实例同时触发,瞬间打进来十几条每批 20 个告警的请求。当时服务没做并发上限,几百个 goroutine 同时往外发 HTTP,把本机的连接数打满,连带健康检查都超时了。后来的改法是加一个带缓冲的信号量:

sem := make(chan struct{}, 20) // 最多 20 个在途发送

发送前sem <- struct{}{},发送后<-sem。超过 20 个的请求在信号量上排队,配合通道级的超时,最坏情况下会丢弃一部分低级别告警,但服务本身活着,高级别告警依然能出去。这个取舍我认。

5. 几个人肉换来的经验

先做通道,再做转换。我一开始的顺序是反的——花了三天写解析和归一化,最后联调时发现某个平台的签名一直不对,前面写的全白搭。后来调整成“先用 curl 把每个平台的签名调通,再回来写转换逻辑”,效率高得多。判断一个平台能不能用,最便宜的验证方式永远是 curl,而不是读文档。

把每条发出去的报文都存下来。我给 Sender 加了一个dry_run开关,打开之后只渲染不发送,把渲染结果打到日志里。这个功能在改模板的时候太有用了——你可以一次性看到七个通道各自会渲染成什么样,而不用真的往群里刷七条测试消息。

级别的划分不要超过四档。我最初按 Falco 的八级做了八档,后来发现值班的人根本不看 P4、P5、P6 的差别,最后简化成 P0 到 P3 四档,配上四个颜色,反而清晰了。级别不是越细越好,是越能驱动行动越好。

恢复通知比告警通知更重要。这个反直觉,但确实是实战结论。告警响的时候大家都会去看,恢复的时候如果没通知,值班的人会一直以为问题还在,要么重复排查,要么不敢下班。所以我在去重逻辑里给 resolved 开了绿色通道,任何情况下都不去重、不限流、不降级,优先发出去。

最后提一个后续可以扩展的方向:把这个服务的入站协议做成可插拔的。现在只有/webhook/prometheus和/webhook/falco两个端点,如果以后要接 CloudEvents 格式的告警源,或者接内部的发布系统事件,只要加一个 handler 和一个 mapper,核心的分发逻辑一行都不用改。这也是当初把“归一化”单独抽成一步的价值所在——所有的复杂度都被挡在了model.Alert这道墙外面。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进