ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从模糊代号到运行系统:自研轻量资源监控工具rea的完整实践

从模糊代号到运行系统:自研轻量资源监控工具rea的完整实践

很多朋友看到"rea"这个标题时,大概率会以为打错了字,或者觉得这是某个库的缩写。我要先坦白一件事——它最初真没有什么标准全称,只是某天我在终端里随手敲的三个字母,用来命名一个空目录。后来这个目录慢慢长成了一个完整的资源效率分析工具(Resource Efficiency Analyzer,内部代号缩写就是rea),专门负责采集服务器的CPU、内存、磁盘与网络指标,再把这些数据整理成实时看板,帮我从被动救火式运维变成主动观察式管理。这篇文章就把"一个模糊标题如何变成一个可运行系统"的完整过程写出来,适合手里有个想法、但还没想清楚架构的开发者参考,也适合那些想自建一套轻量监控体系、又不想直接套用重型全家桶的朋友。

1. 先给 rea 一个定义:从模糊代号到明确需求

1.1 代号背后的真实需求:别再让服务器走在下线边缘

见过凌晨两点的告警邮件吗?说的不是那种"CPU 忽高忽低再观察一下"的假警报,而是磁盘使用率冲到 98%,数据库进程直接崩掉,然后你在睡梦中被电话叫起来,眯着眼睛查哪张表把空间吃光了。我经历过几次这种场景之后,决定不能再靠"感觉"运维了。我需要一个工具,能持续地告诉我每一台机器的资源消耗长什么样,不是事后翻日志,而是事前能看到趋势。

rea 的第一个需求就这么定下来了:采集多台服务器的系统资源指标,存入数据库,用图表展示历史趋势。听起来很简单,但里面藏了几个硬约束。一是采集不能太影响业务,agent 本身要轻;二是数据量会持续增长,存储和查询得接得住;三是展示要让人能快速定位问题,不能打开看板还要等十几秒。

给项目起代号的时候,我压根没花心思。rea 就是 resource efficiency analyzer 的缩写,顺手打的,也没打算对外发布。但正式动手前,我给自己写了一段项目说明:用最低的运维成本,记录基础设施的资源水位,让趋势可见、异常可查。这段话后来帮我挡掉了不少需求膨胀的冲动,谁再说"顺便加个 XX 功能",我就把这段话翻出来给他看。

1.2 谁是它的目标用户?我为什么不做成通用监控平台

市面上的监控平台很多,从老牌开源方案到商业全家桶,随便挑一个都能用。但我做 rea,目标用户从一开始就只有两类:一个是我自己,另一个是和我类似的小团队——机器数量在 5 到 30 台之间,没有专职运维,预算有限,又不想为了看几个指标把整套重型框架搬回家。

自研的边界一定要划清楚。我不追求覆盖所有监控场景,也不打算做高可用集群。rea 要解决的是 80% 的常见问题:机器负载有没有异常、内存是不是长期吃紧、磁盘大概还有多少天会被写满、网络流量是不是有突发。至于分布式链路追踪、日志检索、APM 这类能力,那是另外的战场,硬塞进来只会让项目失焦。

而且自建这条路有个隐形好处:整个系统从采集到展示都是自己写的,每个环节都清楚,出了问题不会像黑盒一样无从下手。公司里如果买了商业产品,排障时只能提工单;用开源方案,遇到不满足的场景得读源码改插件。而 rea 是我自己的,我想让它支持什么业务指标,直接加一个字段就行,这种掌控感用钱买不到。

2. 技术方案怎么定:三层结构是小项目的保命符

2.1 为什么第一反应不是"全家桶"监控系统

有现成的开源监控组合可用,为什么要自己写?这个问题我在动手前认真回答过自己。最直接的原因是学习成本。全家桶方案确实强大,但它的学习曲线很陡,配置项非常多,默认参数不一定适合你的场景,踩坑时要在告警规则、服务发现、模板变量之间来回排查。我见过不少团队把监控系统装起来了,结果没人会配告警阈值,最后变成一个昂贵的图表库。

另一个原因是业务指标接入的灵活性。系统监控指标可以用现成方案采集,但业务指标,比如某个核心接口的响应时间、某条消息队列的积压数量、今天新增的订单量,这些需要业务代码主动上报。与其在自研系统之外再接一套上报通道,不如把 rea 做成一个通用的"指标接收器",无论是系统数据还是业务数据,只要按约定格式推过来就能落库、查询、展示。

