一、先说这个事有多普遍

早上打开邮箱,发现一条告警竟然发到了离职同事的邮箱里。这位同事上个月就已经办完离职手续,工号都销了,可系统里的告警还是照常推。这个情况在很多公司里并不罕见,看着只是一个小疏漏,背后牵扯的权限同步问题和通知合规问题,要比我们想象得严肃。

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 和邮箱之间。它接地气,易理解,能落地。你不需要一下子就把所有告警迁移过来,可以先选一条流量比较少的规则做试点。等跑顺了,再逐步扩大范围。

只要能保证离职员工不再收到内部信息,并且每一次拦截都能被记录,那这套治理就算成功了一大半。接下来要做的,就是持续关注外部身份数据的变化,把“同步、校验、路由”这三个环节固化到日常运维流程里,而不是出了问题再来补救。

监控系统告警是给在职的人看的,这条界限一定要守住。