一、先说这个事有多普遍
早上打开邮箱,发现一条告警竟然发到了离职同事的邮箱里。这位同事上个月就已经办完离职手续,工号都销了,可系统里的告警还是照常推。这个情况在很多公司里并不罕见,看着只是一个小疏漏,背后牵扯的权限同步问题和通知合规问题,要比我们想象得严肃。
Grafana 作为目前最流行的可视化监控平台,它的告警能力很强大,但强大不等于安全。当一个员工从公司“消失”的时候,很多系统的账号会被清理,Grafana 里的用户记录却常常被遗忘。这不是 Grafana 单方面的问题,而是我们整个告警链路里缺少了一道“合规闸门”。
二、员工离职后,为什么还能收到告警
要理解问题,就得先看清楚一条告警从产生到送达的完整路径。简单说,Grafana 的告警可以分成三个阶段:触发条件、通知策略、推送渠道。
比如一个告警规则检查 CPU 超过了 90%,触发之后,Grafana 会去找这个规则绑定的通知策略。通知策略里面写了要发给哪些人、哪些组,下一步就把消息塞给相应的联系点。联系点里定义了具体的接收端,比如企业微信机器人、钉钉群、邮件服务器等等。邮件服务器收到请求后,把邮件发到名单里写的邮箱地址。
问题就出在“通知策略”和“联系点”这两层。如果当初配置规则时,直接把某位员工绑定成了接收人,那么他的邮箱就已经写死在策略里面了。员工离职后,邮箱账号被停用、甚至被删除,可 Grafana 告警规则里的邮箱地址还贴着原来的标签。这种绑定关系是静态的,不会跟着外部身份源的变化自动更新。
所以,告警系统本身没有做错什么,它只是忠实执行既定规则。错在我们的身份数据没有及时同步,错在我们把接收人硬编码成了个人,而不是通过动态的组织角色去关联。
三、为什么权限同步容易被人忽略
很多公司都有统一身份认证系统,比如基于 LDAP 或者 Active Directory。用人部门提交离职流程,HR 在系统里操作一下,第二天这个人的账号就不能登录了。但这套流程只覆盖了需要登录的系统,像 Grafana 这种监控平台,它自己不存真实的身份数据,它只是从外部去读取。
这时就有一个时间差的问题。Grafana 在外部身份源里找不到这个用户了,它往往不会主动去删除本地的缓存数据。特别是通过 LDAP 同步用户的时候,很多团队的配置是“只读不同步”,就是说用户已经存在于 Grafana 的数据库里了,外部认证只是用来验证密码。这样一来,本地用户记录依然保留着,他的邮箱也还挂在那里。
还有一个更隐蔽的地方,就是团队和文件夹的权限。就算这个人的 Grafana 登录账号被禁用了,只要他是某个文件夹或某个团队的管理员,Grafana 里的告警规则和通知策略还是会默认关联他。管理员的权限有时候会让我们不敢轻易做清理,怕删了什么东西引起更大的问题,最后就变成了“没人敢动”,于是离职员工依然活在告警通知里。
在生活的场景里,这就好比老房子墙上挂着一排电闸,其中一个闸对应的是十年前已经搬走的邻居家的电路。你每天关闸,这个搬走的邻居家电器还会响,因为电线从来没改过。
四、用什么思路去治理
既然问题根源是“外部身份同步”和“Grafana 内部静态绑定”产生了矛盾,那么治理方案就要从这两条线同时下手。核心思路就六个字:同步、校验、路由。
同步,是指把外部身份源(比如企微通讯录或者 HR 系统)当成唯一标准,定时把员工在职状态拉取到监控平台旁边的一个小服务里。这个服务独立于 Grafana 自身,不依赖它的数据库。
校验,是指每当 Grafana 要发送一条告警,它会先把这个通知请求转给一个中间层,由中间层去查这个接收人是不是还存在。这里的关键点在于,Grafana 的发信地址并不是邮件服务器,而是我们自建的一个校验服务。
路由,是指校验服务检查完之后,按不同情况去分流。如果这个人还在职,那么正常发邮件;如果这个人已经离职,马上给他标记为“失效接收人”,同时把这条告警改发到团队负责人或者值班组的邮箱里。
这三个环节串在一起,我们就在不改动 Grafana 核心结构的情况下,给它套上了一件“合规外衣”。
五、具体落地方案:设计一个适配器服务
5.1 场景假设
假设我们的公司使用石墨文档系统做办公协同,内部有一个离职名单接口可以拉取。该接口格式如下:
{
"code": 0,
"message": "success",
"data":
{
"total": 2,
"users":
[
{
"user_id": "g002",
"email": "laowang@example.com",
"name": "王大力",
"status": "offline",
"off_time": "2025-11-01 10:30:00"
},
{
"user_id": "g003",
"email": "xiaoli@example.com",
"name": "李小倩",
"status": "onboard",
"off_time": ""
}
]
}
}
Grafana 配置的告警规则,把接收对象填写为邮箱地址。邮件由 SMTP 服务器发送。我们要做的,是把这个“接收对象”指向一个叫“合规播报员”的中间服务,由它来判断是否真的发出去。
5.2 技术栈选择
为了让示例保证统一,后面相关代码和配置都使用 Go 语言。因为 Go 天生适合写这种轻量级中间代理服务,将来部署成云函数或者普通容器都很方便。
项目结构设计:
// 说明:整个服务分为三个区域
// 区域一:接收Grafana发来的告警请求
// 区域二:查外部离职名单
// 区域三:判断并转发告警到邮件服务器
package main
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"time"
)
// 定义接收Grafana告警的数据结构
type GrafanaAlert struct {
Title string `json:"title"` // 告警标题
Message string `json:"message"` // 告警详情
Receiver string `json:"receiver"` // 原始接收人邮箱
Dashboard string `json:"dashboard"` // 关联看板地址
RuleName string `json:"ruleName"` // 规则名字
}
// 定义外部离职名单的返回结构
type ResignedUser struct {
Email string `json:"email"`
Status string `json:"status"` // onboard 表示在职,offline 表示离职
}
type ResignResponse struct {
Code int `json:"code"`
Data struct {
Users []ResignedUser `json:"users"`
} `json:"data"`
}
// 定义最终发给邮件服务器的负载
type MailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
5.3 核心逻辑:中间层服务
这个服务做的事情可以拆成几步。接收 Grafana 的消息,读它里面的接收人邮箱,拿着邮箱去离职名单里查状态。如果查出来是离职,就替换成新的值班邮箱。如果查出来是在职,就原样放行。如果离职接口宕机了,出于安全考虑,应该把告警转到默认的应急组,而不是直接放行到原邮箱,这样更稳妥。
// 全局配置,生产环境可以从配置文件读
var defaultSmtpEndpoint = "http://mail-server.local/v1/send"
var defaultFallbackMail = "oncall@example.com"
// 拉取离职名单
func fetchResignedUsers() (map[string]bool, error) {
// 模拟一次HTTP请求去向外部身份系统要数据
resp, err := http.Get("http://idp.example.com/resigned_users")
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 读取响应体
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 解析JSON结构
var result ResignResponse
err = json.Unmarshal(body, &result)
if err != nil {
return nil, err
}
// 转换成map,方便后面高效查询
// key是邮箱,value是布尔值,true表示此人离职
resignedMap := make(map[string]bool)
for _, user := range result.Data.Users {
if user.Status == "offline" {
resignedMap[user.Email] = true
}
}
return resignedMap, nil
}
上面这段代码的关键点在于把离职名单拉下来以后,放到一个 map 里面。这样做的好处显而易见——当告警量很大的时候,直接查内存比反复请求外部接口要快得多。但也要注意,内存里的数据会过期,所以这个函数在真实项目里应该配合定时任务一起使用。比如每五分钟拉取一次,刷新本地缓存。
5.4 通知拦截与路由
下一步是写一个中间处理函数,它本质上是一个 HTTP 处理器。Grafana 把原来的联系点地址换成这个地址,所有告警都会先经过这里。
// 这个函数用来拦截Grafana的告警请求
func alertHandler(w http.ResponseWriter, r *http.Request) {
// 只接收POST请求
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 读取请求体
rawBody, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Println("读取请求body失败:", err)
http.Error(w, "read body error", http.StatusBadRequest)
return
}
// 把请求体解析成我们定义的结构体
var alert GrafanaAlert
err = json.Unmarshal(rawBody, &alert)
if err != nil {
log.Println("解析Grafana告警结构失败:", err)
http.Error(w, "parse error", http.StatusBadRequest)
return
}
// 拉取最新的离职名单
resignedUsers, err := fetchResignedUsers()
if err != nil {
// 这里是降级策略:如果外部接口不可用,直接发给默认应急组
log.Println("获取离职名单接口失败,使用兜底地址:", err)
forwardToMail(defaultFallbackMail, alert)
return
}
// 检查当前接收人邮箱是否属于离职状态
if _, isResigned := resignedUsers[alert.Receiver]; isResigned {
// 已经是离职员工,走替换逻辑
// 替换成团队负责人的邮箱
replaceMail := "team-leader@example.com"
log.Printf("接收人 %s 已离职,替换为 %s", alert.Receiver, replaceMail)
forwardToMail(replaceMail, alert)
return
}
// 正常的情况下原样发送
log.Printf("接收人 %s 是在职状态,正常发送", alert.Receiver)
forwardToMail(alert.Receiver, alert)
}
这里要特别说明一下注释的作用。注释不仅仅是给机器看的,更是给后来维护的人看的。上面每种分支逻辑都写了什么情况下会走这里,又做了什么事情。就算这个人没接触过 Go,也能大致看懂。
5.5 邮件的实际发送
上面已经处理完路由逻辑,下面就是最基础的发送动作。在小型团队里,只需要模拟一个调用第三方邮件接口的过程就行。
// 把最终确定要发送的内容交给邮件系统
func forwardToMail(targetMail string, alert GrafanaAlert) {
// 构造邮件结构体
payload := MailPayload{
To: targetMail,
Subject: fmt.Sprintf("【监控告警】 %s", alert.Title),
Body: fmt.Sprintf("规则名称: %s\n告警详情: %s\n看板地址: %s\n原始接收人: %s",
alert.RuleName, alert.Message, alert.Dashboard, alert.Receiver),
}
// 将结构体转成JSON
jsonData, err := json.Marshal(payload)
if err != nil {
log.Println("邮件内容序列化失败:", err)
return
}
// 发送HTTP请 求到邮件服务
req, err := http.NewRequest("POST", defaultSmtpEndpoint, bytes.NewBuffer(jsonData))
if err != nil {
log.Println("创建邮件请求失败:", err)
return
}
req.Header.Set("Content-Type", "application/json")
// 执行最终发送
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Println("调用邮件服务出错:", err)
return
}
defer resp.Body.Close()
// 简单记录一下发送结果
log.Printf("告警通知已发送至 %s,响应状态码 %d", targetMail, resp.StatusCode)
}
到这里,一个完整的中间校验服务就跑通了。它不是直接在 Grafana 里改逻辑,而是在中间加了一层“岗哨”。这样改造下来,原来的告警链路没有破坏,只是多了一次转发和判断。
六、配置 Grafana 走新链路
服务程序写好了,Grafana 那边还需要做一个小改动。在 Grafana 的告警联系点里,把原本的邮件 SMTP 配置,替换成我们新服务的地址。因为 Grafana 支持 Webhook 类型的联系点,所以配置起来非常简单。
首先,在 Grafana 里新增一个“联系点”,类型选 webhook,URL 填我们的中间服务地址,比如 http://alert-filter.internal:8080/grafana/hook。同时,把原有邮件发送联系点保留,仍然作为最终通道,但不再提供给个人接收人直接使用。
告警规则的设置里,把通知策略绑定到这个新的 Webhook 联系点。原来通知策略里写死的那几个离职员工邮箱,直接删掉,改成绑定到团队群组这一层。
在 Grafana 7.0 之后的版本里,可以用模板变量来实现动态接收人。比如接收人只写一个占位符,真正发送的时候到外部系统拿具体名单。这个策略对我们的适配器来说只是锦上添花,因为中间层服务已经能做最终兜底判断了。
七、日常怎么防患于未然
上面那个服务解决的是“已经发生”的问题。为了避免“又会发生”,我们还得有一套日常管理流程。
第一,定时任务要跑起来。写一个简单的脚本,每天晚上把外部身份源的全量人员信息拉一次,跟 Grafana 的所有联系人和通知策略做一次对比。凡是发现接收人里有离职状态的邮箱,自动生成一个工单,第二天运维同事直接处理。
第二,发通知前增加“在职确认”环节。对于一些高权限的看板,甚至可以采用双人确认机制,也就是说告警不直接发到个人邮箱,而是先到团队群,再由当前值班人员在群内手动 @ 相关人。
第三,建立轮岗值班机制。告警通知的接收人不能固定不变,应该跟着值班表走。这就要说到外部权限同步的深水区了——我们应该把“谁接收告警”这件事从“用户身份”变成“角色身份”。
比如定义一个“数据库告警接收者”的角色,这个角色名下有三个人。当其中一个人离职,角色不会消失,只需要把人从角色下拿掉。Grafana 只需要关注这个角色对应的通知群组,根本不需要关心是哪个人在接收。这样一来,权限同步的粒度就从“用户邮箱”降低到了“角色”,出问题的概率就小了很多。
八、这种方案的优缺点和注意事项
先说说优点。最直观的好处就是合规,员工离职后不会再收到任何内部敏感信息,无论是告警内容还是看板 URL,都不会泄露出去。其次,运维效率高了,以前需要人工挨个检查 Grafana 里的联系人,现在外部系统一变,我们马上就能拦下来。第三,我们的中间服务足够轻量,可以部署多个副本,挂一个还有另一个在用,比直接改 Grafana 数据库安全得多。
再说缺点。最大的风险在于依赖外部系统的稳定性。如果外部身份同步接口超时或者宕机,我们的服务就会拿不到名单,这时候只能走兜底策略,把告警发给固定应急组。兜底策略虽然保证了“有通知”,但不一定能保证发给最合适的人。另外,因为中间多了一层转发,告警到达可能会有几秒到几十秒的延迟,不过对大多数监控场景来说是可以接受的。
还有一点要特别注意。如果外部身份系统里离职员工的邮箱被回收,分给了新员工,那就有可能出现误伤。比如一个邮箱 username@example.com,离职员工叫张三,新入职员工也叫张三(英文名都是 zhangsan),系统可能只认邮箱不认人。这时必须对“邮箱”和“员工工号”做双维度校验,不能光看邮箱匹配。我们的示例里只用了 email 做判断,真实项目里应该加上 user_id。
再有一点,就是适配器服务的自身安全。它收到的是 Grafana 的可信请求,所以一定要限制来源 IP,避免任何人都能调用这个 Webhook。否则,随便往这个地址发一个伪造的告警请求,就能把虚假消息投递到内部邮箱,这本身也是一种钓鱼入口。
九、拿生活常识来打个比方
把整个逻辑放到生活里,就好像小区物业有一套门禁系统。原来员工离职后,他的门禁卡还能打开机房门,这是很危险的事。我们的治理方案,就是在大门口立了一个保安亭,保安手里有一张最新的员工花名册。不管什么卡,拿到机器上刷一下,保安先看这个人还在不在名册里。在,就放行;不在,除了不让进门,还会把这张卡的情况上报给主管,同时让主管用备用卡进去处理事务。
这个保安不可能避免每一个疏漏,但他能挡住绝大多数不该发生的事情,而且他每次挡下来都会有记录,方便我们事后追查。这就够用了。
十、总结
回到最根本的问题,Grafana 告警发到离职员工的邮箱,表面上是配置失误,本质上是我们把通知路由和员工个人身份绑得过紧。真正稳定的方案,是把个人的身份信息从告警链路里剥离开,让外部认证系统来告诉我们“谁还在,谁不在了”,然后用一个中间适配层去动态决策每一条告警的去向。
这里给出的方案,没有引入特别复杂的框架,只是把一个简单的 HTTP 服务摆在 Grafana 和邮箱之间。它接地气,易理解,能落地。你不需要一下子就把所有告警迁移过来,可以先选一条流量比较少的规则做试点。等跑顺了,再逐步扩大范围。
只要能保证离职员工不再收到内部信息,并且每一次拦截都能被记录,那这套治理就算成功了一大半。接下来要做的,就是持续关注外部身份数据的变化,把“同步、校验、路由”这三个环节固化到日常运维流程里,而不是出了问题再来补救。
监控系统告警是给在职的人看的,这条界限一定要守住。
评论
围绕“Grafana告警通知误发至已离职员工邮箱,基于外部权限同步与通知路由的合规治理方案”参与讨论