Docker容器exited(137)排查指南:从OOM到SIGKILL一次讲透
2026/9/16 22:02:28 网站建设 项目流程

有些报错你第一眼看到时完全摸不着头脑,docker ps -a里那一行exited(137)就属于这一类。我第一次遇到时,第一反应是跑docker logs看容器日志,结果干干净净,什么都没有。后来才明白,137 这个数字根本不是业务日志能解释的,它来自 Linux 内核的进程管理机制。

这篇文章我就围绕 Docker 的exited(137)展开,从退出码的含义、内核层面的触发机制,到具体案例的排查流程和处理方案,一次性讲透。内容会覆盖宿主机内存不足、容器内存限制、JVM 堆内存设置、docker stop 超时强杀这几个最常见的场景,也适合刚接触 Docker 不久、看到exited(137)不知道从哪下手的同学。看完这篇文章,你至少能自己定位 80% 的 137 问题,并且能给出合理的解决方案。

1. 先把137掰开揉碎:128+9意味着什么

1.1 退出码的计算规则,为什么是137而不是别的数

Linux 下进程退出码有一个约定:程序正常退出时返回 0,异常退出时返回非 0。如果进程是被信号杀死的,Shell 和 Docker 会把它换算成128 + 信号编号

比如:

  • SIGTERM 的编号是 15,128 + 15 = 143
  • SIGKILL 的编号是 9,128 + 9 = 137

所以退出码 137 在语义上等价于"这个进程被 SIGKILL 信号终结了"。SIGKILL 是 Linux 里最粗暴的终止方式,它不可被捕获、不可被阻塞、也不可被忽略。你可以用kill -l查看系统所有信号编号,确认这个对应关系。

这个换算规则是理解 137 的第一把钥匙。很多人一看到 137 就去翻应用日志找异常堆栈,这是方向性错误。137 不是应用自己抛出的错误码,而是内核或外部进程给它下达的"死刑判决书"。应用本身没有机会写日志、也没有机会做清理操作,直接被清走了。

1.2 被SIGKILL杀死的容器,为什么日志一片空白

SIGKILL 的粗暴性决定了被杀的进程没有任何善后机会。正常情况下,一个 Java 进程收到 SIGTERM 后,可以执行 ShutdownHook,把未完成的请求处理完,再关掉线程池和连接池,然后从容退出。但收到 SIGKILL 时,内核直接释放进程占用的资源,应用代码连一行日志都来不及写。

所以当你看docker logs发现日志停留在某个时间点,后面突然什么都没有了,就要意识到:这可能不是应用崩溃,而是进程被外力杀死。这时候去应用日志里找原因基本是白费功夫,应该去系统层面找证据。

反过来,如果日志里留下了 OutOfMemoryError、Segmentation fault 之类的信息,那都不属于 137 的排查范畴,那是应用自身的问题。

1.3 谁能发出SIGKILL:四种常见的"凶手"

我实际工作中总结下来,137 的"凶手"基本就这四类:

凶手触发场景特征
内核 OOM Killer宿主机内存耗尽,或容器超过 memory limit最常见,dmesg 里能看到记录
docker stop 超时强杀容器不响应 SIGTERM,等待 10 秒后被强制结束CI/CD 里最常见
外部 kill -9有人手动 kill 进程,或面板/脚本调用查操作记录能发现
Docker 引擎重启守护进程重启时,部分容器被清理看 Docker 事件能溯源

这四类里,第一类和第二类占了 95% 以上。下面我会重点展开这两种情况的完整排查链路,因为在处理方式上它们是完全不同的方向,如果搞混了,可能改了很多配置问题依旧。

2. 排查链路的第一步:第一案发现场在dmesg

2.1 OOM Killer是什么,挨打的条件是什么

OOM Killer 是 Linux 内核的内存保护机制。当系统物理内存和 swap 都用尽时,内核无法再满足任何一个进程的内存申请,这时候必须"杀一个保平安",挑选一个进程下手,释放它的内存,让系统继续运行。

内核为每个进程维护一个oom_score分数,分数越高,被杀的概率越大。正常情况下,占用内存越大的进程,oom_score越高,所以被淘汰的往往是内存大户。在 Docker 场景下,一个容器内所有进程共享一个 cgroup,如果容器整体内存用量超过了 cgroup 的限制(memory.limit_in_bytes),内核直接把该 cgroup 标记为 OOM,然后杀掉容器里得分最高的进程。

