一台自有服务器的网页面板。打开一个网页,就知道机器还剩多少内存、哪个服务掉了、配置改坏没有 —— 不用 ssh 上去,也不用记住那七八条命令分别叫什么。
一台自己的服务器上跑着若干个站、一个反向代理、一堆 systemd 单元。想知道"现在怎么样",原本的动作是 ssh 上去,然后凭记忆敲一串命令。真正的麻烦不在敲命令,在下面这几条:
| 要记的东西太多 | uptime / free -m / df -h / systemctl list-units / docker ps / nginx -t / ss -tln —— 七条命令、七种输出格式。隔两周不碰就要现查参数。→ 取舍:把这组命令固化成 5 个只读端点,一次刷新全部出来,顺序就是排查顺序。 |
| 手机上 ssh 很难用 | 人在外面收到"站打不开了",掏手机开终端、找密钥、在小屏幕上敲长命令,基本等于放弃。→ 取舍:做成网页,任何设备打开就是。 |
| 分不清是站挂了还是机器挂了 | 同一个"打不开",可能是磁盘满、可能是某个 unit 掉了、可能是容器退了、也可能只是配置写坏没重载。要横着看好几层才判得出来。→ 取舍:面板按层分块——主机资源 / 服务 / 日志 / 磁盘,从上往下扫一遍就定位到层。 |
| 就一个人用,上不起监控栈 | Prometheus + exporter + Grafana 三件套要装、要配、要占内存,还得再维护一套;而需求只是"现在怎么样"。→ 取舍:一个约 190 行的 Python 文件。代价是没有长期历史、没有告警,只有此刻与最近几分钟的窗口 —— 这一点是想清楚之后接受的,不是没做完。 |
下面这块是真的在跑:一个前端状态机 + 每 2 秒推进一格的合成采样。启停有 2 秒过渡态、停掉的服务会让内存曲线真的往下走、模拟故障会同时改状态、写日志、顶高 CPU 曲线。
| 一个端点 = 一条命令 | 主机那一块并不是分别去跑 uptime、free、df、nproc,而是把四条串成一条命令,中间插 @@MEM / @@DISK / @@CPU 标记,回来再按标记切段解析。为什么:远程模式下每执行一条命令就是一次 ssh 往返,握手成本远大于命令本身;4 条变 1 条,等待时间不是省 25%,是省掉 3 次往返。 代价:得自己写一个分段状态机,比调四次 API 难读一点。这笔换得值。 |
| 同一份代码,两个地方都能跑 | 启动时判一次:显式标了本机变量、或者"是 Linux 且存在 /var/www" → 在服务器上,直接起子进程读本机;否则 → 在开发机上,走 ssh 远程读。为什么:否则每改一行前端都要先传上去才能看效果。 |
| 远程执行不自己写 | ssh 那一段调的是共享工具库里已有的封装,这个项目里没有一行拼 ssh 命令行的代码。 为什么:ssh 参数、超时、密钥路径这类东西一旦在第二个地方复制一份,两份就会开始漂移,而漂移只在出事那天才被发现。 |
| 失败不抛异常,降级成一块 | 命令执行失败时不抛 500,而是回一个带 __ERR__ 前缀的字符串;每个端点识别到它就返回 {"error": …},前端每块各自渲染自己的错误。为什么:一块探针挂掉不该让整页白掉 —— 恰恰是出事的时候,你最需要看见其他四块还是好的。 |
df -hP 而不是 df -h | -P 强制 POSIX 单行输出。不加它,设备名一长,一行会被折成两行,按列取第 2、3 个字段就整体错位。踩坑点:这类 bug 只在某些机器上出现,在你自己那台上永远测不出来。 |
| 配置自检要两个串同时命中 | 判定配置有没有问题,要求输出里同时出现 syntax is ok 和 successful,缺一个就判红。为什么:只匹配其中一个,容易被告警行、被"failed"那一行里的字样蒙混过去。守卫要么严到能真拦住,要么就是个会让人放心的摆设。 |
| 静态目录必须最后挂 | 前端静态文件挂在根路径上,是个 catch-all;/api/* 这些显式路由必须先注册,否则会被它整个吞掉。源码里这一行带着注释,因为顺序写反的结果是所有 API 一起 404,而页面本身还好好的 —— 症状离原因很远。 |
| 前端零构建 | 一个 index.html,原生 JS,无框架无打包无依赖;五个端点用 Promise.all 并发拉,15 秒自动刷新一次。为什么:使用者只有一个人、页面只有一屏,引入构建链的收益是负的 —— 多出来的是一条会腐烂的依赖链。 |
| 每条命令 12 秒超时 | 网络卡住时,页面要么出数、要么出错,不能一直转圈。转圈是最坏的状态:它既没告诉你好,也没告诉你坏。 |
一个能看见服务器内部状态的网页,它的价值全部建立在"它只能看"这件事上。所以边界不是靠"我不点那个按钮",而是靠结构上没有那个按钮。
| 只读,且不可参数化 | 执行函数收到的命令字符串,全部是源码里的字面量;五个端点没有一个接受任何参数。也就是说:不存在一条外部输入能到达 shell 的路径 —— 想让它多做一件事,只能改代码重新发布,不能靠 URL 上加个参数。这比"对输入做转义"强的地方在于,它根本没有输入。 |
| 不开公网端口 | 应用进程只绑本地回环地址,外面直接打这个端口是不通的;唯一的入口是同机的反向代理。端口号还不写死在服务定义里,从独立的环境变量文件读。 |
| 闸内 | 子域挂访问闸,没过闸拿不到页面。关键是页面和 API 是同一个进程、同一个源——所以闸挡住的不只是那一屏 HTML,/api/* 一并在闸后,不存在"页面进不去但接口裸奔"的缝。 |
| 不暴露源站地址 | 域名解析走 CDN 代理进来,公开记录里只有域名,没有服务器地址;回源端口由边缘规则指定。控制台自己也不回传任何地址信息 —— 本演示里主机那行写的是"内网地址"四个字,就是这个原因。 |
| 返回的东西本身就很薄 | 接口吐出来的只有:单元名与状态、容器名与状态、vhost 文件名、端口号、容量百分比。不读配置文件正文、不回传日志内容、不带任何凭证。即便某天闸破了,泄露的也只是一组名字和几个百分比。 |
| 一处让步:以 root 运行 | 读 systemd、容器、以及跑配置自检需要权限,服务是以 root 起的。这是这套设计里最大的一处妥协,如实写在这里而不是藏起来。之所以敢接受:攻击面被前面几条收在"进程本身"上——没有外部输入能改变它执行什么,能打的只剩"先突破闸、再突破一个不接受参数的只读进程"。要真加上第 2 节演示的那三个按钮,这条让步必须先还掉(降权 + 命令白名单 + 审计留痕),而不是顺手加个按钮。 |
| 后端 | Python + FastAPI,单文件约 190 行,uvicorn 单进程同源同时服务前端 HTML 与 /api/*。依赖只有两行:fastapi、uvicorn[standard]。 |
| 采集 | 本机模式走子进程执行 bash -lc;远程模式调共享库的 ssh 封装。统一 12 秒超时,失败以哨兵字符串回传。没有 agent、没有守护进程、没有数据库——每次刷新现场读一次,读完即弃。 |
| 解析 | 一条命令的输出按 @@ 标记切段,各段用对空格不敏感的字段切分取值;内存占比在服务端算好,磁盘占比直接用系统自己给的百分比字符串,不二次换算。 |
| 前端 | 单个 HTML,原生 JS,无依赖无构建。接口用相对路径调用(同源,因此不涉及跨域)。阈值判定在前端:内存 70% 转黄、85% 转红;磁盘 75% 转黄、90% 转红 —— 第 2 节演示用的就是这套阈值。 |
| 进程管理 | systemd 常驻单元,Restart=always、失败 3 秒后重起;端口与域名从环境变量注入,不硬编码在单元文件里。 |
| 接入 | 反向代理到本地回环端口,vhost 手写并在其中 include 访问闸;子域、端口、访问方式登记在站群的注册表里,由机器对账(注册表说它该有闸,vhost 里必须真有闸,双向校验)。 |
| 在线 | 闸内自用,服务的是自己那一台机器。 |
| 已实现 | 主机资源(uptime / 负载 / 内存 / 磁盘 / CPU 核数)· systemd 单元矩阵 · 容器 · vhost 列表与配置自检 · 代理容器与监听端口。就这五块。 |
| 没实现:证书到期与备份状态 | 站群注册表里给它写的一句话描述包含这两项,但代码里没有对应的采集 —— 这是一处"文档跑在实现前面"的漂移。写在这里而不是把它讲成功能:一份介绍如果把没写的东西说成写了的,那它其余部分的可信度也就没了。 |
| 没有长期历史,没有告警 | 只有此刻快照,不落时间序列,也不会主动通知你。这是第 1 节那笔取舍的直接后果,不是待办。真需要趋势与告警时,正确动作是上专门的监控栈,而不是给这个文件继续加功能。 |
| 部署还是手工四步 | 同步文件 → 装依赖 → 写端口配置 → 启用服务。站群的部署注册表里,这一项的部署命令字段是空的——如实记"没有一键脚本",而不是把四步压成一条从没跑过、但看着很合理的命令。编出来的部署命令是所有自动检查都抓不到的那类错:它长得和真的一模一样,只在真正要发的那一刻炸。 |
| 刻意降为低频件 | 按访问日志,人类访问是个位数/两天 —— 于是它在项目台账里被主动降档为"季节性"。这不是失败:控制台的价值是你需要的时候它在,不是你天天开着它。 |