最近一直在折腾一台部署了六个业务的服务器,每到下午负载就会莫名飙到峰值,可每次登录上去只能看到 top 里一片杂乱,真正的问题进程像躲猫猫一样藏在一堆无关进程底下。为了解决这个痛点,我写了一个叫 rea 的命令行小工具——Resource Efficiency Analyzer,用来定时采集进程资源、算效率得分、给优化建议。它不依赖监控平台,一条命令跑完就能看到到底是哪个进程在稳定地吃空 CPU 或内存。这篇文章就把 rea 从设计到落地,包括那几次让人血压升高的排错过程,完整分享出来,适合正在做运维、搞脚本或者想自己搭轻量监控的朋友参考。
1. 为什么我会写这个叫 rea 的工具:运维监控的最后一公里
先说一下我遇到的现实问题。大部分人在排查服务器性能问题时,肌肉记忆就是登录上去敲 top。top 确实能告诉你在按下按键的那一瞬间谁在最前面,但你刚看到某个 Java 服务的 CPU 冲到 120%,下一秒它可能就掉下去了,你根本不知道这个尖峰是偶发还是周期性出现的。htop 虽然加了颜色和交互排序,但也只是换个姿势看同一时刻的数据。pidstat 可以持续采样,不过它是“逐进程输出”,数据一多就成了流水账,没有人帮你把这些数字归纳成一条可执行的结论。
重型监控平台我也不止一次接入过。为了看一台机器的负载,要专门部署采集器、时序数据库、告警规则和一套 Web UI,等到全部跑通可能已经过了大半天。何况很多临时接手的服务器根本没有预留端口和 DNS,装 agent 都要跟别人反复确认。所以我需要的不是另一个监控系统,而是一个“平时扔在 /usr/local/bin 底下,异常时跑一下能直接告诉我重点在哪里”的小工具。rea 就是在这样的背景下被我从零写出来的。
1.1 现成工具的两块短板
先说第一块短板:所有实时交互式工具都缺少“历史对比维度”。top 和 htop 只能展示当前采样周期的瞬时值,如果某进程每 10 分钟才抖一次,你手速再快也未必能抓到它。更麻烦的是,瞬时值会被系统噪声影响,比如一次随机的磁盘 IO 等待让某个 Java 进程的 CPU 突然飙到 90%,你很难判断这是业务高峰期正常波动,还是真的有死循环在空转。
第二块短板更隐蔽:自动化场景下没有一套统一的“结论语言”。用 pidstat 采到了数据,还要人去读、去排序、去汇总;用 nmon 还要借助其他工具解析它的日志格式。我希望这个东西跑完以后,直接给出一个带着 WARN 或 CRIT 标记的名单,这样不管是人看还是脚本后续处理,都能第一时间锁定问题簇。这也是 rea 把“效率得分”作为核心输出的原因,它不是一个单纯的采样器,而是一个带判断力的分析器。
1.2 rea 三个核心能力与不做什么
rea 这个名字里的 Analyzer 不是随便挂上去的,它必须完成三件事:
第一,按固定频率持续采集系统里的进程资源,而不是只看某一个瞬间。第二,把采集到的原始样本转换成“稳定消耗”和“瞬时尖峰”两类指标,也就是计算 P95、P99 这种带统计意义的数据。第三,把结果输出成人和机器都能读的格式:表格给管理者看,JSON 给脚本消费。
同时我一开始也列了一个“绝不做什么”的清单。不往时序数据库里写数据、不做 IPC 级别的网络监控、不搞 Web UI、不弄分布式。原因很简单,做了这些它就不是 rea 了,又会变成一个臃肿的系统,和企业里现有的监控生态重复。一个工具能稳定跑十年,往往不是因为它功能多,而是因为它做的那几件事足够深。砍掉不必要的能力,反而是对它最好的维护。
2. rea 的核心架构:从 /proc 原始数据到效率评分的完整链路
rea 的所有样本都来自 /proc,这是 Linux 系统状态的核心虚拟文件系统。最初我也图省事,想去解析 top -b -n 1 的输出,后来发现 top 的输出列会因为终端宽度变化而缩进,而且它内部已经做了一层聚合,很多原始字段被丢掉了。直接读 /proc 反而更稳定,只是要自己处理两次采样的差值。
2.1 采集层为什么选择直接读 /proc 而不是解析 top
CPU 时间片来源于 /proc/stat 的第一行,它把系统里的 user、nice、system、idle、iowait 等状态累加成 tick 值。进程自己的 CPU 时间则放在 /proc/[pid]/stat 的第 14 个字段和第 15 个字段,分别表示该进程在用户态和内核态耗掉的 tick 数。一段时间内 CPU 使用率的计算公式为:
delta_total = delta_process_ticks / delta_total_ticks / 核心数 * 100%
注意这里要除以核心数,因为整个系统的总 tick 是所有核心累加后的数值,而进程占用的 tick 是单核视角。我在第一版就犯过这个错误,把多核机器上的 CPU 占用率算成了原来的四倍。
import os, time def read_cpu_total(): with open('/proc/stat') as f: line = f.readline().strip() fields = [float(x) for x in line.split()[1:]] return sum(fields) def read_proc_ticks(pid): try: with open(f'/proc/{pid}/stat') as f: parts = f.read().split() # utime(14) + stime(15),索引从 0 计数 return int(parts[13]) + int(parts[14]) except (FileNotFoundError, IndexError): return None调用时先读取一次 CPU 总 tick 和进程 tick,等待一个采样间隔,再读取第二次,两次差值相除就得到这个进程在当前间隔内的平均 CPU 使用率。这种方式比解析 top 输出稳定得多,因为 /proc/stat 的字段格式由内核固定,不会随 locale 或终端宽度变化。
整个采集流程可以拆成四步:初始化样本桶、记录起始时间点、按间隔周期性采样、计算差值并更新指标。rea 内部没有用多线程,而是一个简单的循环,因为单线程顺序读 /proc 反而能减少对系统状态的扰动。
2.2 计算层:为什么核心指标要使用 P95 而不是平均值
计算层是整个 rea 的核心,因为“瞬时值”和“稳定值”是两个完全不同的事情。举个例子,某个后端服务每隔 10 秒做一次全量缓存刷新,刷新那一下 CPU 会跳到 180%,其余时间只有 5%。如果你用 1 秒采一次样然后算平均,得到的结果可能是 22.5%,你很难判断它到底忙不忙。但如果你把每次采样得到的 CPU 使用率按从低到高排序,看第 95 百分位,就会发现那次刷新峰值被保留了下来,而偶发的 5% 空闲样本也不会拉低判断。
rea 内部维护了一个长度为 30 的环形缓冲,每次采样把当前 CPU 使用率推入,然后对缓冲内的所有样本计算 P95 和 P99。P95 的含义很简单:在最近这段观察窗口里,有 95% 的时间它的 CPU 使用率低于或等于这个值。这比平均值更能体现“稳定消耗的上界”。内存使用则直接取当前 RSS 的均值,因为内存波动不像 CPU 那样剧烈,频繁读 /proc/[pid]/status 反而会增加系统开销。
效率评分则是对几个维度做加权映射。我给的默认公式是:EFF_SCORE = 0.55 * min(100, CPU_P95) + 0.30 * min(100, MEM_AVG/2) + 0.15 * 运行时长得分。运行时长得分会随着进程存活时间超过 7 天而衰减,目的是让那些“一直在重跑、反复出问题”的任务排到前面。这个公式不代表绝对正确,但它足够直观,而且每个权重都可以通过 rea 的配置文件覆盖。
2.3 输出层:同一份数据,两种消费方式
输出层我做了两套视图。默认情况下 rea 会在终端打印一张类似 top 但更聚焦的表格:
PID USER COMMAND CPU_P95 MEM_AVG EFF_SCORE FLAG 1234 app java -Xmx2g -jar svc-a.jar 68.5 812.4 42.3 4567 root python3 report_generator.py 89.2 110.2 78.1 WARN 8910 app /usr/lib/jvm/.../java 12.3 2044.2 25.4EFF_SCORE 综合了 CPU、内存、存活时间等多个维度,分数越高表示“这个地方越值得去查”。光看 CPU 和内存有时候会误判,比如某个进程占着 2GB 内存但它是做缓存的,另一个进程 CPU 不高但每个请求都卡在 D 状态,加权之后前者排名会靠后,后者会浮上来。
同时 rea 支持 --format json 输出,方便 cron 或 CI 消费:
{ "host": "legacy-app-03", "sampled_seconds": 30, "processes": [ {"pid": 4567, "cpu_p95": 89.2, "mem_avg_mb": 110.2, "flag": "WARN"} ] }为了避免 JSON 输出没法直接看,rea 的退出码也做了约定:0 表示没有异常,1 表示存在提示项,2 表示有关键告警。这样外部脚本只需要判断 $? 就能决定是否触发下一步动作。
3. 实现过程中的关键决策:参数设计、阈值判定与扩展性
参数设计是整个工具最能体现经验的地方。我见过不少同类工具把采样间隔定死在 1 秒,短任务没跑完一个完整周期,长任务又浪费资源。rea 开放了两个基本参数,让使用者根据实际情况调整。
3.1 采样频率和采样次数的取舍
rea 的两个核心参数是:
- -i / --interval,控制采样间隔,默认 1 秒。
- -n / --count,控制采样次数,默认 30 次。
用 -i 1 -n 30 跑 30 秒,适合临时排查问题,既能看到 1 秒级别的波动,又不会等太久。如果想让 rea 变成一个常驻巡检命令,我一般会把它放到 cron 里,用 -i 5 -n 288 跑 24 分钟,这样 CPU 占用采集能覆盖大多数业务的下一次小时级波动。对于那种开起来几分钟就退出的短任务,建议把 -i 降到 0.2 秒,并把 -n 提高到 500,但注意别在太老的机器上跑,否则 rea 自身的读取频率反而会干扰系统状态。
| 场景 | 建议参数 | 原因 |
|---|---|---|
| 临时排查单机问题 | -i 1 -n 30 | 平衡响应速度与样本量 |
| 常驻巡检、定时报告 | -i 5 -n 288 | 降低采样对系统的干扰,覆盖长期趋势 |
| 捕捉短生命周期任务 | -i 0.2 -n 500 | 短任务可能秒级退出,需要更高频采样 |
默认值选 1 秒还有一个原因:在大多数 Linux 系统上,/proc 文件读取的粒度就是 jiffy,也就是 10ms 到 1ms 级别,太高的采样频率拿到的数据往往会在进程上下文切换时产生锯齿状波动。1 秒间隔已经能平滑掉大部分噪声,又不会让用户等太久。
3.2 阈值判定:静态 80% 是陷阱,用“基线变化比”更可靠
我第一版做阈值判断的时候很天真,认为 CPU 占用超过 80% 就是异常。后来发现某台机器只有 2 核,跑一个单机版数据处理程序,CPU 长期 90% 以上其实是业务预期;反过来,一台 32 核机器上某个进程从 2% 涨到 8%,占用率绝对值并不高,但变化了 4 倍,多半是有问题的。rea 因此引入了“历史基线变化比”:
ratio = 当前 EFF_SCORE / 历史 EFF_SCORE 基线
默认情况下 ratio 大于 2.0 就给出 WARN,大于 3.0 则给出 CRIT。历史基线会存在 ~/.rea/history.json 里,同一个主机名、同一个完整命令行路径会归为一个键。这样每次跑的分数才有比较的意义。如果你想临时关掉基线对比,可以加 --baseline off。
基线文件本身很小,但有一个隐藏好处:它能记录某个进程在正常时期的稳定消耗。等下次同样的进程悄悄开始异常增长,rea 不需要你重新理解业务,就能直接给出变化倍数。对没有专职黑盒监控的团队来说,这份历史文件就是最轻量级的“黄金指标”。
3.3 扩展点:数据源接口与最小实现
rea 的采集层没有写成一团逻辑,而是拆成了数据源接口。每个数据源需要提供 start() 和 sample() 两个方法。start 负责打开文件句柄或准备缓存,sample 返回当前时间点的所有指标字典。目前内置了 CPU 数据源和内存数据源,以后想加 GPU 使用率,只需要实现一个新的 GPUDataSource 并注册到插件列表里,主程序的采样框架不变。
class DataSource: def start(self) -> None: raise NotImplementedError def sample(self) -> dict: raise NotImplementedError我刻意没有上依赖注入框架或者 ABC 元类那一套,只用一个普通基类。原因很简单:这个工具只有两个数据源,堆砌设计模式只是给自己找麻烦。未来真到了需要支持 GPU 或磁盘 IO 的时候,再按相同接口写一个类接进去就行。插件注册表也只是一个 dict,按名称存数据源实例,运行到对应阶段时统一调用,没有魔法,没有框架。
4. 实测效果:从一台混乱的服务器到一眼定位问题
工具好不好用,必须拿到真实环境下检验。下面这个案例是我前几天在一台实际上线服务器上碰到的,我给它起的主机名叫 legacy-app-03,上面同时跑了两个 Java 服务、三个 Python 脚本和一个第三方闭源程序的采集代理。现象是每天下午 14:30 开始 load 慢慢爬到 12,但 top 里每次看到的都是不同进程冒头:一会儿是 Web 容器,一会儿是日志压缩任务,一会儿又变成 Python 脚本。整台机器就像堵了车的高架,每个路口都亮黄灯。
4.1 现场还原:6 个业务进程挤在同一台机器上
我用 rea 跑了 5 分钟:
rea -i 1 -n 300 --format table
命令执行期间 rea 一共采集了 300 个样本。最突出的不是任何 Java 进程,而是 PID 4567 的 Python 脚本 report_generator.py,它的 CPU P95 达到了 89.2%,而它对应的瞬时 CPU 在 top 里经常只在 5% 和 30% 之间徘徊。原因是这个脚本里有一个 while True 的循环,每次处理完一小批数据后会 sleep 0.2 秒,但某个第三方数据源的返回偶尔会卡住,重试逻辑又写得不对,导致循环在无数据时也拼命空转。
rea 在最终报告里把 report_generator.py 标记为 WARN,因为它的 CPU P95 比历史基线高了两倍多。这个标记给了我们一个很明确的切入点,不再需要在六个业务进程之间来回猜测。接下来要做的,就是用更细的工具验证这个问题到底出在哪一层。
4.2 用 P95 找到嫌疑后,再用 pidstat 和 strace 做二次确认
rea 的分数只能告诉我们“重点在哪里”,并不能直接告诉我们“为什么”。因此定位过程还没有结束。我下一步用 pidstat 对该进程做了逐秒采样:
pidstat -p 4567 1 60
输出里 PID 4567 的用户态 CPU 平均 87.7%,内核态只有 1.2%,几乎全是用户态空转。再用 strace 观察系统调用:
strace -f -p 4567 -e trace=network,read,write -o /tmp/report_gen.strace
跑了 20 秒,看到日志里密密麻麻全是 read(0, "", 4096) 之类的空读,说明它在无数据输入时仍然反复检查标准输入。最后翻代码,果然那一段逻辑少了“没有新数据就 sleep 到下一个重试周期”的分支。把 sleep 时间从固定的 0.2 秒改成指数退避之后,CPU P95 直接从 89% 掉到 7%,机器负载也跟着回落。
这给我非常深的一个印象:P95 和平均值之间的差异,恰恰是很多隐蔽性能问题的照妖镜。如果只依赖 top 的瞬时值,你可能永远不知道一个进程其实是在用 90% 的 CPU 假装忙碌。
4.3 把 rea 接进 cron 和 CI:轻量巡检的两种用法
rea 设计成命令行工具,最大的好处就是可以被任何调度系统调用。我在那台机器上加了这样一条 cron:
0 10 * * * /usr/local/bin/rea -i 5 -n 288 --format json > /var/log/rea/report.json 2>&1 || exit 2
这条任务每天十点跑一次,持续 24 分钟,把结果落到 JSON 日志里。如果 rea 检测到 CRIT 级别的问题,会以退出码 2 结束,这时 cron 的 MAILTO 会给我发一封交互提醒。不需要额外的 agent 或数据库。
CI 场景也可以复用。我会在构建服务器上跑:
rea -i 1 -n 60 --baseline off --format json > /tmp/rea-check.json
然后让脚本从 JSON 里取出 EFF_SCORE 最高的进程,如果某个测试进程在构建期间出现 CPU P99 超过 90%,就判定为性能回归并失败。这比只看构建时长要准确得多,因为有的回归并不会让整体构建延迟太显眼,但会造成资源浪费。
5. 我在 rea 上踩过的坑,以及把它变好用的几条经验
最后聊一聊在开发这个工具时遇到的那些坑。每个坑都让我对 /proc 的理解又深了一层,也让我意识到,一个看起来简单的工具,涉及的边界情况远比想象中多。
5.1 进程名被截断:不要用 /proc/[pid]/comm,要用 cmdline
第一个坑来自 Linux 内核的 comm 字段限制。/proc/[pid]/comm 只能保存 15 个字符,比如 java -Xmx2g -jar 这种长命令行进程,comm 只会显示 java,多个不同 Java 服务根本分不清。rea 一开始就把这个字段当展示名,结果在测试环境同时跑 A 服务和 B 服务时,输出表里全是 java、java、java,完全没法看。
解决方法是优先读取 /proc/[pid]/cmdline,把它按 \0 分割后组装成完整命令行,然后取第一个参数做 basename;如果 cmdline 为空(通常是内核线程或僵尸进程),才回退到 comm。遇到同名进程还要在末尾追加 pid 后缀。处理后的核心逻辑大概是:
def process_display_name(pid): try: with open(f'/proc/{pid}/cmdline', 'rb') as f: raw = f.read() args = raw.split(b'\x00') args = [a.decode('utf-8', 'replace') for a in args if a] if not args: raise ValueError cmd = os.path.basename(args[0]) return f'{cmd}[{pid}]' except Exception: with open(f'/proc/{pid}/comm') as f: return f.read().strip()这个改动看起来很小,却直接影响使用体验。想象一下,你在一台跑着 8 个 Java 应用的机器上做排查,如果工具把所有进程都标成同一个名字,它和 top 就没什么区别了。
5.2 进程在采样期间退出了,怎么处理才不刷屏
第二个坑是进程的生命周期比采样周期短。rea 开始采样时记录了所有存在的 PID,但下个采样点可能有进程已经退出,此时去读 /proc/[pid]/stat 会得到 FileNotFoundError。最粗暴的做法是直接崩溃,但没人想在一个诊断工具里看到 Traceback。我当时的处理是:读不到进程文件就跳过,同时维护一个“消失进程计数”,在最终报告里显示“采样期间有 N 个进程退出”,让用户知道这个波动不是测量误差。
另外一个更隐蔽的问题是 PID 复用。上一秒进程 A 退出,下一秒系统把它的 PID 分配给了进程 B,如果你只看 PID 就会认为 A 的 CPU 突然暴涨。rea 的策略是把进程的启动时间(/proc/[pid]/stat 第 22 字段,starttime)保存下来,如果同一 PID 的 starttime 变了,说明进程已经不是原来那个,必须重建样本桶。这个细节花费了不少精力,但也确实是区分好坏工具的关键。
5.3 容器环境里的 /proc 隔离:在宿主上跑才能看到全部进程
第三个坑和容器有关。我一开始图方便,把 rea 打包进一个很小的容器镜像里,直接在 Kubernetes pod 中跑。结果发现它只能看到容器空间里的十几个进程,宿主机上其他业务一概看不到。原因很直接:没有共享宿主的 /proc,容器内的 pid namespace 是隔离的。如果确实要在容器里用,需要把宿主 /proc 以只读方式挂载进来,并且使用 hostPID,例如:
docker run --rm -v /proc:/host_proc:ro --pid=host rea --proc-path /host_proc
我实测这种方式能看到所有宿主进程,但要注意某些路径下的信息会因为权限问题出现空缺,所以 rea 允许用 --proc-path 覆盖默认的 /proc 路径。更稳妥的做法还是直接在宿主机上运行,毕竟它本身占用极小,不需要常驻。
5.4 保持工具的“小”:一条可以随时扔掉的命令
说实话,rea 到目前为止也只有几百行,很多功能是刻意不做而不是想不到。我见过太多从一个小脚本慢慢膨胀成平台的案例,最后维护成本远超它节省的时间。如果你想模仿这种思路做自己的巡检工具,我的建议是:先只做采集和展示,不要在第一天加入告警推送和趋势图;等你在真实故障里用到它三次以上,再根据痛点补功能。工具是拿来解决问题的,不是用来晒技术栈的。对我个人来说,rea 最大的价值是让我在排查问题的时候多了一个“值得信任的第三方视角”,而不是另一个需要伺候的诊断器。