零依赖服务器监控面板:不用现成工具,自己写一个
利用本地 Bash 脚本读取 `/proc` 里的实时数据,配合 Nginx 通过定制的 location 暴露无缓存 JSON,前端 Astro 页面只做 `fetch` 与渲染。脚本通过两次计数差值计算 CPU 使用率、采用可用内存(空闲+缓存)评估占用、磁盘使用按实际可用块算,所有数值以标准口径生成;cron 任务每 30 秒执行一次,保证采样窗口短、准确。Nginx 端点加随机令牌并禁止搜索引擎收录,减少暴露风险。前端页面具容错与进度条色彩变化,配置化到博客全局设置。整套方案约 70 行 Bash、几行 Nginx 与一个页面,内存占用几乎为零,适合小内存服务器或想与博客统一风格的轻量监控。如果需要历史曲线或告警,仍建议使用专业工具。
《从零搭建个人博客》里提过,我的服务器是 2 核 2G 的小机型。想在博客里放一个服务器状态面板,现成方案不少——beszel、netdata、dashdot 都很漂亮——但动辄吃掉几百 MB 内存,还得单独开端口、配登录。2G 内存的机器经不起这么造。
最后的方案:一段 bash 脚本 + 一段 Nginx 配置 + 一个博客页面,零依赖、零端口、零额外内存占用,效果见本站的「状态」页。这篇把完整实现过程和踩过的坑记录下来。
一、整体架构
先想清楚数据从哪来、到哪去:
┌─ 服务器 ────────────────────────────┐│ monitor.sh(cron 每 30 秒) ││ ↓ 读 /proc 内核数据,算出指标 ││ ↓ 写成 JSON 文件(原子替换) ││ Nginx 把这个 JSON 以固定路径暴露 │└──────────────┬──────────────────────┘ ↓ HTTP(浏览器 fetch)┌─ 博客页面 ──────────────────────────┐│ JS 每 30 秒拉取 JSON → 渲染卡片 │└────────────────────────────────────┘关键认识:数据采集根本不需要 SSH,也不需要在浏览器端连服务器。脚本跑在服务器本地,读 /proc 虚拟文件——那是内核自己维护的运行时账本,数据最权威。浏览器端只是一个普通 HTTP 请求拉 JSON。
二、第一步:采样脚本
完整脚本如下,可以直接抄(保存为 /opt/server-mon/monitor.sh,记得 chmod +x):
#!/usr/bin/env bash# 读取 /proc 生成服务器状态 JSON,由 cron 定时执行set -uOUT=/var/www/mon/status.jsonSTATE=/var/lib/server-mon/prev.txt
# 自动识别默认网卡IFACE=$(ip route show default 2>/dev/null | awk '{print $5; exit}')[ -z "${IFACE:-}" ] && IFACE=eth0
# --- CPU 计数器 ---read -r _ user nice system idle iowait irq softirq steal _ < /proc/statCPU_TOTAL=$((user + nice + system + iowait + irq + softirq + steal + idle))CPU_BUSY=$((user + nice + system + iowait + irq + softirq + steal))
# --- 内存 ---memtotal=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)memavail=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
# --- 磁盘(根分区)---dtotal=$(df -kP / | tail -1 | awk '{print $2}')dused=$(df -kP / | tail -1 | awk '{print $3}')davail=$(df -kP / | tail -1 | awk '{print $4}')
# --- 网络计数器 ---RX=$(grep "^[[:space:]]*$IFACE:" /proc/net/dev | awk '{print $2}')TX=$(grep "^[[:space:]]*$IFACE:" /proc/net/dev | awk '{print $10}')
# --- 负载 / 运行时长 ---load1=$(cut -d' ' -f1 /proc/loadavg)UPTIME=$(cut -d. -f1 /proc/uptime)TS=$(date +%s)
# --- 读上次采样,算差值 ---prev_ct=0; prev_cb=0; prev_rx=0; prev_tx=0; prev_ts=0[ -f "$STATE" ] && read -r prev_ct prev_cb prev_rx prev_tx prev_ts < "$STATE"
dct=$((CPU_TOTAL - prev_ct)); dcb=$((CPU_BUSY - prev_cb))drx=$((RX - prev_rx)); dtx=$((TX - prev_tx))dt=$((TS - prev_ts))[ "$dt" -lt 1 ] && dt=1[ "$dtx" -lt 0 ] && dtx=0[ "$drx" -lt 0 ] && drx=0
if [ "$prev_ts" -eq 0 ] || [ "$dct" -le 0 ]; then cpu_pct=-1; rx_s=0; tx_s=0else cpu_pct=$(awk -v b="$dcb" -v t="$dct" 'BEGIN{p=b/t*100; if(p<0)p=0; if(p>100)p=100; printf "%.1f", p}') rx_s=$(awk -v r="$drx" -v t="$dt" 'BEGIN{printf "%.1f", r/t/1024}') tx_s=$(awk -v r="$dtx" -v t="$dt" 'BEGIN{printf "%.1f", r/t/1024}')fi
printf '%s %s %s %s %s\n' "$CPU_TOTAL" "$CPU_BUSY" "$RX" "$TX" "$TS" > "$STATE"
MEM_PCT=$(awk -v t="$memtotal" -v a="$memavail" 'BEGIN{printf "%.1f", (t>0)? (t-a)/t*100 : 0}')DISK_PCT=$(awk -v u="$dused" -v a="$davail" 'BEGIN{t=u+a; printf "%.1f", (t>0)? u/t*100 : 0}')TOTRX=$(awk -v r="$RX" 'BEGIN{printf "%.2f", r/1073741824}')TOTTX=$(awk -v t2="$TX" 'BEGIN{printf "%.2f", t2/1073741824}')
mkdir -p /var/www/mon# 注意:JSON 里刻意不写主机名、IP、网卡名,避免泄露服务器信息printf '{"ts":%s,"uptime":%s,"load1":"%s","cpu":{"usage":%s},"mem":{"total_kb":%s,"avail_kb":%s,"used_pct":%s},"disk":{"total_kb":%s,"used_kb":%s,"used_pct":%s},"net":{"rx_kbps":%s,"tx_kbps":%s,"total_rx_gb":%s,"total_tx_gb":%s}}\n' \ "$TS" "$UPTIME" "$load1" "$cpu_pct" "$memtotal" "$memavail" "$MEM_PCT" \ "$dtotal" "$dused" "$DISK_PCT" "$rx_s" "$tx_s" "$TOTRX" "$TOTTX" > "$OUT.tmp"mv "$OUT.tmp" "$OUT"几个准确性设计,都是查过资料才定的:
CPU 用两次计数差值,不是瞬时值。 /proc/stat 的 CPU 时间是开机以来的累计值,单次读没有任何意义。脚本把上次计数存在 prev.txt,这次减上次,差值才是这段时间的真实占用率。这也是为什么页面刚上线时 CPU 会显示几秒的 ”…”——第一次运行还没有差值,用 -1 标记,前端识别后显示省略号而不是错误数据。
内存用 MemAvailable 口径。 Linux 会把空闲内存拿去做文件缓存(buff/cache),free 命令里的”可用”就是 MemAvailable:等于空闲 + 可回收缓存。用它算占用率才贴近真实体验,直接拿 MemTotal - MemFree 会把缓存算成”已用”,2G 机器看起来永远 90%+。
磁盘百分比和 df 命令同口径。 有两种算法:used/total 和 used/(used+avail)。后者才是 df 命令显示的那个百分比——因为 total 里含一部分 root 保留块(普通用户写不进去),分母用 used+avail 才不会把面板显示得比实际乐观。
JSON 用「写临时文件 + mv」原子替换。 如果前端 fetch 的瞬间脚本正写到一半,会拿到半截 JSON 直接解析报错。先写 .tmp 再 mv,mv 在同一分区是原子操作,任何时刻文件都是完整的。
计数器回绕保护。 服务器重启后 /proc/net/dev 的流量计数会归零,差值算出负数,[ "$dtx" -lt 0 ] && dtx=0 把它钳到 0,面板不会出现负速率。
三、第二步:定时采样
cron 最小粒度是一分钟,想要 30 秒采样有个经典技巧——一条规则里执行两次,第二次延后半分钟:
* * * * * /opt/server-mon/monitor.sh >/dev/null 2>&1; sleep 30; /opt/server-mon/monitor.sh >/dev/null 2>&1用 crontab -e 添加后,crontab -l 确认。采样间隔缩短一半有两个好处:网速/CPU 的统计窗口更短,短时突发流量更能捕捉到;页面每 30 秒刷新时每次都能拿到新数据,而不是同样的数字看一分钟。
脚本的差值逻辑天然支持任意间隔(用实际时间差 dt 做除数),所以就算 cron 延迟几分钟跑,速率计算依然准确。
四、第三步:Nginx 暴露数据
JSON 文件在 /var/www/mon/status.json,在站点 Nginx 配置里加一个 location:
# 路径里那串随机字符是「路径令牌」,防止端点被扫描,需与博客配置一致location ^~ /server-status/<随机令牌>/ { alias /var/www/mon/; add_header Cache-Control "no-store"; add_header X-Robots-Tag "noindex, nofollow" always;}三个细节:
no-store:状态数据永远不能被缓存,否则访客看到的是几分钟前的旧数据;noindex, nofollow:告诉搜索引擎不要收录这个数据端点;- 路径令牌:JSON 的真实地址里拼一段随机字符串。改完
nginx -t测试语法,再systemctl reload nginx平滑生效,站点不中断。
五、第四步:博客展示页
页面是 Astro 写的(其他框架同理,核心就是 fetch + 渲染)。骨架:
const DATA_URL = "/server-status/<随机令牌>/status.json";const REFRESH_SEC = 30;const STALE_SEC = 180; // 数据超过 3 分钟没更新视为异常const WARN_THRESHOLD = 60; // 占用 60% 进度条转黄const HIGH_THRESHOLD = 85; // 占用 85% 转红
async function refresh() { try { const res = await fetch(DATA_URL + "?t=" + Date.now(), { cache: "no-store" }); if (!res.ok) throw new Error(res.status); const d = await res.json(); render(d); // 按 CPU/内存/磁盘/网络分块渲染 const stale = Date.now() / 1000 - d.ts > STALE_SEC; setStatus(!stale, stale ? "数据延迟(采样服务可能异常)" : "运行正常"); } catch { setStatus(false, "无法获取状态数据"); }}refresh();setInterval(refresh, REFRESH_SEC * 1000);?t=时间戳 是防止浏览器或中间层自作聪明缓存请求。渲染上有两个容错设计:每个字段赋值前都判空,某个字段缺失只跳过那一处,不会让整个面板白屏;进度条按占用分三档变色(正常主题色 → 黄 → 红),一眼就能看出哪项吃紧。
六、第五步:配置化
把所有参数收进博客的统一配置文件(src/config/index.ts 的「服务器状态」模块),页面和导航都从这里读:
export const serverStatusConfig = { enable: true, // 总开关:关闭后导航隐藏入口,页面跳转 404 title: "服务器状态", dataUrl: "...", // 由路径令牌拼出 refreshSec: 30, staleSec: 180, warnThreshold: 60, highThreshold: 85, cards: { cpu: true, mem: true, disk: true, net: true }, // 卡片开关};导航栏入口也写成条件渲染——enable 一关,导航上「状态」直接消失,不需要再改第二个文件。
七、关于防泄露
面板数据是公开的(所有访客都能看),所以做了四层处理:
| 措施 | 防什么 |
|---|---|
| JSON 路径拼随机令牌 | 扫描器批量猜 /server-status/status.json 这类常见路径,直接 404 |
noindex 响应头 | 搜索引擎不收录数据端点 |
| JSON 字段脱敏 | 不输出主机名、IP、网卡名、系统版本,只有资源占用指标 |
| 总开关联动 | 关闭后整个站不再出现数据地址 |
要诚实说明边界:令牌会出现在网页源码里,所以它防的是扫描器和顺手试探,不防决心翻你源码的人。这是「隐藏入口」不是「加密认证」,对个人博客是成本收益比最高的一档。真要更严格,可以上 Nginx 的 secure_link 模块做签名校验,或者限制访问 IP——对个人博客来说通常没必要。
另外,想换令牌随时可以:改博客配置里的一处 + Nginx 配置里的一处,reload 即可,旧地址立刻作废,相当于换门牌号。
八、踩过的坑
记录几个调试时真实撞上的问题:
- Windows 编辑的脚本在 Linux 跑不了:报
integer expected。Windows 换行是 CRLF,bash 把\r当成了内容的一部分。修复:sed -i 's/\r$//' monitor.sh。写服务器脚本最好直接在服务器上用 nano/vim 写,或传完必跑一次这条命令。 - 网速永远是 0:
/proc/net/dev每行行首有空格,直接awk -F'[: ]+' '{print $2}'会把空字段算进去取错列。修复:先用grep "^[[:space:]]*eth0:"锁定网卡那一行,再用默认分隔符取列。 - 磁盘百分比和
df对不上:算法口径问题,见前文,改用used/(used+avail)。 - 内存显示 1.69 TB:前端单位换算错位——
1048576 KB是 1 GB 不是 1 TB,把 2G 内存显示成了 1.69 TB。修完的换算表:KB → 1024 → MB → 1024 → GB → 1024 → TB,写的时候直接把 1073741824 这类常数标注在代码注释里。 - 文章时间偏移 8 小时:CI 在 UTC 时区构建,frontmatter 里的时间不写时区就会被按 UTC 解析。修复:所有
published明确写+08:00。
九、小结
这套面板的全部成本:约 70 行 bash + 一段 Nginx 配置 + 一个页面组件,运行时内存占用约等于零(脚本每 30 秒跑一次,跑完就退)。准确度上因为直接读内核数据、用差值法和标准口径,数字和 free、df 完全对得上。
适合的场景很明确:小内存服务器、想要和博客风格统一的轻量面板、不想多开端口和服务。如果你需要历史曲线、告警通知,那还是上 beszel 这类专业工具——但我 2G 的小机器,这个刚好。
系列文章:《从零搭建个人博客》 · 《blog 日常使用指南》
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!












