网站打不开,面板转圈,SSH 有时也连不上。重启一下,全部恢复。查看监控,CPU 空闲,内存也没有占满。
这个现象重复过很多次。直到我开始问一个更基础的问题:如果内存没有占满,是什么导致了机器无响应?
先说结论
| 症状 | 全站卡死几十秒到几分钟,重启即恢复,监控指标正常 |
| 根因 | 前端进程耗尽内存,内核反复终止该进程,容器按重启策略又将其启动 |
| 处理 | swap 缓冲、容器内存上限隔离、Node 堆上限限制,并修正了一个错误的健康检查 |
如果只想看处理步骤,可以直接跳到「处理」一节。下面是完整的排查过程。
症状与初步判断
先列出已经确认的事实,它们后来都成了干扰项。
- 卡死时网站和面板同时无响应,不是 502,而是一直转圈直到超时;
- SSH 有时能连上,有时连不上;
- 重启之后立刻恢复;
- 事发时监控上 CPU 和内存都正常。
第 3 点最容易误导判断。重启有效,很容易得出“某个服务卡住了,重启就清掉了”的结论。这个判断看起来合理,却让排查方向偏离了正确路径。
第 4 点更麻烦:它看起来证明机器状态一切正常。
排查方向一:网络和反向代理
应用层面看不出问题,于是怀疑请求没有到达应用。
那段时间做的检查:
结果全部正常。本机回环响应很快,反向代理配置没有语法错误,错误日志里也没有连接超时或上游不可达。DNS 解析稳定。
还排查过一个更边缘的可能:云厂商宿主机是否出现异常。
这个方向的结论是:卡死发生在服务内部,网络只是同时受到影响。但当时并不知道这一点意味什么。
排查方向二:应用日志
第二个思路:服务出问题,日志里应该有记录。
把容器日志完整查了一遍:
docker logs --tail 200 --timestamps <container>
docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' <container>
日志里确实有异常,但不是崩溃。有几条连接超时,有几条请求排队,没有任何堆栈。容器重启过,日志里却没有对应的启动错误。
当时的判断是:某个服务静默退出了。
事后看,docker inspect 会直接输出 ExitCode 137 和 OOMKilled: true。这两个字段当时就在结果里,只是没有意识到它们的含义。
转折:监控指标不可靠
两个方向都没有结果之后,开始怀疑监控数据本身。
有两个问题之前一直没有分开看。
第一,free 列不等于可用内存。
Linux 会把读过的文件内容缓存到内存中,这部分叫 page cache。它是可回收的:当进程需要内存时,内核会先释放这部分。所以 free -h 里的 free 列通常很小,真正应该看的是 MemAvailable,它已经把可回收的部分计算在内。
第二,监控是定时采样的。
采样间隔是一分钟,而一次 OOM 从发生到进程被终止、内存释放,只有几秒钟。
内存曲线大概是这样的:
采样点都落在低谷,监控上于是始终显示“内存不高,负载很低”。
当一次事件的持续时间短于采样间隔时,采样曲线反映不了这次事件。
改查内核日志
如果监控不可靠,就应该换一个数据源。内核在内存不足时的行为是明确且有记录的。
内存不足时,内核会调用 OOM Killer,选择一个进程终止以释放内存,并把这次操作写入日志。
先看当前启动周期:
journalctl -k -p err --no-pager | tail -50
dmesg -T | grep -iE "out of memory|oom-kill|killed process"
没有结果。这属于正常情况,因为机器刚重启过,相关记录在上一次启动的日志里。
journalctl --list-boots --no-pager
上一次启动的日志仍然保留。syslog 有轮转机制,可以直接查历史文件:
cd /var/log
(zcat syslog*.gz; cat syslog.1 syslog) | grep -iE "Out of memory|oom-kill|Killed process"
输出了大量记录:
按进程归类:
grep -h "Out of memory: Killed process" syslog* | \
sed -E 's/.*Killed process [0-9]+ \((.*)\) total-vm.*/\1/' | sort | uniq -c | sort -rn
32 next-server (v1
七天,32 次,全部是同一个进程。
在排查网络和超时的那几天里,这个进程已经被内核终止了几十次,只是每次都被容器重启策略重新启动。
之前无法解释的现象到这里有了答案:所谓的“自动恢复”,是内核终止了占用内存最多的进程后,容器按 restart: unless-stopped 策略将其重新启动。“重启就恢复”,本质上是重启释放了内存,然后等待下一次内存再次被占满。
为什么应用日志里没有记录
前面查不到崩溃记录,不是日志丢失,而是进程没有机会写入。
OOM Killer 使用的是 SIGKILL。这个信号无法被捕获,也无法被忽略,进程直接终止,没有 finally,没有 flush,没有堆栈。
从容器侧只能看到三件事:进程消失、退出码 137、随后被重启策略重新启动。
所以这类问题的排查顺序必须是先内核、后应用。反过来做,只会一直停留在应用层,这正是前面两次排查没有结果的原因。
根因
内存去了哪里?查看实际的内存分配情况:
free -h
grep -E "MemTotal|MemAvailable|Committed_AS|Swap" /proc/meminfo
有三个事实。
没有 swap。 内存被占满时,内核没有可换出的后备空间,只能终止进程。
基础组件本身就占用大半内存。 前端一个 Node 进程、后端一个、PostgreSQL、Redis、面板的 core/agent/反向代理、云厂商监控、一个 APM agent。每一个占用都不大,累计之后余量就很少了。
所有容器都没有内存上限。 docker inspect 里是 Memory=0、MemorySwap=0。没有上限意味着任何进程都可以向整机申请内存,内存增长快的进程会挤占其他进程。
而 Next.js 恰好是其中增长最快的。SSR 要渲染页面,ISR 要缓存结果,Node 堆随着访问量逐步增长。它本身没有问题,只是在这台机器上,它的内存需求最先触碰到上限。
触碰到上限后,内核按 oom_score 选择要终止的进程。这个分数由内核为每个进程计算,占用内存越多分数越高,越容易被选中。前端分数最高,于是:
内存耗尽 → 内核终止前端 → 容器重启 → 用户看到卡死 → 手动重启 → 恢复 → 几小时后重复
每次卡顿持续几十秒到几分钟,并不是内核处理慢,内核执行终止操作只需要毫秒级。这段时间消耗在其他进程等待内存,以及容器重启和重新预热的流程上。
处理
根因明确之后,没有立刻升级配置,而是先做了三层处理,不需要修改业务代码,随时可以回滚。
三层分别是:swap 缓冲、容器隔离、应用层堆上限限制。
第一层:swap,缓冲内存峰值
没有 swap 时,瞬时内存峰值无法被吸收。增加一块 swap:
验证持久化:
swapoff /swapfile && swapon -a && swapon --show
没有写入 fstab 的话,重启后会恢复原状。“改完好了几天又出问题”很可能就是这一步遗漏了。
swap 只是延后崩溃,不解决哪个进程应该被终止的问题。所以还需要第二层。
第二层:容器内存上限
数值只是示例,按机器实际容量分配。Compose V2 接受这个字段;如果所用版本要求 deploy.resources.limits,按同样的数值改过去。
这一层改变的是崩溃影响的范围。
cgroup 是内核的资源记账单位,容器的内存限额记录在这一层。超额时触发的是 cgroup 级别的 OOM,内核只在这个 cgroup 内选择进程终止,不会影响其他容器。故障范围从“全站和面板一起卡住”缩小为一个容器重启。
这是 swap 做不到的:swap 只是延后崩溃,cgroup 上限改变的是崩溃的边界。
第三层:限制 Node 堆大小
只加容器上限还不够。Node 默认可以使用很大的堆,它会在达到 cgroup 上限之前持续申请内存,然后被 cgroup OOM 终止,效果和之前一样,只是终止的执行者变了。
front:
environment:
- NODE_OPTIONS=--max-old-space-size=640
app:
environment:
NODE_OPTIONS: --max-old-space-size=384
这样 V8 会在自身范围内频繁 GC,释放不再使用的对象,而不是持续增长直到被内核终止。经验值是把堆上限设为容器上限的七成左右,剩下三成留给 Buffer、native 内存和栈。
验证:
修正一个错误的健康检查
排查时发现后端容器一直显示 unhealthy,但服务本身是正常的。原因是探活路径返回 404:
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:<port>/api/v2/ping"] # 返回 404
改成实际可用的接口后,状态立刻恢复为 healthy,FailingStreak: 0。
这种错误告警的危害不比真实故障小。它会让人怀疑一个本来正常的服务,也可能掩盖真正的问题。
清理无效消耗
一个 APM agent 在小内存机器上是额外负担:常驻几十 MB,还持续重试连接不上的本地 Redis,不断产生日志 IO。停掉并卸载。
容器日志同样需要限制,否则会占满磁盘:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
验证结果
验证分三项,每条都可以直接执行:
第 2 条要查当前周期。只执行一次 grep 得到空结果说明不了问题,真正的验证需要等下一次长时间运行之后再检查。
修改完成后,站点和接口返回 200,OOM 计数为 0,容器状态为 healthy,MemAvailable 明显上升。更重要的是,卡死没有再出现。
小结
| 现象 | 处理方式 |
|---|---|
| 重启就恢复 | 这是症状,不是解决方案。重启只是重置了时间 |
| 监控指标正常 | 先确认采样间隔,再查看 MemAvailable,不要只看 free |
| 应用日志没有崩溃记录 | OOM Killer 使用 SIGKILL,进程来不及写入任何内容。应先查内核 |
| 没有 swap | 在小内存机器上是明显短板 |
| 容器没有内存上限 | 内存增长快的进程会挤占其他进程 |
最后
以上处理只能缓解问题。根本解决方式是增加内存。
在这么小的机器上运行 Next.js、数据库、缓存和面板,本身就很勉强。swap 和限流可以让它保持运行,代价是性能下降,本质上是用磁盘 IO 换取内存。
如果也遇到“CPU 不高、内存没满、重启就好”的情况,先不要急着重启。先检查 syslog 中是否出现 Out of memory。