老实说,我第一次在服务器上看到容器状态是Exited (137)的时候,第一反应不是查内存,而是到处翻日志。后来排查的次数多了,才摸清楚这个退出码基本就是一条明确的信号:你的容器进程是被系统强制杀掉的,绝大多数情况下,罪魁祸首是内存超限,被 Linux 内核的 OOM Killer 给“点名”了。
这篇文章我打算把docker exited(137)这件事从头到尾讲透,包括退出码背后的计算规则、内核杀进程的完整机制、怎么从日志和状态里快速确认根因,以及最常见的几类触发场景和对应的解决办法。如果你正在为容器莫名其妙退出而头疼,或者想提前在代码和部署层面做好防护,这篇内容应该能帮你少走不少弯路。
1. 先搞清楚 exited(137) 到底是什么
1.1 退出码不是乱写的,背后是 128 + 信号编号
很多刚开始接触 Docker 的朋友看到 137 这个数字会觉得很懵,以为是什么业务错误码。其实 Docker 容器退出时返回的 137,来自 Linux 系统的退出码约定:当一个进程被信号终止时,Shell 和容器运行时返回的退出码是 128 加上对应的信号编号。
信号 9 在 Linux 里叫SIGKILL,是不可被捕获、不可被忽略的强制终止信号。128 + 9 = 137,所以看到 137,基本可以立刻断定:容器里的主进程是被 SIGKILL 干掉的。换句话说,不是进程自己崩溃退出,也不是业务代码 return 了一个错误码,而是操作系统层面直接把它“处决”了。
搞明白这一层,排查方向就不会跑偏。你不需要去业务日志里找什么空指针、超时异常,第一步要做的是问自己一个问题:系统为什么要对这个进程发送 SIGKILL?
1.2 谁在杀容器?基本就是 OOM Killer
在 Docker 环境中,向容器进程发送 SIGKILL 最常见的就是 Linux 内核的OOM Killer(Out Of Memory Killer)。当系统全局内存或者某个 cgroup 的内存限制被突破时,内核会启动一次“内存回收 + 杀进程”的应急流程。
这里需要理解一个关键概念:Linux 内存是“过量分配”的。你申请了 10GB 内存,可能实际只用了 2GB,系统不会在申请时立刻把物理页全部分配给你,而是等你真正写入数据时才按需分配。这就导致一个情况:系统上的所有进程加起来申请的内存总量,可能远超物理内存大小。当大量进程同时开始写入内存、触发真正的物理页分配时,系统内存会被瞬间耗尽。
内核在发现物理内存不够用、又没有干净的页可以回收时,就会启动 OOM Killer,它会根据一套打分机制挑选一个“最值得杀”的进程,直接 SIGKILL 掉。这个分数考虑的因素包括:进程占用的内存量、进程存活时间、进程优先级(oom_score_adj)等等。在 Docker 环境下,容器进程往往占内存大,又没什么“保护罩”,很容易成为被选中目标。
另外,Docker 本身也允许你在启动容器时通过--memory参数给容器设置内存上限。如果容器内的进程想申请的内存超过了这个上限,同样会触发内核针对该 cgroup 的 OOM 处理,效果就是杀掉容器里的进程——退出码同样是 137。
1.3 怎么快速确认是被 OOM 杀的
确认是不是 OOM,最直接的方法是看内核日志。在宿主机上执行:
dmesg | grep -i -E "killed process|out of memory" | tail -20如果输出里能看到类似下面这样的内容:
[12345.678901] Out of memory: Killed process 12345 (java) total-vm:4194304kB, anon-rss:2097152kB, file-rss:0kB, shmem-rss:0kB那就可以百分百确定,这个进程是被内核 OOM Killer 杀的。后面那个括号里的名字,就是被杀的进程名,通常和容器里 PID 为 1 的主进程一致。
另外也可以看容器状态:
docker inspect <container_id> --format '{{.State.OOMKilled}}'如果输出是true,说明容器确实因为内存超限被内核杀掉了,ExitCode也会显示为 137。顺便说一句,我排查的时候习惯把这两个命令一起执行,docker inspect是“官方结论”,dmesg能看到更详细的内核视角和当时的进程内存分布情况。
2. 常见的几种触发场景,你不一定都是“真的内存不够”
2.1 容器内存上限设置得太小
很多人在启动容器时根本没有设置--memory参数,但也有相当多的人设置了却给得太保守。举个真实例子,我之前帮人排查过一个 Node.js 应用,Docker Compose 里写着mem_limit: 512m,但应用在高峰期需要处理比较大的内存数据,结果就是每到流量高峰,容器就exited(137)重启一次。
这种场景最容易判断——你只需要把内存上限调大,或者干脆先去掉限制再观察一段时间,如果不再复现,那问题就是上限设置不合理。
不过这里要提醒一句:调上限是治标,如果应用本身存在内存泄漏或者无限增长的缓存,调再大也会在某个时间点再次触发 OOM。所以调完配置之后,一定要配合内存监控去观察曲线的趋势。
2.2 物理机或虚拟机本身内存不足
还有一种情况,容器运行在一台内存只有 4GB 的机器上,上面同时跑着好几个容器,每个都没设置内存限制。这种情况下,当几个容器同时有大量内存写入时,宿主机全局内存被耗尽,内核会挑一个进程杀掉来缓解压力。
这时候 dmesg 的日志里可能显示杀掉的进程不一定是容器主进程,也有可能是某个 sidecar 或者别的服务的进程。但不管杀的是哪个,对 Docker 容器来说,只要主进程被杀,容器就会退出,状态可能就是exited(137)。
这种场景下,你要解决的就不是某一个容器的问题,而是整个宿主机的资源规划问题。要么加物理内存,要么合理控制每个容器的内存上限,要么减少同时运行的服务数量。
2.3 JVM 内存参数没设置,堆外内存还被算进去
Java 应用可以说是exited(137)的重灾区,这里面有个特别容易踩的坑:JVM 的堆内内存设置和容器内存限制之间的匹配问题。
很多 Java 开发者在 Dockerfile 里直接java -jar app.jar,让 JVM 自己决定堆大小。在没有明确配置的情况下,JVM 在容器里识别到的“最大可用内存”可能是宿主机内存,也可能是它启动时默认的物理内存 1/4。如果你的容器内存上限是 1GB,但 JVM 以为有 8GB 可用、直接申请了 2GB 堆,容器就会立刻被打爆,触发 OOM,退出码 137。
另外一个容易忽略的是 G1 垃圾回收器和其他堆外内存(metaspace、线程栈、JIT 编译器、DirectByteBuffer 等)也会占用内存。所以哪怕你给 JVM 设了-Xmx512m,但整个 Java 进程实际占用的内存可能远超 512MB。留给容器的内存,必须包含堆 + 堆外 + 其他开销的总和。
2.4 代码本身的内存爆炸:死循环、大集合、无限缓存
这种情况就纯粹是应用层的问题了。比如 Python 脚本里循环读取大文件,把所有行都存进列表不释放;再比如 Node.js 里有人把请求结果缓存到一个永不清理的 Map 里;又或者业务上有个死循环在不停地往内存里塞数据。无论语言是什么,只要进程的常驻内存以肉眼可见的速度持续增长,到达某个临界点后必然触发 OOM。
值得一提的是,就算你给容器设置了足够大的内存上限,如果代码里有内存泄漏,内存照样会涨到触发 OOM 的那一天。这类问题往往更隐蔽,因为排查到最后,你会发现根本不是配置问题,而是应用代码问题。
3. 从实操角度解决 exited(137) 的完整思路
3.1 第一步:看日志,区分是偶发还是必然
接到容器 137 退出的时候,先不要急着改配置。我会先看两个地方:一个是容器最后一次退出前的日志,确认退出前有没有异常刷屏;另一个是宿主机dmesg,确认内核有没有 OOM 记录。
如果 dmesg 没有 OOM 记录,说明进程不是被内核杀的。那 137 就可能是被手动 kill 或者其他外部因素触发的 SIGKILL。举个例子,有人直接执行docker kill,那退出码也会是 137,但这就不是内存问题,而是人为操作。有些容器编排工具在滚动更新时也会主动 kill 旧容器,这种是正常现象。
确认了是 OOM 之后,再判断是偶发还是必然。你可以把容器重启起来,用docker stats盯着内存曲线看。如果内存持续高位运行,那大概率是常态性内存不足;如果平时很低,某几个时间段才涨上去,那就和业务高峰或者定时任务有关。
3.2 第二步:给容器设置合理的内存限制
从运维角度讲,我强烈建议每个容器都显式声明内存限制,而不是“裸奔”。裸奔的问题在于,一个失控的容器可能会拖垮整台机器上的其他服务。设置内存限制的方式有三种,按需选择。
用docker run时直接带参数:
docker run -d --name myapp --memory=1g --memory-swap=1g myapp:latest用 Docker Compose 时在 service 里声明:
services: myapp: image: myapp:latest mem_limit: 1g memswap_limit: 1g这里有个细节要说清楚:--memory-swap的值。如果不带这个参数,Docker 默认会允许容器使用2 * memory的大小(其中一半是交换分区)。大多数情况下,我们希望容器不要用 swap,因为一旦用了 swap,内存不会立刻触发 OOM,而是先走交换,性能会大幅下降。把memory-swap设置成和memory相同,就等于禁用了容器的 swap 能力。
3.3 第三步:如果是 Java 应用,正确设置 JVM 内存参数
Java 应用的正确做法是,在启动参数里显式指定堆内存大小,并且让 JVM 感知容器内存限制。现在主流 JDK(8u131+ 之后的版本)都支持-XX:+UseContainerSupport,默认开启的,JVM 能自动识别容器内存限制。
手动设置时的一个经验值是:堆上限不要超过容器内存上限的 60%-70%,剩下的留给堆外内存、线程栈和 JVM 自身开销。比如容器内存是 1GB:
java -XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=50.0 -jar app.jar这个做法比固定-Xmx700m更灵活,因为 JVM 会根据容器实际内存上限自动计算。但要注意 MaxRAMPercentage 不是没有上限的,如果容器内存设得很大,这个百分比也要在压测环境里验证过再上生产。
另外,如果你的 Java 服务用了比较多的 DirectByteBuffer(比如 Netty、Spring WebFlux 这类框架),那么堆外内存很容易超标。这类场景下可以结合-XX:MaxDirectMemorySize来限制直接内存,同时把容器的--memory再多留出 20%-30% 的余量。
3.4 第四步:对不可避免的峰值内存做削峰
有些业务的峰值内存是绕不过去的,比如某个定时任务要在启动时加载 500MB 的配置数据。这种情况下,单纯加内存不现实,可以考虑在代码层面做削峰:
- 把一次性加载改成流式处理,分批读入内存,用完即释放。
- 对大对象加缓存淘汰策略,比如用 Redis 做二级缓存,别让所有热点数据全堆在本地内存里。
- 对频发的同步计算改为异步处理,避免同时大量请求触发高内存占用。
这个其实就是架构层面的事情了,但反过来说,如果你真的做过一次内存排查和优化,回头看代码时往往能发现很多“能省内存但没省”的地方。内存这个东西,能省一点,系统就稳一点。
3.5 第五步:设置自动重启策略兜底
不管上面几步做了多少,我都会建议在容器编排层面加上自动重启策略。Docker 本身支持--restart参数:
docker run -d --restart=on-failure:5 --memory=1g myapp:lateston-failure:5表示在容器异常退出时自动重启,最多尝试 5 次。这样一来,即使某次 OOM 偶发了,容器也能快速恢复,不至于服务长时间不可用。
不过要强调一点:自动重启只是兜底措施,不能代替根因分析。如果一个容器每五分钟重启一次,一定是哪里有问题,别指望重启能解决所有事情。
4. 我用一个实际的 Python 案例完整走一遍排查
4.1 构造一个必现 OOM 的容器
为了演示完整排查过程,我在本地用 Python 构造了一个会内存暴涨的容器。Dockerfile 很简单:
FROM python:3.10-slim WORKDIR /app COPY app.py . CMD ["python", "app.py"]app.py 的内容更简单,就是一个死循环往列表里塞数据:
import time data = [] while True: data.append("x" * 1024 * 1024) time.sleep(0.01)我用 256MB 内存限制来启动它:
docker build -t mem-demo . docker run -d --name mem-demo-test --memory=256m --memory-swap=256m mem-demo启动之后,用docker stats观察内存变化:
docker stats --no-stream mem-demo-test输出会显示内存持续增长,直到达到 256MB 的极限。大概几十秒后,容器就会退出。这时候看状态:
docker ps -a | grep mem-demo-test docker inspect mem-demo-test --format '{{.State.OOMKilled}} | {{.State.ExitCode}}'正常情况下,输出是true | 137,完美复现了我们要排查的问题。
4.2 从内核日志确认 OOM Killer
接着在宿主机上执行:
dmesg | grep -i "killed process" | tail -5能看到类似这样的输出:
[678901.234567] Out of memory: Killed process 34567 (python) total-vm:262144kB, anon-rss:256004kB, file-rss:0kB, shmem-rss:0kB到这一步,整个证据链就完整了:
- 容器状态里
OOMKilled=true,退出码 137 - 内核日志确认进程 python 因内存超限被 OOM Killer 杀死
docker stats显示内存涨到 256MB 上限后容器退出
这里我顺手记录了一个小技巧:docker inspect里的OOMKilled字段是布尔值,False只代表“当前最后一次运行没被 OOM”,如果容器重启过,这个值会被更新。所以排查历史问题时,最好还是以dmesg的内核日志为准。
4.3 针对这个案例的解决思路
对这个 demo 案例来说,解法不是去调容器内存上限,而是修代码。把无界列表改成有界队列,或者在合理时机清理数据,内存自然就不会无限增长。
但在真实业务里,如果你确认一个服务确实需要这么大的内存,那可以做两件事:第一,给容器设置符合实际需求的内存上限,比如从 256MB 调到 2GB;第二,给 JVM/Python 解释器等运行时设置合理的内存策略,避免一次性申请过多内存。Python 本身没有类似 JVM 那样的容器感知参数,所以主要靠 cgroup 限制和代码优化来控制。
5. 一些深入的做法:从进程级别看内存构成
5.1 RSS、VSZ、Swap 到底怎么读
排查内存问题时,free -h和docker stats只能看到总量,想更精确地定位内存都花在哪里,得看进程级别的内存指标。进入容器里执行:
ps aux | head -1 ps aux | grep python能看到%MEM、RSS、VSZ这些列。RSS 是常驻物理内存,表示这个进程实际占用的物理内存页;VSZ 是虚拟内存大小,通常远大于真实占用。注意 OOM Killer 判断内存压力时,主要看的是整体可用物理内存和 cgroup 内存计数,RSS 是一个重要的参考项,但内核计算底层用的是更细致的页表统计。
如果容器被 OOM 杀了,但你想复盘当时的内存占用细节,可以在 dmesg 日志里看到total-vm和anon-rss字段。anon-rss是匿名页内存,也就是进程自己分配堆和栈使用的内存,这个值最能反映“业务内存占用”。
5.2 用 cgroup 内存事件定位容器内存使用峰值
Linux cgroup v2 提供了memory.events文件,可以查看容器曾经触发的内存事件。在宿主机上找到容器的 cgroup 路径:
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.events输出里如果有oom事件计数,就说明这个容器曾经被 OOM 处理过。这个方法的好处是,即使容器现在已经被删除了,只要 cgroup 目录还在或者有对应的日志记录,就能看到当时的事件。配合memory.peak文件,还可以查看容器历史内存峰值:
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.peak这些指标对复盘历史故障特别有用。比如容器明明现在内存占用量很低,但就是会 137,一查 memory.peak,发现某个时间点内存冲到过天上去了,那就说明代码里有突发性的内存尖峰。
5.3 给 Docker Desktop 用户一个特别提醒
如果你的环境是 Docker Desktop(Windows 或 macOS)而不是 Linux 服务器,exited(137)的原因可能不太一样。Docker Desktop 运行在虚拟机里,虚拟机本身有内存配额。当整个虚拟机的内存不够时,容器也会被 OOM 杀掉。
这种场景下,排查方向要调整一下:进入 Docker Desktop 的 Settings -> Resources,看看 Memory 的配额是多少。如果你给容器设置的内存上限加起来超过了虚拟机的总内存,那必然出问题。把虚拟机的内存调大,或者减小容器内存限制,问题就能缓解。
另外,如果 Docker Desktop 所在的宿主机本身内存不够,操作系统级的内存压缩和回收也可能导致 Docker 虚拟机内的容器表现异常,包括各种奇怪的退出码。搞开发环境时,我一般建议电脑物理内存至少 16GB,否则跑几个容器就捉襟见肘。
6. 避坑指南与个人经验总结
6.1 我看过的几个典型的 137 误判
排查 137 的时候,最常见的误判就是把问题归咎于代码,但实际上根本不是。举几个我真实见过的例子:
- 某团队容器里跑的是 PHP-FPM,OOM 日志偶尔出现,但业务量并不大。最后发现是日志采集组件(sidecar 容器)同时打开的文件描述符过多,导致内存增长。这个案例说明,不只是主进程会占内存,容器里的所有进程都要纳入内存规划。
- 另一个案例是某个 Go 服务,明明内存占用很低,但容器也 137 了。查到最后发现是宿主机上有另外一个容器疯狂占内存,把整台机器的内存吃完了,内核挑进程杀的时候,因为负载均衡策略“随机”选中了无辜的这个。这种情况处理方案是给所有容器都设上内存限制,谁也不许抢谁的内存。
- 还有一个比较隐蔽的,MySQL 容器 137,但 MySQL 本身内存设置得也很合理。后来发现是
vm.overcommit_memory这个内核参数被调成了2(不允许过量分配),导致即使还有物理内存,大块内存分配也会失败,触发 OOM。这种问题和特定内核配置强相关,排查起来比较费劲。
6.2 给新手的排查顺序速查表
如果现在你的容器突然 137 了,我建议按下面的顺序从简单到复杂排查:
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | docker inspect <容器> --format '{{.State.OOMKilled}}' | 快速确认是否 OOM |
| 2 | dmesg | grep -i "killed process" | 从内核日志确认谁被杀了 |
| 3 | docker stats | 观察容器内存实时变化 |
| 4 | free -h | 查看宿主机物理内存和 swap 情况 |
| 5 | 查看应用日志 | 确认退出前有无业务异常 |
| 6 | 检查容器内存限制和运行时参数 | 确认配置是否合理 |
这六步做完,90% 的 137 问题都能定位到原因。剩下的 10%,基本就是内核参数、存储驱动、Docker 版本 bug 这类比较冷门的问题了。
6.3 关于 137 之后要不要马上重启容器
生产环境里,容器 137 之后服务就不可用了,运维的直觉肯定是赶紧拉起来。我的建议是:自动重启策略可以保留,但根因排查一定要跟上。如果容器重启后几分钟内又 137,那就不是“偶发内存尖峰”能解释的了,要立刻进入严肃排查。
从个人经验来看,这类问题最怕的就是“重启就好”的伪解决。因为 OOM 往往意味着有一个内存增长的过程,如果你不去查监控、看日志,下一次高峰来临,它还是会崩。与其反复被打断,不如花一两个小时把根因找出来。放在长期看,这种投入绝对值得。
6.4 最后分享一个小技巧:给容器加健康检查
前面说的都是“事后排查”,但更聪明的做法是“事前预防”。除了设置内存限制和自动重启,我强烈建议给每个容器加上健康检查。Dockerfile 里可以这样写:
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1健康检查和内存限制配合,可以在服务内存出问题之前就让编排系统感知异常,及时重启或者摘除流量。比如在 Kubernetes 环境里,探针检测到服务响应超时(往往伴随内存飙高),就会自动摘掉 Pod,条件触发后重启,这比等 OOM 发生了再被动恢复要稳得多。
还有一个小细节:如果容器里有资源占用比较高的定时任务,最好和服务请求的主流程放在不同的进程中处理,避免内存争夺导致主进程 OOM。这个在 Java 和 Go 服务里尤其明显,一个 GC 长暂停就足以让健康检查超时。
写在最后
我自己的排查习惯是,碰到 dockerexited(137),第一步从来不是看业务日志,而是先看一眼宿主机内核日志和容器 OOMKilled 状态。这两个信息能帮你快速画出一个边界:到底是内存问题、配置问题,还是其他外部因素。边界画清楚之后,再往里面填细节,就算问题再复杂也不怕。
说实话,用了这么久的 Docker,这个报错出现的频率不算低,但它也是问题最明确、最好定位的一类故障。只要掌握了退出码的规律和排查工具,处理起来其实很快。希望这篇分享能帮你把这个“坑”填上,下次再遇到 137,就不用再发愁了。