ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

状态页事故归档:Python爬虫实现SLA复盘与监控联动

状态页事故归档:Python爬虫实现SLA复盘与监控联动 干运维和SRE的朋友应该都体会过这种场景半夜被监控告警吵醒打开服务商的状态页确认是不是对方出事了等恢复之后想翻历史事故记录发现页面只展示最近几条要么就是翻起来特别费劲。后来我干脆写了个Python爬虫把依赖的软件状态页事故记录定期采集下来落到库里做归档。这套东西别看简单实际用起来能解决很多问题做SLA核算、复盘会议、向领导汇报月度稳定性、甚至对齐我们自己的监控告警时间线都有现成数据可查。这篇文章我会把完整思路和可复现代码拆开讲。核心就是Python加requests解析方式分JSON与HTML两种路子数据存储用SQLAlchemy再配合定时调度做增量归档。适合刚接触爬虫、想通过真实项目练手的开发者也适合运维和SRE同学直接拿去改改就能用。整个项目不用上分布式不用造轮子单机跑就够。1. 项目背景与整体设计思路1.1 软件状态页每个依赖里的“晴雨表”值得长期留档软件状态页就是服务商用来公布系统可用性和事故进展的页面。几乎每个正经云服务商、开源项目、SaaS厂商都有例如你熟悉的那些大型开发者平台出问题时会在状态页同步“已确认问题”“正在监控”“已恢复”这些阶段。状态页在正常时没什么存在感但一出事故它就成了上下游最准的信息源。问题是这类页面默认只保留最近一段时间的记录过了窗口期就查不到了。而实际上做故障复盘、SLA核算、容量规划又恰恰需要历史数据。我自己就吃过亏某天我们接的短信通道半夜抖动当时只截图、没留数据等到月底写复盘报告时想找出运营商状态页当时到底写了什么发现已经查不到了。从那以后我就决定凡是公司关键依赖链路里的状态页全部定期采集归档。事故归档听起来像是“把页面存下来”这种简单操作但真正做起来有几个点要提前想清楚状态页上的事故不是单个字段它包含标题、状态、开始时间、结束时间、影响组件、更新记录甚至Postmortem链接。页面呈现形式五花八门有的是纯静态HTML有的是动态渲染后端偷偷走JSON接口。归档不能只存“当前快照”还需要做增量更新否则同一事故会被反复入库。所以这套采集不是随手写个脚本那么潦草而是要按“数据管道”的思路来设计。采集、清洗、存储、调度四层分开后续哪一层出了问题都好排查。1.2 先定目标再选方案命中最稳的采集路径写爬虫之前我习惯先花十分钟做一件事打开目标状态页按F12看Network面板刷新页面观察有没有XHR请求返回结构化数据。这一步决定了整个采集方案的走向。优先级是这样的优先找JSON API接口。现在很多状态页是商业模板做的比如Statuspage.io这套方案页面背后通常直接暴露公开API返回的字段结构干净采集最稳定几乎不会出现解析错位的问题。没有API就看有没有RSS或Atom订阅。部分状态页会提供事故Feed解析XML工作量不大。都没有才用BeautifulSoup做HTML解析。HTML解析最容易写也最容易坏因为前端一改版选择器就失效。选择路径时别嫌麻烦。直接上HTML解析虽然也能跑但下次页面微调你就会想骂人。我实测下来多数状态页其实都有某种隐藏API只是入口藏得比较深。先找接口比硬啃HTML高效得多。存储方面我选SQLAlchemy配SQLite起步。SQLAlchemy的好处是ORM建表清晰后面如果数据量大了要切换MySQL或PostgreSQL只要改连接串和少量方言配置就行。SQLite对单机采集完全够用没必要一上来就搭复杂的存储环境。2. 核心细节解析与实操要点2.1 拆解状态页的数据结构事件列表与事件详情不管页面长什么样状态页信息的底层逻辑基本一致。一份完整的事故归档记录至少要包含以下几个字段字段含义采集注意点incident_id事件唯一标识去重和增量更新的关键name / title事故标题一般都在事件列表里status当前状态investigating / identified / monitoring / resolvedimpact影响级别有none/minor/major/critical等枚举有时在components里created_at创建时间解析ISO8601格式时注意时区resolved_at恢复时间不一定存在未恢复的事故该字段为空updated_at最后更新时间增量更新的重要依据components受影响组件列表有的是数组里面带组件id和影响状态postmortem_url复盘链接官方发布事故报告时会存在为什么要特别强调这些字段因为归档的目的不只是“留个页面截图”而是未来能在库里做过滤查询。比如你想统计“过去两个季度影响级别为major的事故平均恢复时间”如果上面这些字段被平铺存下来一条SQL就能查出来。如果只是存整个HTML后期用起来等于大海捞针。另外要留意时区问题。状态页API给的时间基本都是UTC入库时我建议统一转成UTC字符串或时间戳展示时再转本地时区。很多新人在这里掉坑让恢复时间计算出来差了几个小时。2.2 请求细节请求头、超时、重试与速率控制采集状态页不像采集电商网站那样对抗激烈但也不代表可以裸奔乱请求。我一般会用requests.Session维护连接并设置合理的请求头。请求头里最重要的是User-Agent。别用Python默认的python-requests/2.x很容易被一些边缘节点拦截。我习惯用一个稳定的UA比如Chrome桌面版UA简单可靠。超时设置更不能省。状态页挂了是常有的事如果不设超时脚本可能卡在某次请求上几个小时。我用timeout(3.05, 10)前者是连接超时后者是读取超时这种元组写法可以规避一些因为网络波动导致的Socket超时坑。重试策略我推荐用tenacity库或者自己写一个简单的指数退避。权重建议是连接超时和状态码500/502/503必须重试但HTTP 404和403一般不要无脑重试前者是地址变了后者可能代表你被拦了再试也没用。def fetch_json(url, session, retries3): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } for attempt in range(retries): try: resp session.get(url, headersheaders, timeout(3.05, 10)) resp.raise_for_status() return resp.json() except (requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as e: print(frequest failed: {e}, attempt{attempt 1}) time.sleep(2 ** attempt * 0.5) return None注意自己的请求频率。状态页虽然公开但也是生产系统的一部分我通常把两次请求间隔控制在1到3秒不要并发轰炸。你只是归档数据不是压测。2.3 解析策略JSON优先HTML兜底Selenium最后解析JSON没什么好说的拿到dict之后按字段取值就行。但要注意两点一是部分接口会把大量记录塞在一个大列表里需要确认有没有分页二是有些状态页的API返回是包裹在某种Wrappe结构里比如{incidents: [...]}别一眼看到dict就当成列表。没有API时用BeautifulSoup解析HTML是可行方案。页面上的现象通常是每条事故记录放在一个article或div容器里标题、状态、时间分布在不同的class属性中。常见定位方式soup BeautifulSoup(page_html, lxml) incident_nodes soup.select(div.incident-cell, article.status) for node in incident_nodes: title_node node.find(h1) or node.find(a, class_title) time_node node.find(time) ...这种写法最大的问题就是脆弱。不说前端彻底改版哪怕把某个class从incident-title改成incident-heading都足以让解析结果变成空列表。所以我只会把HTML解析当保底手段同时做好失败告警数据没抓到不可怕可怕的是静默丢数据。至于Selenium我认为能不上就不上。启动浏览器渲染整个页面资源占用高、速度慢、还要额外维护驱动版本。除非目标状态页是那种所有数据都靠JS异步渲染、且后端接口加密混淆的特殊场景才会考虑WebDriver。绝大多数状态页到不了这一步。3. 实操过程与核心环节实现3.1 环境准备与项目目录这套项目的依赖很少Python 3.9以上就完全够用。初始化虚拟环境后安装四个包python3 -m venv venv source venv/bin/activate pip install requests beautifulsoup4 lxml sqlalchemy目录结构我习惯这么组织虽然简单但也做到分层清晰status_collector/ ├── collector.py # 采集和解析逻辑 ├── models.py # SQLAlchemy ORM模型 ├── store.py # 入库与去重逻辑 ├── config.py # 目标页面配置 └── run.py # 入口和调度别小看这个目录结构。很多爬虫脚本写着写着就变成一千行单文件没人看得懂也没人敢改。拆开放的好处是以后新增一个状态页源多半只用改config和解析函数。3.2 抓取模块核心代码这里给出一个能跑的骨架。假设目标状态页有公开JSON API返回格式是{incidents: [...]}事件列表里的每条记录包含我在2.1节列的那些字段。import time import requests from datetime import datetime, timezone def parse_incident(item): 把接口返回的原始dict清洗成我们关心的结构。 components item.get(components, []) component_text , .join( f{c.get(name, )}:{c.get(status, )} for c in components ) return { incident_id: item.get(id), name: item.get(name), status: item.get(status), impact: item.get(impact), created_at: item.get(created_at), resolved_at: item.get(resolved_at), updated_at: item.get(updated_at), components: component_text, postmortem_url: item.get(postmortem_url) or item.get(shortlink), raw_payload: str(item), }清洗这一步正是体现“经验”的地方。接口给你的数据不一定都适合直接入库比如components是个嵌套列表直接塞进关系型数据库并不方便提前转成摘要文本反而更好用。raw_payload则保留完整原始数据万一以后想换解析规则还能从库里的原文回溯。3.3 SQLAlchemy建表与数据入库ORM模型我按事故的维度设计字段类型要收敛。时间字段用DateTime但是入库前我会把ISO字符串转成Python的datetime对象。为了避免时区算错统一用UTC。from sqlalchemy.orm import declarative_base from sqlalchemy import Column, String, DateTime, Text Base declarative_base() class Incident(Base): __tablename__ incidents incident_id Column(String(128), primary_keyTrue) name Column(String(512)) status Column(String(64)) impact Column(String(64)) created_at Column(DateTime) resolved_at Column(DateTime, nullableTrue) updated_at Column(DateTime, nullableTrue) components Column(Text) postmortem_url Column(String(1024), nullableTrue) raw_payload Column(Text)用incident_id作为主键是整套增量采集的地基。入库逻辑写成upsert语义已有记录则更新字段没有则新增。这种写法不用每次推倒全量重灌既能保证数据完整也能支持页面内容修正后的同步更新。from sqlalchemy.dialects.sqlite import insert as sqlite_insert def upsert_incident(session, parsed): stmt sqlite_insert(Incident).values(**parsed) stmt stmt.on_conflict_do_update( index_elements[Incident.incident_id], set_{ name: stmt.excluded.name, status: stmt.excluded.status, impact: stmt.excluded.impact, resolved_at: stmt.excluded.resolved_at, updated_at: stmt.excluded.updated_at, components: stmt.excluded.components, postmortem_url: stmt.excluded.postmortem_url, raw_payload: stmt.excluded.raw_payload, }, ) session.execute(stmt)这里用了SQLite的INSERT ... ON CONFLICT DO UPDATE方言。如果后续换MySQL记得把引擎部分换成对应的mysql_insert。ORM的好处在这时候就体现出来了表和代码一致迁移成本可控。3.4 增量更新、历史回填与定时调度增量更新主要靠updated_at每次采集时先查出库里最大的updated_at请求接口时如果支持时间过滤参数就带上如果不支持就拉列表后在内存里过滤掉比这个时间更早的记录。有些状态页API支持分页但限制单页条目数。做法是循环拉取直到拿完为止。保持脚本幂等启动位置不同也能跑到一致的结果。历史回填是另一个常见需求。我第一次部署的时候库里是空的直接跑增量逻辑等于什么也拉不到因为没有任何时间锚点。解决办法是先允许一个“全量模式”第一次启动时从头翻页把已知事故全部存下来之后就靠updated_at做增量。代码上最简单的方式就是加一个full_sync开关。定时调度我推荐先用系统cron0 */6 * * * cd /path/to/status_collector /path/to/venv/bin/python run.py logs/collector.log 21每6小时采集一次既不会错过状态更新又不至于给对方服务器制造压力。想更轻量也可以扔到GitHub Actions里用schedule事件定时跑。只要目标状态页API本身不需要鉴权跑在托管环境里完全没问题。4. 常见问题与排查技巧实录4.1 页面结构一变选择器瞬间失效这是HTML解析方案最容易踩的坑。今天跑得好好的明天选择器就抓不到东西而且脚本不会主动报错返回的往往是一个空列表很容易被忽略。我的排查经验分三步第一步立刻打印抓下来的HTML前一千个字符确认页面内容本身有没有变化。第二步用浏览器的Copy Selector功能重新定位元素比对之前选择器的差异。第三步也是最关键的审视一下当初选HTML解析是否真的合理。如果页面存在JSON接口与其维护一堆选择器规则不如切换到接口解析一劳永逸。除此之外建议在采集程序里加一个“空数据告警”。抓到的事件数量低于历史平均值的20%时直接发通知而不是默默入库。数据管道里最怕的不是报错而是数据静默缺失。4.2 遇到限流、拦截与“伪静态”状态页的处理状态页服务商一般不会刻意搞反爬但并不意味着你可以每分钟请求几百次。有一次我做多页面采集时没控制好间隔连续抓了十几个状态页结果有一半请求返回403或429。对方在没有封死你的情况下抛限流响应其实是温和警告。处理办法很简单降低频率间隔从1秒提高到3至5秒。用同一个Session保持连接复用减少握手次数。如果看到响应头里有Retry-After老老实实等对应秒数再继续。偶尔遇到403先不要怀疑IP被拉黑检查一下UA是否被重置成默认值了。所谓的“伪静态”是另一种情况页面看起来是静态HTML但实际内容靠一段JS从接口渲染。这种情况直接抓HTML会拿到一堆空壳标签真正想要的数据在XHR里。遇到这种页面我的建议是回到2.3节的规则打开开发者工具找接口别去祭Selenium。4.3 采集失败时如何保证数据完整采集不稳定是常态能保证数据完整才是真功夫。我的做法是多层防护内存容错单条事件解析出错不要整个程序崩溃用try/except包住打印异常后继续处理下一条。存储容错入库前把原始JSON打印成JSONL文件写一份到本地备份。即使数据库表结构调整出错原始数据也还在随时能重建库。进程容错给脚本套一层死信机制同一个接口连续失败超过5次就发钉钉或邮件告警而不是无限重试下去。千万别小看“写一份JSONL备份”这一步。有一次我改表结构时误操作把SQLite表清了就是因为本地留着原始JSONL备份重跑一遍入库就全部恢复了。这比任何数据库备份机制都简单有效。4.4 归档数据怎么用起来SLA核算、复盘周报与告警联动数据存下来只是开始真正有价值的是事后消费这些数据。我从实际使用中总结出三个高频场景SLA核算统计某段时间内的“事故次数、影响级别、恢复时长”算出来给领导看比自己纯靠记忆回忆靠谱太多。复盘周报每周跑一条SQL把本周所有主要事故的标题、影响组件、起止时间导出来就是一份现成的稳定性周报初稿。告警联动把状态页事故记录和我们自己的监控告警按时间轴对齐能快速判断“是我方问题还是依赖方问题”减少半夜误判。时间字段用UTC存储查询展示时再用程序或SQL转换为本地时区。周报里最实用的一个查询是把同一时间段内“影响级别为major或critical”的记录过滤出来按恢复时间排序马上就能看出这个月哪几家依赖商不给力。最后再多说一句这类采集方案我在实际运维中已经持续跑了快一年除了偶尔页面改版需要调整选择器基本可以放养。如果你手上刚好有几个天天用但不敢放心的服务商建议也搭一套自己的状态页归档跑两周之后你就会发现月底写复盘报告时再也不用到处翻聊天记录和截图了。
返回列表