凌晨三点手机震动监控群弹出一条告警某台开发机 CPU 使用率连续 5 分钟超过 90%。登录上去一看top显示满屏的进程列表SSH 敲命令都开始卡顿。当时那台机器没有接入任何监控平台我手头能用的只有 Shell 环境。排查完原因之后我就在想如果有一套不依赖外部组件的轻量 CPU 监控脚本哪怕放在 crontab 里每分跑一次是不是就能避免这种被动局面今天这篇就来完整聊聊怎么用 Shell 脚本监控系统 CPU 使用率数据从哪来、脚本怎么写、阈值怎么定、告警怎么发、定时任务怎么挂以及实测中常见的坑。内容适合刚接触 Linux 运维的人也适合想补一套“备用监控方案”的开发者。你不需要装额外的监控软件只要机器上有最基础的 Shell 环境照着抄就能用。1. 先说结论什么样的场景真正需要 Shell 监控脚本1.1 最合适的兜底场景不是“替代监控平台”很多朋友一听到监控第一反应是要上完整的监控方案又要有数据采集又要有图形面板还要配置告警路由。这些方案当然强大但现实情况是开发机、测试机、临时搭建的演示环境甚至一部分内部业务服务器经常不在监控覆盖范围内。我见过不少团队生产环境的大集群监控做得滴水不漏但恰恰是那几台“临时用的”“先顶着用的”机器出了状况。等发现问题时往往已经过去了几个小时。Shell 脚本的定位不是替代监控平台而是给那些覆盖不到的机器提供一个低成本兜底方案。另外还有一类场景你在排查某个具体的性能问题时需要短时间、高频率地记录 CPU 使用率变化。比如压测时观察一段时间的趋势或者定位某个 Java 服务的 CPU 飙升是不是周期性发生。这时候写个十几行的 Shell 脚本比去调整监控系统的采集频率要快得多。1.2 先把指标定义清楚CPU 使用率和负载不是一回事我发现不少人在使用率上栽过跟头。top右上角显示load average: 8.10, 6.23, 4.05很多人看到这个就说“CPU 用满了”。负载高并不直接等于 CPU 使用率高。在 Linux 里平均负载指的是单位时间内处于可运行状态和不可中断状态的进程数。它同时考虑了 CPU 上正在运行的进程、排队等待 CPU 的进程以及处于不可中断睡眠状态的进程。换句话说即使 CPU 完全空闲如果有一堆进程阻塞在磁盘 I/O 上负载也可能很高。CPU 使用率的定义则朴素得多一段时间内CPU 处于非空闲状态的时间占比。注意这里的“空闲”也有讲究我后面专门讲。脚本的监控目标必须清晰你是要看 CPU 到底忙不忙还是看系统当前扛不扛得住。前者用使用率后者看负载。我下面要写的脚本核心指标是前者同时可以把 load average 作为辅助指标一并输出。2. 数据源解析/proc/stat 的字段到底怎么看2.1 cpu 行的 10 个数字其实是一次累计快照要监控 CPU 使用率最准确、最轻量的数据来源是读取/proc/stat文件。在绝大多数 Linux 发行版上执行cat /proc/stat前几行长这样cpu 1258234 3794 458679 51234567 89012 4567 89123 12345 0 0 cpu0 248912 612 89745 10239045 18234 1123 23456 3456 0 0 cpu1 249023 589 91345 10123456 17892 1145 23123 3412 0 0以cpu那一行为例后面的数字不是一次性统计值而是从系统启动到当前时刻累计的 CPU 时间片单位是 USER_HZ通常是 1/100 秒。换句话说每一次读取看到的都是单调递增的计数器而不是当前状态。所以计算使用率的唯一正确方式就是取两次快照然后算差值。字段顺序通常是user、nice、system、idle、iowait、irq、softirq、steal、guest、guest_nice。每个字段的意义如下表字段含义实际影响user用户态程序运行时间普通应用进程比如 Nginx、Java 进程nice低优先级nice 值 0用户态程序时间被 renice 过的进程system内核态运行时间系统调用、内核线程、驱动处理idle完全空闲时间什么都没做iowait空闲但等待 I/O 完成的时间通常意味着磁盘或网络 I/O 阻塞irq硬中断处理时间硬件设备触发的中断softirq软中断处理时间内核后台处理比如网络收包steal被虚拟化 hypervisor 抢走的时间云服务器上邻居抢占 CPU 的反映guest / guest_nice客户机虚拟机运行时间已经包含在 user 里不要重复加这些字段在不同的内核版本上略有差异但前八个字段在主流内核上基本一致。实际操作中遇到过一个问题有些版本/proc/stat的 cpu 字段有时会带尾随空格用awk {print $NF}取最后一个字段会不稳定所以我建议显式按列取别用$NF。2.2 top 命令给的使用率为什么“看起来不一样”另一个常见的数据源是直接跑top -bn1。确实能在输出里看到%Cpu(s): 12.5 us, 6.2 sy, 0.0 ni, 81.2 id, 0.0 wa这样的内容提取一下就能当使用率用。但我一直不推荐把这个作为脚本的主力数据源主要有一个原因top的单次输出是瞬时采样波动极大。我实测过跑一个简单的死循环脚本top -bn1里显示的 CPU 使用率可以从 85% 跳到 99%再跳到 70%。如果你是每 5 秒跑一次 cron哪怕间隔相同看到的曲线也会像锯齿一样。真正可靠的做法是在脚本里自己做一段时间的采样比如每隔 1 秒读一次/proc/stat持续 5 秒再对差值做平均。这样得到的数据平滑得多也有参考价值。2.3 直接读 /proc/stat 的三点理由我最终选择直接读取/proc/stat有三个理由第一不加额外依赖。/proc/stat是 Linux 内核自带的虚拟文件系统接口任何发行版都有不需要安装sysstat也不用确认top的版本差异。第二可控性强。想采样 1 秒、3 秒还是 5 秒是脚本自己决定的。想分开统计 user、system、iowait还是只看整体使用率也都是自己说了算。第三性能开销低。读一次文件的数据量只有几百字节跑一万次对系统的影响也可以忽略不计。而频繁调用top反而会引入额外的进程创建开销。3. 脚本核心实现两次采样与差值计算3.1 第一次采样先把累计时间和空闲时间抓下来下面这个脚本是这套监控的核心逻辑。我不搞花哨的写法用最直白的 Bash 保证可读性先用两个函数来做两次采样#!/bin/bash # 读取一次 /proc/stat 中 cpu 行的累计时间 # 输出格式total_cpu_time idle_time iowait_time read_cpu_stat() { local line line$(grep ^cpu /proc/stat) local user nice system idle iowait irq softirq steal user$(echo $line | awk {print $2}) nice$(echo $line | awk {print $3}) system$(echo $line | awk {print $4}) idle$(echo $line | awk {print $5}) iowait$(echo $line | awk {print $6}) irq$(echo $line | awk {print $7}) softirq$(echo $line | awk {print $8}) steal$(echo $line | awk {print $9}) local total total$((user nice system idle iowait irq softirq steal)) echo $total $idle $iowait }注意grep ^cpu 这里有一个空格目的是只匹配cpu也就是所有 CPU 合计的那一行不要匹配cpu0、cpu1这些单核行。很多第一次写的朋友在这里少打一个空格结果后面所有计算全乱套。关于 guest 字段guest 时间实际上已经被内核统计进 user 字段了如果我在 total 里再加一次会导致与 user 重复计算。所以稳妥的做法是total只加前八个字段。3.2 计算使用率awk 浮点运算与边界处理拿到两次采样结果后就可以计算了# 第一次采样 read TOTAL1 IDLE1 IOWAIT1 EOF $(read_cpu_stat) EOF sleep 1 # 第二次采样 read TOTAL2 IDLE2 IOWAIT2 EOF $(read_cpu_stat) EOF DIFF_TOTAL$((TOTAL2 - TOTAL1)) DIFF_IDLE$((IDLE2 - IDLE1)) # idle 是否把 iowait 算进去用开关控制 # 默认把 iowait 算为空闲如果你的场景需要把 iowait 视为“被占用”见下文 DIFF_IDLE$((IDLE2 IOWAIT2 - IDLE1 - IOWAIT1)) USAGE$(awk -v idle$DIFF_IDLE -v total$DIFF_TOTAL BEGIN { printf %.2f, (1 - idle / total) * 100 }) echo $(date %Y-%m-%d %H:%M:%S) CPU Usage: ${USAGE}%为什么用awk而不是bc或者整型除法Bash 的$(( ))只支持整数运算。假设两次采样间隔 1 秒总时间差约 100 个 tick空闲差 95那使用率是 5%。如果用整数运算95/100直接得到 0然后(1-0)*100 100%这数据完全是错的。awk的printf %.2f天然支持浮点而且任何 Linux 发行版都有 awk一般是 mawk 或 gawk不用担心依赖问题。这里要特别解释一下 idle 和 iowait 的处理。普通情况下CPU 在iowait状态时处于空闲只是进程在等待 I/O 完成。如果脚本只统计user system使用率会偏低但大部分监控工具就是这么算的。要不要把 iowait 当作“被使用”取决于你的业务视角如果服务器主要是跑计算型任务比如视频转码iowait 占比一般不高忽略不忽略影响不大。如果是跑数据库或者大量读写磁盘的应用iowait 高就说明存储拖了后腿这时候把 iowait 算进“非空闲”更能反映用户体验。我个人的习惯是脚本同时输出三个指标——整体使用率不含 iowait、含 iowait 加权使用率、纯用户态使用率。告警主要看第一个但排查时会三个一起看。写成完整实现差不多是这样#!/bin/bash # 简易 Linux CPU 使用率采样脚本 # 用法: ./cpu_usage.sh [采样秒数] [默认2秒] SAMPLE_SECONDS${1:-2} read_cpu_stat() { grep ^cpu /proc/stat | awk {printf %d %d %d, $2$3$4$5$6$7$8$9, $5, $6} } read TOTAL1 IDLE1 IOWAIT1 EOF $(read_cpu_stat) EOF sleep $SAMPLE_SECONDS read TOTAL2 IDLE2 IOWAIT2 EOF $(read_cpu_stat) EOF DIFF_TOTAL$((TOTAL2 - TOTAL1)) DIFF_IDLE$((IDLE2 - IDLE1)) DIFF_IDLE_IOWAIT$((IDLE2 IOWAIT2 - IDLE1 - IOWAIT1)) USAGE$(awk -v idle$DIFF_IDLE -v total$DIFF_TOTAL BEGIN {printf %.2f, (1 - idle/total) * 100}) USAGE_WITH_IOWAIT$(awk -v idle$DIFF_IDLE_IOWAIT -v total$DIFF_TOTAL BEGIN {printf %.2f, (1 - idle/total) * 100}) echo 采样间隔 ${SAMPLE_SECONDS}s echo CPU 使用率(不含iowait): ${USAGE}% echo CPU 使用率(含iowait): ${USAGE_WITH_IOWAIT}%3.3 单核与多核整体使用率会掩盖单核打满的问题还有一个很典型的问题整体使用率低不代表每个核都空闲。比如一台 32 核的机器整体使用率 10%看起来很低但可能其中一个核已经被跑满 100% 了。为什么因为/proc/stat里的cpu行是所有核的累计值如果你只取这一行你看到的就是平均结果。有的程序是单线程的它只能跑在一个核上。这时候单核打满会导致那个核上的其他进程显著变慢。为了发现这类问题脚本可以考虑把每个核的使用率也采样进来read_cpu_stat_all() { grep ^cpu[0-9] /proc/stat | awk {printf %s %d %d %d, $1, $2$3$4$5$6$7$8$9, $5, $6; printf \n} }拿到所有核的两次采样后可以对每个核单独算一个使用率再挑最大值输出。如果最大值长期大于 90%即使整体不高也值得去查一查到底是哪个进程在单线程消耗 CPU。这一步在排障时的价值远高于平均使用率本身。4. 告警与通知从“记录数值”到“发现问题”4.1 阈值设计的经验值而不是拍脑袋光把使用率打印出来意义有限。监控的真正价值在于发现问题并触发响应。那阈值设多少合适我见过很多新手的第一个版本直接把阈值写成 80%理由是“感觉挺高”。但实际运行一段时间就会发现80% 在有些业务上是家常便饭在另一些业务上一出现就意味着马上要宕机。阈值的设定需要结合机器规格和历史基线机器规格建议起始告警阈值说明云主机 2C4G跑小型 Web 服务85%短时间冲到 85% 还能扛持续超过 5 分钟就要警惕数据库服务器 8C16G75%DB 对 CPU 敏感预留缓冲空间给慢查询和处理阻塞计算型任务节点90%CPU 高负载属于常态但长时间满负荷影响任务调度桌面开发机跑 IDE 和编译无固定阈值编译时冲高正常重点看持续时长和温度有个关键点阈值要配合“持续时长”来判断。一次瞬时冲到 90% 可能是垃圾回收或者定时任务不可怕。持续 3~5 分钟都在 90% 以上才是真正的告警。所以脚本里我会记录一个连续超限次数连续 N 次才触发通知。4.2 用 webhook 发告警最简单可靠的联动方式发告警的方式很多写日志、发邮件、调短信接口但我最推荐的是 webhook。企业微信、钉钉这类协作工具都提供群机器人接口本质就是一个 HTTP POST 请求。用curl就能把消息推进群里运维值班的人第一时间就能看到。代码实现非常短WEBHOOK_URLhttps://example.com/hook/cpu_alert send_alert() { local message$1 curl -fsS -X POST $WEBHOOK_URL \ -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\$(date %Y-%m-%d %H:%M:%S) $message\}} \ /dev/null 21 || echo $(date %Y-%m-%d %H:%M:%S) 发送告警失败 /var/log/cpu_monitor.log }如果你们公司内部用的是邮箱也可以用mail -s或者sendmail但邮箱容易成为“告警黑洞”值班的人不一定及时看。Webhook 推送到手机群相对直接。注意curl加--connect-timeout 3 --max-time 5避免告警发送请求本身卡死脚本。4.3 防抖和恢复通知别在三更半夜刷屏这是运维告警里最招人烦的问题一台机器 CPU 忽高忽低每隔 2 分钟触发一次告警值班群一晚上刷几十条消息。刚开始还会紧张后来直接麻木真出大事也没人看了。解决方案是加一个防抖状态文件。脚本记录上次告警的时间戳如果距离上次告警小于 10 分钟就不重复通知。当使用率回落到阈值之下后再发一条“恢复”通知。恢复通知的作用不只是让值班人安心也能形成告警闭环证明问题确实被处理了。ALERT_FLAG_FILE/tmp/cpu_alert_flag is_in_cooldown() { if [ -f $ALERT_FLAG_FILE ]; then local last_alert_time last_now last_alert_time$(cat $ALERT_FLAG_FILE) last_now$(date %s) if [ $((last_now - last_alert_time)) -lt 600 ]; then return 0 fi fi return 1 } mark_alert() { date %s $ALERT_FLAG_FILE } clear_alert() { rm -f $ALERT_FLAG_FILE }把“连续超过阈值 N 次才告警”和“10 分钟冷却时间”结合起来告警噪音基本可以压到最低。5. 定时任务与日志让脚本长期稳定运行5.1 crontab 的配置与执行间隔选择如果只想在 CPU 异常的时候收到通知最省事的做法是把脚本挂到 crontab 里每分钟跑一次*/1 * * * * /opt/scripts/cpu_monitor.sh --check /var/log/cpu_monitor.log 21但“每分钟跑一次”有个问题脚本启动、读取/proc/stat、sleep、计算、退出、再启动这样一个循环采样的粒度其实很粗。CPU 使用率如果在 30 秒内飙升又回落一分钟一次的 crontab 很容易漏掉。所以更好的做法是脚本常驻内部用循环采样。crontab 只是用来确保脚本还在活着*/5 * * * * pgrep -f cpu_monitor_daemon /dev/null || /opt/scripts/cpu_monitor_daemon.sh 这样 daemon 脚本自己控制采样间隔比如 5 秒同时持续统计连续超限的次数反应速度远快于 cron 触发式。5.2 日志轮转与磁盘空间控制脚本每次运行都往日志里写一条时间长了日志文件会膨胀。最简单有效的办法是用系统自带的logrotate。在/etc/logrotate.d/下新建一个配置/var/log/cpu_monitor.log { weekly rotate 4 compress missingok notifempty }每周轮转一次保留四周压缩后存储基本不用担心日志占满磁盘。这里有个容易忽略的点logrotate 配置里不要写dateext因为日志名里已经带了时间戳。我用的是文件追加方式文件名固定配合 logrotate 切割即可。5.3 进程锁避免脚本重入导致监控失真还有一个坑假设脚本因为某种原因卡住了比如 webhook 发送超时或者 sleep 被中断下一次 cron 又启动了新实例。两个实例同时往同一份日志写同时做采样结果互相干扰还可能造成误告警。解决办法是给脚本加一个文件锁。Linux 里最标准的做法是flockexec 9/var/lock/cpu_monitor.lock flock -n 9 || exit 1flock -n是非阻塞模式。如果锁已经被其他进程持有就直接退出避免重入。这行代码放在脚本顶部成本极低却能让脚本的长期稳定性上一个台阶。我见过太多脚本跑着跑着自己把自己搞挂了最后排查出来都是没做锁。6. 实测常见的五个坑与排查思路6.1 采样间隔过短导致使用率剧烈跳动第一次实现脚本时我把sleep写成了 0.2 秒。结果数据跳得厉害上一秒显示 70%下一秒显示 15%再下一秒又是 95%。原因在于/proc/stat的计数器虽然一直在涨但 0.2 秒的累计 tick 基数太小任何一点中断抖动都会被放大成“看起来很高的使用率”。正确做法是采样间隔至少 1 秒最好 2~5 秒。间隔越大数据越平滑但对瞬时的尖峰也越迟钝。我通常在生产环境用 5 秒间隔、连续 5 次采样取平均值既平滑又不漏掉持续性问题。6.2 iowait 算不算“被使用”的困惑有一次帮人排查数据库服务器性能问题脚本输出整体使用率才 30%但业务方反馈“慢得要死”。后来发现磁盘在做大量同步写CPU 大量时间处于 iowait 状态严格意义上的“CPU 使用率”并不高但用户体验极其糟糕。从那以后我就养成了一个习惯脚本输出里永远把 iowait 单独列出来。你不能只看使用率一个数字就下结论。数据库和文件服务器场景iowait 高比 user 高更需要警惕。如果只做“CPU 使用率”监控而完全忽略 iowait相当于看仪表盘只看时速不看转速遇到上坡会误判。6.3 脚本自身的 CPU 消耗被计入总使用率监控脚本是用 Shell 写的但它自己也消耗 CPU。如果脚本循环里频繁调用date、启动新的grep、awk子进程这些开销都会体现在系统整体的 CPU 统计中。平时还好但在机器本身已经高负载时脚本反而会让负载更高。改进办法减少不必要的子进程调用。比如一次性用awk把所有字段取出来避免多次管道不要在每个循环里重复调用date改成只打印一次到日志循环内部尽量减少外部命令。一个设计良好的监控脚本自身 CPU 占用应该稳定在 1% 以下。6.4 多核机器上单核打满但是整体不高前面提过整体平均会掩盖单核问题。实际排查中我遇到过这种情况32 核机器整体使用率只有 8%但某个应用的延迟持续偏高。通过脚本加上单核使用率输出后发现 cpu7 已经被打满 100%。进一步调查发现这是一个单线程的定时任务因为绑核配置错误始终跑在同一颗核上。这个坑靠“整体使用率”永远发现不了。所以脚本里建议至少输出一个“单核最大使用率”如果最大核使用率超过 90% 而整体不高排查方向就要转向进程的线程分配和 CPU 亲和性设置。6.5 浮点计算与 awk 的版本差异脚本里的浮点计算我用的是awk但不同发行版上 awk 的表现会有细微差异。比如某些精简版系统上printf %.2f的输出可能带前导空格这在解析时会造成字段错位。解决办法是给 awk 的输出包一层tr -d 或者在读取变量时直接去掉空白字符。另一个更隐蔽的问题如果两次采样之间系统刚好发生过热降频CPU 时间片的计数单位不会变但实际执行能力下降了使用率的数值会表现出异常波动。这种场景不影响脚本逻辑但解读数据时要意识到“使用率 100%”和“计算能力下降”是两个不同层面的问题。我自己把脚本跑在十多个机器上快一年最大的体会就是监控脚本不能只做“会输出一个数字”就完事要从可观测性的角度出发把使用率、负载、iowait、单核状态都拆开看。一次告警触发后值班人员拿到的不应该只是一个孤零零的“CPU 90%”而是可以顺着数据往下排查的线索集。最后再分享一个小技巧脚本里的阈值、告警地址、采样间隔这些参数统一放在文件头部用变量定义好别散落在代码中间。等某天你要把阈值从 80 改成 85 的时候会发现这个习惯能帮你省下很多翻代码的时间。