基于Python的自建OnCall告警值班平台设计与实践

发布时间:2026/10/6 13:52:28
基于Python的自建OnCall告警值班平台设计与实践 值班系统大概是运维体系里最不受待见但又最离不开的东西。我去年在团队里从零搓了一套OnCall告警值班平台纯Python实现从最开始只是想响应Prometheus的告警到最后把排班、路由、升级、通知全部塞了进去前后大概两个月踩了不少坑也沉淀出来一套可以复用的设计思路。这篇文章就把整个落地过程完整拆开从需求梳理、技术选型、模块设计到核心代码和上线后的坑一次性讲透希望能给准备自建OnCall系统的人省点弯路。先说清楚这东西是什么OnCall字面意思就是“有人在线值守”一套成熟的OnCall系统要解决的核心问题就三个——告警来了找谁、找不到人怎么办、通知是否可靠送达。如果你团队还在用“群里吼一嗓子”或者“谁看见了谁处理”的方式那你一定能体会到那种半夜被连环电话叫醒、结果发现根本不是自己负责的模块的酸爽。OnCall就是把这套流程做成系统告警接入 → 去重聚合 → 路由分派 → 排班匹配 → 多级升级 → 通知触达每一步都由程序自动完成人只负责处理真正需要处理的事。这套东西用什么语言框架搭我的答案是Python。后面我会把选型的理由讲清楚不是因为它性能多强而是因为运维生态里所有告警源、监控工具、推送渠道几乎都有Python的SDK接入成本极低。你不需要做一个多复杂的系统但你需要一个能快速跟现有监控体系咬合的框架。1. 先想清楚你想要的OnCall系统到底长什么样1.1 从一个真实告警说起OnCall的完整链路我习惯在设计系统之前先从一个具体场景倒推需求。假设现在是凌晨2点17分Prometheus突然给Alertmanager发了一条告警订单服务的error rate超过5%。接下来会发生什么正常的OnCall流程是这样的。Alertmanager把告警POST到我们的OnCall系统系统先判断这条告警是不是之前已经报过的重复告警然后根据告警里的服务标签找到对应的负责人团队再查一下当前这个时间点的值班人是谁接着给值班人发一条带确认链接的通知。如果值班人在15分钟内没有点击确认系统自动升级给上二级负责人同时发短信加电话。直到有人确认“我来处理”告警状态从notified变成acknowledged这条链路才算闭环。这个链路看起来简单但每一步都有细节。比如重复告警Prometheus默认的repeat interval可能是4小时但如果你的监控规则写得不好同一个问题可能一次性涌进来几十条告警。再比如路由订单服务的告警不该发给计算存储团队的人但服务的名字在不同环境里可能不一样路由规则就必须做成可配置的还有升级策略一级值班、二级值班、乃至全天候的OnCall经理每一级之间隔多久才往上跳这些都是要提前定好的否则就是人肉在群里来去等于没做系统。所以我在设计初期就花了大量时间梳理现状把团队的告警来源、核心服务列表、负责人体系、值班习惯全都摸了一遍。你得清楚自己的组织架构和监控体系才能设计出真正能落地的OnCall而不是拍脑袋抄一个开源的成品配置。这个调研阶段看着不起眼但后面所有模块的参数都是从这里来的。1.2 为什么用Python来搭这套框架技术选型上我主要比对了Go和Python两条路。OnCall系统本质上是一个IO密集型的Web服务加任务调度系统真正的瓶颈在网络请求、数据库读写、通知发送这些环节不在CPU计算。所以在性能上Python的异步框架完全扛得住除非你要支撑每秒上万条告警的极端场景那种体量已经属于企业级SRE团队才需要考虑的事不在这个话题范围内。真正决定我选Python的是生态。Prometheus的告警推送格式解析有现成的库飞书、钉钉、企业微信的机器人推送APIPython的SDK一个比一个成熟连语音通知都能找到开源方案。作为一个运维团队我们最熟悉的语言就是Python日常脚本维护、监控数据清洗都在用那么OnCall系统用Python来写后续迭代维护就不需要额外引入一门新语言的学习成本。Go的优势在于部署是一个静态二进制文件、并发模型更优雅但对我们这种场景Python的Django或者FastAPI用一个虚拟环境就能跑起来部署也不复杂。框架本身我选了FastAPI。原因是原生异步支持处理告警回调这类IO密集操作很合适。Pydantic做数据类型校验对告警payload这种结构复杂的JSON很方便。自带OpenAPI接口文档测试和对接都不用额外写文档。社区活跃文档质量高遇到问题几乎都能搜到答案。Flask和Django我也用过。Django适合做包含完整后台管理的系统但它太重了OnCall这种轻量级服务用不上Admin后台和ORM全套。Flask更轻但异步支持要额外配对长时间运行的值班进程来说不够顺手。FastAPI就刚好卡在中间轻量、现代、异步友好跟OnCall的需求非常匹配。1.3 模块划分与数据流转设计整个系统我拆成了五个模块边界尽量清楚告警接入模块Ingest接收各种监控源的Webhook做格式归一化和去重。路由引擎Router根据告警的服务名、级别、标签匹配对应的处理团队。排班模块Schedule维护值班表和轮换规则回答“当前时间点谁负责”。升级执行器Escalator定时检查告警确认状态超时未确认触发升级动作。通知模块Notifier统一封装短信、语音、IM、邮件等渠道管理模板和重试。数据流转是单向的。告警进来之后先把原始payload存一份到数据库然后进入路由判断。路由拿到团队ID之后去查排班表得出值班人再生成一条“待通知”记录并推送出去。通知模块回调更新告警状态升级执行器在后台不断扫描那些“已通知但未确认”的告警一旦超过阈值就触发升级。这样每一个环节都只是改数据库里的状态不会互相直接调用后面排查问题会非常舒服。这里我要多说一句数据库状态机是这个系统的命根子。告警的状态规划必须一开始就定义好我是用五个状态来打通全流程的created已接收、notified已通知、acknowledged已确认、resolved已解决、escalated已升级。每个状态之间的流转只能走固定的操作比如resolved只能从acknowledged转换过来不允许从created直接跳到resolved——这能避免很多诡异问题比如告警还没通知就被人为标记解决了导致没有值班人真正处理过。2. 告警接入与路由引擎整个系统的入口闸门2.1 统一Webhook所有监控源的对接方式OnCall的适配器模式应该从第一版就设计好。不同监控源的告警格式五花八门Prometheus Alertmanager推送的JSON里服务名在labels里Zabbix走的是它自己的一套协议自研脚本可能只是POST一段纯文本。我的做法是在接入层做一个统一的Webhook入口外面所有请求都先打到/webhook/{source}这个地址由每个source的适配器把原始payload解析成系统内部的Alert对象再交给下游处理。内部Alert对象的字段要有讲究。我定义的字段包括source来源、external_id原始告警ID、title、description、severityP1到P4、service、labels保留原样的一组kv对、occurred_at告警发起时间。这里最核心的是external_id和service这两个字段。external_id要能识别同类告警因为去重逻辑完全靠它service则是路由的依据。因为每个监控源给的服务命名风格不同我的做法是在适配器里强制指定一个service字段后续的默认值可以在DB里配置。接收告警之后第一件事就是落库。先把原始payload存到一个noise_table里再把解析后的Alert存到alert表。这一步看着多余但其实非常实用——即使后面解析逻辑有bug原始数据还在随时可以复盘。另外Webhook的签名校验必须加至少校验来源IP或者用Header里放token不然任何人都能伪造告警把值班人的手机打爆。这个安全上的教训是我吃了亏换来的。2.2 告警去重与聚合避免半夜手机被打爆的命根子去重是整个OnCall系统里最影响使用体验的功能。告警风暴发生时如果没有去重值班人手机能在三分钟内收到一百多条短信之后这个系统就再也没有人信任了大家宁可不接电话也不看消息。去重的核心就是利用external_id。具体实现上我在Alert入库时查找同external_id、且状态不是resolved的现有记录。如果已存在说明这条告警是重复的此时只更新原告警的最后发生时间和重复次数不再触发新的通知流程。这里要额外注意状态判断只有当前状态为resolved的告警才允许再次创建新的通知其他状态一律归到重复告警里。这个逻辑需要配合监控源的配置通常Prometheus的repeat interval已经做过一轮过滤了OnCall这边再兜底做一次双重保险。聚合则是进一步的优化。告警风暴时几十条不同的告警可能指向同一个根因比如某个交换机挂了上面跑的服务全都告警。我的做法是引入一个alert_group表通过配置的聚合规则按service severity 时间段自动归组组内告警不会都往值班人那边推而是推一条摘要“checkout服务有24条P1告警”值班人确认的时候可以展开看明细。这一步从体验上说是“把人从噪音里捞出来”的决定性设计。2.3 路由规则告警怎么找到正确的人路由引擎的设计核心是“规则引擎 配置表”。告警级别和团队之间的映射不应该写死在代码里因为组织结构会变服务的负责人也会变。我的做法是在数据库建一张route_rules表每条规则包含匹配条件和目标团队。匹配条件用JSON存储例如{ service: checkout, severity: P1 }路由的时候按规则的优先级从高到低逐条匹配命中即停止。规则之间支持通配符比如service填*就是匹配所有服务的兜底规则。在后面的实现里我默认配置了一条兜底规则任何未经匹配的告警都到负责OnCall流程的SRE团队那里防止告警变成无主案件沉没在数据库里。路由规则要支持热更新因为告警随时来不可能每次改规则都重启服务。我的做法是路由时不直接查数据库而是把route_rules表的内存副本缓存起来再监听一个配置变更事件变更就刷新缓存。实现起来不复杂但能避免“数据库被路由查询打爆”这种非常容易踩的坑。3. 值班排班与升级策略把“人”的问题算法化3.1 排班表的数据模型设计排班是OnCall里业务上最容易变需求的部分。一开始我总觉得排班就是简单的“每周轮换”直到我的同事说“下周我休假把我从排班里踢出去”我才意识到排班必须支持覆盖override功能。数据模型我用了三层schedule_users表维护值班人员名单包括姓名、手机号、邮箱、IM ID、通知偏好。schedule_rules表定义周期性的轮换规则比如“每两周轮一次只轮周一到周五”。schedule_overrides表临时覆盖记录比如某天某人休假换另一个人顶上。拿到某个时间点当前值班人的逻辑是先查override有就返回没有再查schedule_rules选出命中当天且属于当前轮换周期的那条如果都没有返回一个默认的备份组。这套三层设计的好处是日常轮换完全自动化特殊情况通过覆盖表手动微调两者互不干扰。排班规则的时间粒度我推荐做到天。按小时排班虽然更高级但绝大多数团队的使用场景是“一天一个值班人”太多粒度只会让排班表变成一团乱麻。真需要精细控制比如大促当天加一个“应急值班人”直接在override表加记录就好了。3.2 时区处理分布式团队最容易踩的坑时区是排班模块里最隐蔽的坑。如果你团队成员都在同一个城市问题不大但稍微分散一点比如杭州、上海、成都、甚至海外有几个同事时间就乱了。我一开始用datetime.now()取当前时间结果晚上9点多的告警被路由到了下午3点多的值班人对方睡得正香却被电话叫醒那才叫惨。后来我统一按UTC时间存储所有数据库时间戳展示和排班判断时再转成各自团队配置的时区。每个团队在配置里显式指定自己的时区比如Asia/Shanghai或Europe/Berlin排班查询时先把“当前时间”换算成目标时区的本地时间再跟排班规则比对。一个细节是夏令时地区的时间处理Python的zoneinfo在这方面做得很稳直接用它管理即可。这里给个实用的例子如果一个团队配置时区是America/Los_Angeles数据库里存的排班规则是“每周三值班”那查询当前值班人时我会先把UTC换算成洛杉矶当地时间再看“今天”是周几。换算过程用ZoneInfo(America/Los_Angeles)不要用offset偏移量硬算因为夏令时会疯的。3.3 升级机制谁兜底什么时候兜升级机制是OnCall的灵魂。没有升级机制的告警通知就是一个电话号码的碰运气游戏——第一联系人没看到告警就石沉大海了。我的升级设计是走一条链式策略escalation_policy: - level: 1 users: [当前值班人] wait_seconds: 900 - level: 2 users: [二级负责人] wait_seconds: 600 - level: 3 users: [SRE经理] wait_seconds: 600 - level: 4 users: [全体值班组] wait_seconds: 0意思是告警先通知当前值班人15分钟未确认升级到二级负责人二级也没确认再过10分钟找SRE经理还不解决就拉全体值班组紧急会议。第四级其实是个“警报已经彻底无人处理”的信号必须用电话轰炸式的通知让人无法忽略。升级逻辑放在一个后台轮询的任务里。每隔30秒扫描一遍所有状态为notified且超过对应等待时间的告警触发升级操作。这里要注意升级操作本身必须发送“告警被升级了”的额外通知给所有人的同时还要保留原告警上下文使得接手的人能快速了解情况。另外升级要记录完整日志因为事后复盘时“几点几分升级给谁了”是追责和改进流程的重要依据。4. 通知触达链路可靠比快更重要4.1 通知渠道的抽象层设计通知模块是OnCall里跟外部系统打交道最多的部分。短信、语音、IM、邮件四条渠道的技术实现完全不同所以我从第一天起就把它们抽象成同一种接口class Notifier(ABC): abstractmethod def send(self, contact: Contact, message: Message) - DeliveryReceipt: pass所有渠道都实现这个接口上层只关心调用send返回的结果成功、失败、还是未知比如渠道超时。这个抽象层看起来多余但后面接新渠道的成本几乎是零。我后来接一个新IM渠道只花了一个下午就是因为上层完全不用动。不同渠道的可靠性也不一样。IM渠道在公司内网里有可能断连短信偶尔有延迟语音电话的成本最高但最能保证触达。所以通知策略我做成可配置的P1告警短信加语音P2告警IM加短信P3只发IM。这个偏好可以按团队设置甚至可以按人的偏好来比如有的老哥就是短信收不到只信电话。4.2 重试、退避与熔断不是推出去就算完通知发送的可靠性要靠重试来保证但无脑重试会雪上加霜。我的策略是分渠道设定重试次数和退避间隔。短信渠道重试三次间隔分别是1分钟、5分钟、15分钟语音渠道重试两次间隔5分钟IM渠道重试一次就够因为IM服务基本不会大面积故障。这里要特别提“熔断”。有一次短信供应商接口超时2000条告警通知全部卡在队列里反复重试把供应商的接口打出了限流。后来我加了一层针对渠道的熔断器如果一个渠道连续失败超过阈值比如10次就标记当前渠道为不可用后续通知自动切换备用渠道并且让主渠道冷却5分钟再恢复。熔断器自己在内存里用状态机维护实现不复杂但能救系统于水深火热。4.3 确认回执与值班状态机值班人收到通知之后点“确认处理”这个动作为什么必须做扎实因为它直接关系到升级系统会不会误报。我做一个独立的接口/alert/{alert_id}/ack值班人从通知里点链接跳到确认页面再点“我来处理”按钮接口就把告警状态从notified改成acknowledged同时把acknowledged_by字段记录为当前用户并记录确认时间。这里要处理好幂等如果已经有人ack了其他人再点就要提示“此告警已由XX处理”不能重复修改数据库。确认回执触发的是状态机的核心功能没有回执升级系统就只能靠“你是否看过了”这种无法验证的方式运转整个系统都是纸糊的。我还要多说一句值班人处理完之后操作日志一定不要省。每一条告警从接收、通知、升级到确认的全过程都值得详细记录下来这是系统后续优化最重要的数据来源。哪一级升级触发得最多哪个时段告警最多谁的确认响应最慢通通能从这里分析出来。5. 实操过程从零搭出一个最小可用版本5.1 环境准备与项目骨架纸上谈兵说完了下面直接进实操。这部分我假设你有一台装了Python 3.10的服务器或者本地开发机按我的步骤来两个小时内你就能跑起来一个能接收告警、排班、通知、升级的OnCall原型。先建项目虚拟环境和依赖mkdir oncall cd oncall python3 -m venv venv source venv/bin/activate pip install fastapi uvicorn sqlalchemy apscheduler requests pydantic-settings目录结构我建议按功能拆分避免一个文件几百行那种后期无法维持的慢性痛oncall/ ├── app/ │ ├── api/ # FastAPI路由层 │ ├── core/ # 配置、DB连接、日志 │ ├── models/ # SQLAlchemy模型 │ ├── services/ # 告警接入、路由、排班等业务逻辑 │ ├── notifiers/ # 各通知渠道 │ └── workers/ # 后台任务、升级扫描 ├── migrations/ # DB迁移脚本 └── tests/ # 单元测试和调试脚本5.2 核心代码实现快速能跑的最小闭环由于篇幅控制我挑几条最核心的线来讲。首先是告警接入的路由接口app.post(/webhook/{source}) async def ingest_webhook(source: str, payload: dict): raw_logger.info(received_raw_alert source%s payload%s, source, payload) alert ingest_service.parse_alert(source, payload) if alert is None: return {error: unsupported source} exists alert_service.find_duplicate(alert.external_id) if exists: alert_service.update_duplicate(exists, alert) return {status: duplicated} alert_service.create(alert) team router_engine.route(alert) schedule schedule_service.current_oncall(team) alert_service.assign_to(alert, schedule) notifier_service.notify(alert, schedule) return {status: created, alert_id: alert.id}这条简短的代码就是我们上面所有设计落到的位置解析、去重、落库、路由、排班、通知一条线穿完。find_duplicate是怎么判断的查询同external_id且状态非resolved的记录有就认为重复更新字段。排班查询的逻辑大概是def current_oncall(team_id: str, at: datetime None): if at is None: at datetime.now(UTC) local_time at.astimezone(ZoneInfo(team_timezone(team_id))) override schedule_repo.find_override(team_id, local_time.date()) if override: return override.user rule schedule_repo.find_matching_rule(team_id, local_time) if rule: return rule.user return default_fallback_group(team_id)这个实现里同学会注意到override和rule的优先级不同先覆盖后规则。这个顺序很重要否则休假同事的override会被周期规则覆盖掉该休息的人又被拉起来值班。升级扫描的后台任务用APScheduler来跑def escalation_scan(): alerts alert_service.list_notified_timeout() for a in alerts: if a.current_level len(a.policy): notifier_service.alert_whole_team(a) continue a.escalate() next_user a.policy[a.current_level] notifier_service.notify(a, next_user) alert_service.save(a)这里要注意list_notified_timeout怎么定义状态是notified且last_notified_at已经超过了当前层级设定的等待时间。每次升级之后last_notified_at要重置否则会不停触发同一个层级的升级。5.3 本地联调与模拟验证代码写完就要联调了。我在调试阶段写了一个模拟Prometheus告警的脚本省得每次去等真实监控触发。本地起服务之后uvicorn app.main:app --reload --port 8000再手动POST一条测试告警curl -X POST http://localhost:8000/webhook/prometheus \ -H Content-Type: application/json \ -d { status: firing, labels: { alertname: HighErrorRate, service: checkout, severity: critical }, startsAt: 2024-01-15T14:30:00Z }我建议在本地调试阶段先把通知渠道都配置成输出到控制台的假渠道日志打出来看全链路流转结果确认逻辑没问题再去接真实渠道。这里我提供一个细节技巧把notifier_service里的发送函数写成一个可切换的类环境变量ONCALL_NOTIFY_MODEconsole时打印到终端webhook时真实发送。这样既可以在本地调试完整流程又可以随时切回生产模式非常顺手。联调时重点验证三条链路重复告警连发两条相同的告警第二条应该返回duplicated。路由正确不同service的告警应落到不同团队的值班人。升级触发把wait_seconds改成10秒等告警超时未确认观察升级逻辑是否触发。6. 上线后才会遇到的坑问题排查速查表6.1 告警重复推送的真相上线第一周就遇到一个案例某服务重启期间Prometheus的指标出现断崖监控规则连续报了30多次firing告警但我的OnCall只发了一条通知。我一开始以为是去重逻辑有bug后来扒原始数据才发现Prometheus Alertmanager在repeat_interval期间重复发送的告警具有完全相同的指纹external_id所以被去重掉了这是正常行为。真正要警惕的反而是另一种情况监控源挂了之后恢复或者重启了Alertmanagerexternal_id变化了同样的故障就会变成一条新告警重新推送一遍。这类问题要在去重逻辑里加一个“同服务同级别窗口内合并”的兜底策略不能只信外部ID。另外告警恢复的resolve通知也要做去重设计如果主告警已经被ack了后续resolve通知可以让值班人顺手关闭但如果主告警一直没被处理resolve通知就不能被当作新告警推送否则值班人半夜还是会收到“问题已恢复”的短信一样烦人。我当时的经验是resolve通知只在对应告警状态为acknowledged时推送给当时处理人其他情况只更新数据库状态不触发通知。6.2 时区导致的排班漂移上线第二周出现的最诡异问题某团队的值班人总是隔一天才收到告警排班判断晚一天。我查了很久才发现他们配置的时区是对的但排班规则的生效日期是按UTC存储的而生效日期是当天当目标团队时区已经走到下一天时规则匹配就认为当天没有值班人于是全部走了兜底组。解决方式很简单排班规则的表里直接存“本地日期”查询时把当前时间转换到目标时区后跟这个日期比较不要让日期和时区混在一起判断。这也是我给这个设计花过最多时间的一课。6.3 任务积压与延迟当告警量突然暴涨时问题不在Webhook接收而在通知队列。我第一版通知是同步发送的每个渠道都等供应商响应500条告警可以让整个服务卡死几十秒。后来我改成FastAPI的BackgroundTasks加内存队列性能好了不少但服务重启就会丢队列里的待发通知。生产环境我建议至少要上一个Redis做任务队列把notify任务入队之后立刻返回worker再异步消费。数据丢一条通知的后果可能让你被投诉一整周这个风险不值当。6.4 后续还能扩展的方向一个可用的OnCall只是开始。我准备在接下来做几个方向的增强对接企业微信/钉钉/飞书的机器人把告警卡片推到群里同时在卡片上直接带“确认”“解决”按钮省掉跳转网页的时间再接入事件单系统让值班人在OnCall里确认后自动创建一条事件记录和工单体系打通最后加一个告警报表系统统计MTTA、MTTR这些核心指标每隔一段时间复盘看看值班体系和监控规则哪里需要优化。如果你团队已经有明确的节假日值班表也可以把节假日规则做进排班模型里而不是每年手动加override。这需要一张holidays配置表然后排班查询时先判断今天是不是节假日再选择对应的节假轮流组。这个功能在我们团队上线后好评率极高毕竟谁都不想大年三十被误排到值班岗上。讲到最后我个人的真实感受是OnCall系统看起来是个基础设施但本质上是个“信任问题”的解决方案。只有当每个值班人都确信告警一定会精准找到对的人且错误通知会被系统拦截他们才会真正信任这套系统愿意在深夜被叫醒时用最快速度响应。从0构建一套属于自己的Python OnCall框架真正宝贵的不是跑的流程多完美而是通过这个过程你彻底梳理了自己团队的值班体系把人的混乱变成了可控制的确定性。如果照着这篇文章的思路也搭了一套欢迎来聊聊你踩到的新坑咱们互通有无。