开发机一天死了两次,两次原因不一样
冻结的服务器诊断现场
说个挺离谱的事情,我的开发机一天之内死了两次。而且第二次是在我修完第一次、觉得已经挺稳了之后死的。整个过程 claude code 全程包办,我一共就说了两句话,还挺有意思的,记录一下。
事情是这样的。早上八点,跟平常一样 SSH 进开发机准备干活,连不上。这台机器是 GCP 上的一台 c3-standard-4,16GB 内存,Ubuntu 24.04,上面跑着几个长驻服务,包括一个无头浏览器。昨晚还好好的,两小时前我还上去过。
我没自己动手查,直接开了 claude code,第一句话:「devbox 连不上了,帮我看看。」
它做的第一件事是查 GCP 实例状态:
gcloud compute instances list --filter="name~claude-devbox"
状态是 RUNNING,公网 IP 也在,机器本身没挂。然后试 TCP 连通性:
nc -z -w 5 136.109.155.206 22
端口 22 是通的,但 SSH 卡在「banner exchange」,就是 SSH 协议握手的第二步。这一步信息量挺大的:TCP 连接能建立,说明内核的网络栈还活着;sshd 不响应,说明用户态已经冻住了。内核活着,用户态死了,大概就是这么个状态。
接着它去读 serial console:
gcloud compute instances get-serial-port-output claude-devbox --zone=us-west1-b
日志在二十多个小时前就停了,最后几条是 rsyslogd 的 omfile action 在反复 suspend/resume,系统连自己的日志都写不出去了。到这里结论就比较清楚了:资源耗尽,用户态全部冻结,只剩内核还在维持 TCP。只能硬重启:
gcloud compute instances reset claude-devbox --zone=us-west1-b
30 秒后,SSH 恢复。
元凶:snap Chrome + PM2 的无限崩溃循环
机器活过来之后,claude code 开始翻上一次启动的日志,元凶很快找到了:snap 装的 Chrome,加上 PM2,凑成了一个无限崩溃循环。
开发机上跑着一个无头 Chrome,用 PM2 管理,Chrome 是通过 Ubuntu 的 snap 装的。snap 包有 cgroup 隔离机制,进程必须运行在 snap.chromium.chromium 这个 cgroup 里;但 PM2 是一个 systemd service,它启动的子进程全在 system.slice/pm2-user.service 里。cgroup 对不上,snap-confine 直接拒绝启动。然后 Chrome 退出,PM2 看到退出,立刻重启;重启又被拒绝,又退出,又重启。一秒钟几百次。PM2 里既没设 max_restarts 也没设 restart_delay,所以这个循环没有任何东西拦得住。
snap Chrome 和 PM2 凑成的无限崩溃回路:cgroup 不符被拒 → PM2 立刻重启 → 再被拒,一秒几百次直到资源耗尽
数字挺吓人的:上一次启动周期一共 33 个小时,错误日志里有 3,055 次 snap cgroup 拒绝,成功启动只有 12 次,而且这 12 次还是靠 cgroup 检测的竞态条件碰巧让 snap-confine 放行的。讲道理,就算 Chrome 起不来,也不应该让它试三千次。每一次失败的启动都要 fork 进程、分配内存、写错误日志、然后退出,几千次下来,PID 耗尽、内存耗尽、磁盘 I/O 打满,机器就是这么被拖死的。
光有元凶还不够,还有两个帮凶。
第一个是 SSH 暴力破解。开发机有公网 IP,SSH 直接暴露在互联网上,又没装 fail2ban。claude code 数了一下上一次启动期间的失败登录次数:
sudo journalctl -b -1 --no-pager | grep -c 'preauth'
6,066 次。33 小时,平均每分钟三次,每次尝试都会 fork 一个 sshd 子进程。这个单独看不致命,但系统已经被 Chrome 的崩溃循环拖到资源紧张了,再来这么多额外的 fork,等于帮着一起把机器往死里拖。
第二个是没有 swap。16GB 内存,一点 swap 都没配,从「正常」到「死亡」之间没有任何缓冲。内存一满,OOM killer 要么杀掉关键进程(比如 sshd),要么自己死锁。哪怕只配 2GB swap,内核至少还有时间做点回收,不至于直接锁死。
日志里还有一个预警信号,两次冻结之前都出现了同一个模式:
rsyslogd: action 'action-8-builtin:omfile' suspended
rsyslogd: action 'action-8-builtin:omfile' resumed
rsyslogd 连日志文件都写不出了,这基本上是系统冻结前的最后一个信号。以后监控里看到这个模式,得立刻介入。
第一轮:一口气修了五件事
一、换掉 snap Chrome。根治方案是卸掉 snap 版 Chromium,改装 Google Chrome 的 .deb 包:
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub \
| sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg
echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] \
https://dl.google.com/linux/chrome/deb/ stable main' \
| sudo tee /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update -qq && sudo apt-get install -y google-chrome-stable
换完在 PM2 里把路径更新到 /usr/bin/google-chrome-stable,之后零 snap cgroup 错误,零重启。这里的教训是:在 Ubuntu 上,不要把 snap 装的浏览器交给 PM2 或 systemd 管,snap 的 cgroup 隔离跟外部进程管理器就是不兼容的,老老实实用 .deb 包。
二、给 PM2 加重启限制。就算换了 Chrome,所有 PM2 进程也得有安全网:
// ecosystem.config.cjs
{
name: 'my-service',
script: '/path/to/script.js',
max_restarts: 10, // 崩溃 10 次后停止
min_uptime: '10s', // 跑满 10 秒才算"启动成功"
restart_delay: 5000, // 每次重启间隔 5 秒
}
没设 max_restarts 和 restart_delay 的 PM2 进程,随便一个崩溃都可能变成无限 fork 循环,这次算是亲身验证了一遍。
三、装 fail2ban:
sudo apt-get install -y fail2ban
sudo tee /etc/fail2ban/jail.local << 'EOF'
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
EOF
sudo systemctl enable --now fail2ban
装完不到十分钟,fail2ban 就封了第一个 IP。公网 IP 上的 SSH 就是这样,每分钟都有人在敲门。
四、加 swap:
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
4GB,差不多是内存的 25%。这个不是拿来当主内存用的,是给 OOM killer 一个缓冲窗口,让内核有时间做 reclaim,不至于直接死锁。
五、SSH 加固:
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf << 'EOF'
PermitRootLogin no
MaxStartups 5:50:10
MaxSessions 5
LoginGraceTime 30
EOF
sudo sshd -t && sudo systemctl reload ssh
这里面最有用的是 MaxStartups 5:50:10:允许 5 个未认证连接,之后每个新连接有 50% 概率被丢掉,硬上限 10 个。等于在 TCP 层就把暴力破解的流量限流了,任何加密和认证操作都还没开始。
几个小时后,又死了
修完这五项我觉得挺稳的,回去写代码了。结果几个小时后,又连不上了。
这次我连惊讶都没有,直接开 claude code,第二句话:「又死了,查。」查下来的结论是:第一轮修的是表面,机器的体质问题一点没动。
claude code 重置 VM 之后,第一件事是看内存分布:
| 服务 | RSS |
|---|---|
| Chrome(6 个进程) | 861 MB |
| PM2 + Node(9 个服务) | 529 MB |
| openclaw-gateway(Docker) | ~500 MB |
| claude code(opus) | 412 MB |
| LiteLLM(Docker) | 258 MB |
| Docker daemon | 194 MB |
| Xvfb | 80 MB |
| 合计 | ~2.8 GB |
开机就吃掉 2.8GB。Cursor 远程连上来再加 1GB,跑两个 claude code 实例再加 1GB,Docker 容器又没有内存上限,理论上可以无限膨胀。第一轮修复解决的是崩溃循环,但「每个进程都可以无限吃内存」这件事完全没人管:snap Chrome 是不再 fork 炸弹了,系统的内存总量还是没人管。
还有一个挺离谱的发现:snap Chromium 居然还装在系统里。上一轮只是在 PM2 里把路径换成了 Google Chrome,snap 包本身根本没卸,AppArmor 日志里还能看到 snap chromium 的 DENIED 记录。
第二轮:七件事
一、Docker 内存限制。openclaw-gateway 限 1.5GB,litellm 限 512MB,写进 docker-compose.override.yml,重启后生效。
services:
openclaw-gateway:
deploy:
resources:
limits:
memory: 1536M
二、彻底删掉 snap Chromium。sudo snap remove chromium --purge,这次是真卸了。
三、sysctl 调优。vm.swappiness=10,少用 swap、优先回收缓存;vm.overcommit_memory=0,严格模式,不让进程申请超过实际可用的内存。写进 /etc/sysctl.d/,重启生效。
四、OOM killer 优先级。给 sshd 设 oom_score_adj=-1000,永远不杀,保证任何时候都能 SSH 进去;给 Chrome 设 +500,内存不够先牺牲它。通过 systemd service 开机自动设置。
五、内存看门狗。一个 cron 脚本每分钟检查一次内存使用率,超过 85% 自动杀 Chrome 并写日志,超过 95% 杀 PM2 里最耗内存的进程。
六、装 GCP Ops Agent。内存、CPU、磁盘指标实时上报 Cloud Monitoring,再配一条告警策略:内存使用率超 85% 持续 2 分钟就发通知。
七、PM2 内存上限。每个 PM2 进程加 max_memory_restart,Chrome 400MB,其他服务 150-200MB,谁超了就重启谁,不让单个进程吃光整台机器。
顺便发现机型也选错了
聊到「要不要加内存」的时候,claude code 顺手查了下机型。c3-standard-4 是计算优化型,Intel Sapphire Rapids,2.7GHz,给 HPC 和游戏服务器设计的。我拿它跑的是什么呢?等 API 响应的 claude code、空闲 99% 的 Cursor IDE、转发 LLM 请求的 Docker。等于一直在给完全用不到的 CPU 性能付溢价。
对比拉出来是这样的:
| 机型 | vCPU | 内存 | 月费 | 对比 |
|---|---|---|---|---|
| c3-standard-4(当前) | 4 | 16GB | ~$152 | — |
| e2-highmem-4(推荐) | 4 | 32GB | ~$131 | 省 $21,内存翻倍 |
| e2-standard-4 | 4 | 16GB | ~$97 | 省 36%,内存不变 |
e2-highmem-4 内存翻倍,月费反而更低。e2 是通用型,共享核心调度,适合突发负载,而我的工作负载就是等 API、等键盘输入、偶尔跑个 npm run build,完全用不着专用的 Sapphire Rapids 核心。
切换只要三步,停机、改机型、启动,前后 30 秒,磁盘、IP、配置全部保留:
gcloud compute instances stop claude-devbox --zone=us-west1-b
gcloud compute instances set-machine-type claude-devbox \
--zone=us-west1-b --machine-type=e2-highmem-4
gcloud compute instances start claude-devbox --zone=us-west1-b
切完 32GB 内存,28GB 空闲。原来在 16GB 上挤得很难受的 6 个 claude 会话,现在连一半都用不到。
12 项加固清单
两轮加起来一共 12 项。
第一轮是止血:换掉 snap Chrome(.deb 包替代 snap,消除 cgroup 不兼容);PM2 重启限制(max_restarts + restart_delay,防 fork 炸弹);fail2ban(SSH 暴力破解防护);4GB swap(给 OOM killer 缓冲窗口);SSH 加固(MaxStartups 5:50:10,TCP 层限流)。
第二轮是根治:Docker 内存限制(每个容器有上限,吃不光宿主机);彻底删除 snap Chromium;sysctl 调优(swappiness=10、overcommit_memory=0);OOM killer 优先级(sshd 永不被杀,Chrome 优先牺牲);内存看门狗(cron 每分钟检查,85% 自动止损);GCP Ops Agent 加告警(实时监控,85% 阈值通知);机型切换(c3-standard-4 换 e2-highmem-4,内存翻倍,费用还降了)。
学到了什么
回头看,第一次死其实是好几个问题凑到一起了。snap Chrome 的崩溃循环是直接原因没错,但有 PM2 重启限制它就循环不起来;配了 swap,内存耗尽也不至于直接锁死;fail2ban 装上,那六千次暴力破解也凑不上热闹。单独拎出来哪个都不致命,问题是一层防线都没有,随便哪个环节兜一下都死不了。所以防线这个东西得有好几层,一层漏了还有下一层。
纵深防御:故障像坠落的小球,一层防线漏了还有下一层接住,多层叠起来任何单点失效都兜得住
第二次的教训更扎心:修完一轮别急着觉得安全。我当时是真觉得稳了,结果 Docker 没有 memory limit,PM2 没有 max_memory_restart,监控告警一样没有,连机型都是选错的。只要还有进程可以无限吃内存,机器就还有办法死。现在我修完东西会多想一步,还有什么方式能死,想不出来才算修完。
修完表面≠修完根因:第一轮止住崩溃循环,但每个进程仍能无限吃内存,得一直问『还能怎么死』直到问不出来才算修完
反正开发机挂在公网上,每分钟都有人来敲门,出事是迟早的,区别只是出事的时候你有几层防线兜着。这 12 项现在都跑着,先观察观察。
