监控告警里有个容易被忽略的现实:监控数据采集得再漂亮、大盘画得再炫,最后一步“把问题告诉该知道的人”如果做不好,整套监控体系的最终价值就打了折扣。我在团队里维护过一段时间基于 Prometheus 和 Grafana 的监控体系,node-exporter 负责采机器指标,Grafana 出图,告警规则也配了不少,但大家反馈最多的却是“告警没收到”“收到的时候已经过去了半小时”。后来我花了一个周末,用 C# 写了约 800 行代码的独立邮件告警服务,专门解决监控告警触达的最后一公里问题。这篇文章把整个实现过程中的设计思路、关键代码、部署方式和踩过的坑完整复盘一遍。
这个系统不是什么大而全的监控平台,它解决的核心问题非常聚焦:把分散在各处的监控事件统一收拢,经过规则判定后,以邮件形式把告警准确、及时、不轰炸地送到负责人手里。适合同样被困在“告警触达”环节的运维和开发同学参考,也适合想了解 C# 后台服务、定时调度、邮件发送、简单规则引擎怎么落地的人。
1. 一个独立邮件告警服务要解决什么:不只是发信这么简单
1.1 监控链路里最容易被忽视的“触达层”
大多数监控系统的数据流长这样:被监控对象暴露指标,采集器定时抓取,时序数据库存储,告警规则引擎根据阈值条件算出是否触发,然后交给通知渠道。这里面 Prometheus + Alertmanager 已经能覆盖绝大多数场景,Alertmanager 内置了邮件、Webhook、Slack 等通知方式,理论上也能直接发邮件。但在实际落地中,我遇到几个很现实的问题:
- 内网环境为了安全,出站邮件往往需要走专用的 SMTP 中继,而这些中继对发件域名、发信频率、邮件格式有自己的要求,Alertmanager 那一层要做得够灵活,配置会越堆越复杂。
- 告警链路往往不止一套。Kubernetes 集群里有事件告警,业务系统有自定义的健康检查,数据库有单独的监控脚本,这些东西各自有各自的告警输出方式,全部统一到 Alertmanager 里需要改造每个采集端。
- 收件人、值班表、告警级别、静默时间段这些运营层面的需求,变化极其频繁。每次都改 Alertmanager 配置再 Reload,能扛住,但体验并不好,而且容易出现改错导致告警失联。
所以我的想法是:写一个轻量级的独立服务,它不负责采集指标,也不负责存储时序数据,它只做一件事——接收各类监控事件输入,按照本地规则判断算不算告警,该不该发邮件,发给谁,用什么样的文案发出。相当于给监控体系加了一个“告警触达层”。
1.2 为什么用 C# 写而不是继续堆 YAML
选型时有人问过我:为什么不直接在 Alertmanager 里配 route 规则?为什么不用 Python 写个脚本扔在 crontab 里?
我当时的判断是这样:
- Alertmanager 的 route 和 receiver 配置确实强大,但它本质上是为 Prometheus 生态设计的,接入非 Prometheus 事件时需要额外包一层 Webhook 接收器,调试起来比较割裂。而且规则复杂到一定程度,YAML 会变得难以维护和测试。
- Python 脚本放 crontab 是最快能跑起来的方案,但定时精度、并发控制、异常恢复、优雅退出这些都需要自己处理。C# 基于 .NET 的后台服务模型(BackgroundService)天然支持优雅启停、依赖注入、结构化日志,做长驻进程比脚本稳定得多。
- 更重要的一点:团队里 C# 的技术栈积累更厚,后续如果有人要加功能,比如加个企业微信通知、加个 Webhook 转发、接个内部值班系统,用 C# 都顺理成章。技术选型不能只看当前能不能跑,还要看三年后谁在维护。
800 行这个规模不是刻意为之,而是这个需求本身的复杂度决定。再少代码就会开始缺边界情况处理,再多代码就说明你可能在重复造轮子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统设计:800 行代码如何拆出清晰的边界
2.1 模块划分:两个后台服务加三个核心组件
整个项目是标准的 .NET 6+ Worker Service 模板,我把它拆成两个 BackgroundService 和一个核心处理管道。
第一个后台服务是 事件接收监听器。它监听两类输入:一个轻量级 HTTP 接口,用来接收来自 Jenkins 脚本、自定义监控脚本、K8s Webhook 等外部系统推过来的事件;另一个是定时触发器,每隔 N 秒轮询一组配置好的 HTTP 数据源(比如某个内部服务暴露的健康检查 JSON),把数据源的状态变化转换为事件。
第二个后台服务是 调度扫描器。它按固定周期扫描当前处于活跃状态的告警,检查是否满足恢复条件、是否需要升级、是否已经超时未确认。这个服务是告警状态机的驱动引擎。
三个核心组件分别是:规则引擎(把原始事件映射为告警)、告警状态仓库(在内存中维护当前告警的状态与生命周期)、邮件通知器(负责渲染模板、投递、重试)。
整体数据流是这样的:
code复制外部事件源 内部服务
| |
| HTTP POST | 定时轮询指标
v v
事件接收通道 调度扫描器
| |
+-----------+------------+
|
v
规则匹配/归一化
|
v
告警状态仓库
|
v
邮件通知器
|
v
SMTP 中继 / 邮箱
这个设计里最关键的一点是:事件流和状态流是分开的。外部系统推过来的只是“发生了什么事情”这个事实,至于要不要通知、通知谁、以什么级别通知,完全由内部状态机决定。这样一来,外部系统完全不需要知道告警策略,它们只负责上报事实。
2.2 事件模型的归一化设计
事件接收层收到的原始数据五花八门:有的是 JSON 数组,有的是单条 JSON,有的根本没有业务含义只有一段状态文本。如果每一路输入都写一套解析逻辑再进告警判断,代码很快会失控。
我的做法是定义了一个统一的事件模型,所有外部输入先转换为这个模型再进入后续流程:
csharp复制public class MonitorEvent
{
public string Source { get; set; } // 来源标识,如 "jenkins" / "k8s" / "healthcheck"
public string EventType { get; set; } // 事件类型,如 "service_down" / "disk_usage_high"
public string Host { get; set; } // 涉及的主机或实例
public string Fingerprint { get; set; } // 唯一指纹,用于告警去重
public int Level { get; set; } // 0=info, 1=warning, 2=critical
public string Message { get; set; } // 人类可读的描述
public DateTime OccurTime { get; set; } // 事件发生时间
public Dictionary<string, string> Extras { get; set; } // 扩展字段
}
麻烦的点在于不同来源的字段名不同,所以我在接收层针对每个来源写了一个很薄的字典型转换。这里不建议新手在转换层做太多业务逻辑,它只负责字段映射和基本校验,真正的判断都交给规则引擎。转换层保持薄,后续新增数据源时只需要写一个映射函数,不会动到核心逻辑。
3. 规则引擎与告警状态机:避免告警风暴的关键
3.1 告警规则如何表达:简单规则和复杂边界
规则引擎是整个系统的核心,我用了最笨但也最可控的方式实现:规则先按事件来源和事件类型筛选,然后对 Level 做阈值判断,再加上一个可选的关键字匹配。
csharp复制public class AlertRule
{
public string RuleId { get; set; }
public string Source { get; set; } // 匹配事件的 Source
public string EventType { get; set; } // 匹配事件的 EventType,支持通配符
public int MinLevel { get; set; } // 低于这个级别不处理
public string Keyword { get; set; } // 可选,Message 中包含此关键字才匹配
public List<string> NotifyUsers { get; set; } // 收件人列表
public bool Enabled { get; set; }
}
匹配逻辑很简单:Source 相等且 EventType 相等或通配,Level 达到阈值,Message 包含关键字,这个事件就命中规则。
这套设计能覆盖我 90% 的场景。它不追求像 PromQL 那样能做复杂的表达式计算,因为真正需要复杂计算的部分应该留在 Prometheus 那一层。这个系统的定位是规则判定和通知分发,不是新的计算引擎。
3.2 告警生命周期:从触发到恢复的完整状态机
处理告警很容易犯的一个错误是:来了一个事件就发一封邮件。如果某台机器磁盘持续告警,监控脚本每分钟上报一次,收件人一小时内会收到 60 封一模一样的邮件,这就是典型的告警轰炸。
所以告警状态仓库里每一个活跃告警都有一个生命周期,我定义了五个状态:
Firing:告警已触发,通知已发送Acknowledged:有人在邮件里点了确认链接(通过 Web 入口实现)或者系统检测到事件已经不再重复上报Resolved:同一指纹的恢复事件到达,确认问题已解决Suppressed:告警处于静默期,不发送通知Expired:超过设定的超时时间未恢复,进入升级流程
核心是指纹去重。每个告警根据 Source、EventType、Host 计算出一个指纹,同一个指纹在未恢复期间,即使收到重复事件,也只会重置一个内部计数,不会重复发送邮件。只有当事件信息出现明显变化(比如 Level 升级)才会触发新的通知。
csharp复制public string ComputeFingerprint(MonitorEvent evt)
{
return $"{evt.Source}|{evt.EventType}|{evt.Host}".ToLowerInvariant();
}
实际用下来,这个简化版指纹能覆盖绝大多数情况。如果你的场景里同一个 Host 同一个指标存在不同维度的告警,比如磁盘使用率和磁盘 INODE 使用率同时告警,那指纹再加一个维度区分即可。
3.3 告警升级机制:两个级别就够
我还加了一个简单的升级机制。一条告警持续 Firing 超过一定时间(比如 30 分钟),系统会把通知升级到更高级别的负责人,或者通过更紧急的通道再发一次。
实现方式不复杂:在后台调度线程里定时检查所有 Firing 状态的告警,对比当前时间与首次触发时间,超过预设阈值就执行升级动作。
这个机制的意义在于,默认你值班时收到的告警邮件不会第二天早上才被处理。即使第一次通知没有被看到,30 分钟后升级通知会再次提醒,60 分钟后如果还没恢复,会直接发给团队 leader。这个时间阈值是配置化的,我建议从宽松开始调,先 60 分钟再逐步缩小,避免刚上线时因为阈值太紧导致半夜邮件轰炸。
4. 邮件发送层的健壮性设计:SMTP 不是调用一下就能完事
4.1 为什么不用 SmtpClient 直奔 MailKit
.NET 自带的 System.Net.Mail.SmtpClient 经过几年的口碑发酵已经被很多人拉黑,微软官方也明确不建议在新代码中使用。它的问题集中在:连接复用能力弱、异常处理不够精细、对现代 SMTP 服务的认证支持不友好。我在第一个内部测试版本里用了 SmtpClient,还真的踩过连接池用完导致后续邮件全部超时的坑。
后来换成 MailKit,连接管理、超时控制、重试机制都更加可控。MailKit 基于 MimeKit 的邮件构造方式也比较顺手。这里给一个最基础的发送封装:
csharp复制using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
public class EmailSender
{
private readonly SmtpOptions _options;
public EmailSender(SmtpOptions options)
{
_options = options;
}
public async Task SendAsync(string subject, string htmlBody, List<string> recipients)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(_options.FromAddress));
foreach (var recipient in recipients)
{
message.To.Add(MailboxAddress.Parse(recipient));
}
message.Subject = subject;
var builder = new BodyBuilder { HtmlBody = htmlBody };
message.Body = builder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_options.Host, _options.Port,
_options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto);
await client.AuthenticateAsync(_options.Username, _options.Password);
await client.SendAsync(message);
await client.DisconnectAsync(true);
}
}
这个代码看起来很简单,但生产环境直接用这个版本还不够,至少有三处要补:连接超时、发送超时、重试。SMTP 服务偶发 4xx 是非常正常的事,网络抖动、服务端临时限流,都需要客户端有耐心。
4.2 重试策略:指数退避不是空话
邮件发送失败后直接放弃是不行的。监控告警邮件发不出去,等于告警链路断了,比没有告警更可怕。我实现了一个带指数退避的重试策略:
- 第一次失败后等待 5 秒重试
- 第二次失败后等待 15 秒
- 第三次失败后等待 45 秒
- 最多重试 3 次,之后进入死信队列
重试的粒度是一条告警邮件的“发送任务”,而不是每封邮件。这意味着如果同一时间有 20 条告警要发,SMTP 连续两次失败,第三次重试我会把 20 条合并成一次批量发送,降低服务端压力。
另外,我在发送层加了一个每分钟最大发送量的配额控制,默认 30 封/分钟。这个数字来源于我们实际 SMTP 中继的限制。这么做的好处是即使系统突然涌入大量告警,也不会把中继打到限流封禁状态,从源头保护发信通道。
4.3 邮件内容模板:克制排版,突出关键信息
告警邮件的目的是让人快速判断“发生了什么、影响面多大、该找谁处理”,不是展示 UI 炫技。我的 HTML 模板很简单,固定几块内容:
- 第一行:告警标题和级别(用颜色区分,但不夸张)
- 第二行:告警对象与指标具体值
- 第三行:触发时间与持续时长
- 第四行:相关日志或链接
有团队喜欢在告警邮件里放 Grafana Dashboard 的截图或者详细 PromQL,我的经验是这些内容会分散注意力,尤其在手机上查看邮件时,长截图根本看不清。告警邮件越接近“一张卡片”越好,具体分析的入口通过链接放在末尾就行。
用 RazorEngine 渲染模板也试过,但后来发现简单的字符串插值配合 StringBuilder 已经足够,没必要为一个小工具引入额外模板引擎依赖。
5. 部署与配置:从开发机到真正稳定运行
5.1 用 NSSM 把 exe 注册成 Windows 服务
开发调试时直接 dotnet run 跑起来没问题,但生产环境部署到 Windows 服务器上必须做成服务。
我最初用过 Windows 自带的计划任务程序,每 5 分钟检查一次进程是否在运行,不在就拉起来。这种方式的问题在于计划任务的启动环境和交互式终端不一样,有些路径和权限问题会非常隐蔽。后来改用 NSSM(Non-Sucking Service Manager),把 exe 注册成一个 Windows 服务,简单干净:
bash复制nssm install AlertSentry "D:\services\alert-sentry\AlertSentry.exe"
nssm set AlertSentry AppDirectory "D:\services\alert-sentry"
nssm set AlertSentry AppStdout "D:\services\alert-sentry\logs\stdout.log"
nssm set AlertSentry AppStderr "D:\services\alert-sentry\logs\stderr.log"
nssm set AlertSentry AppRotateFiles 1
nssm set AlertSentry AppRotateBytes 10485760
nssm start AlertSentry
特别提醒一句:服务运行目录一定要通过 NSSM 设为 exe 所在目录。我踩过一次坑,NSSM 默认的工作目录是 C:\Windows\System32,导致程序里用相对路径读取配置文件时直接找不到,看起来像程序没启动成功。
5.2 配置文件与敏感信息:别把密码写死在代码里
配置管理我用 appsettings.json 加环境变量覆盖的方式。SMTP 密码这类敏感信息,在开发环境放在 user secrets,生产环境通过环境变量注入,避免密码泄露在配置仓库里。
运行时配置热加载我倒是强烈建议打开,因为值班表、告警静默时间段这类运营配置会经常变。我用 .NET 自带的 IConfiguration 配合 AddJsonFile(..., reloadOnChange: true),改完配置文件后无需重启服务,几秒内生效。
但要注意:规则变更不是所有内容都能热加载。如果修改了核心规则逻辑并重新编译,必须重启服务。热加载只适用于配置层面的调整,这个边界要心里有数。
5.3 日志与自监控:告警服务自己挂了怎么办
我在这套系统里加了一个心跳日志,每 60 秒写一条 INFO 日志,内容包括当前活跃告警数、最近一次扫描耗时、邮件发送队列深度。这些指标再接入到 Prometheus,和现有的监控体系打通。
更有意思的一个设计是:系统启动时会给自己发一封测试邮件,包含当前版本号、启动时间、核心规则数量。这样无论是升级还是重启,第一封邮件就确认了整个链路是通的。
之前遇到过一次 SMTP 密码过期导致所有邮件发送失败,连续失败重试堆了大量死信,但因为死信队列本身没有进入监控,一直没发现。后来加了这个启动自检和心跳日志后,这种问题在服务重启时就能第一时间暴露。
6. 上线后的真实复盘:几个让我头疼的坑
6.1 SMTP 限流的真实表现:不是报错而是延迟
前面提到限流,我最初以为 SMTP 被限流时会明确返回一个错误码。实测下来,中继的表现是:接受连接,但发送后响应极其缓慢,一个原本 1 秒的发送操作能拖到 30 秒以上,最终抛出超时异常。这种问题如果没在意,看起来像是网络抖动,实际上是限流已经发生了。
解决方式是两层:客户端强制设置 Timeout = 15 秒,同时限制每分钟发送总量。另外在接收层把所有需要发送的邮件全部塞入一个内存队列,由一个单线程的发送协程(或者说是发送任务)串行消费,避免并发发送把 SMTP 连接数也打爆。
6.2 时区问题:调度计划怎么面对夏令时
服务跑在 Windows Server 上,系统时区是 China Standard Time 这类全局时区。调度器里如果直接使用 DateTime.Now,一旦暑假调整时间,定时任务会跟着偏移一小时。有几次告警延迟一小时才通知出去,排查半天发现是时区偏移。
我建议所有内部时间统一用 DateTimeOffset.UtcNow 存储,展示层再转本地时间。具体到定时调度,如果一天内需要按固定的本地时间执行(比如每天早上 9 点发汇总报告),用一个单独转换的调度配置,而不是直接依赖系统时区。
6.3 多实例部署时的问题:告警重复发送
随着系统承载的监控源越来越多,我把它从单实例扩展到了双实例部署做高可用。结果第一次演练就发现同一个告警两封邮件几乎同时发出。原因很简单:两个实例各自运行了调度扫描器,内存状态是独立的,同时判断出某个事件需要发邮件。
解决办法有三种,我选择了最轻量的:给邮件发送加一个基于数据库的唯一约束。每个告警通知在发之前先往本地 SQLite 表插入一条指纹记录,如果存在则跳过。SQLite 的并发写入能力虽然不如 MySQL,但做这种去重标记已经够用。升级一下思路,如果后续数据量大了,也可以换成 Redis 的 SETNX 实现同样效果。
这里也可以选择只让一个实例跑调度扫描器,另一个只处理接收和发送,但这会让部署拓扑变得不对称,不利于后续扩展。用去重标记的方式,两个实例都能干活,最终效果一致,更简洁。
6.4 半夜告警没人看的最后防线
邮件告警最大的问题是:半夜发出去的邮件,可能到早上才被看见。所以我在部署手册里明确建议:需要紧急响应的告警,邮件通知只是记录,必须搭配企业微信或短信通道。这个系统的架构天然支持多通道扩展——通知层抽象成接口,邮件是目前唯一实现,但新增一个渠道不需要改核心逻辑。
我遇到过最夸张的一次是某核心服务凌晨 3 点挂掉,系统在 3 分钟内向值班人员发了 3 封告警邮件,结果值班人员手机静音,完全没看到。第二天早上 8 点上班才发现页面打不开,实际故障时长超过 5 小时。这个案例说明:告警系统本身不能只依赖一种通知方式。邮件适合记录和排查用,实时触达必须配合移动端推送或电话告警,这个问题与技术水平无关,纯粹是人的注意力规律决定的。
7. 代码规模控制与演进方向:800 行不是终点
很多人看到“800 行”会怀疑这么点代码能做什么。事实上这 800 行是把核心逻辑完全覆盖的,不算测试代码和配置文件。我后来一直严格控制这个项目的规模,任何一个文件超过 200 行就会触发重构信号。目前的项目结构是:
Program.cs(约 40 行):入口与依赖注入BackgroundServices/(约 180 行):两个后台服务的调度执行Engine/(约 200 行):规则匹配与状态机Notification/(约 150 行):邮件发送、模板、重试Configuration/(约 80 行):配置模型与校验HttpApi/(约 150 行):接收外部事件,仅一个 POST 端点
这个规模的代码量非常适合一个人维护,也适合团队快速理解。如果再往下加需求,比如接入钉钉/企业微信通知,告警历史持久化到数据库,基于因果关系的告警聚合,每加一个都保持小步迭代,避免一次引入过多抽象。
下一步我计划把事件接收端改成通用 Webhook 网关,支持更多格式的自动识别,同时把告警状态仓库从内存改为 Redis,这样多实例部署时状态可以真正共享,去重就不再依赖 SQLite 的小聪明。还需要一个简单的 Web 页面展示当前活跃告警和值班表,但不打算做成大平台,保持轻量级工具属性。
从这套系统的演进过程来看,监控告警触达是运维体系里很细但是很关键的一环,值得用一种简单可靠的方式长期维护。一个周末的投入换来的,是后续每天早上不用再翻告警记录核对晚上发生了什么,系统自己会把该说的都说清楚。这种安全感,是监控体系真正跑顺之后最直观的回报。
