前言:平均 CPU 163.05%,告警却是 null

有一次,我在 Vestigra 的真实运行数据里看到了一组很离谱的数字:

  • 过去 10 分钟一共有 387 个样本;
  • 统计出了 11 个 60 秒窗口;
  • Endpoint CPU 平均值是 163.05%;
  • P95 达到 200.12%;
  • 最大值是 230.76%;
  • 没有采样缺口;
  • 资源不是 unavailable;
  • Endpoint runtime 也没有显示不可用。

按我当时设计的 PerformanceWatch,这已经不是偶尔抖一下,而是持续高 CPU。

但我往下一看:

1
latest alert: null

这就很尴尬了。

系统已经快把风扇转冒烟了,负责发现性能异常的 Watchdog 还在告诉我:没事,天下太平。

而且自动化测试全是绿的。

Vestigra 第二阶段的 Runtime 重构,就是从这个 null 开始的。

一、测试里的时间戳太完美,真实 daemon 没那么配合

PerformanceWatch 最初的判断逻辑并不复杂:观察最近 10 分钟的数据,每 10 秒形成一个 rollup bucket,连续 60 个 bucket 都超过 85%,再生成告警。

测试数据里,这套逻辑完全正常。

因为我构造的时间戳很规整,采样和评估刚好落在 10 秒桶边界上。要 60 个完整 bucket,就真能得到 60 个。

真实 observability daemon 可不会配合我表演。

采样时刻和评估时刻存在相位偏差,大多数情况下只能得到 59 个完整 bucket。evaluator 看到数量不够,就返回 insufficientData。于是 CPU 哪怕连续十分钟超过 160%,最后的告警仍然是 null

这个 bug 给我的冲击不在于少算了一个 bucket,而在于它把几件我原本混在一起的事硬生生拆开了:

  • 测试里“能触发告警”,不等于真实世界“会触发告警”;
  • 某个函数返回正确,不等于 daemon 长时间运行语义正确;
  • 控制面显示 running,不等于关键证据仍在产生;
  • 单元测试通过,更不等于产品闭环已经成立。

以前我总觉得自动化测试写得够多,心里就会踏实一点。这次以后,我对那种只有一片绿色、却没有现场长期运行证据的状态,开始本能地不信任。

二、性能问题不是突然出现的,早期架构已经埋了雷

回头看,最早的 Runtime 设计有一个很大的问题:采集和保护共享了太多路径。

大量 NOTIFY event、Story 计算、Rule evaluation、projection 维护,都可能挤进真正的 AUTH 判断链路。

刚开始它当然能跑:

  • 项目可以编译;
  • 测试可以通过;
  • CLI 有输出;
  • Demo 也演得出来。

但只要在本机长期运行,问题就会慢慢露头:

  • 采集路径越重,保护快路径越容易被拖慢;
  • 事件量越大,CPU、内存和队列压力越高;
  • pressure 策略越复杂,越容易误伤真正需要的证据;
  • Endpoint 一旦崩溃重启,控制面状态和真实订阅可能不一致。

这类问题最阴的地方,是它不一定 crash。

产品可能仍然显示 running,CLI 也还能吐东西,但 Story 已经漏了,selector 可能被错误裁剪,live projection 可能已经截断,path muting plan 也可能没有按预期生效。

Crash 至少会大声告诉我“我挂了”。

静默漏检不会。它只会让安全产品一边亮绿灯,一边悄悄失去证据。

三、既然我自己会漏看,那就让程序盯着程序

PerformanceWatch 最初就是从这个念头来的:

为什么不能让程序自己盯着程序?

它不应该只是偶尔取一次 CPU,而要持续观察 Endpoint runtime:

  • CPU;
  • Memory;
  • Queue;
  • Latency;
  • Rollup windows;
  • Gaps;
  • Degraded state。

当然,也不能 CPU 一冒头就报警。短暂波动很正常,真正有意义的是持续异常,所以判断必须建立在时间窗口上。

这条链路后来逐渐变成:

1
2
3
4
5
6
7
8
runtime plist config
→ PerformanceWatchConfig / baseline rule
→ EndpointPerformanceWatchEvaluator
→ PerformanceAlertStore
→ alert.json / alert-window.json / latest.json / events.jsonl
→ Darwin notification
→ CLI JSON
→ Skill / Python 读取稳定输出

我最看重的其实不是多了一个性能告警,而是它形成了一条 Self Feedback 原型:

1
2
3
4
5
6
系统发现异常
→ 自动收集上下文
→ 生成可验证报告
→ 通过稳定 CLI JSON 暴露
→ AI / Skill 参与解释和修复
→ 修复后再由 Watchdog 验收

