Docker 说它干净退出,内核说它被 OOM 杀掉了
一条我以为又是配置写错的告警
我的 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 |
|---|---|---|
| 1406539 | 10-12 约 07:37 | 491 MB |
| 1407759 | 10-12 约 07:57 | 494 MB |
| 1432574 | 10-12 约 14:49 | 486 MB |
| 1734793 | 10-16 约 02:45 | 487 MB |
| 1735495 | 10-16 约 02:58 | 485 MB |
| 1737797 | 10-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
验证(因为「我把上限调大之后感觉好多了」不叫验证):
- 只有那一个容器被重建。 wiki、代理内核、robots 容器都还报着两周的 uptime——用户看到的是几秒的空窗,而不是整个 stack 重启。
HostConfig.Memory = 2147483648、MemorySwap = 2147483648,cgroup 的memory.limit_in_bytes与两者一致;RestartCount归零。- 18 分钟后峰值用量 210 MB——而之前它一直贴着 512 MB 的天花板。到中午是 94 MiB。
- 挂载列表仍与文件一致,包括插件目录那条挂载——丢掉它曾经让这个 stack 里所有路由全部失效。
- 两个插件都加载且在生效(国家白名单、机器人过滤器),等过滤器的计分窗口过期后,各条路由都返回 200。
以及它不是什么: 这是权宜之计。驱动因素是洪水,所以大一个数量级的洪水照样能摸到 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。
如果重来一次
- 把「意外停止」加
ExitCode 0当成「去读 dmesg」的指令,而不是干净退出的证据。重启计数大于零的容器,在证明清白之前都当作内存事故来查。 - 对 cgroup 高水位告警,而不是只对重启告警。 重启后那个已经贴着天花板的读数,在通知到达前约十八分钟就告诉我第 7 次击杀要来了。
- 早点拿到「谁在打我」这个数字。 一份日志样本里 25,976 个不同 IP 就是全部故事,而我是在看完整条内存曲线之后才找到它。
- 怀疑容器,别怀疑服务器。 「服务器内存不够」和「一台 64 GB 的机器上某个容器只有 512 MiB 上限」需要完全不同的应对,而后者你不去看那个上限就根本看不见。
结果
改动之后再没有出现 OOM 击杀,18 分钟后的峰值是 210 MB,而过去每一个周期都会摸到上限——四天里一直悄悄重启公网入口的那七次击杀,也从 dmesg 里消失了。爬虫洪水并没有停,所以我把这个失败形态记进了那套 stack 的运维笔记:「容器意外停止」+ exit 0 / OOMKilled=false 是一个会误导人的签名,而在这类平台上,诚实的证据住在内核日志和 cgroup 的高水位里。