ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一场由 Cursor Remote 引发的生产服务器卡死:从 I/O 阻塞到根因定位

一场由 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 的机器系统负载却上升到 3585同时有 2078 个任务处于阻塞状态。这一组数据说明卡顿的直接表现是磁盘 I/O 阻塞而不是普通的 CPU 计算瓶颈。sar-usar-q磁盘确实被打满了但这还不是根因继续查看块设备历史数据OS Disk 在故障期出现了非常明显的饱和读取吞吐约 129 MB/s磁盘队列深度约 180405平均等待时间约 193527 msCPU I/O Wait 超过 80%。sar-d-p如果停在这里结论很可能是“磁盘规格不够”。但磁盘繁忙只说明有大量读写请求无法解释这些请求为什么突然出现。尤其这次主要是读而不是持续写入。数据库批处理、日志爆量和备份任务通常会留下比较明显的写入特征这与现场数据并不完全一致。于是排查方向从“谁在读磁盘”前移了一层系统为什么需要在短时间内重新读取这么多数据真正的转折匿名内存从 5 GB 涨到 15 GBsar -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 GB09: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 --typefileWatcher ~/.cursor-server/.../bootstrap-fork --typeextensionHost这就是 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 GB09: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设置为 10swapon--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、同时承载数据库和业务容器的生产服务器上它足以拖死整台机器。
返回列表