AI 可以协助分析,但不能自己偷偷去读产品数据,再把自己的推断当成事实源。产品必须先通过稳定接口把证据交出来。

四、Watchdog 只能告诉我疼,真正的手术是拆开 Recorder 和 Protection

PerformanceWatch 能发现 Runtime 在发烧,但它不会自动把架构治好。

真正的转折点是我开始追问一个以前觉得没必要问的问题:

为什么 Recorder 和 Protection 一定要混在一起?

采集再慢,也不应该拖住保护。保护快路径必须短、确定、可审计,而且在退化时能够明确暴露状态。采集路径则可以更丰富,去做 Story、investigation、projection 和 case export。

我最开始的直觉方案很简单:一个 ES client 负责采集,一个 ES client 负责阻挡。

继续推下去以后,我发现“两 client”还是太粗,最后收敛成了更细的结构:

  • 一个统一的 NOTIFY ES client;
  • 每个受支持的 AUTH event 使用独立 ES client;
  • event-specific path muting;
  • Chrome Behavior Protection 使用独立 domain;
  • 在编译期生成完整 path execution plan。

NOTIFY 事件合并成一个 source stream,是为了避免多个采集流之间再去猜排序。

Protection 的 AUTH 则按事件类型拆 shard。像 AUTH_OPEN 这种高频事件,不应该和其它 AUTH 类型共享 callback、deadline 或 audit backlog。每类 AUTH event 都应该有自己的 subscription、callback、deadline、audit state 和 failure handling。

这样某一个高频事件堵住了,至少不会顺手把其它保护快路径一起拖下水。

这不是为了追求架构图好看,而是前面的 CPU、队列和静默退化已经证明:边界不拆,后面所有优化都只是在打补丁。

五、我也放弃了“Close Event 多收一点总没坏处”的想法

做 Story 时很容易产生一种冲动:事件多收一点,以后总能用上。

真实运行以后才知道,Close Event 全局监听的成本非常高,而且绝大多数 close 对安全分析并没有价值。

真正关键的通常只是少数路径:

  • LaunchAgents;
  • LaunchDaemons;
  • Chrome 用户数据目录;
  • Vestigra 自己的产品数据目录;
  • 其它 high-value authority assets。

所以我把思路从“全局收,再慢慢筛”改成了 event-specific path muting:在编译期生成 execution plan,只让关键路径进入运行时。

事件量下来了,Story、projection 和 rule pipeline 的压力也会跟着下降。

但这里又不能假装没有代价。如果 path plan 编译错了,关键路径可能直接在内核侧被 mute,用户态连一条错误事件都收不到。

因此 execution plan 不能只生成,还必须能够审计、验证,并且把 applied 状态和 desired 状态分开看。

省 CPU 不是目的。省完 CPU 以后,关键证据还在,才算优化。

六、从这以后,我不再把 apply success 当成成功

这次重构最后逼出了一组我现在经常拿来检查系统的问题:

1
2
3
4
5
6
desired plan ≠ actual subscriptions
compiled path plan ≠ applied muting
raw admission ≠ materialized evidence
live projection ≠ durable story
health ≠ no gap
API apply success ≠ live semantics verified

以前看到配置 apply 成功,我会下意识觉得这一段结束了。

现在不行。

配置成功,只能说明控制面接受了它;Endpoint 有没有按预期订阅、muting plan 有没有真实应用、durable story 有没有落下、权限和 coverage 有没有退化,都必须另外验证。

安全产品尤其不能把“接口调用没报错”当成“用户真的被保护了”。

如果系统无法证明 live semantics 符合预期,就应该老实暴露 gap 或 degraded,而不是继续亮一盏假绿灯。

目前的结论:性能优化,最后修的往往不是性能

我最开始以为,Vestigra 的性能问题就是 CPU 太高、内存太大、队列太长,逐个优化就行。

最后真正有效的改动,却几乎都在重新划边界:

  • Recorder 和 Protection 分离;
  • NOTIFY 和 AUTH 分离;
  • 全局事件和 scoped path muting 分离;
  • 编译期语义和 Runtime 执行分离;
  • API apply success 和 live semantics verified 分离;
  • Story live projection 和 durable story reader 分离。

边界没拆对时,我修掉一个局部问题,它总会换个地方再冒出来。

边界拆清楚以后,很多优化反而自然出现了。

所以这次我真正学到的不是怎么把 CPU 从 163.05% 降下来,而是另一件事:

性能问题表面上是资源问题,本质上经常是架构边界问题。