当然,现成方案也有它的价值。我并没有完全否定它,只是在 rea 这个特定项目里,我希望它足够轻、足够可控。如果哪天机器规模涨到上百台,需要服务发现、自动告警、多租户隔离这些能力,到时候再评估迁移也不迟。先解决眼前的问题,不为了"未来可能的规模"提前背上复杂度的包袱。

2.2 前后端与存储选型的一个参考组合

rea 的技术栈没有用花哨的新框架,全是经得起时间考验的组合。前端选了 Vue3 加 TypeScript,用 Vite 做构建工具,图表库用 ECharts。后端用 Python 的 FastAPI,接口写起来快,性能也够用。存储这块是重头戏,选的是 TimescaleDB,跑在 PostgreSQL 之上,既保留关系数据库的熟悉感,又针对时序数据做了专门优化。

选择依据其实很务实:

环节选型核心理由
前端框架Vue3 + TypeScript + Vite组件化清晰,类型安全,开发效率高
图表库ECharts折线图、面积图开箱即用,大数据量渲染有内置优化
后端框架Python FastAPI异步支持好,自动生成接口文档,开发速度快
数据存储TimescaleDB原生支持时序表分区,压缩率高,SQL 查询降低成本
部署方式Docker Compose + Nginx一套命令拉起全部依赖,适合小规模场景

为什么不用 MySQL 或者 MongoDB?MySQL 存时序数据不是不行,但数据量上来后,按时间范围删除旧数据、按时间窗口聚合这些操作都会变得别扭。MongoDB 文档模型灵活,但聚合查询写起来不如 SQL 直观,而且对时间序列的压缩能力几乎没有。TimescaleDB 本质上还是 SQL,我可以用熟悉的 JOIN 和 GROUP BY,同时获得自动分区和压缩带来的性能红利,这是它最打动我的地方。

2.3 一次架构的"简化-膨胀-再简化"

很多个人项目死在"想得太多"上,rea 也不例外。最初画架构图的时候,我列了一堆组件:消息队列缓冲采集流量,流处理引擎做实时聚合,NoSQL 存原始数据,宽表存聚合数据,再配一套 Redis 做缓存,前端还要考虑 WebSocket 推送。画完之后自己看着都害怕,这哪是个人工具,这是照着大型互联网公司的基础设施在搭。

后来我做了个减法,把架构压缩成三条链路:采集器定时把指标 POST 到后端接口,后端写入数据库,前端通过查询接口取数绘图。中间的消息队列和流处理全部去掉,理由是数据量没有大到需要异步削峰的程度,一台采集器 10 秒上报一次,20 台机器也就每秒两条请求,FastAPI 处理这点请求绰绰有余。

这个"砍需求"的过程让我学到一点:小项目最值钱的优势就是简单,任何你觉得"以后可能用得上"的组件,都先记在文档里而不是装进代码里。真到了需要消息队列的那天,再把它加进来,而不是一开始就为想象中的人流量修一条十车道的高速公路。

3. 核心链路落地:采集、写入、查询、展示一个都不能少

3.1 数据表设计:时序数据的第一课

rea 落地的第一个关键环节是数据库表结构。时序数据的特点是写多读少、数据只追加、旧数据需要定期清理,所以表结构不能照搬普通业务表。我建的第一张表是主机资源指标表,核心字段包括采集时间、主机名、CPU 使用率、内存使用率、磁盘使用率、网络流入量、网络流出量,以及一个 JSONB 类型的扩展字段。

建表语句如下:

CREATE TABLE host_metrics ( time TIMESTAMPTZ NOT NULL, host TEXT NOT NULL, cpu_usage DOUBLE PRECISION, mem_usage DOUBLE PRECISION, disk_usage DOUBLE PRECISION, net_in BIGINT, net_out BIGINT, tags JSONB DEFAULT '{}' ); SELECT create_hypertable('host_metrics', 'by_range', 'time');

这里有几个设计细节值得展开说一下。第一个是为什么用TIMESTAMPTZ而不是TIMESTAMP。时序数据最怕时区混乱,如果用不带时区的TIMESTAMP,采集器从不同时区的机器上报数据时,时间轴会错位。统一用带时区的类型,所有时间都按 UTC 存储,展示层再按用户时区转换,这是最不容易出错的方案。

