背景:一台贴着天花板跑的小服务器
这台 2 核 / 3.7GB 内存的云服务器上常年跑着一套不小的家当:
- 19 个 Docker 容器(nginx 反代、两套应用数据库、缓存、监控面板、文档站……)
- 宝塔面板 + 主机版 MySQL + Redis 各一套
- 两个 Node 应用 + 一个文档系统 + 一个 AI 编码工具的 web 进程
内存占用常年在 2.6GB / 3.7GB 附近,swap 早已用掉 1.8GB / 1.9GB——也就是说,机器已经贴着天花板硬撑了很长时间,只差一次波动。
故障在凌晨爆发。下面是完整的时间线、排查过程和修复方案,全部是本次真实记录。
一、故障时间线
| 时间 | 现象 |
|---|---|
| 约 00:10 | 站点开始无法访问,HTTPS 443 连接建立失败 |
| 00:11:02 | 内核 OOM killer 触发,MySQL 进程被杀 |
| 00:12~00:15 | mysqld_safe 自动重启 MySQL,磁盘 IO 拉满,load 飙到三位数 |
| 00:15 后 | 负载逐渐回落,站点恢复 |
| 00:20 排查时 | load average: 0.48, 49.60, 106.52 |
最刺眼的是 load:1 分钟前 0.48、5 分钟前 49.6、15 分钟前 106——对一台 2 核机器来说,这是彻底的雪崩。
二、排查过程(四步定位)
第 1 步:先判断是「机器挂了」还是「服务挂了」
ping -c 3 服务器IP # 通,但丢包 33%,rtt ≈ 60ms
# TCP 22 能握手,SSH banner 却超时
# TCP 443 直接连不上
机器活着,但端口层面一通一不通——这个组合通常指向系统级过载(CPU/内存/IO 打满),而不是服务进程崩溃。方向定为:先抢 SSH 进去看系统状态。
第 2 步:SSH 进去,一眼看到元凶
load average: 0.48, 49.60, 106.52
Mem: total 3724 used 2625 free 316 available 1098
Swap: 1987 total / 1876 used / 111 free
swap 只剩 111MB,内存 available 只剩 1GB——内存和 swap 双双见底。磁盘(63%)和负载回落速度都正常,排除磁盘满和持续性攻击。
第 3 步:内核日志给出确凿证据
journalctl --since "00:00" --until "00:20" | grep -i oom
kernel: Out of memory: Killed process 1625616 (mysqld)
total-vm:2066996kB, anon-rss:763988kB, UID:1003
kernel: oom-kill:constraint=CONSTRAINT_NONE, global_oom,
task_memcg=/system.slice/mysqld.service, task=mysqld
systemd: mysqld.service: A process of this unit has been killed by the OOM killer.
根因坐实:00:11:02 全局 OOM,RSS 763MB 的 mysqld 被选中杀掉。
第 4 步:排除「被攻击」的可能
# 查看故障时段的 nginx 访问日志
awk '$0 ~ 日期' /www/wwwlogs/站点.log | 统计来源 IP 和状态码
故障 20 分钟内全站只有 4 个请求、全部 200——没有流量攻击,纯粹是自己的内存问题。
三、根因分析:为什么偏偏是 MySQL?
OOM killer 按「谁占内存大杀谁」的原则选择目标。这台机器当时的内存构成是:
| 进程 | 内存占用 | 备注 |
|---|---|---|
| 主机 mysqld | 664MB(被杀时 763MB) | innodb_buffer_pool_size = 512M |
| AI 编码工具 web 进程 | 272MB | |
| Node 应用 ×2 | 88 + 75MB | |
| nginx worker | 73MB | |
| 文档系统 | 73MB | |
| 19 个 Docker 容器 | 数百 MB~1GB+ | 各自波动 |
整条因果链是:
配置总量 > 物理内存上限
→ 系统长期依赖 swap 撑着(已用 1.8G/1.9G)
→ 某次正常业务波动(查询/连接数上升)
→ RAM + swap 双满
→ 内核 OOM → 杀掉最大进程 mysqld
→ mysqld_safe 自动重启(全量初始化、刷脏页、刷缓存)
→ IO/负载风暴 → load 106 → 443 无响应 → 站点全挂
值得注意:站点挂掉不是 OOM 直接造成的,而是 MySQL 重启风暴的次生灾害。这也是为什么「重启恢复了」不等于问题解决——不加缓冲,下一次波动还会重演。
四、修复:先止血,再治本
4.1 止血:追加 2GB swap 作为缓冲垫
机器原有 2G swap 文件(几乎用尽),磁盘还有 20G+ 空闲,追加 swap 是零风险、立即生效的手段:
fallocate -l 2G /swapfile2
chmod 600 /swapfile2
mkswap /swapfile2
swapon /swapfile2
# 写入 fstab,重启后自动挂载
echo "/swapfile2 none swap sw 0 0" >> /etc/fstab
# 控制换出倾向(有 swap 后可以稍调低)
sysctl vm.swappiness=10
验证:
$ swapon --show
NAME TYPE SIZE USED PRIO
/swap.img file 1.9G 1.8G -2
/swapfile2 file 2.0G 0B -3
$ free -m
Swap: 4035 total / 1875 used / 2160 free
swap 余量从 111MB 变成 2.1GB——下次内存波动有足够的腾挪空间,不会再直接触发 OOM kill。
补充一点:
fallocate创建的 swap 文件在多数主流文件系统(ext4/xfs)上可用;如果内核报swapon: swapfile has holes,改用dd if=/dev/zero of=/swapfile2 bs=1M count=2048重新生成即可。
4.2 治本方向(三项,按性价比排序)
- 调小主机 MySQL 内存参数:
innodb_buffer_pool_size 512M → 256M、max_connections 500 → 150(500 对小机器明显过大),可回收约 400MB。动手前先SHOW DATABASES确认主机 MySQL 真有业务在用——如果没有,甚至可以停掉它,让业务全走容器。 - 清理闲置容器:19 个容器逐一确认
docker ps里谁还在被使用,停掉不用的,收益立竿见影。 - 限制单个进程的膨胀:给 MySQL 配
OOMScoreAdjust,或用 cgroup 给非核心服务设MemoryMax,避免「谁大谁被杀」杀到业务核心。
4.3 防复发:一个 30 分钟一次的轻量看门狗
把「站点可用性 + 内存 + swap + 负载 + 关键容器」打包成一个纯脚本巡检(无外部依赖、正常时静默零输出):
#!/bin/bash
# vps_watchdog.sh — 正常时无输出;异常时输出一行告警
SSH="ssh -o ConnectTimeout=20 root@服务器IP"
ALERT=""
# ① 关键站点 HTTP
for u in https://站点A/ https://站点B/; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$u")
[ "$code" != "200" ] && ALERT="$ALERT 站点异常:$u→HTTP${code:-超时}"
done
# ② 内存 / swap / load
MEM=$($SSH 'free -m | awk "/^Mem:/{print \$7}"; free -m | awk "/^Swap:/{print \$4}"; uptime | grep -oE "[0-9.]+" | head -1')
AVAIL=$(echo "$MEM" | sed -n 1p)
SWAPFREE=$(echo "$MEM" | sed -n 2p)
LOAD=$(echo "$MEM" | sed -n 3p)
[ "${AVAIL:-9999}" -lt 250 ] && ALERT="$ALERT 内存告急:available=${AVAIL}MB"
[ "${SWAPFREE:-9999}" -lt 150 ] && ALERT="$ALERT swap将尽:剩${SWAPFREE}MB"
awk "BEGIN{exit !($LOAD > 8)}" && ALERT="$ALERT 负载过高:1m_load=${LOAD}"
# ③ 关键容器是否退出
DOWN=$($SSH 'docker ps -a --filter "status=exited" --format "{
{.Names}}" | grep -E "mysql|nginx|app" | tr "\n" " "')
[ -n "$DOWN" ] && ALERT="$ALERT 容器退出:$DOWN"
[ -n "$ALERT" ] && echo "⚠️ 服务器告警:${ALERT# }"
exit 0
接到任意调度器上(每 30 分钟一次):正常时零输出、零打扰;异常时立刻收到一行可执行的告警,不用等用户来报「网站打不开了」。
五、经验总结
| 教训 | 对策 |
|---|---|
| swap 用掉 80%+ 仍是「健康」状态,监控不会报警 | 把 swap 剩余量纳入监控阈值,它比内存更早示警 |
| 「机器 load 高」和「网站挂了」可能是同一件事的两个阶段 | 先查 journalctl -k 找 OOM/硬件事件,再查应用日志 |
| OOM 杀掉的是最大进程,不一定是肇事者 | 主动限制核心服务的内存上限,别让「谁大谁死」 |
| MySQL 自动重启本身会引发次生雪崩 | 看门狗同时盯 load 和站点,两者一起异常时优先怀疑重启风暴 |
| 一切「自愈」都可能复发 | 加 swap 缓冲 + 持久化 fstab + 周期巡检,三件套缺一不可 |
结语
小内存服务器跑全家桶,本质上是在做「配置总量」和「物理上限」的长期博弈。这次故障没有数据损失(业务数据库在独立容器里,毫发无伤),但它暴露的信号很明确:swap 见底就是 OOM 的倒计时。把 swap 余量放进监控、给核心进程上内存约束,比事后追查 OOM killer 日志要省心得多。
本文记录的是一次真实故障的排查与处置全过程,机器配置、日志时间线、内存数据均来自现场。