Featured image of post 使用 CC 时计算机卡死可能是 IO 问题

使用 CC 时计算机卡死可能是 IO 问题

排查并解决了 Linux 上 code agent(OMP/Claude Code/OpenCode)冷缓存预热触发 major page fault 风暴、IO 飙到 500MB/s 并频繁死机的问题。

语速

我的习惯是在 Linux 上运行 OMP (oh my pi) 以及 Claude Code,还有 OpenCode。

最近发现服务端经常死掉。从监控日志中查,发现是 IO 频繁写入 500MB/s。

如果你也遇到了类似的问题,可以读一下这篇文章。

TL;DR

  • 元凶不是 sqlite/WAL,是 code agent 冷缓存预热触发 major page fault 风暴:%util < 10% 但 majflt 6000–10000/分,10+ 核卡 D 状态。看 %util/iowait 看不出来,要看 majflt/s
  • 治标:iogo 用 cgroup v2 io.max 把重活限到 30MB/s + 200 IOPS。设备号要从 rootfs 自动探测——写死会在 virtio↔scsi 切换后静默 no-op。
  • 治本:工作集瘦身(清 worktree + 旧 .venv/node_modules,124G→74G)+ swap/zswap + 关 LSP plugin。
  • 架构共性:node + 巨大 node_modules + 随机遍历 + 多实例 + 每 worktree 独立 cache key,工作集 ≫ page cache 就会互害。这不是某一个 agent 的 bug。

分析过程

最近我经常遇到 IO 爆掉的情况。

监控图

但实际上内存和CPU占用量还好。这让我有些困惑。

后来尝试了几次,发现是 Claude 每次 /resume 的时候整个系统都会卡死。逐步怀疑到是不是 sqlite + WAL 随机读取问题。

以为是 session 太多,数量太庞大导致的,因此暂且停了 Claude Code。

不对劲

随后我继续使用 opencode 进行 vibe。然后 Opencode 又出现了同样的问题。我甚至都没有执行 /session。让我相当困惑。

随后我部署了 systemd 服务来监控 IO 情况。发现 OpenCode 也出现了类似的问题。多开 4 个 instance,就会卡死服务器。

iotop 显示 top-5 READ 是 OpenCode。后来 OMP 也出现了类似的问题。

继续排查

那么现在观察看基本上不是 sqlite3 的读写问题。更可能是其他的原因。我发现 omp 也有类似的表现。

如果 opencode 没有进行 session,是不会读取自己的 sqlite3 db 的。那么问题只可能出现在别的地方。

既然三个 agent 都出现了类似的问题,说明问题是共性的。

尝试解决

我一开始怀疑是系统病毒或者挖矿病毒。因此我短暂停留了一段时间,没有使用 coding agent。然后发现 IO 没有上升。

所以排除了病毒的问题。然后重复使用不用的 agent,发现都能触发这个问题。那么大概率问题出现在我的工作的代码库下面。

为了保险起见,我首先处理了 swap。将 swap 扩展到了 16GB。(我之前关闭了 swap)。看看在这种读取的情况下,能否获得较好的性能。

其次,通过 coding agent 排查到 opencode major page 频繁切换。

1
2
- majflt 6000–10000/分(每秒 100–167 次缺页)
- %wait sum 970–1378%——10+ 个 CPU 核全卡 D 状态等 IO

存在CPU/内核锁开销 + page cache 抖动 + 冷读 IOPS 累积。

我们已经知道,没有进行 resume,服务器性能也会下降,磁盘也会大量的 IO。那么,现在最大的可能性,就是 coding agent 在分析当前项目的过程中产生了较多的 IO。那么项目中最大、最零碎的是什么呢?node_modules

开玩笑的。随手黑一把文件系统破伤风病毒 node_modules

最大的可能就是大量的 worktree(300个)影响了 opencode/claude code 加载速度。omp 也受影响。因此,我大量清理了 worktree,从 300 降低到 76 个。

然后我通过编写 systemd 服务 + cgroup-v2 限制了 opencode/claude code的最大 IO 读取量。

还是不对

我发现启动了6个omp实例后系统速度还是很慢。仍然会卡死系统。

于是怀疑是其他系统的问题。

进程级记账:点名元凶

前面的排查都停在"特征推断"——IO 高、整机卡死,但到底是哪个进程在猛读,journal 里只剩 autossh 心跳,内核也没记 OOM / hung task。因为 %util 才 12.4%,内核觉得天下太平。

于是我部署了 pidstat-io.service:root 跑,每 60 秒把全进程的磁盘读写、CPU、内存记到 /var/log/pidstat-io.log,配 logrotate 日切保留 7 天。目的就一个——下次风暴能秒级点名进程。

第二天就复现了。三轮 IO 风暴,每轮都逼我手动重启。复盘 pidstat log,元凶清清楚楚:

窗口opencode 读全机读majflt/分sum %wait结局
10:53–11:20175–213 MB/s480–498 MB/s6000–7700970–1378%重启
11:43–11:52318–346 MB/s475–507 MB/s7800–10011重启
15:21–15:2778–91 MB/s472–486 MB/s5900–6700重启

opencode,多实例,启动 / 会话恢复时加载大量代码文件(node_modules、源码、索引),第一次访问全是 major page fault。单进程读 175–346 MB/s,持续 5–10 分钟。OMP、Claude Code 同理——三个 agent 全中,说明是共性问题。

真正的机制:不是磁盘满,是缺页风暴

这里有个关键认知反转。

磁盘 %util < 10%——根本不是容量瓶颈。vda 本来就是 SSD,单 IO 是 μs 级的,介质不是问题。真正拖死整机的是 major page fault 风暴:majflt 6000–10000/分(每秒 100–167 次缺页),10+ 个 CPU 核全卡在 D 状态等 IO,%wait sum 970–1378%。SSH、cc resume 全部超时。

而内核呢?一看 %util 不高、没 OOM、没 hung task,觉得没事,journal 里啥也不记。这就是为什么这问题极易漏诊——监控看 %util / iowait 根本看不出来,得看 majflt/s

顺带订正一个我自己的误判:之前看 lsblk 里 vda 的 ROTA=1,以为是机械盘,还想换 SSD。其实 virtio-blk 默认 ROTA=1 不反映底层介质,vda 本来就是 SSD。换盘无效,治本得限速 + 控工作集。

机制串起来:工作集(数十 GB mapped)远大于 page cache(2G 左右)→ 持续 majflt → 多个 agent 互相挤掉对方的 page cache 页 → majflt 洪水 → 内核 page cache 锁热点 + D 状态阻塞 → 整机拖死。这不是某一个 agent 的 bug,是 node + 巨大的 node_modules + 随机遍历 + 多实例 + 每个 worktree 独立 cache key 的架构共性。

限速:iogo + cgroup io.max

既然是冷读 majflt 拖死的,就从源头限速。我写了 iogo——一个包装器,用 systemd-run --user --scope 建一个 transient scope,往 cgroup v2 的 io.max 里手写带宽上限,命令退出 scope 自动清理。然后把 ccopencode 的 alias 都包进去:

1
2
alias cc="iogo -- claude ..."
alias opencode="iogo -- /home/svtter/.opencode/bin/opencode"

默认限 30 MB/s + 200 IOPS。dd 实测:写 841 MB/s → 31.7 MB/s,读 970 MB/s → 31.4 MB/s,限速确实生效。冷启动时间从 ~3 分钟拉长到 ~15–30 分钟,但整机不卡死——这是我要的取舍。

200 IOPS 的依据:majflt 风暴本质是 4–16KB 随机小读。30MB/s ÷ 4KB = 7680 IOPS 仍可能打满队列,加 riops=200 双保险。

但 iogo 坑了我四天

这是整个排查里最恶心的一段。iogo 的保护反复失效,而且两种截然不同的失效模式叠在一起:

  1. loudtee: .../iogo-*.scope/io.max: Permission denied——io 控制器没委托下去,io.max 文件不存在。这个有报错,看得见。
  2. silent:io.max 能写、没报错,但限速作用在一个零 IO 的空设备上 → no-op,opencode 完全裸奔。这个更危险,整整四天没被发现。

根因有两个独立的 bug。

根因 A(loud,Permission denied):cgroup v2 的 io 控制器必须在链路每一层 cgroup.subtree_control 显式启用,子 cgroup 才有 io.max。systemd 的 Delegate= 默认只有 pids memory cpu 不含 io,每次 unit start 还按 Delegate= 重建 subtree_control 把 io 清掉;而且 Delegate= 只在 start 时生效,运行期间 subtree_control 也可能被悄悄清掉(systemd cgroup 事件、scope 生命周期,还没有日志)。

修复三件套:① [email protected]Delegate=...io drop-in(systemd 自动委托)② 外部 io-delegate.service 补 app.slice / session.slice 两层 ③ iogo 内加 ensure_io(),每次运行前自检链路、缺 io 就补,~5ms 自愈。

根因 B(silent no-op,主导)⭐:iogo 默认 IOGO_DEV=254:0——这是 virtio-blk /dev/vda 的假设。但我之前一个 PR 把 dev 从 virtio 改成了 virtio-scsi,磁盘从 vda(254) 变成了 sda(8)。cgroup io.stat 里真实 IO 全在 8:0254:0 是空的。于是 iogo 写进 io.max 的 254:0 rbps=30MB/s 限制了一个零 IO 的空设备 → opencode 裸奔,设备读飙到 500MB/s、SSH 卡死、手动重启。

更恶心的是——根因 B 从那个 PR 应用起就存在,但被根因 A 的 loud 报错掩盖了。我反复修 Permission denied,修好报错就以为修好了,根本没回头验证限速是不是真作用在产生 IO 的设备上。silent no-op 就这么拖了四天,直到把机器读死。