第二个是为什么保留一个tags字段。单台机器的指标字段是固定的,但业务上可能想给不同机器打标签,比如区分线上环境、测试环境,或者标注机器所属的可用区。把这些维度塞进单独的列会很死板,放在 JSONB 里就灵活多了,查询时用tags->>'env'就能做过滤。

第三个是为什么time和host不加联合主键。刚开始我也习惯性地想加,后来实测发现唯一约束会严重拖慢写入速度,时序数据本身就带有时间戳这个天然标识,重复数据靠应用层去重就够了,数据库层的唯一性约束在这里性价比很低。

3.2 写一个不添乱的采集器

采集器是 rea 的神经末梢,它要足够轻,不能给被监控的机器添乱。我用 Python 的 psutil 库实现资源采集,这个库的优势是跨平台、接口稳定、文档全,Linux 和 Windows 都能跑,对个人项目来说覆盖面足够了。

核心采集逻辑比较直接:

import socket import time import requests import psutil INTERVAL = 10 ENDPOINT = "http://rea-server:8080/api/metrics" def collect_once(): return { "host": socket.gethostname(), "cpu_usage": psutil.cpu_percent(interval=1), "mem_usage": psutil.virtual_memory().percent, "disk_usage": psutil.disk_usage("/").percent, "net_in": psutil.net_io_counters().bytes_recv, "net_out": psutil.net_io_counters().bytes_sent, "ts": int(time.time()) } while True: try: data = collect_once() requests.post(ENDPOINT, json=data, timeout=3) except Exception as exc: log_to_file(f"report failed: {exc}") time.sleep(INTERVAL)

代码看着简单,但有几个细节如果处理不好,后续会相当难受。第一个是cpu_percent的interval参数。psutil.cpu_percent(interval=0)是绝大多数初学者的写法,结果往往返回 0,因为第一次调用没有历史采样数据。代码里传interval=1,含义是阻塞 1 秒、计算这 1 秒内的平均使用率,这样第一次拿到的值就是可信的。当然这 1 秒阻塞是可以接受的,毕竟采集频率本来就不高。

第二个是网络流量读的是累积值而不是速率。bytes_recv和bytes_sent是从开机以来的累计字节数,存到数据库里之后,绘图时要用当前值减去上一时刻值再除以时间间隔,才能得到平均速率。我可以选择在采集器里先把速率算好,也可以把累积值存下来交给查询端计算。我选了后者,理由是想保留最原始的计数,这样即使后来想调整时间窗口,也能灵活重算。

第三个是采集器不需要花哨的并发能力,单线程循环加异常隔离就足够了。上传失败不能影响采集,日志要写到文件而不是控制台,不然服务跑起来之后根本看不到报错。启动之后先用一次cpu_percent(None)做预热,避免第一次上报的数据点失真,这也是容易忽略的小坑。

3.3 服务端接口:别让单条写入拖垮写入性能

后端接口是整个链路的中转站,它要同时面对两个问题:来自多台采集器的持续写入请求,以及来自前端的时间范围查询请求。写入接口的写法一定要考虑批量处理。最直观的做法是每收到一条数据就 INSERT 一条,但这样做数据库压力会大,连接频繁开关,TPS 一口就被压死了。

我采用的方案是内存队列加定时 flush。接口收到指标后,先把数据放进一个线程安全的队列,后台任务每隔 5 秒或者队列累积到 500 条时,统一执行批量插入。这样数据库每次操作面对的是一批数据,写入吞吐量能差出一个数量级。下面是简化后的核心结构:

