人话名称 + 当前值 + 1h / 6h / 12h / 1d / 3d / 7d 前同一时刻的值 + 变化百分比,按 CPU / 内存 / 磁盘 / 网络分组;
多块盘、多张网卡时逐设备各一行。历史值服务端精确取(24h 内原始层,更早 5 分钟长期层),
没有历史返回 null、页面显示"—"(不当作 0);本机没有的指标整行不显示;
红色只标"往坏方向变化 ≥20%"(可用内存往下为坏)。GET /api/compare。
z 热力图与诊断链移到下方,回答"变化是否异常、谁干的"。
- 限流由"全局 1 分钟"改为"同一类别 1 分钟"。实机注入发现:IO 故障先触发一份 CPU 证据(写入本身吃 CPU), 全局限流挡掉了紧随其后的 IO 结论,真正的 IO 现场没留下;内存证据被推迟到检查窗口之外;
- 证据里空字段输出
[]而不是null; - faultlab 核对证据时同时比对类别与 PID(此前只比 PID,把 CPU 类证据误算成 IO 场景通过)。
磁盘与网络采集此前有多处数值错误(不是风格问题):
/proc/diskstats字段错位一格:disk.inflight实际是"累计 IO 毫秒"单调计数器;disk.util读的是加权时间, 且上一轮值从不保存 —— 几乎从不输出;整机 util 各盘相加,两块盘各 60% 就超过上限被丢弃;- "名字以数字结尾就是分区":
nvme0n1、dm-0、md0全被跳过,NVMe 服务器没有任何磁盘指标; disk.merged用本次合并数减上次的 IO 数;net.tx_drop实际是接收包数(nums[11%10]);- 只读前 8 个网卡,bond、成员口、VLAN 全部相加,绑定网卡的物理机流量翻倍甚至三倍。
现在:
- 每块整盘出 util / await_r / await_w / 读写 IOPS / 读写吞吐 / 队列(
disk.util@nvme0n1);整机汇总不重复计 dm/md, 整机 util 取最忙那块盘,统一 0–100; - 每个网卡出 rx / tx / 丢包 / 错包(
net.rx_drop@eth1);整机只汇总有/sys/class/net/<if>/device的网卡; - 每个进程新增块设备读写(
/proc/PID/io)、主缺页、状态(D)、1 小时 RSS 增长; 快照 = CPU 前 10 ∪ 读写前 10 ∪ RSS 增长前 5 ∪ RSS 最大前 5 ∪ D 状态 ∪ 自身;新增proc.io.<名字>序列。
- 偏离按症状归成 CPU / IO / 内存 / 网络,每类一条结论:标题、责任方、下一步命令;
- 责任方排序:自身序列在"领头指标偏离的档位"上也偏离 > 在变化开始后才出现 > 此刻用得最多(注明"未必是原因")。 必须按档位比较:失控的新进程跑 5 分钟后自己的 L1 就绪且平稳,只看"有无历史"会让归因翻回常年最大的进程;
- 虚机
cpu.steal领头 → 宿主机;slab领头 → 内核;网络只到接口;责任方是自己时明说; - 结论要求偏离持续 3 个采样点(15 秒)。约 360 个 z 格下单点 |z|≥3 纯靠噪声几乎每次评估都有一个: 回放里出现过三次假的"IO 劣化:disk.await_r 3.0σ"。L3 热力图照样显示单点尖峰。
后台每 15 秒跑 L4(不依赖页面);带责任方的结论出现时写 <data-dir>/incidents/<时间>-<类别>.json:
结论与诊断链、|z|≥2 的指标、进程快照、责任进程线程级 CPU 与内核栈、D 状态进程及其栈、最近 100 条内核日志、
原始 /proc 文件。同一责任方 30 分钟一次,保留 100 份;不采集命令行(防密码泄露)。/api/incidents,页面可"立即留证"。
- 回放(CI 每次跑):8 个场景 8/8 通过;安静的一天 5 小时、601 次评估零误报;
cmd/faultlab:实验机上真实注入 cpu / io / mem / net 故障,检验类别与 PID,报告耗时;.github/workflows/ci.yml:vet、race、回放语料库、性能护栏;手动触发时在 runner 上跑 faultlab。
- 故障持续时基线被污染:σ 每 5 分钟重算,包含正在发生的故障;稳健尺度用 10–90 分位差, 故障占到一个小时桶约 10% 以上时短档 σ 被撑大、短档先被掩盖(长档仍能检出,结论不丢)。 正确做法是结论活跃期间冻结相关指标的基线,并设最长冻结时间以便学习真实的新常态。
此前只有内存里 24 小时的原始点,重启即清空,ClickHouse 代码从未接入:L9–L14(1~28 天) 永远是斜纹,7d/30d 按钮实际只有 ≤24h 数据。
- 每 5 分钟从原始点里抽一个点(不求平均:抽出的仍是原始点,Δ 与原始数据同分布;
平均会压小 σ、放大 z),保留 56 天,追加写
<data-dir>/history/YYYY-MM-DD.jsonl; 启动时读回。85 条序列 56 天实测:磁盘 37MB,读回 1.05s;内存(含 24h 原始层)约 72MiB; - 为什么 56 天而不是 28 天:σ 用最近 28 天的同时段 Δ 估计,而 L14 的每个 Δ 要往回够 28 天。 只留 35 天时 L14 的参照只有 7 天,持续 3 天的劣化占参照 43%,中位数被拖走, 劣化自己成了"正常"(测试里 L14 z=1.0,漏报);56 天时同一场景 z=5.8;
- σ 取哪一层按规则而非拍脑袋:配 v(t−H) 的容差 ≥ 长期层步长一半时(L6=3h 起)用长期层, 否则(L1–L5)用原始层。L6–L8 因此也不再因重启清零;
- 新增
--history-dir; - 新增
TestLongLagsSurviveRestart:60 天历史落盘 → 重启只从磁盘装回 → L9–L14 立即就绪、 正常指标不报、"平稳后最近 3 天每天涨 5%"的指标在 L10–L14 上被抓到;TestHistoryTruncatedLineAndRetention:崩溃留下的半行跳过、NaN 不落盘、过期日文件删除。
z 的语义提醒:它比的是"这次的 Δ"与"同 lag 的 Δ 平时多大"。一个一直匀速上涨的指标 (每天都涨 1%)7 天 Δ 平时就是 +7%,z≈0 —— 长 lag 抓的是"行为变了",不是"一直在涨"。 容量类问题(磁盘按固定速率写满)归 L0 的绝对判定。
L0 在 serve 里每个采集周期都跑,结果在首页与 /api/check,独立子命令只剩误用
(install.sh 装的服务跑的就是它,跑完即退)。check.PrintResults 一并删除。
- 页面用
go:embed编进二进制,只需分发nodedata;--web-root仅用于前端开发覆盖; - README 给出带资源上限的
systemd-run一行命令;install.sh重写为约 40 行, 写出同样限制的常驻 unit(CPUQuota=20%、MemoryMax=300M、GOMAXPROCS=1、GOMEMLIMIT=200MiB、Nice=10、IOSchedulingClass=idle); --data-dir默认./data,不再依赖 web 根目录推断;- 删除
setup.sh(下载 ClickHouse、传serve根本不认的--dsn)与Caddyfile(反代到没人监听的 9701);README 按现状重写。
/ 原来是 http.FileServer(http.Dir(webRoot)):带目录列表、监听 0.0.0.0,
webRoot 找不到 index.html 时回落到当前目录 —— 在 /root 下启动即可从网络浏览 /root/.ssh。
现在 / 只返回首页,其他路径 404(/l0.html 301 到 /)。
根因是 L3 的 Series.Lookup 对 17280 点的缓冲区做线性扫描,而 Build 对窗口里
每个点、每个就绪 lag 都要 Lookup 一次。成本随缓冲区长度线性上涨,所以启动时正常、
跑满 24 小时后最重。实测(20 个指标、满 24h 缓冲):
| v3.0.8 | v3.0.9 | |
|---|---|---|
| 一轮转储(5 个窗口) | 34.6 s | 0.08 s |
| σ 全表重算 | 2.7 s | 0.49 s |
转储周期是 30 s,旧实现一轮要 35 s,转储 goroutine 永不空闲(≈1 核);页面每 5 s 调一次
/api/diagnosis,它每次又完整 Build 一个 1h 窗口,叠加后到 1.5 核。
Series.Lookup/Range改二分;拒收时间倒退的点以保证有序前提;/api/diagnosis只算每个指标最后一个点(HeatmapBuilder.Latest);- σ 重算由每指标 14×24=336 遍全量扫描改为每 lag 一遍(双指针配 v(t−H)),
每个桶只排一次序。结果与旧实现逐位一致(
TestComputeSigmaLagMatchesLegacy); - 采集器路径审计
openedPaths每次读都 append 且从不清理(一天 ~170 万条, 内存与 GC 扫描随运行时长上涨),改为按/proc/<pid>/...归一的去重集合; - 新增
cmd/nodedata/perf_test.go回归护栏:满缓冲一轮转储 > 2 s 即失败 (把旧series.go换回去,该测试以 34.6 s 失败)。
旧的进程采集每轮从全部 PID 里随机抽 100 个,只输出 top-5 的 comm:
- 进程要好几轮才被抽中一次,增量是"距上次被抽中以来"的累计值,首次抽中时 prev=0,
直接把进程一辈子的 CPU 当 5 秒增量报(
proc.cpu.systemd的偏离就是这么来的); - 序列时有时无,同时段差分攒不够,z 基本是斜纹;同名多进程同一时刻互相覆盖;
- 按空格切
/proc/PID/stat,comm 带空格时 utime/stime 取错字段; kworker/u4:2-events_unbound每个变体一条新序列,序列数无界增长;- 结果里没有 PID,从表格无法走到具体进程 —— nodedata 自己占 150% 也看不到自己。
现在:每轮扫全部 PID(一个 PID 一次 read,1000 进程 < 10 ms);用 starttime 识别 PID 复用,
首次见到只记基线;以最后一个 ) 为界解析 comm;内核线程名归一(kworker/… → kworker);
CPU 达到 1% 核的名字进入跟踪集合(上限 32),之后每轮都出点(空闲出 0),空闲 1 小时退出;
单位统一为"占一个核的百分比",与 cpu.user 相同。
- 转储 JSON 新增
procs:此刻 CPU 前 10 的进程(PID、comm、CPU、RSS),nodedata 自身永远在列; health.json新增self.cpu_pct、self.rss_mb、collector.procs_scan_ms;- 24 小时无新点的序列连同 σ 一起回收(
Series.Prune)。
- L3 表所有列可点击排序(数值列先降序,文本列先升序,z 列按 |z|,空值垫底), 排序状态在 5 秒自动刷新后保持;
- L3 表下方新增"此刻谁在用 CPU":PID、进程、CPU、RSS 与下一步命令(点击复制),
proc.cpu.<名字>行直接标出对应 PID; - 修正降采样:旧实现从第一个点起步长取点,24h 窗口里表格的"当前值"最多落后 6 分钟, 现在保证最后一个点是最新点。
缓冲区只在内存里存 24 小时,L9–L14 永远不会就绪install.sh装的 systemd 单元跑的是nodedata check
页面上几乎每个指标每一档都是 ±6.00,等于什么都没说。根因是两个:
- z 少了位置项。 公式写的是
z = Δ / σ,其中Δ = v(t) − v(t−H), 等于假设"Δ 的正常值是 0"。这个假设对任何有稳定漂移的指标都不成立: 网卡累计字节数、disk.inflight、page cache、slab 的 Δ 恒为一个大正数, 于是 z 永远顶在 +6。现在改成标准化残差z = (Δ − Center) / σ,Center 是同时段 Δ 的历史中位数,也就是"平时就该涨这么多"。 - σ 的下限是编的。 旧式
σ = max(1.4826·MAD, 0.02·median|Δ|, N<20 ? 1e-6 : 1/6):0.02·median|Δ|是 Δ 的量级而不是它的离散程度,代进去 z 恒等于 50;1/6这个硬地板让整数型指标(进程 CPU jiffies 在 0/1 之间跳)动一格就恰好是 6.00。 现在尺度用三个在正态下都收敛到 σ 的稳健估计取最大者 (1.4826·MAD、四分位差/1.349、十分位差/2.563),完全退化时(速率恒定的计数器、 恒 0 指标)落到「分辨率」与「量级的 5%」取大者,并统一以分辨率为地板 —— 整数指标动一格 ⇒ z≈1,动三格 ⇒ z≈3。取三者最大是为了兜住多峰分布 (周期性抖动、定时任务、锯齿型缓存),MAD 只看中位数附近那一团,会把另一个峰当异常。
ComputeSigma 里配 v(t−H) 传的容差是 H 本身 —— 对 L14 来说"28 天内任何一点
都算 28 天前的值",而 Z 用的是固定 30 秒。σ 是松配对的尺度、Δ 是紧配对的量,
两者不是同一个分布,比出来的 z 无意义。现在两边共用
LagTolerance(H) = clamp(H/50, 30s, 30min)。
Ready 从"历史跨度够"改成"历史跨度够且同时段差分样本 ≥ 8",不够就画斜纹。
另外样本量不足时 z 按 N/(N+8) 向 0 收缩(N=8 ×0.5,N=20 ×0.71,N=200 ×0.96),
避免刚启动几分钟就给出满量程结论。
internal/deviation/saturation_test.go:恒速计数器正常时 |z|<1、速率×10 时 z≥4; 整数指标跳一格 |z|≤2;恒 0 指标 z=0 但跳到 50 要报;样本不足返回 NaN;小样本收缩。cmd/nodedata/heatmap_z_test.go:13 个"长得像真实节点"的合成指标 + 1 个注入异常, 跑完整的 Series → RefreshSigma → Build 链路,要求正常指标红格比例 < 2% 且注入的异常必须被抓到(实测 0.05%,异常抓到 41 格)。
internal/deviation.SigmaTable 内部是裸 map,由 sigmaLoop(5 分钟一次整表重写)
写入,同时被转储循环(30 秒)和 HTTP 处理器读取。Go 的 map 不是并发安全的,
第一次 RefreshSigma 落在读取上就会触发
fatal error: concurrent map read and map write —— 这是 runtime 级错误,
recover 不住,进程直接消失。v3.0.5 及更早版本都有这个问题,必须升级。
- 改为
sync.RWMutex保护,map 的值改成数组指针,避免每次写入拷贝 14×24 的 σ 数组; - 索引越界不再 panic,回落到
LagSigma{Sigma:1e-6}; - 新增
internal/deviation/race_test.go与cmd/nodedata/heatmap_race_test.go, 在go test -race下把 sigmaLoop 与 Build/DumpAll 并发跑;把锁去掉后这两个 测试确实会报 DATA RACE,也就是说它们能拦住这个 bug 再次出现。
conntrack 丢弃、listen 溢出、OOM 击杀、内核 taint 都是自开机起单调累加/置位的, 按绝对值判定会导致"三个月前抖过一次"永远挂着告警。现在一律只判增量:
- 有人工基线时只对超过基线的新增量告警,没有基线时以本进程首次观测值为起点;
- 计数器回绕/重启(增量为负)自动重新起算;
- taint 是位图,只对基线之后新出现的位告警,已有位仅作说明;
- 累计值照实显示,不再单独构成告警(OOM 历史保留 1 级提示)。
POST /api/baseline把当前状态设为标准状态,GET查看,DELETE清除;- 落盘在
<data-dir>/baseline.json,原子写入,重启后自动加载; - 首页"绝对判定"下方有"把当前状态设为基线 / 清除基线"按钮。
-proc 之前只传给了 L1 采集器,L0 仍在读真 /proc。现在 runServe 会调用
check.SetRoots,check 子命令也支持 -proc / -sys / -data-dir。
41 项判定全部平铺展开,异常项排在前面,保留"只看异常项"开关。
L0 Sanity Check release: 39 absolute judgments independent of time-series infrastructure. First stable layer of v3.0 architecture (L0-L4).
nodedata check [--timeout 5s]runs 39 absolute judgments in ≤5 seconds- No ClickHouse, no baselines, direct /proc/sys reads only
- Exit codes: 0=pass, 1=fail, 2=warn
39 Checks:
- Time/Sync (3): NTP, accuracy, timezone
- CPU (7): online CPUs, governor, turbo, IRQ balance, preemption, sched, RT
- Memory (8): fragmentation, swappiness, cache, compaction, OOM, zone reclaim, overcommit, dirty pages
- Disk/IO (8): queue depth, scheduler, throttling, IOPS, writeback, trim, RAID, journaling
- Network (6): NICs, MTU, offload, RX/TX buffers
- Filesystem (3): capacity, inodes, mount modes
- Conntrack (2): limit, timeout
- Socket (2): buffer memory, listen queue
- Errors (2): OOM kills, I/O errors
CollectErrorSources(): dmesg OOM, I/O errors, FS errors, conntrack full, network dropsCollectLinkConfig(): per-NIC speed, duplex, MTU, offload flagsCollectSocketStates(): TCP state breakdown (ESTABLISHED, TIME_WAIT, CLOSE_WAIT, LISTEN)
ComputeSigmaV2(): median-based MAD instead of mean baseline- Proper sigma floor: median(values) / 6 when N ≥ 20
ZWithTolerance(): interval-adaptive tolerance to prevent false positives- Signed differences in MAD calculation (fixes T_LAG_01)
install.sh v0.2.0: dependency check, smoke test, systemd integration- Support for --systemd, --port, --interval, --ch-port parameters
- Go 1.21+ requirement validation
-
T_LAG_01: Sigma calculation was using absolute diffs instead of signed
- Result: z-scores were 2x larger than should be
- Fix: Use signed differences for proper MAD
-
T_LAG_02: Sigma floor too small (1e-6) for zero-valued metrics
- Result: z-scores explode when sigma near zero
- Fix: Use median(values)/6 floor when N≥20
-
Z-score false positives: Idle systems trigger spikes on noise
- Result: False alerts on completely normal systems
- Fix: Z-score tolerance inversely proportional to sampling frequency
ARCHITECTURE.md: 5-layer overview, L0-L1 detail, design invariantsDESIGN.md: zero-allocation strategy, 14-lag rationale, sigma reasoninginstall.sh: improved inline comments and error messages
Binaries:
nodedata-check-amd64,nodedata-check-arm64: L0 only (2.6M each)nodedata-linux-amd64,nodedata-linux-arm64: Full v2.x (L0+L1+backend)
Code Quality:
- 37 existing acceptance tests still passing (1 SKIP: T_LAG_09)
- Zero-alloc validation: ≤20 allocs per collection round
- Build: CGO_ENABLED=0, pure Go, static binary
- L0 check: <1s (target ≤5s)
- Collection: ≤20 allocs/round (v0.2.0 baseline maintained)
- Binary size: 2.6M (amd64)
- L0 now returns exit code 2 for warnings (was not applicable before)
- Sigma computation changed: old baseline calculations no longer apply
- L0 error checks require dmesg/journalctl (not available in all containers)
- Socket CLOSE_WAIT attribution still heuristic (needs tcp_diag for certainty)
- Network offload flags default to true (require ethtool -k for accuracy)
- L2 aggregation not yet implemented (planned for v3.1)
- L3 production deployment pending (deviation matrix operational, alerting TBD)
From v2.x:
- L0 check is opt-in:
nodedata checkdoes not interfere with existingnodedata run - Deviation layer improvements are backward-compatible but may change alert thresholds
- Consider re-tuning per-metric floors after upgrade
- nodedata core team (v3.0 architecture design)
- v0.2.0 PRD from customer feedback (sigma fixes, deploy clarity)
- v3.1: L2 aggregation (5-min windows, baseline comparison)
- v3.2: L4 diagnosis chain (64 states → 49 diagnoses)
- v3.3: Production hardening + monitoring dashboard
(See git history for v2.0.0 and earlier releases)