修复:iogo 的 DEV 不再写死,改成从 rootfs 自动探测(findmntstat → major:minor → 8:0),对以后 virtio↔scsi / nvme 切换免疫。

教训:「io.max 可写」≠「限速生效」。修完 loud bug 必须回归实测吞吐。定位真设备读要看 majflt/s(major fault = 真设备读),别看含 cache 命中的 kB_rd/s——后者会骗你。

叠加的几层缓解

光限速不够——限速是"拖慢启动换不卡死",治标。又叠了几层:

  • swap + zswap(zstd) 压缩缓冲:冷数据先压缩留 RAM,majflt 不直接打盘。在已有的 swap 上开 zswap,GRUB cmdline 持久化 zswap.enabled=1 zswap.compressor=zstd
  • vfs_cache_pressure=50:保留更多 dentry/inode cache,缓解 agent 反复 stat 同一项目树时的 cache 抖动。
  • 工作集瘦身(最治本的一步):清理 ~/work 下 332 个 >30d 的 worktree + 97 个 >60d 的 .venv/node_modules,释放 57G。工作集 124G → ~74G,冷读总量直接下降,majflt 基数减小。
  • iothread + rotational + scheduler:PVE 端 iothread=1,guest 内 rotational=0(纠正内核 readahead 启发式)+ scheduler=none(16 hw queue + SSD 后端直通),udev 固化。
  • 关掉 Claude Code 的 LSP plugin:typescript-lsp / pyright-lsp / gopls-lsp / serena 四个全 disable。language server 索引 node_modules 也是 majflt 大户——opencode 内置 LSP 本来就是禁用的,真正起 language server 的是 Claude Code 的 plugin。

第四次复发:iogo 够不着的地方

7 月 8 号又复发了第四次。这次元凶换了——zcode + next-server,5 个进程 majflt 合计 ~9000/s。问题在于这些进程在 session-*.scope(logind 登录会话),而 iogo 的 scope 在 app.slice 下,够不着。这是我之前列的"未覆盖缺口"第一次被实测确认——iogo 本身没坏,是覆盖范围不够。

这次清了 latex-agent 的 130 个 worktree → 3,释放 ~36G。根治得把限速扩到 session-*.scope(systemd 原生 IOReadBandwidthMax= unit property,或 logind 会话级配置),以及 pnpm node_modules 软连共享,让多个 worktree 不再各存一份 cache key。

清了 worktree,为什么还会复发

写完上面那段没几个小时,又复发了。我的第一反应和你一样:worktree 不是清了吗?还来?

这次我不再凭感觉,直接看现场。ps 里 zcode 已经退场了——“似乎还是 zcode"是直觉,现场抓不到。真正的 majflt 大户是两个 next-server:

PIDcgroup scopecwdmajflt 累计RSS
960400session-9919.scopelatex-agent/.worktrees/fix-merged-view-ai-resize-direction/v0-latex-editor3091162.1G
1539622session-4.scopeexplore-cath/cathodic-next266090112M

答案就在第一行。latex-agent 的 worktree 我确实从 130 清到了 3,但 fix-merged-view-ai-resize-direction 是留下来的 3 个之一,它还活着、还在跑 next dev,独立的一份 node_modules cache key 照样触发 majflt 风暴。

所以「清 worktree」清的是死的、不跑的 worktree,把磁盘占用和冷读基数压下来;但活着的、在跑 node dev server 的 worktree 才是 majflt 源,清数量治不到它。只要这种 worktree 还在,工作集 ≫ page cache 的条件就还在,风暴随时来。

而且两个 next-server 全在 session-*.scope——iogo 的 app.slice scope 一个都够不着。这把上面那个「未覆盖缺口」从"理论上够不着"坐实成了"实测就是这么漏的”。

治本只有两条路:要么把限速扩到 session-*.scope(systemd 原生 IOReadBandwidthMax= unit property,或 logind 会话级配置),要么从根上让多个 worktree 共享同一份 node_modules(pnpm 软连),消灭"每个 worktree 独立 cache key"这个放大器。光清数量,不够。

写在最后

回头看,这趟排查走了不少弯路:先是怀疑 sqlite/WAL,停了 Claude Code;又怀疑病毒,停了所有 agent;再怀疑机械盘想换 SSD。真正卡住诊断的是两个认知盲区——%util 低不代表没 IO 问题(majflt 才是信号),以及"限速工具没报错"不代表"限速生效"(设备号静默 no-op 坑了四天)。

治本是工作集瘦身 + swap/zswap + 关 LSP,治标是 iogo 限速。但架构共性还在——只要 node + 巨大 node_modules + 多实例 + worktree 独立 cache key 这套组合在,工作集 ≫ page cache 就会互害。这不是某一个 coding agent 的 bug,是它们共同的运行模式踩中了同一个坑。