这里有个大多数人容易忽略的点:OOM Killer 开枪时,打中的不一定是内存占用最大的那个进程,而是"在申请内存那一刻分数最高"的进程。所以有时候你发现 MySQL 容器退出 137 了,但真正把内存吃满的可能是隔壁 Redis,只是 MySQL 恰好在那时又去申请了一块新内存,内核就顺手把它给收拾了。

2.2 用 dmesg 找证据,别靠猜

遇到 137,我的第一反应永远是执行下面这条命令:

dmesg | grep -i -E "killed process|out of memory|oom-killer" | tail -20

dmesg 是内核环形缓冲区日志,OOM Killer 动手的时候一定会在这里留下记录。输出大概是这样的:

Out of memory: Killed process 12345 (java) total-vm:4194304kB, anon-rss:1024kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:4096kB oom_score_adj:0

如果你用的是 systemd 的系统,也可以这样查:

journalctl -k --since "2025-01-01 00:00:00" | grep -i -E "oom|killed process"

注意,dmesg需要 root 权限,普通用户执行会报权限错误。还有一点:如果容器已经被自动重启了,dmesg 里的日志不会消失,它依然在那,所以这条命令永远是最先执行的。

如果 dmesg 里什么都没有,那基本可以确定不是 OOM,直接看下面的docker inspect

2.3 docker inspect 还原案发时间线

不是所有 137 都是 OOM,所以在 dmesg 没有发现的情况下,我要用docker inspect确认容器的状态字段:

docker inspect <容器名> --format '{{.State.OOMKilled}}'

这个字段返回truefalse,它是 Docker 自己记录的 OOM 标记。

OOMKilleddmesg 记录结论
true有 Killed process 记录确实是内存触发的 OOM
false无记录大概率是 docker stop 超时或其他外部 kill

再结合下面这条查看容器的启动和结束时间:

docker inspect <容器名> --format '{{.State.StartedAt}} -> {{.State.FinishedAt}}'

然后回到宿主机上,查看同一时间段的内存变化:

free -h

或者查系统日志:

cat /var/log/messages | grep -i oom cat /var/log/syslog | grep -i oom

把容器被杀的精确时间、宿主机当时的空闲内存、以及有没有 OOM 记录这三条信息拼在一起,案发时间线就出来了。这一步做完,你基本就能确定到底是"被人杀的"还是"被系统杀的"了。

3. 我见过的几种137:原因各不相同

3.1 宿主机内存被写满:最常见的死法

先讲一个最常见的案例。我之前在一台 2G 内存的小主机上同时运行 MySQL 8.0、Redis 和一个 Spring Boot 应用,结果 MySQL 容器每隔一阵就exited(137),但其他容器好好的。

当时我第一直觉是 MySQL 配置问题,检查了innodb_buffer_pool_sizemax_connections,全部调低了,问题依旧。后来执行dmesg才发现,内存紧张的根源根本不是 MySQL,而是同一台机器上的 Redis。Redis 默认没有任何内存上限,当时有个定时任务往里写了一批大数据,把宿主机剩余内存直接吃光。内核 OOM Killer 开枪时,恰好 MySQL 正在申请新内存分配,于是中弹倒地。

这个案例提醒我:排查 137 时,视角不能只局限在报错容器本身,一定要看整个宿主机的内存布局。有时候出问题的容器只是"替死鬼",真正把内存吃满的是隔壁邻居。

排查手段很简单,装个htop或者直接看:

free -h top -o %MEM

重点看两点:宿主机还剩多少内存?是否有某个进程的内存占比异常高?

3.2 容器自身的memory limit被击穿

这种场景比宿主机内存耗尽更隐蔽,也更冤。很多人在部署时习惯带上-m参数:

docker run -m 512m --memory-swap 512m myapp

这个命令的意思是:容器最多只能用 512M 内存,而且禁用了 swap 兜底。超过 512M,内核直接杀容器进程,不管宿主机内存有多充裕。

这类问题的典型特征是:宿主机内存明明还有一大半空闲,但容器就是反复 137。看docker stats能发现规律——容器的内存使用率逼近 100% 的瞬间,容器就退出了。

如果你用 docker compose,对应配置是这样的:

services: app: image: myapp mem_limit: 512m memswap_limit: 512m

这个memswap_limit的值容易搞混,我单独解释一下:

  • --memory 512m --memory-swap 512m:容器最多 512M,且禁用 swap
  • --memory 512m --memory-swap 1g:容器最多可用 512M 物理内存 + 512M swap
  • 只设--memory 512m不设 swap:容器默认可以无上限使用宿主机的 swap(取决于宿主机vm.swappiness),这其实很危险,后面第 4.4 节会详细说

