Docker 说它干净退出,内核说它被 OOM 杀掉了

· 7 分钟 · devops dockeroomtraefiksynologycrawlers

一条我以为又是配置写错的告警

我的 NAS 发来那条所有 Docker 用户都学会一眼扫过的通知:容器意外停止。出事的是我自托管 wiki 前面的那个边缘代理,它已经连着跑了两周。那天早上没人部署过东西,compose 文件也没被动过,所以当时只有两个故事可选:「它崩了」,或者「有东西把它重启了」。

两个都错。而真正值得写下来的是:光看 Docker 自己的报告,我根本分不出来。

Docker 的报告说「什么都没发生」

State:
  Status:      running
  Running:     true
  ExitCode:    0            <- 干净退出
  OOMKilled:   false        <- 不是被内核杀的
  FinishedAt:  2026-10-15T19:39:33.63057879Z
  StartedAt:   2026-10-15T19:39:38.769326672Z   <- 5 秒后重启
RestartCount: 6
RestartPolicy: always

照字面读,结论就是:进程干净退出了六次,每次都由重启策略续了命。一个长期运行的代理不会自己以状态 0 退出——而「干净退出」重复六次本身是矛盾,不是答案。ExitCode 和 OOMKilled 恰好是我在这里最信任的两个字段。在这台机器上,两个都是错的。

内核自己留了另一份记录

在 Linux 上,cgroup 的内存击杀由内核记录,哪怕容器运行时的账本没有把它带过来:

[1737797.044856] traefik invoked oom-killer: gfp_mask=0x2400040, order=0, oom_score_adj=0
[1737797.123736] Memory cgroup out of memory: Kill process 615 (traefik) score 970 or sacrifice child
[1737797.132800] Killed process 615 (traefik) total-vm:2525800kB, anon-rss:478480kB, file-rss:0kB

开机秒数 1737797 对应 19:39:33 UTC——和容器的 FinishedAt 一秒不差。整个诊断到此结束,代价只是一次 dmesg。容器没有退出,它是在自己的内存上限内被杀掉,5 秒后又被拉起来。

dmesg 里这样的记录一共 6 条,而 RestartCount 正是 6。同一个 stack 里其他容器的重启计数都是 0、启动时间都是几周前,所以这六次击杀可以钉在这一个容器上:

dmesg 开机秒本地时间被杀时 anon-rss
140653910-12 约 07:37491 MB
140775910-12 约 07:57494 MB
143257410-12 约 14:49486 MB
173479310-16 约 02:45487 MB
173549510-16 约 02:58485 MB
173779710-16 03:39 ← 那条通知478 MB

每一次被杀都发生在 478–494 MB,而上限是 512 MiB(536,870,912 字节)。这是天花板被摸到,不是失控。

还有两个读数把机制说清了。cgroup 记录的高水位(重启后仍然保留的那个计数器)在重启 18 分钟后就已经回到了正好 536,870,912——也就是说同一个周期里它又摸到了天花板,只是这次没被杀。而用量曲线是来回震荡的(176 → 308 → 361 MB),不是单调爬升,所以这是负载下的分配抖动撞上小上限,不是一个能证明的缓慢泄漏。最大嫌疑是那个 geoblock 插件:每遇到一个没见过的客户端 IP,它就要做一次外部 HTTPS 查询。

同一天早上 05:33 又来第 7 次击杀——在改动落下去之前。

在这台 DSM 上,真相只存在于 dmesg 加上 cgroup 的 memory.max_usage_in_bytes。如果你在普通主机上追同一个形状,先看 docker inspect -f '{{.State.OOMKilled}}'——它本该是权威。而这里它读出来是 false,就在内核记下那次击杀的 5 秒后。这就是这篇文存在的理由。

真正在崩的是什么:一场爬虫洪水

宿主内存从来不是问题——64 GB 里约 39 GB 可用。出问题的是某个容器的 512 MiB 上限。

驱动因素是流量,数字是从容器自己的访问日志里读出来的。最后 50,000 行里:

不同客户端 IP          25,976
403 响应              33,636   (geoblock / 拒绝名单在正常工作)
429 响应              16,082   (inflight 与限流在正常工作)
容器 CPU              118–230%
宿主 load average     26–32

每次重启后,堆在 12 到 40 分钟内又被填满。配置里没有任何东西是错的,那些 403 和 429 也不是——它们正是过滤器按设计拒绝爬虫的结果。问题在于谁在敲门:这个容器是一个多 TB 级 Wikipedia 语料的公网入口,托管在家庭 NAS 上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。

修法,以及必须跟着的那句话

 deploy:
   resources:
     limits:
-      memory: 512M
+      memory: 2g
 restart: always
+memswap_limit: 2g

验证(因为「我把上限调大之后感觉好多了」不叫验证):

以及它不是什么: 这是权宜之计。驱动因素是洪水,所以大一个数量级的洪水照样能摸到 2 GB。真正的修法是缩小公网入口面,而不是把笼子做大——一条没被标明为权宜之计的上限调整,最终会变成一个永久解释。

我选择让这个容器不换页:memswap_limit 与内存上限相等就是关掉它的 swap。这台宿主当时已在 40 GB swap 里用掉 16 GB,commit 总量 92 GB,而一个开始换页的边缘代理会把整台 NAS 拖慢——磁盘阵列才是瓶颈,所以换页买到的是更慢的失败,不是更长的命。

那个差点让我报告「自伤事故」的坑

验证期间,我第一次用裸 curl 打过去,返回 403,看起来完全像我刚重启代理造成的事故。并不是:机器人过滤器给我自己的客户端 IP 打了分(blocked user-agent,分数 80),然后把它放进一个 15 分钟的计分窗口——因为我一直在猛敲这台机器。

线索就在同一轮测试里:/robots.txt 返回 200,内部的 /cdn 路径返回 400,而两者是同一时刻——说明路由是健康的,只有我自己的请求被过滤。在一台「你同时是客户端」的宿主上,永远不要用裸 curl 做判断:去探一个过滤器会忽略的路径,或者从访问日志里读出一个 200。

如果重来一次

  1. 把「意外停止」加 ExitCode 0 当成「去读 dmesg」的指令,而不是干净退出的证据。重启计数大于零的容器,在证明清白之前都当作内存事故来查。
  2. 对 cgroup 高水位告警,而不是只对重启告警。 重启后那个已经贴着天花板的读数,在通知到达前约十八分钟就告诉我第 7 次击杀要来了。
  3. 早点拿到「谁在打我」这个数字。 一份日志样本里 25,976 个不同 IP 就是全部故事,而我是在看完整条内存曲线之后才找到它。
  4. 怀疑容器,别怀疑服务器。 「服务器内存不够」和「一台 64 GB 的机器上某个容器只有 512 MiB 上限」需要完全不同的应对,而后者你不去看那个上限就根本看不见。

结果

改动之后再没有出现 OOM 击杀,18 分钟后的峰值是 210 MB,而过去每一个周期都会摸到上限——四天里一直悄悄重启公网入口的那七次击杀,也从 dmesg 里消失了。爬虫洪水并没有停,所以我把这个失败形态记进了那套 stack 的运维笔记:「容器意外停止」+ exit 0 / OOMKilled=false 是一个会误导人的签名,而在这类平台上,诚实的证据住在内核日志和 cgroup 的高水位里。

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts