--- title: "Docker 报告退出码 0,内核却早已执行 OOM-Kill" description: "NAS 上的一个容器反复异常退出,Docker 却上报 exit 0 与 OOMKilled=false。真实的证据在 dmesg 里:六次 cgroup OOM-kill。" pubDate: 2026-10-16 category: devops tags: ["docker", "oom", "traefik", "synology", "crawlers"] ogImage: /og/zh-docker-reported-exit-0-the-oom-killer-disagreed.jpg banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.jpg draft: false summary: audience: "SREs, DevOps Leads & 系统工程师" painPoint: "公网边缘反向代理容器(Traefik)4天内无预警重启7次,但 Docker daemon 谎报 ExitCode=0 且 OOMKilled=false,常规监控完全失明。" impact: - metric: "精准定位真凶" detail: "穿透 Docker 状态假象,通过内核 dmesg 与 cgroup 内存水位线抓出 25,976 个代理爬虫 IP 引发的 512MB 触顶" - metric: "0 停机热修复" detail: "单容器精准扩容至 2GB 并锁死 Swap(防磁盘阵列雪崩),18分钟峰值回落至 210MB,连续运行 0 崩溃" - metric: "防自锁诊断规范" detail: "沉淀边缘网关高并发防爬虫压测与避免自身 IP 被 WAF 误封(403 假象)的排障 SOP" stack: ["Docker", "Linux Kernel (dmesg/cgroups)", "Traefik", "Synology DSM", "Security Filtering"] jumpLinks: business: "#the-result" architecture: "#the-kernel-keeps-its-own-record" result: "#what-i-would-do-differently" dynamicCta: true --- ## 一条我以为又是配置写错的告警 我的 NAS 发来那条所有 Docker 用户都学会一眼扫过的通知:**容器意外停止**。出事的是我自托管 wiki 前面的那个边缘代理,它已经连着跑了两周。那天早上没人部署过东西,compose 文件也没被动过,所以当时只有两个故事可选:「它崩了」,或者「有东西把它重启了」。 两个都错。而真正值得写下来的是:**光看 Docker 自己的报告,我根本分不出来。** ## Docker 的报告说「什么都没发生」 ```text 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 的内存击杀由内核记录,哪怕容器运行时的账本没有把它带过来: ```text [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 行里: ```text 不同客户端 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 上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。 ## 修法,以及必须跟着的那句话 ```yaml 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。 ## 如果重来一次 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 的高水位里。