如果你只是"感觉"容器内存不够,但不知道具体峰值是多少,可以在容器运行期间跑docker stats观察曲线,不要拍脑袋决定 limit 数值。

3.3 Java应用与JVM堆的无底洞

Java 应用是容器化进程中 137 的高发区,而且问题往往出在 JVM 和容器的内存认知不一致。

默认情况下,JVM 会按照宿主机物理内存的 1/4 来设置堆最大值。如果你在一台 32G 的机器上跑容器,给容器设置了-m 1g,但 JVM 启动参数里没有显式指定-Xmx,那 JVM 会认为可用的堆有 8G(32G 的 1/4)。当容器内应用实际使用超过 1G 时,容器被杀,但 JVM 自己还懵然不知。

针对这个问题的标准解法是让 JVM 感知容器配额:

  • Java 10 及以上:默认开启容器感知(-XX:+UseContainerSupport),用-XX:MaxRAMPercentage=75.0指定堆占容器内存的百分比
  • Java 8u191+:需要显式加-XX:+UseContainerSupport,或者用老参数-XX:+UseCGroupMemoryLimitForHeap
  • Java 8 早期版本:老老实实写-Xmx512m,没有别的捷径

我推荐的启动参数组合:

java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XshowSettings:vm -jar app.jar

这里用 75% 而不是 100%,是因为 JVM 不只吃堆内存,还有 metaspace、线程栈、DirectByteBuffer 这些堆外内存。如果你把堆直接拉满到容器上限,堆外内存一旦增长,照样会被 OOM。保留 25% 是给堆外内存和系统缓存留的缓冲。

3.4 docker stop超时后的kill:最容易忽略的"假"137

这个场景我要单独提,因为很多人没意识到,Docker 部署流程里的 137 可能根本不是内存问题,而是docker stop的强杀机制。

Docker 执行docker stop时,会先向容器内 PID 1 进程发送 SIGTERM,然后等待容器优雅退出。默认等待时间是 10 秒,也就是 docker CLI 里的-t参数(compose 里是stop_grace_period)。如果 10 秒内容器没有退出,Docker 会发送 SIGKILL,强制执行。

很多 Java 应用如果没有配置优雅停机,或者进程里有长时间运行的任务无法被打断,就很容易触发这个超时强杀。此时容器退出码同样是 137,但State.OOMKilledfalse,dmesg 里也没有任何记录。

真实场景在我这边发生过:CI 流水线每次构建完执行docker compose down时,某个服务总是返回 137,每次都要等 10 秒才退出。排查后发现是这个应用在收到 SIGTERM 后没有立即结束,Docker 等满 10 秒后只能强杀。

解决的思路有两个:

一是给应用配置优雅停机。Spring Boot 可以开启 graceful shutdown:

spring: lifecycle: timeout-per-shutdown-phase: 20s server: shutdown: graceful

二是在编排层面留足时间:

services: app: stop_grace_period: 30s

两者配合使用,CI 流程里的 137 很快就消失了。这个案例说明一个问题:遇到 137 先别急着加内存,先看OOMKilled字段,这个字段能帮你少走很多弯路。

3.5 低配置设备上的常驻任务:青龙这类脚本容器的典型处境

青龙面板在低配设备上跑挂机任务,变成 137 的情况也很典型。这类设备内存通常只有 512M 到 2G,同时跑着下载工具、监控脚本、Web 服务等一堆容器。青龙每次执行依赖管理和脚本任务时,内存峰值会瞬间冲高,设备内核直接开枪。

这类 137 的教训是:小内存设备上跑 Docker,首先要降低单容器内存尖峰。青龙这类容器可以在 compose 里限制日志大小,避免日志文件占满磁盘分区,同时把容器的ulimits内存限制写好:

services: qinglong: image: whyour/qinglong:latest mem_limit: 512m memswap_limit: 512m logging: driver: json-file options: max-size: "10m" max-file: "3"

同一台设备上如果跑着多个容器,建议把所有容器的内存上限加起来,低于设备总内存的 80%,留出系统本身的开销。小内存设备上不要用硬件的 swap 来救场,那只会让 OOM 变成更隐蔽的性能问题,后面会单独讲。

4. 对症下药:从容器参数到应用配置的调整清单

4.1 先给容器加显式内存上限:limit是底线也是保护

排查完根因,处理第一步永远是给容器一个明确的内存上限。这一步的意义不只是防止单容器内存失控,更重要的是让 OOM Killer 的"判决范围"可控。

