一个静态二进制,读 /proc,无外部依赖(不需要 ClickHouse / Caddy / 数据库)。
页面已内置在二进制里;只需要分发 nodedata 这一个文件。
临时跑(带资源硬上限,停掉或重启机器即消失):
sudo systemd-run --unit=nodedata -p CPUQuota=20% -p MemoryMax=300M -p Nice=10 -p IOSchedulingClass=idle -p Restart=always --setenv=GOMAXPROCS=1 --setenv=GOMEMLIMIT=200MiB /usr/local/bin/nodedata serve --port 8888 --data-dir /var/lib/nodedata看状态 / 日志 / 停止:
systemctl status nodedata ; journalctl -u nodedata -f ; sudo systemctl stop nodedata开机自启:把下面这段存成 /etc/systemd/system/nodedata.service,然后
systemctl daemon-reload && systemctl enable --now nodedata。
(仓库里只有 Go 与 HTML,不提供安装脚本;这段与上面那一行命令的限制完全相同。)
[Unit]
Description=nodedata — single-host deviation monitor
After=network.target
[Service]
ExecStart=/usr/local/bin/nodedata serve --port 8888 --data-dir /var/lib/nodedata
Restart=always
RestartSec=5
CPUQuota=20%
MemoryMax=300M
Nice=10
IOSchedulingClass=idle
Environment=GOMAXPROCS=1
Environment=GOMEMLIMIT=200MiB
[Install]
WantedBy=multi-user.target不要 systemd、只在前台看一眼:
GOMAXPROCS=1 ./nodedata serve --data-dir ./data| 设置 | 理由 |
|---|---|
CPUQuota=20% |
正常开销约 2% 核(按实测外推:85 条序列、1000 个进程);20% 是给页面访问留的余量,同时是硬上限 —— v3.0.8 那种 bug 再出现也只能用到 0.2 核而不是 1.5 核 |
GOMAXPROCS=1 |
Go 1.24 及以前不感知 cgroup CPU 配额,多核机上会开满 P,突发后被 CFS 节流卡住 |
MemoryMax=300M + GOMEMLIMIT=200MiB |
满 24h 原始层 + 56 天长期层、85 条序列约 72MiB;GOMEMLIMIT 让 GC 在内核 OOM 之前先收紧 |
Nice=10、IOSchedulingClass=idle |
和业务抢资源时让路 |
cgroup v1 的机器(CentOS 7/8 默认)上 MemoryMax 不生效,改用 -p MemoryLimit=300M。
| 参数 | 默认 | 说明 |
|---|---|---|
--listen |
127.0.0.1 |
监听地址。设为 0.0.0.0 前请确认有防火墙或反代鉴权 |
--port |
8888 |
监听端口 |
--data-dir |
./data |
转储 *.json、baseline.json、history/ |
--history-dir |
<data-dir>/history |
长期层落盘目录 |
--rootfs |
/ |
要监控容量的挂载点 |
--interval |
5s |
采集与 L0 巡检周期 |
--dump-interval |
30s |
页面数据转储周期 |
--proc / --sys |
/proc / /sys |
测试用的替代根 |
--web-root |
空 | 从磁盘读 index.html 覆盖内置页面(前端开发用) |
- 原始层:每个采集周期一点,内存里保留 24 小时。L1–L5(5 分钟 ~ 1.5 小时)用它。
- 采集维度:CPU / 内存 / 磁盘(含容量)/ 网络(含 UDP 缓冲区溢出)/ 套接字(TCP 状态分布、conntrack 水位)
- 每块整盘(
disk.util@nvme0n1等)+ 每个网卡(net.rx_drop@eth1等) - 每个进程(CPU、块设备读写、主缺页、RSS 与 1 小时增长、状态)。整机磁盘汇总不重复计 dm/md, 整机网络只汇总物理/virtio 网卡(不重复计 bond 与 VLAN)。
- 每块整盘(
- 长期层:每 5 分钟从原始点里抽一个(不求平均,保证 Δ 的分布不变),保留 14 天,
追加写到
history/YYYY-MM-DD.jsonl(每天约 0.7MB,14 天约 9MB),重启时读回。 L6–L10(3 小时 ~ 7 天)和 7d/14d 窗口用它。 - 为什么 56 天:σ 用最近 28 天的同时段 Δ 估计,L14 的每个 Δ 又要往回够 28 天。 首次部署后 L9 约 1 天就绪、L14 约 29 天就绪,满 56 天后 L14 的参照才完整。
L4 把偏离按症状归成 CPU / IO / 内存 / 网络四类,每类一条结论,写明责任方和下一步命令:
CPU 劣化:cpu.user 上升 6.0σ — 责任方 burner(PID 4242)
责任方 burner PID 4242 占 150% 核,占整机忙碌的 41%;在这次变化开始之后才出现,是首要嫌疑
下一步 top -H -p 4242 · pidstat -u -t -p 4242 1 5 · cat /proc/4242/status · perf top -p 4242
- 责任方排序:自身序列也偏离的进程 > 变化开始后才出现的进程 > 此刻用得最多的进程(会注明"未必是原因")。 常年 200% 的数据库不会因为一直最大就被指认。
- IO 同时给出设备(按盘的 util/延迟)和进程(/proc/PID/io 块层读写),并列出 D 状态进程。
- 内存看 1 小时 RSS 增长、主缺页;slab 领头时指认内核而不是进程。
- 虚机上
cpu.steal领头时指认宿主机,不冤枉本机进程。 - 网络只到接口(按进程拆流量需要 eBPF,未做)。
- 责任方是 nodedata 自己时会明说。
- 结论要求偏离在最近 3 个采样点(15 秒)持续,单点噪声不下结论。
后台每 15 秒跑一次 L4(不需要打开页面)。出现带责任方的结论时,把现场存到
<data-dir>/incidents/<时间>-<类别>.json:结论与完整诊断链、|z|≥2 的指标、进程快照、
责任进程的线程级 CPU(1 秒采样)与 wchan/内核栈、全部 D 状态进程及其栈、最近 100 条内核日志、
原始 pressure/meminfo/vmstat/diskstats/net 文件。同一责任方 30 分钟内只留一次,保留最近 100 份。
不采集进程命令行(参数里常有密码)。页面上可以"立即留证"。
- 回放(每次 CI):
go test -run TestFaultCorpus -v ./cmd/nodedata。8 个物理机/虚机场景 (新失控进程、老进程变异、nodedata 自身、单盘写入、3 小时内存泄漏、网卡丢包、虚机 steal、安静的一天), 断言类别与责任方,报告出结论耗时和误报率。 - 真实注入(实验机):
./faultlab --url http://127.0.0.1:8888 --scenarios cpu,io,mem, nodedata 需先运行约 10 分钟。网卡丢包:sudo ./faultlab --scenarios net --iface eth0(需要 tc 和该口上的 TCP 流量)。
页面上单独一个"服务"区块,回答"这台机器上跑着什么、各自什么时候起来的、昨天那个还在不在"。
识别只读 /proc:可执行文件名查表 → java 命令行(区分 ELK / Kafka / Tomcat)→ 监听端口反查 → 都不认就显示可执行文件名。不连服务、不读配置、不执行任何命令。 把 mysqld 改名部署也能靠 3306 认出来。内置表覆盖 Nginx / Apache / Caddy / HAProxy / MySQL / MariaDB / PostgreSQL / MongoDB / Redis / Memcached / ClickHouse / etcd / Elasticsearch / Kibana / Logstash / Kafka / ZooKeeper / RabbitMQ / Docker / containerd / BIND / CoreDNS / PHP-FPM 等。
每行给出:服务名与监听端口、进程名、PID、实例数、启动时间、已运行时长、CPU、内存,
以及 1h / 6h / 12h / 1d / 3d / 7d / 14d 前在不在(✓ 在 / — 不在 / ? 当时 nodedata 没在跑 /
重启 那时在但之后换过一次)。消失的服务留在表里标灰,不会凭空不见。
服务变化只呈现、不告警:我们分不清"挂了"和"运维手动停的",分不清就不该报警。
GET /health.txt 一行文本,关键字打头,主机名在同一行:
NODEDATA host=jp02-dns-01 status=WARN cpu=182% load=9.14 mem_avail=1.2GiB disk_util=96 \
culprit=fl-io/236 reason="IO 劣化:disk.await_w 上升 6.0σ" ts=2026-09-12T03:10:51Z
curl -s http://127.0.0.1:8888/health.txt | grep -q '^NODEDATA .*status=NORMAL' || 告警status 取 L0 绝对判定与 L4 归因结论中较严重者:DOWN(L0 有 fail)、
WARN(L0 有 warn,或 L4 给出了带责任方的结论)、NORMAL。异常时 reason= 说明原因,
culprit= 直接给出进程名/PID,派单时不用再登机器。
自由文本里的 NODEDATA、status=、换行与引号都会被中和 —— 进程名是攻击者可控的,
否则一个叫 NODEDATA status=NORMAL 的进程就能让监控的 grep 在真告警时匹配成功。
默认只监听 127.0.0.1。页面会列出进程名、PID 与主机负载水位,是内网侦察的现成材料,
所以要给别人看必须显式 --listen 0.0.0.0,并自行用防火墙限制来源或放在带鉴权的反代之后
(程序本身不做鉴权)。绑非回环地址时启动会打印一行警告。
| 路径 | 说明 |
|---|---|
/ |
页面 |
/data/{1h,6h,24h,7d,30d}.json |
窗口数据(含 procs 进程快照) |
/data/health.json |
采集器自检,含 self.cpu_pct、self.rss_mb |
/api/check |
L0 绝对判定 |
/api/diagnosis?z=3 |
L4 诊断链 |
/health.txt |
一行文本,给监控 grep |
/api/services |
服务清单与历史在否 |
/api/baseline |
GET / POST / DELETE 人工基线 |
/api/incidents |
GET 留证列表;POST 立即留证 |
/api/incidents/<id> |
一份证据 |
CGO_ENABLED=0 go build -ldflags "-X main.version=$(git describe --tags --always)" -o nodedata ./cmd/nodedata