import asyncio import asyncpg from fastapi import FastAPI app = FastAPI() _batch = [] async def flush_batch(): if not _batch: return records = _batch[:] _batch.clear() async with pool.acquire() as conn: await conn.executemany( """ INSERT INTO host_metrics (time, host, cpu_usage, mem_usage, disk_usage, net_in, net_out, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, records ) @app.post("/api/metrics") async def receive_metrics(item: MetricItem): _batch.append(( datetime.fromtimestamp(item.ts, tz=timezone.utc), item.host, item.cpu_usage, item.mem_usage, item.disk_usage, item.net_in, item.net_out, json.dumps(item.tags) )) if len(_batch) >= 500: await flush_batch() return {"status": "ok"}

这个设计的关键不只是批量,还包括失败重试和容量控制。如果后台 flush 任务抛异常,不能把队列里已有的数据丢掉,要记录日志并保留备份,否则会出现看板缺数据的诡异现象。队列本身也需要一个上限,比如最大 5000 条,超过上限的请求先返回错误而不是无限堆积内存,否则采集器一旦异常大量上报,后端的进程内存会被撑爆,这时候反而需要采集端主动降频。

3.4 查询与前端看板:如何让页面不卡

数据写进去之后,查询接口要能支持时间范围、主机过滤、指标选择这些常见条件。最关键的一个优化是降采样。一张时序表里如果存了数十万条数据,前端小屏幕根本画不下,强行渲染只会让页面卡死。SQL 层用time_bucket函数把秒级数据聚合成分钟级或小时级数据,曲线既平滑又轻量,这才是正确姿势。

一个典型的趋势查询是这样的:

SELECT time_bucket('1 minute', time) AS bucket, host, avg(cpu_usage) AS cpu_avg, max(cpu_usage) AS cpu_max FROM host_metrics WHERE host = $1 AND time >= $2 AND time < $3 GROUP BY bucket, host ORDER BY bucket;

前端看图只关心大体趋势,分钟级均值足够用了。如果用户想看某个瞬间的精确值,再加一个下钻接口返回原始明细数据,这样的两级策略兼顾体验和性能。

前端看板用的是 Vue3 加 ECharts。整体布局分三块:顶部是时间范围选择器,左侧是主机列表,中间是多个指标折线图。主机列表支持多选,选中的每台机器用不同颜色区分,时间范围改变后所有图表联动刷新。实现联动时有一个并发问题要注意:多个请求同时发出,返回顺序可能不一致,后返回的旧数据不能覆盖新数据。我用请求序号做了一层校验,只渲染最后一次发起的请求结果,这个细节花了我一晚上才定位到。

4. 部署细节与踩坑实录:rea 从本机跑到服务器的过程

4.1 Docker Compose 一键拉起整套环境

rea 的部署目标是一台 2C4G 的云服务器,所有组件用 Docker Compose 管理。这个选择意味着环境依赖被全部封进容器,换机器、重新部署都很从容,不用在一台新服务器上手动装 Python、Node、PostgreSQL,也避免版本不一致的问题。

docker-compose.yml 的骨架如下:

version: "3.8" services: timescaledb: image: timescale/timescaledb:latest-pg16 environment: POSTGRES_PASSWORD: rea_db_pass POSTGRES_DB: rea volumes: - rea_db_data:/var/lib/postgresql/data ports: - "5432:5432" rea-server: build: ./server environment: DATABASE_URL: postgresql://postgres:rea_db_pass@timescaledb:5432/rea depends_on: - timescaledb ports: - "8080:8080" rea-web: build: ./web ports: - "80:80" depends_on: - rea-server volumes: rea_db_data:

容器化部署里有几个容易踩的点。第一个是数据库数据卷必须单独挂载,否则容器一删数据全没。第二个是容器之间通信用服务名而不是 IP,因为每次重建容器 IP 都可能变,而timescaledb这个服务名在 Compose 网络内是稳定的。第三个是整个链路的前端与后端分离部署,前端服务不仅负责提供静态页面,还要用 Nginx 反向代理后端接口,这样页面里的请求地址可以统一写相对路径,避免浏览器跨域问题。

4.2 我踩过的四个坑,每一个都是性能杀手

rea 从能跑变成好跑的过程中,我踩了不少坑,捡四个印象最深的分享出来。

第一个是唯一约束拖垮写入性能。开始建表时我习惯性地加了PRIMARY KEY (host, time),想着防止重复数据,结果写入一压测,TPS 只有几百,完全不是 TimescaleDB 该有的水平。去掉唯一约束之后,写入吞吐直接翻了几倍。时序场景下,重复数据很少,偶尔重复也可以容忍,数据库不应为"不可能发生"的情况买单。

第二个是时区混乱导致曲线偏移八小时。采集器上报的是 UTC 时间,前端 ECharts 默认按浏览器本地时间格式化,看起来所有曲线都平移了一段。折腾了半天才发现是时区问题。现在我的原则是通信全程用时间戳,展示层转成用户本地时间,数据库里统一存 UTC,三层各管一段,绝不混用。

第三个是cpu_percent首次调用返回 0。这个问题说起来有点可笑,但新手真的会碰到。psutil 的 CPU 使用率计算依赖前后两次调用之间的差值,第一次调用没有基准,直接返回 0。在采集器启动后先做一次预热调用,问题就消失了。

第四个是前端图表一次性拉全量数据导致浏览器崩溃。最初我没做降采样,一次查 7 天秒级数据,几万甚至十几万个点,ECharts 直接卡成幻灯片。后来改成time_bucket降采样加 ECharts 自带的sampling: 'lttb',图表不卡了,加载速度也快了。在数据可视化里,聪明地丢弃细节比死磕完整性重要得多。

4.3 常见问题速查表

把日常运行中出现过的问题整理成一张速查表,方便自己排查,也方便后来者:

现象可能原因处理方式
看板长时间没有新数据采集器进程挂了或网络不通检查采集器日志,telnet 测试接口连通性
数据库磁盘快速增长保留策略未配置,旧数据无限堆积添加定时任务删除超期数据
图表曲线出现缺口批量写入失败且无重试检查后端日志,确认 flush 队列是否丢失数据
跨天数据时间轴错乱采集端时区与数据库不一致统一用 UTC 时间戳,展示层再做时区转换
多台机器曲线颜色重复前端只按主机名取色,主机名过短改用主机列表的索引作为颜色区分依据
查询接口响应越来越慢没有按时间范围建立索引确保time列有索引,查询尽量带时间下推条件

5. 给 rea 留出的后路:从监控工具变成分析平台

5.1 告警、预测、权限,优先级怎么排

rea 目前已经能稳定采集和展示了,接下来要扩展的方向按优先级排:告警第一,预测第二,权限第三。告警最好做,因为数据已经有了,只需要在后端加一个定时检查任务,对最近一分钟的指标做阈值判断,触发条件时把消息推到通知渠道。这个功能能极大降低人肉盯看板的频率,算是对自由时间的解放。

预测功能更有意思,也更能体现数据分析的价值。比如磁盘使用率,可以根据过去三十天的增长趋势做线性拟合,估算出"按当前速度还能用几天"。这种预测不需要复杂的机器学习模型,最小二乘法就够了,但它的提醒效果比生硬的阈值告警好得多,让人有充分的缓冲时间去清理或扩容。

权限这块暂时不急,因为目前只有我自己在看。但如果未来某个团队要用,多用户体系就必须考虑了。我的思路是在 API 层面加一层 Token 鉴权,前端每个请求带上身份标识,后端按用户维度限制可见的主机范围。暂时不需要引入完整的关系权限模型,保持简单,等需求真的来了再细化。

5.2 业务指标接入后,看板才真正有生命力

系统资源指标只能告诉你基础设施健不健康,但业务指标才是决定要不要立刻起床的关键。rea 的上报接口是通用的,这意味着业务代码可以把关键指标直接 POST 进来,和系统指标存在同一张链路里,看板就能把"业务异常"和"资源异常"对照着看。

比如某个核心服务响应时间突然上涨,很可能是部署机器 CPU 被打满导致的。只看业务指标,你只知道慢了;只看 CPU 指标,你不知道它影响到什么。两个维度叠加在一个时间轴上看,因果关系就清晰多了。我的做法是给业务指标加一个metric_type标识字段,系统指标使用system,业务指标使用business,查询时既可以在同一张图上叠加展示,也可以按类型分开过滤。

我在实际使用中还有一个体会是:自研监控最有价值的点,不是省掉了采购成本,而是在这个过程中建立的"数据第一"的观念。当你连续观察了一段时间的曲线之后,你会开始相信数据而不是直觉。以前我总觉得机器快不行了,打开看板一看流量平稳,心里就踏实了;有时候我感觉一切正常,但曲线显示内存缓缓爬升,反而能提前发现隐患。这种感觉,用任何人都给你装不上的监控配方也换不来。

最后再分享一个小技巧:如果你也想做类似的项目,名字叫什么并不重要,哪怕像我一样随手敲三个字母也行。重要的是从第一天开始就把数据模型当成地基来打,采集频率、存储格式、时间精度这些基础决策一旦定了,后面很难改,改一次就牵一发动全身。先把地基打稳,再慢慢加楼层。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进