为什么这么说?如果容器没有 limit,它和宿主机其它进程共享整个内存空间。一旦某个进程发生内存泄漏,可能连累其它无关容器被杀。而有了 limit,内核会在容器内部优先"处决"最活跃的内存进程,不会波及外部。

我建议的上限设置原则是:单进程实际峰值 + 30% 余量。比如你观察到应用内存峰值稳定在 400M,那么 limit 给 512M 是底线,给 1G 更稳妥。给太紧会让系统频繁杀进程,给太松又起不到限制作用。

以 docker run 为例:

docker run -d --name myapp -m 1g --memory-swap 1g myapp:latest

如果使用 compose:

services: myapp: image: myapp:latest mem_limit: 1g memswap_limit: 1g

注意,memswap_limitmem_limit相等,表示关闭 swap 兜底,让容器内存用量严格可控。这是生产环境的标准做法。

4.2 Java:让JVM感知容器配额

Java 应用在容器里的内存参数,是我每次部署都要重点检查的部分。经验不丰富的人往往只关心应用代码,忽略了 JVM 本身的资源认知,这导致很多 Java 容器在 K8s 或 Docker 里"无缘无故"被杀。

第一步,确认 Java 版本。Java 8u191+ 和 Java 10+ 支持容器感知,Java 8 早期版本不支持,需要使用-Xmx硬编码。第二步,配置合理的堆内存。我推荐用比例而不是固定值,这样当容器 limit 调整时,不用同步改代码和 Dockerfile:

java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Xss512k -jar app.jar

这里-Xss512k是把每个线程的栈空间限制在 512K,可以避免在创建大量线程时堆外内存过度膨胀。如果你用的是 Java 8 老版本,需要这样:

java -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1 -jar app.jar

MaxRAMFraction=1意味着用满整个容器内存,堆外内存可能没空间,所以我更建议老版本直接写成-Xmx600m这种固定值。

另外,如果你的应用是 Spring Boot,可以在application.yml里设置 JVM 参数:

spring: application: admin: jmx-enabled: true

不过这只是辅助,最主要的还是启动命令行里的 JVM 参数。

4.3 MySQL/Redis:调参不是调内存大小,是调"上限"和淘汰策略

数据库类容器遇到 137,核心原因往往是它们"默认值太贪心"。MySQL 8.0 安装完默认配置不调,在繁忙场景下非常容易吃满内存。这里的重点不是简单地"调小内存",而是理解每个参数对应的内存池在哪。

MySQL 方面,我给出的建议:

[mysqld] innodb_buffer_pool_size = 256M max_connections = 100 performance_schema = OFF max_heap_table_size = 64M tmp_table_size = 64M

innodb_buffer_pool_size是 MySQL 最大的内存池,建议设置为容器 limit 的 50%~60%。max_connections越大,内存消耗越线性增长,因为每个连接都要分配线程栈和缓冲区。performance_schema在 MySQL 8.0 默认开启,它本身就要占用不少内存,不是必须时可关闭。

Redis 方面,重点不是maxmemory设置多大,而是设置之后要有淘汰策略:

maxmemory 256mb maxmemory-policy allkeys-lru

如果不设置maxmemory-policy,即使触达maxmemory,Redis 只是拒绝写入请求,不会淘汰旧数据。但如果你连maxmemory都没设置,Redis 就会无限吃内存,直到被 OOM Killer 打中。日志、消息队列容器同理,建议大家根据实际应用场景,把所有常驻容器的内存占用上限都梳理一遍。

4.4 swap与overcommit:是救场还是帮倒忙

swap 在 Docker 场景里是一个需要谨慎使用的选项。很多人以为给系统加 swap 就能避免 137,但实际情况比这复杂。

Linux 内核默认的vm.swappiness是 60,表示内核会积极地把不常用的内存页换到 swap。在容器场景下,如果把整个宿主机内存和 swap 都用满,OOM Killer 依然会开枪。而且 swap 的存在可能导致"系统看起来很卡才被杀",排查问题时更难判断。

Docker 容器的 swap 策略通过--memory-swap控制。如果你想让容器可以"借用"宿主机 swap,可以这样设置:

docker run -m 512m --memory-swap 1g myapp

这个配置给了容器 512M 物理内存 + 512M swap 额度。对于内存峰值偶发性的任务型容器,这个配置能明显降低被杀概率;对于数据库这种对延迟敏感的服务,我不建议用 swap,因为 swap 的读写延迟会导致查询速度骤降,最终引发超时,比 137 更难受。

