先说结论:Zabbix 自带的 Email 介质类型不是不能用,但用到真实生产环境里,你迟早会碰上需要把它替换掉的一天。我最近在给一套某公司的监控平台重新梳理告警链路,核心需求就一句话:把触发器产生的报警信息稳定地发到值班邮箱,问题恢复后再补一封恢复邮件,延迟低、能追踪、还能按人员分流。折腾一圈之后,我放弃了内置 SMTP 媒介,改成用 Python 脚本收尾,反而把整个链路看得明明白白。这篇文章就把整套方案完整记录下来,从 Zabbix 如何调用脚本、Python 发信脚本怎么写,到 Media Type、用户媒介、Action 怎么串联,再到上线后踩过的坑,一次性说清楚。
1. 内置SMTP媒介能用,但为什么我最后还是换成了Python脚本
1.1 内置Email类型确实开箱即用
Zabbix 内置的 Email 介质类型,界面里把 SMTP 服务器、端口、用户名、密码、发件人地址填上,就能立即发信。通道少的测试环境里,这种配置方式真的省事。当时我刚接到需求时,第一反应也是走这条路:新建一个 Email 介质类型,填好邮箱服务商给的 SMTP 地址和授权码,再绑定用户,动作里选择这个媒介发送,十几分钟就能把邮件告警跑起来。Zabbix 本身还会自动在告警记录里标记“已发送”,操作路径短,任何刚接触 Zabbix 的运维都能快速上手。所以说,内置 Email 介质类型不是没有价值,它覆盖了大量对格式、送达率、分发策略没有特殊要求的场景。
1.2 生产环境里几道绕不过去的坎
可真实环境里的问题,往往不是配置不出来,而是配置出来的东西不稳定,还没法快速定位。
第一道坎是邮箱服务商的认证策略越来越严。很多企业邮箱和公共邮箱服务商不再接受简单的“账号加登录密码”方式,而是要求使用客户端授权码,甚至要单独设置 IP 白名单。Zabbix 内置 Email 类型给到你的就是用户名、密码、发件人地址这几个固定字段,一旦遇到服务商返回“认证失败”或“TLS 版本不支持”,你基本只能靠猜。
第二道坎是排障链路不透明。内置类型的发送逻辑封装在 Zabbix 内部,邮件队列阻塞、SMTP 握手超时、对方服务器拒绝接信,都只会体现在告警记录里的一条失败状态上。你想看具体是哪一步断了,没有日志可查。对于生产告警,这种“黑盒”是不可接受的。
第三道坎是灵活性不够。同一个动作里,有人希望把多台主机的问题汇总成一封摘要邮件,有人希望按严重级别走不同的通知策略,有人想在企业微信、钉钉、邮件之间做切换。内置 Email 类型只能笨拙地发一封标准邮件,任何内容加工都得另起炉灶。
所以我的思路很明确:把发信动作集中到一个 Python 脚本里,把 SMTP 连接、参数校验、日志、重试、内容格式化全放进脚本,Zabbix 只负责把事件转换成参数并调用脚本。控制权完全回到自己手里。
1.3 自建脚本后,换来的是什么
换成 Python 脚本之后,最明显的变化是问题可观测了。脚本每次运行都会写日志,收件人、主题、发送结果一目了然。SMTP 握手失败会输出具体异常栈,TLS 协商报错能看到详细原因,不再像内置媒介那样只有一个孤零零的失败状态。另一个变化是规则可编排了。脚本里可以加收件人白名单,可以按收件人前缀走不同 SMTP 通道,也可以把正文先格式化再发送。Zabbix 侧的逻辑保持简单,复杂业务都在脚本层解决,职责非常清晰。
2. 告警链路和参数传递:先搞清Zabbix怎么调用脚本
很多人在这一步卡住,不是因为不会写 Python,而是不理解 Zabbix 到底用什么姿势调用脚本。先把这个机制讲透,后面配置起来就顺了。
2.1 脚本型媒介在Zabbix内部的执行过程
Zabbix 的告警发送是由服务端的 Alert Manager 组件负责的。当一条动作命中某个触发器事件时,Zabbix 会把事件封装成一条发送任务,然后根据介质类型找到对应脚本。所有脚本型媒介都会在zabbix_server.conf配置的AlertScriptsPath目录下执行,也就是说,你在界面里填的“脚本名称”其实只是一个文件名,完整路径由服务端拼接而成。
执行时,Zabbix 其实就是在命令行里运行了这样一条命令:
/usr/bin/python3 /usr/lib/zabbix/alertscripts/send_mail.py "ops@example.com" "告警标题" "告警正文"这个细节很重要。你要先确认AlertScriptsPath到底配置在哪个目录。可以这样查:
grep -E "^AlertScriptsPath" /etc/zabbix/zabbix_server.conf如果搜不到相关配置,不同发行版的默认目录可能不同,常见的是/usr/lib/zabbix/alertscripts或/etc/zabbix/alertscripts。想省事的话,也可以直接在配置文件里显式加一行,比如:
AlertScriptsPath=/usr/lib/zabbix/alertscripts改完配置记得重启zabbix_server服务,否则不会生效。
2.2 三个核心宏参数和一张映射表
脚本型介质类型在界面里有一项“脚本参数”,这一项决定了 Zabbix 按什么顺序、把哪些内容作为命令行参数传给脚本。常用配置就是下面三个宏,顺序固定但可以由你自定义:
{ALERT.SENDTO} {ALERT.SUBJECT} {ALERT.MESSAGE}对应关系如下表:
| 参数位置 | Zabbix宏 | 含义 | 脚本中变量 |
|---|---|---|---|
| 参数1 | {ALERT.SENDTO} | 用户媒介里配置的收件邮箱 | sys.argv[1] |
| 参数2 | {ALERT.SUBJECT} | 动作里设置的默认主题 | sys.argv[2] |
| 参数3 | {ALERT.MESSAGE} | 动作里设置的默认正文 | sys.argv[3] |
说白了,脚本收到的就是三个普通字符串,不用关心 Zabbix 内部事件模型,只需要把这些字符串组织成一封 MIME 邮件发送出去。这种设计非常朴素,但对脚本编写者来说反而是好事,逻辑简单,便于测试。你完全可以在命令行里手工模拟同样的调用,把脚本调通之后再回到 Web 界面里配置。
2.3 执行身份、环境变量与调试入口
在 Zabbix 里,守护进程zabbix_server一般以zabbix用户身份运行,所以脚本也必须让这个用户可读、可执行。很多新手直接复制脚本文件到 alertscripts 目录后忘了加权限,结果界面里触发动作时脚本就静默失败。目录和文件权限至少要是:
chown zabbix:zabbix /usr/lib/zabbix/alertscripts/send_mail.py chmod 755 /usr/lib/zabbix/alertscripts/send_mail.py还有一点经常被忽略:zabbix_server是从受限环境去执行外部脚本的,PATH环境变量非常短,不会自动加载你平时 shell 里那些路径。所以脚本中调用外部命令时尽量使用绝对路径,避免依赖环境变量。调试时也建议完全模拟 Zabbix 的执行身份:
sudo -u zabbix /usr/bin/python3 /usr/lib/zabbix/alertscripts/send_mail.py "ops@example.com" "测试标题" "测试正文"如果这条命令能成功,再回 Web 界面测试,至少能排除一大部分脚本本身的问题。
3. 一封像样的告警邮件:Python发送模块的设计与实现
脚本本身不难,难点在细节处理。SMTP 连接、认证、超时、邮件格式、编码、日志,每一项都直接影响告警的可靠度。
3.1 为什么用smtplib而不是封装好的邮件HTTP接口
你可能会问,现在很多邮箱服务商都有 HTTP 接口,直接用requests调接口不是更省事吗?我当时也对比过。对于邮件告警这种高频、低延迟、需要稳定认证的场景,SMTP 协议反而是最成熟可靠的方案。smtplib是 Python 标准库,不需要额外安装第三方包,服务器上只要有 Python 就能用。企业邮箱和各类邮件服务商对 SMTP 的兼容性普遍很好,SASL 认证、SSL/TLS 都支持得比较完整。另外,SMTP 排障路径清晰,你只要盯住握手、认证、发信、退信这四个阶段就够了。
3.2 能直接落地的一段发信脚本
先给出完整可用的脚本,再逐步解释关键点。这套脚本我放在/usr/lib/zabbix/alertscripts/send_mail.py,内容如下:
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Zabbix 邮件告警发送脚本 用法: send_mail.py <收件人> <主题> <正文> """ import os import sys import logging import smtplib from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr # SMTP 基础配置,改成你自己的邮箱服务商参数 SMTP_HOST = "smtp.example.com" SMTP_PORT = 465 SMTP_USER = "zabbix@example.com" SMTP_PASS = "your-auth-code" SMTP_FROM = "zabbix@example.com" SMTP_FROM_NAME = "Zabbix Alert" # 允许通过环境变量覆盖,方便调试和不同环境部署 SMTP_HOST = os.getenv("ALERT_SMTP_HOST", SMTP_HOST) SMTP_PORT = int(os.getenv("ALERT_SMTP_PORT", SMTP_PORT)) SMTP_USER = os.getenv("ALERT_SMTP_USER", SMTP_USER) SMTP_PASS = os.getenv("ALERT_SMTP_PASS", SMTP_PASS) SMTP_FROM = os.getenv("ALERT_SMTP_FROM", SMTP_FROM) LOG_FILE = "/var/log/zabbix/alert_mail.log" def setup_logging(): log_dir = os.path.dirname(LOG_FILE) if log_dir and not os.path.isdir(log_dir): os.makedirs(log_dir, exist_ok=True) logging.basicConfig( filename=LOG_FILE, level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) def send_mail(to_addr, subject, body): msg = MIMEText(body, "plain", "utf-8") msg["Subject"] = Header(subject, "utf-8") msg["From"] = formataddr((str(Header(SMTP_FROM_NAME, "utf-8")), SMTP_FROM)) msg["To"] = to_addr if SMTP_PORT == 465: server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) else: server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) server.starttls() try: server.login(SMTP_USER, SMTP_PASS) server.sendmail(SMTP_FROM, [to_addr], msg.as_string()) finally: server.quit() def main(): setup_logging() if len(sys.argv) != 4: logging.error("参数数量不对,收到 %d 个参数: %s", len(sys.argv) - 1, sys.argv[1:]) sys.exit(1) to_addr, subject, body = sys.argv[1], sys.argv[2], sys.argv[3] if ( "@" not in to_addr or to_addr.count("@") != 1 or len(to_addr) > 128 or any(ch in to_addr for ch in [" ", ",", "\n", "\r"]) ): logging.error("非法收件人地址: %s", to_addr) sys.exit(1) if not subject.strip(): subject = "(无主题)" if len(body) > 200000: logging.warning("正文长度 %d,可能超过单封邮件大小限制", len(body)) try: send_mail(to_addr, subject, body) logging.info("告警邮件已发送 -> %s | 主题: %s", to_addr, subject[:100]) except Exception as exc: logging.exception("告警邮件发送失败 -> %s | 错误: %s", to_addr, exc) sys.exit(70) if __name__ == "__main__": main()你只需要把脚本里的SMTP_HOST、SMTP_PORT、SMTP_USER、SMTP_PASS、SMTP_FROM换成你自己邮箱服务商的参数,然后保存、赋权,就能测试了。
3.3 中文主题、正文编码与邮件头规范
邮件告警里最容易出乱码的就是中文主题和中文正文。我把这部分的处理单独拎出来讲。
正文部分使用MIMEText(body, "plain", "utf-8"),明确指定纯文本和 UTF-8 编码。主题部分则通过Header(subject, "utf-8")进行编码,这样主题里的中文在邮件头中会被正确编码成可传输的格式,各个邮件客户端都能正常解码。发件人名称同理,Header(SMTP_FROM_NAME, "utf-8")确保“Zabbix Alert”这类中文或特殊字符不会破坏整个邮件头结构。
有一个很多人忽略的规范问题:SMTP_FROM这个发件地址,最好和SMTP_USER的认证账号保持同一域名。很多邮件服务商在 SMTP 认证之后,会校验信封里的发件地址是否属于当前账号域,不一致会直接拒发。所以我建议让SMTP_FROM = SMTP_USER,或者至少保证域名一致。
3.4 参数校验、日志记录和异常退出
脚本里的参数校验不是摆设。Zabbix 传入的{ALERT.SENDTO}来自用户媒介配置,正常情况确实是邮箱地址,但为了安全,还是要防止邮件头注入。脚本里对收件人做了检查:必须只含一个@,不能有空格、逗号、换行符。因为如果to_addr里被塞进换行符,理论上可以在 MIME 头部注入额外字段,这会成为一种利用途径。
日志设计也很关键。我将日志写到/var/log/zabbix/alert_mail.log,每次发送都记录收件人、主题摘要和最终异常信息。脚本最终是否返回非零退出码,会影响 Zabbix 对这个动作标记为成功还是失败。正常情况返回 0;参数错误返回 1;发送异常返回 70,这个数字是sysexits.h中“软件内部错误”的约定值,便于统一识别。
提示:日志目录必须让
zabbix用户有写权限。否则脚本运行到logging.basicConfig就会直接抛异常。建议提前执行mkdir -p /var/log/zabbix && chown zabbix:zabbix /var/log/zabbix。
4. Zabbix前端串联:Media Type、用户媒介、Action做到位
脚本准备好之后,剩下的就是在 Zabbix 前端把整条链路串起来。这一步看着简单,但每一步都有容易踩的细节。
4.1 在管理界面创建脚本型Media Type
进入“管理 → 告警媒介 → 媒介类型”,点击“新建媒介类型”。名称我建议起一个相对具体的名字,比如python-mail-alert,不要叫mail这种太泛的名字,避免以后和企业邮件方案混淆。类型选择“脚本”。脚本名称填send_mail.py,注意这里只填文件名,不填路径。脚本参数区域依次加三行:
{ALERT.SENDTO} {ALERT.SUBJECT} {ALERT.MESSAGE}创建后一定确认“已启用”勾选状态是开启的。这一步漏掉的话,动作会直接跳过这个媒介,而且不会给你任何特别明显的报错。Zabbix 不同版本对“媒介”和“介质类型”的中文翻译略有差别,但路径都在管理模块下,对应关系是明确的。
4.2 用户媒介的时间窗、严重性过滤和归属
介质类型建好后,还要把它分配给具体接收人。在“用户 → 用户列表”里选择一个用户,进入“告警媒介”页签,添加媒介。选择刚才创建的python-mail-alert,在“发送到”里填真实收件邮箱。那个“时间窗口”如果不特别设置,默认是全天候,也就是一周七天、每天 00:00 到 24:00。如果你是做夜间值班轮换,可以在这里按群体配置不同的时间段。
严重性过滤也建议在这里合理设计。比如普通值班人员只接收“高”和“灾难”两个级别,工程师级别接收“中”以上,这样能减少半夜被无关告警轰炸的疲劳感。用户媒介没有配置好的话,就算后面动作写再完美,也会出现“动作执行成功但没人收到邮件”的情况。
4.3 触发动作的消息模板与恢复通知
介质和用户都准备好了,接着是动作。进入“配置 → 动作 → 触发动作”,新建动作。动作里有两块核心配置,一块是操作,另一块是恢复操作。
操作里选择“发送消息”,目标选择你配置过媒介的用户组,或者直接选具体用户,然后在“仅发送到”里选择python-mail-alert。消息模板可以这样设计:
主题:
[告警] {TRIGGER.STATUS}: {TRIGGER.NAME}正文:
告警主机:{HOST.NAME} 告警IP:{HOST.IP} 触发器:{TRIGGER.NAME} 级别:{TRIGGER.SEVERITY} 状态:{TRIGGER.STATUS} 当前值:{ITEM.NAME1} = {ITEM.VALUE1} 触发时间:{EVENT.DATE} {EVENT.TIME}恢复操作同样选择发送消息,主题用[恢复] {TRIGGER.STATUS}: {TRIGGER.NAME},正文里可以补上恢复时间:{EVENT.RECOVERY.DATE} {EVENT.RECOVERY.TIME}。这里有个值得推广的做法:问题操作和恢复操作不要共用同一套模板。恢复邮件里的关键信息应该是“什么时候恢复的、当前值恢复到多少”,这样值班人员才能快速关闭工单。
条件设置建议从宽到严逐步收敛。初上线时可以只选“主机组属于 XX”,不加严重级别条件,跑几天之后再根据告警量细化。Step duration 在测试阶段可以调成 60 秒,方便快速看到重复通知;生产建议保持默认的 1 小时,或者按值班安静时段的实际诉求调整。
4.4 从命令行到Web端的完整验证路径
配置完成后,不要直接等真实故障来验证。我习惯按下面顺序做完整验证:
- 先确认配置已重载:执行
zabbix_server -R config_cache_reload。 - 在命令行手工模拟脚本调用,确认脚本本身能发信。
- 回到 Web 界面,临时建一个监控项和触发器,比如
system.uptime[].last()小于 0 这种必失败的表达式,触发一条问题事件。 - 到“报表 → 动作日志”里看动作是否执行,再到脚本日志和收件邮箱确认最终结果。
- 确认恢复动作正常后,把临时触发器和监控项清理掉。
如果动作日志里有记录、脚本日志也显示发送成功,但邮箱收不到,基本可以排除 Zabbix 侧的问题,接下来就是第 5 节的送达类问题了。
5. 上线后最常见的四个坑及排查思路
这套方案我前后用了很长时间,踩过的坑基本集中在四类问题上,写出来帮大家少走弯路。
5.1 脚本不执行:路径、权限、换行符三件套
如果配置了动作却迟迟收不到邮件,首先怀疑脚本本身没有执行,或者执行时报错。排查顺序建议是:
- 先查
zabbix_server.conf里的AlertScriptsPath,确认脚本文件确实在对应目录。 - 再看脚本权限,用
ls -l看属主和执行位。属主是root但权限 600 的话,zabbix用户根本没有读取权限。 - 最后用
file send_mail.py检查文件类型。如果在 Windows 上编辑后直接上传,文件可能带了 CRLF 换行,Linux 执行时会出现: No such file or directory这类诡异错误。
排查时的关键动作是回到第 2 节那句手工调用命令,由zabbix用户直接执行脚本。如果手工执行能发信,Web 端却不行,那大概率还是 Zabbix 查不到脚本或者参数配置错了。
5.2 中文乱码不是邮箱问题,是编码处理不到位
有时候手工执行测试内容正常,但 Zabbix 发出来的邮件中文乱码,常见原因有三个:脚本没有用Header(subject, "utf-8")编码主题;MIMEText 没有指定charset;或者终端环境向脚本传入了错误的字节序列。
前两个问题代码里已经解决。第三个问题要注意的是:Zabbix 动作模板里如果是从别处复制过来的文本,有可能带着 BOM 头或者特殊换行符,传参后邮件内容看起来一堆横线和问号。解决办法是把动作里的“默认消息”重新手工敲一遍,尤其注意引号和换行。还不行的话,就在脚本里对正文做一次body.encode("utf-8", errors="replace").decode("utf-8"),把非法字节清理干净再发送。
5.3 发件被拦截或延迟到达的根因查找
脚本执行成功,日志也显示“发送成功”,但用户一直没收到,问题往往出在邮件信任链上。最典型的原因有三个:
一是发件地址与认证账号不一致。很多邮件服务商要求认证身份和MAIL FROM地址必须在同一域,这也是我前面强调SMTP_FROM要和SMTP_USER一致的原因。
二是域名没有 SPF/DKIM 授权。你通过某个 SMTP 服务器发信,等于让那台服务器代替你的域名发信。为了证明“这封邮件确实是该域名主动发的”,域名管理员需要在 DNS 里配置 SPF 记录,允许这个发信服务器的 IP 段。没有 SPF 的邮件很容易被对方判断为仿冒邮件,丢进垃圾箱。
三是发信频率触发了限流。大批量告警时会遇到 SMTP 服务商返回连接超时或450之类的临时错误,这是临时限流。脚本里遇到异常退出会返回非零码,Zabbix 会把这个动作标记失败。但邮件本身可能已经进了服务商队列,后面又补投了,结果就是用户收到的告警时间有偏差。遇到这种情况,建议在脚本里加一段简单的重试逻辑:发送失败后等待 5 秒再重试一次,最多三次,降低偶发失败概率。
5.4 告警风暴造成并发涌堵时的应对
最后说一个所有监控系统都会遇到的场景:某台核心数据库或网络设备一抖动,底下成百上千台主机的触发器同时报警。这时候 Zabbix 会在极短时间内部署大量脚本进程,每个脚本都去建一次 SMTP 连接。结果不是你邮箱爆了,就是对方的 SMTP 网关先把你限流甚至拉黑。
应对思路,从根源上讲,要在动作层做好两条:合理使用严重级别条件,避免把“信息级”事件也全套发一遍;在“操作”步骤里设置升级循环,同一问题的重复通知不要无限刷。其次,在动作里加条件,比如“触发事件最多 N 次”,或者在触发器表达式里用nodata()、last()做一次抑制,让故障恢复前的中间状态不再反复产生新事件。
脚本层也可以做一点保护。最简单的方法是加一个临时文件锁,让同一时刻只允许一个发信进程运行,但这种方式在高并发场景会丢告警,我不建议默认使用。更稳妥的做法是设置 SMTP 连接的超时时间,避免脚本因为网络阻塞而长时间占住进程资源。脚本里的timeout=30就起到这个作用,宁可放弃某一条消息,也不要让大量脚本堆积在 Zabbix 端拖垮整个服务。
邮件告警的稳定性不在脚本多复杂,而在链路每一环都可控。把 Zabbix 的调用机制、脚本的编码细节、SMTP 的送达链路都摸清楚之后,这套 Python 发信方案就是一套可以反复复用、出了问题能按日志快速定位的成熟方案。之后再遇到更复杂的告警场景,比如摘要合并、按值班人分流、多渠道联动,你只需要在这个脚本基础上继续延伸即可,不需要再动 Zabbix 侧的框架。