凌晨八点,我的服务器死了(两次)
冻结的服务器诊断现场
SSH 连不上了
早上八点,惯例 SSH 进开发机干活。连不上。
我的开发机是一台 GCP 的 c3-standard-4,16GB 内存,Ubuntu 24.04。跑着几个长驻服务——包括一个无头浏览器。昨晚还好好的,两小时前还上去过。
开了 Claude Code,一句话:"我的 devbox 连不上了,帮我看看。"
一层一层往下扒
Claude Code 做的第一件事是查 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 进程不响应——用户态冻住了。
Claude Code 去读了 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 恢复。
三千次崩溃,十二次成功
重启之后,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 看到退出,立刻重启。重启又被拒绝。退出。重启。退出。重启。
一秒钟几百次。
没有设 max_restarts。没有设 restart_delay。
上一次启动周期 33 个小时。错误日志里找到了 3,055 次 snap cgroup 拒绝。只有 12 次启动成功——靠的是 cgroup 检测的竞态条件碰巧让 snap-confine 通过了。
每一次失败的启动都会 fork 进程、分配内存、写错误日志、然后退出。几千次下来,PID 耗尽、内存耗尽、磁盘 I/O 打满。
帮凶一:6,066 次 SSH 暴力破解
开发机有公网 IP,SSH 暴露在互联网上。没装 fail2ban。
Claude Code 数了一下上一次启动期间的失败 SSH 登录次数:
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: action 'action-8-builtin:omfile' suspended
rsyslogd: action 'action-8-builtin:omfile' resumed
rsyslogd 连日志文件都写不出去了——这是系统冻结前的最后信号。如果你在监控里看到这个模式,立刻介入。
五刀下去
Claude Code 一口气修了五件事。
一、换掉 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 swap,内存的 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 记录。
七刀根治
Claude Code 这次做了七件事:
一、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。 安装 Google 的监控代理,把内存、CPU、磁盘指标实时上报到 Cloud Monitoring。然后创建了一条告警策略:内存使用率超过 85% 持续 2 分钟就发通知。
七、PM2 内存上限。 给每个 PM2 进程加上 max_memory_restart——Chrome 400MB,其他服务 150-200MB。超了就自动重启单个进程,而不是让它吃光整台机器。
机型都选错了
聊到"需不需要加内存"的时候,Claude Code 查了一下我的机型:c3-standard-4。
c3 是计算优化型——Intel Sapphire Rapids,2.7GHz,专为 HPC 和游戏服务器设计。我拿它跑的是什么?等 API 响应的 Claude Code、空闲 99% 的 Cursor IDE、转发 LLM 请求的 Docker。
我在为完全用不到的 CPU 性能付溢价。
Claude Code 给了一个对比:
| 机型 | 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,6,000 次暴力破解不会雪上加霜。
任何单点都不致命。几个一起来,就是灭顶之灾。
第二幕的教训是修表面不等于修根因。 第一轮修复消除了崩溃循环,但没有解决"每个进程都可以无限吃内存"的根本问题。Docker 没有 memory limit,PM2 没有 max_memory_restart,没有监控,没有告警,连机型都选错了。
治了症状,没治体质。
两幕合在一起,真正的教训是:不要在第一次修复后就觉得安全了。 问自己一个问题——"如果这个修复生效了,系统还有什么方式可以死?"如果答案不是"没有了",就接着修。
开发机不是生产环境,但公网上的每台机器都在被持续扫描。问题不是"会不会出事",而是"出事的时候你有几层防线"。