做监控告警的同学多半都遇到过这种场面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或resolvedalerts[]里每一项还有自己的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_outputhttp_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 (userroot file/etc/shadow commandcat), 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是一个动态 mapkey 随规则变化值是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 侧指向generatorURLFalco 侧指向内部的事件检索页。这里有个设计上的取舍值得说一下为什么不把这些字段塞进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”“我只关心 sourcefalco”“我可以接收 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给 P0orange给 P1blue给 P2grey给恢复通知。这套映射我写死在配置的模板层里不放在代码里方便以后调。三个平台的频率限制也得记住超了不是报错就是静默丢弃平台限制超限表现应对钉钉每分钟 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 去重、状态机与重试策略去重我用的是进程内的 LRUkey 是fingerprintvalue 是一个小结构体记录上次发送的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(1uint(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_ENABLED0 go build -trimpath -ldflags-s -w -o /out/webhook ./cmd/server FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata ENV TZAsia/Shanghai COPY --frombuilder /out/webhook /usr/local/bin/webhook EXPOSE 8080 ENTRYPOINT [/usr/local/bin/webhook, -config, /etc/webhook/config.yaml]TZAsia/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.ymlroute: 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: 20send_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 TZAsia/Shanghai并装 tzdata日志里客户端 IP 全是同一个Gin 默认不信任反代头SetTrustedProxies配置内网段中文显示成问号Content-Type 缺 charset设为application/json; charsetutf-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这道墙外面。
阅读完成 · 觉得有帮助?