两个晚上的排查,还剩一起事故没写。8 月 14 日那晚,一个 Claude Code 会话在 VS Code 插件里卡了整整一个小时,我把它追查到了一个逐 token 的流式卡顿——前两篇,《两个钟,都没说谎》和《合拢这道流》,讲的就是那次追查,以及修好它的那个中间件。第二天早上,同一台四核机器,在 38.7 的负载均值下差点扛不住——结果发现,这是同一起事故,只是往下游流了二十个小时。那天早上什么都没有"爆炸"。一根引信从前一天下午就开始燃烧,11:09 只是它烧到火药那一刻而已。
动手之前,先读懂这台机器
先开口的是负载数字:11.08、38.69、23.59——一分钟、五分钟、十五分钟。一场还在恶化的危机,数字次序应该是反过来的,一分钟这个数字先炸;我这几个数字是在往下掉的,说明我还在读数据的时候,峰值其实已经过去了。接着是 /proc/pressure——PSI,内核对 CPU、内存、IO 各自卡顿情况的统计,也是 Linux 主机最接近"记得过去五分钟发生了什么"的东西:内存 full 是 10%(进程真的被彻底卡住了,在等页面回收),IO some 是 45%。内存压力已经变成了一场 IO 风暴。
跟那个最扎眼的数字挂钩的,是 D 状态。停在不可中断 IO 等待里的进程——当场逮到的有 runc 和一个 node 的健康检查——会被算进负载均值里,跟它们真的在跑没有任何区别。四核机器跑出 38.7,不是真有三十八个核在算东西;是不多的真实计算量,加上一大群堵在内核门厅里排队的进程。
排除嫌疑排得很快,因为这台机器很"健谈"。内核日志里没有 OOM kill——内核还没开始"开枪"。docker stats:每个容器都在 500 MB 以下,CPU 个位数——不是 compose 那些服务栈的问题。磁盘用量 65%——没满。按内存排序再分组的进程列表:llama.cpp 占 3.9 GB,七个 Java 服务占 2.3 GB,k3s 占 1.1 GB——都是稳态,都正常,没一个算新闻。而五十九个 vscode-server 进程,占着 7.3 GB——这可不是什么稳态。
一台服务器,两个窗口,以及从地图里冒出来的那条规则
起决定性作用的那一个小时,花在按 PPID 一层层画出进程树上,因为"59 个进程"只是个数字,不是结构。真正的结构是:Remote-SSH 会在机器上按客户端版本各跑一个 server,挂在 init 底下——跟任何一个 SSH 会话都脱钩了,所以也不会随任何一个会话的结束而被判死刑——而这一个 server,托管着客户端打开的每一个窗口,每个窗口对应一个 extension host。
一个 8 月 9 日启动的 server,托管着两个窗口。一个是我正在敲字的这个。另一个是 8 月 14 日的旧窗口,它的 extension host 已经长到了十七个进程、3.2 GB——里面就装着前一晚卡住的那个 Claude 会话,外加凌晨 2:04 到 2:11 之间重试出来的另外四个 claude 进程,每一次重试都是一个新进程,旧的那些从没退出过。另外还有一棵独立的 server 树——十三个进程,是另一台设备当天早上 8:26 打开的窗口——把这次清点补全了。
操作上该怎么做,直接从这张图里就能读出来:我不能杀掉某个 server,只能杀掉窗口。杀了这个共享的 server,等于把自己坐着的那根树枝锯断。要杀的名单是:旧窗口的整棵 extension-host 树,另一台设备的整棵 server 树,仅此而已。
二十个小时,然后是十一分钟
作为一条时间线,这起事故其实是以两种速度在跑。积累阶段:从 14 号 15:17,旧窗口连上、那个卡住的会话开始它长达一小时的挂起,一直到第二天早上 8:26。重试不断堆出不会退出的进程;脱钩的 server 谁也动不了;~/.vscode-server 底下堆了六个版本的 server 二进制——每次客户端升级都会下载一份新的——加起来 3.8 GB。内存爬过 85% 的速度慢到没有任何东西介入:没有 swap 去吸收这种缓慢的爬升,而设在 80% 和 90% 的内存告警本来就是给突刺设计的探测器——面对这种渐进式的增长,它们赶到现场时,身后已经积了好几个小时的势能。
点火阶段:10:59 到 11:09。一次新连接,一次重连,两个全新的 extension host 同时拉起各自的语言服务器——Pylance 之类,吃 CPU 也吃 IO——而这台机器的页面缓存,当时已经被挤压到只能勉强维生。在一台毫无余量的机器上,十一分钟的同时启动:内核开始回收所有进程正在读的那些页面,IO 卡死,进程一个个掉进 D 状态,负载均值把这次引爆记录了下来。38.7 那个五分钟峰值,覆盖的正好就是这十一分钟。
四层防线,四次失守
事后复盘真正有用的问题,不是"哪里坏了",而是"本该拦住这个、却没拦住的是哪一层"。四层防线,各自有各自的职责,各自因为一个具体的原因失守。
触发层就是那个卡顿本身,也是唯一守住的一层——中间件在前一晚就已经上线了。这起事故抵达的时候,点火源其实已经被拆掉了;剩下的只是那根引信要把自己烧完。
积累层的失守,是架构性的。Server 活得比它托管的会话还久,因为从来没有什么东西去杀掉它们;一个旧版本的客户端,启动 server 时没带新版本才有的那个自动关闭标记;还有——这部分是我自己的——一台由我自己"合理反应"组成的复制机器。每一次"重开窗口再试一次",就我当时所知都是正确的操作,而每一次,都在复制这个问题:一个新进程,长在那些从没离开过的旧进程旁边。
缓冲层的失守,是因为它压根不存在。没有 swap,内核在内存压力下唯一的泄压阀,就是回收页面缓存——而页面缓存恰恰是每个正在跑的进程都在读的东西——内存短缺就是这样变成 IO 风暴的。swap 的有无,就是"变慢"和"卡死"之间的全部差别。
容量层的失守,是设计本身的问题。这台机器的稳态占用,23 GB 里大概用掉了 15 GB——k3s、llama.cpp、十个 compose 服务栈、七个 JVM——留给唯一一种真正会突发的负载(交互式会话)的弹性余量,薄得很。这起事故,就是这种布局要付的账单。
手术,以及两个让人谦卑的注脚
清理工作按顺序小心进行:先列一份保留名单——我正在敲字的这个窗口的整条进程链、共享的 pty host、agent host。然后递归遍历,收集每一个被标记为该杀的"根进程"的全部后代,每个根进程的身份都要拿它的命令行重新核对一遍,因为 PID 是个会被复用的标签,不是名字。先发 TERM;四秒后对还活着的发 KILL。还有一条我从不跳过的规则:杀戮名单要跟自己的进程链做交集检查,因为这个检查程序本身,也是这台机器上的一个进程,它唯一绝对不能收割的那棵树,就是自己。五十九个 VS Code 进程变成了十九个;尘埃落定之后,是十四个。
然后是 4 GB 的 swap——fallocate、mkswap、swapon、写进 fstab——加上之后,可用内存从 2.8 GB 跳到 8.2 GB,大半是因为内核不再掐着页面缓存的脖子了。接着清理版本堆积:六个 server 目录删掉五个,收回 3.2 GB。
注脚一:删那几个目录之前,我先用 ps 的输出去 grep 目录名,确认没有东西还在引用它们。结果每一个目录都显示"正在使用"——因为 ps 会把我自己这条检查命令本身的命令行也打印出来,连目录名都在里面。检查命令认出了自己的作者。改正之后的版本,又带上了自己的 bug——一次无效的 ps 调用,悄无声息地什么都没验证,删除就这么在毫无保护的情况下执行了;事后一次完整的人工清点,确认没有任何无辜的东西被误杀。这个教训可以推广:任何用 grep 一份进程列表、再根据结果采取行动的检查,都必须把检查程序自己排除在外——观察者,永远身处被观察的对象之中。
注脚二:11:28,那台被杀掉的设备自己重新连上了——桌面客户端还开着,主动伸手,重新下载了 600 MB 的 server 二进制,因为我刚把它删了——旧窗口又在共享 server 上重新生出了一个 extension host。再杀一次;之后安静了四分钟。这场仗,光在服务端打是赢不了的。服务端清理,加上客户端窗口真的关掉,两者缺一不可,不然这棵树还会长回来。
| 11:09 | 11:33 | |
|---|---|---|
| 负载均值(1 分钟) | 11.1(5 分钟峰值 38.7) | 0.68 |
| 内存 已用/可用 | 20 Gi / 2.8 G | 15 Gi / 8.2 G |
| Swap | 无 | 4 GB,已写入 fstab |
| IO PSI(10 秒 / 5 分钟) | 21% / 45% | 0.01% / 2.1% |
| VS Code 进程数 | 59 | 14 |
| 磁盘上的 server 版本数 | 6 个,约 3.8 GB | 1 个,570 MB |
写这篇文章的时候又检查了一遍:swap 在,很安静,只剩一个 server 目录,负载在 1 以下。
我到底修好了什么
老实说,那天早上的清理,处理的是残骸,不是病根。真正的修复发生在前一晚,在一个中间件文件里:管道里不再有逐 token 的流之后,会话该结束就结束,进程该退出就退出,积累层失去了燃料。这起事故补上的,是剩下那部分防御——swap 作为减震器,外加七条新的告警规则,覆盖四类信号:PSI(io 和内存压力)、卡在 D 状态的进程数、swap 的存在与消耗,以及对内存趋势做 predict_linear 预测。这四类信号读的都是 node_exporter 早就在吐出来的指标,所以没有一样需要新的基础设施。我第一反应想到的方案——直接对 vscode-server 的进程数量告警——本来会是更直观的信号,但要上线它,意味着一个主机上的 cron job、一个 textfile collector、还要为了这一个应用专属的数字去改 node-exporter 的挂载配置;现在先搁置,用已经在流动的通用压力信号顶上。还有一个习惯:关掉窗口,别再打开它。
我原本所有的告警,瞄准的都是那十一分钟。而现在,趋势预测这一条,瞄准的是它之前的那二十个小时。