如果宿主机内存本身紧张,我建议先压容器,而不是盲目加 swap。vm.swappiness可以临时调低:

sysctl vm.swappiness=10

不过重启会失效,想要持久化需要写/etc/sysctl.conf,这个操作影响宿主机全局,要谨慎。

4.5 重启策略能不能兜底

很多人遇到容器反复被 137,想到的第一招就是加restart: alwaysrestart: unless-stopped,让容器被杀后自动拉起。我的看法是:这可以作为应急处理,但绝不能当作根治手段。

restart: unless-stopped的逻辑是:容器无论怎么退出,只要不是手动 stop,Docker 都会自动重启它。对于 OOM 的情况,容器被拉起后很可能再次 OOM,形成"被杀—重启—再被杀"的循环,不仅服务不可用,还在反复消耗系统资源。

我见过最极端的例子,有个容器一小时重启了 40 多次,日志上千条,最后连 Docker 守护进程都开始不稳定。所以重启策略的正确使用方式是:配合健康检查,让容器在内存吞掉后能自动恢复,同时在监控层面设置告警,及时介入。

如果你的服务对可用性要求高,推荐用restart: unless-stopped加健康检查,比如 compose 里这样:

services: app: image: myapp mem_limit: 512m restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 3s retries: 3

但别忘了,根本问题还是内存管理,重启策略只是止血带。

5. 防患于未然:让137不再回来的运维习惯

5.1 按需监控:不用上重武器,但要看得见趋势

137 发生的瞬间,再优秀的排查手段也是事后补救。真正省心的方式是让内存问题的"伏笔"在爆发前就被看到。

如果只是自己的一台或几台服务器,没必要上完整的 Prometheus + Grafana 全家桶,但至少应该把docker stats的数据采集下来。一个非常轻量的做法是写个简单的脚本,每分钟把容器内存使用写入日志:

while true; do echo "$(date +'%Y-%m-%d %H:%M:%S')" >> /var/log/docker-mem.log docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" >> /var/log/docker-mem.log sleep 60 done

这比什么都不做要强太多。当你遇到"某个容器半夜被 137,醒来才发现"的情况时,这份日志能帮你精确地还原当时每个容器的内存使用率。

如果机器数量多、容器规模大,再考虑 cAdvisor + Prometheus 这条路。cAdvisor 本身也是个容器,它会暴露容器级监控指标,配上告警规则,可以在容器 OOM 之前提前预警。

5.2 评估内存时,要看"宿主总内存-其他容器占用"这一层

之前讲过,137 的根源经常不在报错容器身上,而在邻居容器。为了避免这种"冤案",我建议每台机器维护一份内存地图:每个容器的 limit 是多少、实际峰值是多少、整台宿主机的总内存是多少、系统保留内存预留多少。

我习惯在部署新容器前,先用公式估算一遍:

可用内存 = 宿主机物理内存 - swap - 系统预留(约10%) - 现有所有容器的limit之和

如果这个"可用内存"是负数,说明这台机器已经不能再加容器了。这个简单的判断能避免大量部署上的踩坑。

5.3 优雅停机:这一点比大多数人想的更重要

最后聊一个被很多人忽略的问题:应用的优雅停机能力。前面讲 docker stop 超时强杀会引发 137,它背后真正的问题其实是应用没有处理 SIGTERM 信号。

一个健壮的容器应用,收到 SIGTERM 后应该做这几件事:

  1. 停止接收新请求
  2. 处理完正在进行的请求(或有限度地等待)
  3. 刷新缓存、关闭连接池、释放资源
  4. 正常退出,让退出码为 0 或 143

对于 Spring Boot,开启 graceful shutdown 后,应用会在收到 SIGTERM 时自动执行上述逻辑:

server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s

对于 Node.js 应用,可以自己监听信号:

process.on('SIGTERM', () => { server.close(() => { process.exit(0); }); });

这样处理之后,docker stop基本不会触发 SIGKILL,退出码会变成 0 或 143,不会再有莫名其妙的 137。


我现在遇到 137,排查顺序已经固定了:先看dmesg确认是不是 OOM,再看docker inspectOOMKilled字段,然后结合宿主机的内存占用来判断是"自身超限"还是"被邻居连累"。这个流程基本不会跑偏。最后想提醒一句:如果某个容器反复 137,别硬扛,先在 compose 里把内存限制和优雅停机写好,再考虑是不是要加机器。容器退出可以靠自动重启恢复,但被 SIGKILL 打断的数据写操作,损失是无法通过重启挽回的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询