一场由 Cursor Remote 引发的生产服务器卡死:从 I/O 阻塞到根因定位
这次事故最麻烦的地方,不是服务器卡死本身,而是它留下的表象几乎都指向磁盘。
生产服务器先后出现两次严重卡顿。故障发生时,接口超时、数据库连接失败、Docker 命令迟迟不返回,连 SSH 都很难登录。重启后所有服务又恢复正常,磁盘检查也没有发现硬件错误。如果只看故障末期,很容易得出“云磁盘性能不够”或者“数据库把磁盘打满了”的结论。
但最后定位到的触发源既不是数据库,也不是业务容器,而是一组运行在宿主机上的 Cursor Remote 进程。有人连接服务器时把/当成了远程工作区,Cursor 的 Extension Host、File Watcher 和 Agent Worker 开始面对整台服务器的文件系统工作。它们的内存不断增长,在一台 16 GB、没有 Swap 的机器上,最终把系统拖进了页面回收和磁盘读取风暴。
下面记录完整的排查过程。文中的服务器地址、账号和业务名称都已脱敏,时间统一使用 UTC,需要换算北京时间时加 8 小时。
故障现象:所有服务一起变慢
第一次故障恢复后,我们最初怀疑的是某个业务任务瞬间放大了数据库压力。因为当时能看到的现象包括:
- PostgreSQL 和 MongoDB 响应异常;
- Docker 命令超时;
- 应用接口大量超时;
- SSH 连接困难;
- 重启服务器后立即恢复。
几天后问题再次出现,而且症状几乎相同。第二次事故发生后,我们没有立即把重点放到某个应用日志上,而是先回答一个更基础的问题:服务器到底在等什么?
CPU 使用率高并不一定代表计算繁忙。如果大量 CPU 时间落在 I/O Wait,说明处理器本身没有工作,而是在等磁盘完成请求。通过sar查看历史 CPU 数据后,故障高峰的 I/O Wait 一度达到约 84%。对于一台只有 2 vCPU 的机器,系统负载却上升到 35~85,同时有 20~78 个任务处于阻塞状态。
这一组数据说明,卡顿的直接表现是磁盘 I/O 阻塞,而不是普通的 CPU 计算瓶颈。
sar-usar-q磁盘确实被打满了,但这还不是根因
继续查看块设备历史数据,OS Disk 在故障期出现了非常明显的饱和:
- 读取吞吐约 129 MB/s;
- 磁盘队列深度约 180~405;
- 平均等待时间约 193~527 ms;
- CPU I/O Wait 超过 80%。
sar-d-p如果停在这里,结论很可能是“磁盘规格不够”。但磁盘繁忙只说明有大量读写请求,无法解释这些请求为什么突然出现。尤其这次主要是读,而不是持续写入。数据库批处理、日志爆量和备份任务通常会留下比较明显的写入特征,这与现场数据并不完全一致。
于是排查方向从“谁在读磁盘”前移了一层:系统为什么需要在短时间内重新读取这么多数据?
真正的转折:匿名内存从 5 GB 涨到 15 GB
sar -r ALL保留了故障前完整的内存变化。第二次事故中,宿主机匿名内存出现了一条非常规律的上升曲线:
09:20 kbanonpg ≈ 5.12 GB 09:30 kbanonpg ≈ 6.07 GB 10:30 kbanonpg ≈ 8.22 GB 11:30 kbanonpg ≈ 10.80 GB 12:30 kbanonpg ≈ 13.17 GB 13:20 kbanonpg ≈ 15.09 GB服务器总内存约 16 GB,而且当时没有配置任何 Swap。随着匿名内存上涨,系统可回收的文件缓存和 Slab 不断下降,可用内存最终只剩几百 MB。
sar-rALLfree-m这解释了为什么最终表现为磁盘读满。匿名内存无法被直接丢弃,又没有 Swap 可以换出,内核只能尽可能回收 Page Cache。业务进程随后再次访问刚被回收的文件页时,只能从磁盘重新读取。内存越紧张,缓存回收越激进;缓存越少,重新读盘越频繁,最终形成页面回收、Major Page Fault 和磁盘读取相互放大的恶性循环。
系统日志里也反复出现了对应信号:
systemd-journald: Under memory pressure, flushing caches.到这里可以确定:严重 I/O 阻塞是内存耗尽之后的结果。接下来真正要找的是,谁制造了这 10 GB 左右的匿名内存。
先排除 Docker:容器总内存不升反降
生产服务基本都运行在 Docker 中,因此最先检查的是各容器内存。我们部署了一个轻量黑匣子,周期性保存docker stats、容器 PID 映射和pidstat输出。把 Docker 数据与sar时间线对齐后,结果与最初预期相反。
在宿主机匿名内存从约 5.12 GB 增长到 15.09 GB 的同一时段,16 个容器的内存总量从约 4.86 GB 降到了 4.50 GB:
09:00 全部容器约 4.86 GB 09:30 全部容器约 4.87 GB 11:00 全部容器约 4.76 GB 12:00 全部容器约 4.63 GB 13:20 全部容器约 4.50 GB业务 API、Elasticsearch、PostgreSQL 和 MongoDB 都没有出现与宿主机内存曲线相匹配的增长。
Celery Worker 曾在另一个时间点短暂达到约 1.99 GB,这确实是需要治理的资源风险,但它发生得更早,随后已经回落,无法解释后面持续增加的 10 GB 匿名内存。因此 Celery 不是这次事故的直接触发源。
这一步排除了整个容器层。问题进程一定运行在宿主机上。
从宿主机进程中找到 Cursor Remote
服务器重启后,历史进程的 RSS 已经消失,原来的黑匣子又只记录了pidstat -d,没有记录进程内存。因此无法直接回看“哪个 PID 占了多少 GB”。我们只能把剩余证据按时间重新拼起来:
- 从
sar找到匿名内存开始增长的分钟; - 从 Docker 数据证明增长不在容器内;
- 从宿主机持久日志查找同一时间启动的进程;
- 用
pidstat验证这些进程当时是否持续读盘。
在宿主机进程列表中,除了 Azure Agent 和 Docker 守护进程,还存在一组位于用户目录下的 Node.js 进程:
~/.cursor-server/.../server-main.js ~/.cursor-server/.../bootstrap-fork --type=fileWatcher ~/.cursor-server/.../bootstrap-fork --type=extensionHost这就是 Cursor 通过 Remote SSH 连接 Linux 服务器时安装的远程组件。Remote Server 负责连接和文件访问,Extension Host 运行远程扩展,File Watcher 监听工作区文件变化,Agent Worker 为 Cursor Agent 提供远程工作区能力。
真正关键的证据来自 Cursor 自己的日志。
Cursor 把整台服务器当成了工作区
第二次事故中,Cursor Remote 会话在 09:21 启动:
09:21:34 Extension host agent started 09:21:37 Launched Extension Host Process <2757769> 09:21:40 File Watcher: Permission denied (path: /) 09:21:53 [cursor-agent-worker] Workspace roots: /匿名内存在 09:20 时还保持在约 5.12 GB,09:30 已经升到约 6.07 GB,之后一路增长到 15 GB。进程启动时间与内存曲线的起点几乎完全重合。
问题就出在这行日志:
Workspace roots: /远程工作区不是某个代码目录,而是 Linux 根目录/。这意味着 Cursor 的文件监听和扩展宿主面对的是整台服务器,包括/etc、/var、/home、/proc、/sys,以及/var/lib/docker下的大量容器数据。
从日志看,Cursor Grep 因为/不是 Git 仓库而初始化失败,代码索引 Watcher 也没有成功建立,所以不能简单地把责任归给某一个“代码索引扩展”。真正持续存在的是 Extension Host、Universal File Watcher 和 Agent Worker 这一组进程。Agent Worker 每隔一段时间都会再次记录Workspace roots: /,File Watcher 则持续尝试处理根文件系统。
pidstat也记录到了对应 PID 的磁盘活动:
Extension Host PID 2757769 平均读取约 4.27 MB/s 峰值约 18.7 MB/s 另一个 Cursor Node 进程 PID 2757576 平均读取约 4.79 MB/s 峰值约 15.7 MB/s在系统进入抖动后,Cursor 日志开始连续出现客户端重连。由于 Remote Server 为断线客户端保留数小时的重连窗口,即使本地 Cursor 窗口已经关闭,远端进程也不一定立即退出。
更早的日志还留下了一条直接证据:Cursor Extension Host 曾经在这台服务器上发生过 V8 Heap OOM。
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory第二条时间线验证:不是一次偶然巧合
只靠一次时间重合还不够。我们重新检查了第一次事故的sar和 Cursor 日志。
第一次事故中,匿名内存在 00:50 时约为 5.14 GB。Cursor Agent Worker 于 00:57 启动并记录Workspace roots: /;01:00 匿名内存升到约 5.83 GB,之后持续增长,04:50 达到约 15.16 GB,服务器随即进入严重阻塞。
00:50 匿名内存约 5.14 GB 00:57 Cursor 工作区根目录确认为 / 01:00 匿名内存约 5.83 GB 04:50 匿名内存约 15.16 GB两次事故具有相同的起点、相同的增长曲线和相同的最终表现:Cursor Remote 打开/后,宿主机匿名内存开始线性增长,直到机器进入内存压力和 I/O 风暴。这使根因证据从“相关性”变成了可重复的共同模式。
完整故障链
最终还原出的过程如下:
Cursor Remote 将 / 作为工作区 ↓ Extension Host、File Watcher、Agent Worker 处理整台文件系统 ↓ 宿主机 Cursor Node 进程匿名内存持续增长 ↓ 16 GB 内存耗尽,且没有 Swap ↓ 内核大量回收 Page Cache 和其他可回收页面 ↓ 业务进程产生 Major Page Fault,反复从 OS Disk 重新读取 ↓ 磁盘吞吐、队列深度和延迟达到上限 ↓ Docker、数据库、SSH 和 journald 一起阻塞这里需要区分三个概念:
- 直接根因:Cursor Remote 错误地将
/作为工作区,引发宿主机进程无边界增长; - 放大因素:服务器只有 16 GB 内存、没有 Swap、宿主机进程没有内存限制;
- 故障表现:OS Disk 饱和、数据库超时、Docker 无响应和 SSH 失联。
如果只处理磁盘或数据库,只能暂时缓解症状,无法阻止问题再次发生。
处理方案
1. 生产服务器不再使用 Cursor Remote
最直接的处理是停止在生产环境通过 Cursor Remote 开发。普通 SSH、SCP、SSH 隧道和只读诊断脚本不会启动.cursor-server进程,可以继续使用。
如果确实需要远程编辑,也必须只打开明确的项目目录,绝不能选择/、/home、/var或/var/lib/docker。
关闭本地 Cursor 后,还要确认服务器没有遗留进程:
ps-ef|grep'/.cursor-server/'|grep-vgrep2. 增加 4 GB Swap 作为缓冲
我们在 Azure 临时资源盘/mnt上增加了 4 GB Swap,并将vm.swappiness设置为 10:
swapon--showfree-hsysctlvm.swappiness最终状态为:
Swap: 4.0 GiB,当前使用 0 B vm.swappiness = 10Swap 不是根因修复。它的作用是在未来再次发生异常内存增长时,为运维登录、告警和进程终止留出时间,避免机器直接进入不可操作状态。Azure 临时资源盘可能在 Stop/Deallocate 后清空,因此同时配置了 cloud-init per-boot 脚本,在启动时自动重建 Swap。
3. 改进黑匣子
原来的黑匣子记录了每进程 I/O,却没有记录 RSS,导致重启后无法直接看到历史内存责任进程。后续采集至少应包括:
pidstat-r-dps-eopid,ppid,rss,vsz,etime,comm,args--sort=-rssdockerstats --no-stream每条记录都需要时间戳,同时保存 PID、PPID、完整命令行和 cgroup/容器映射。采集命令本身要设置超时,避免系统已经卡顿时监控进程反过来增加负担。
4. 增加资源边界
开发工具不应该和生产业务共享无限制的宿主机资源。即使以后偶尔需要远程工具,也应使用独立账号或 systemd slice,为其配置MemoryHigh和MemoryMax。
业务容器同样需要逐步增加内存限制。虽然它们不是这次事故的根因,但 Celery Worker 出现过接近 2 GB 的峰值,没有边界的容器仍然可能制造下一次内存事故。
这次排查留下的几个经验
第一,不要把最后出现的指标当成最初原因。磁盘队列和延迟确实很高,但这次磁盘只是替内存问题“背锅”。从 I/O 阻塞继续向前追,直到解释请求为什么产生,才找到了真正的触发源。
第二,容器化环境不能只看容器。宿主机上的编辑器、监控 Agent、运维脚本和远程扩展都可能消耗大量资源。容器总内存稳定,是这次排查最重要的转折点。
第三,重启会消除最有价值的进程现场。服务器恢复后仍然可以依靠sysstat、持久日志和时间线完成定位,但如果黑匣子提前记录 RSS、命令行和 cgroup,整个过程会简单很多。
最后,生产服务器不是远程开发机。将/作为 IDE 工作区,看起来只是一次路径选择错误,实际却把整个操作系统、Docker 数据目录和运行时文件都交给了文件监听与扩展系统。在资源充足的开发机上,这可能只表现为编辑器变慢;在一台没有 Swap、同时承载数据库和业务容器的生产服务器上,它足以拖死整台机器。
