周一早上刚到工位,运维就在群里丢了一张截图:容器状态Exited (137),日志最后一行停在某个看似正常的输出上,之后什么都没有,像被人从外面硬生生掐断了呼吸。这种问题我处理过不下十次,几乎每一次排查路径都类似:先看日志,没异常;再看退出码,137;最后查内核日志,果然是OOM。今天就把这套完整的排查思路和解决办法整理出来,遇到docker报exited(137)的时候,你可以直接照抄。
这篇文章适合谁看?只要你的服务跑在Docker里,不管是在云服务器、物理机还是Mac/Windows桌面版Docker上,都可能撞上这个退出码。它不像普通的业务异常那样有堆栈、有报错文案,往往杀掉进程之前一切都还正常,排查起来特别容易走弯路。读完这篇,你能看懂137背后的信号机制,知道用哪几条命令快速定位,也能根据不同的应用场景选对调参方案。
1. 先搞清楚137到底是怎么来的
1.1 退出码换算背后的信号机制
很多同学第一次看到137,第一反应是去搜"137是什么错误",搜出来一堆"内存不足",然后就回去加内存。其实你更需要理解的是退出码的计算规则。
在Linux系统里,如果一个进程是被信号杀死,shell拿到的退出码不是信号编号本身,而是"128 + 信号编号"。SIGKILL的信号编号是9,所以128 + 9 = 137。换句话说,exited(137)这个状态已经明确告诉你:进程不是自己主动退出的,也不是崩了解析出错的,而是被内核直接强制杀掉。
这里有个关键点值得强调:SIGKILL信号是不能被进程捕获的,应用没有任何机会去处理它。所以你在容器日志里永远看不到"我要退出了"之类的记录,最后一条日志可能正好停在一次正常请求的中间,后面就什么都没有了。这也是137比一般退出码更容易让人困惑的原因——它看起来非常像"应用被外部神秘力量消失"。我还见过有人因为这个问题去怀疑运维误杀、怀疑安全软件,折腾半天,其实信号机制早就把答案写在退出码里了。
和137容易混淆的是139,那是128 + 11,对应SIGSEGV段错误。段错误通常说明应用自己访问了非法内存,比如C++里的野指针、JVM的本地方法崩溃,这类问题方向完全不一样。所以看到退出码的第一时间,一定要先做减法:137 - 128 = 9,心里默念一遍"SIGKILL",后面的排查思路才会对。
1.2 OOM Killer一般在什么场景下触发
既然是内核主动杀的,那就要搞清楚内核在什么情况下会动手。我自己把它分成两个层面:系统级OOM,也就是整台物理机或虚拟机内存耗尽了;另一个是cgroup级OOM,也就是容器自己设定的内存上限被触发了。
系统级OOM很好理解。Linux内核有个OOM Killer机制,当系统空闲内存非常低、内核觉得再分配下去就要影响稳定运行了,它会挑一个进程杀掉。挑谁呢?根据进程的oom_score、运行时间、内存占用等综合评分,得分高的先杀。所以有时候你发现"明明还有几个G内存,为什么我的容器被杀",可能是别的进程一瞬间把内存打满了,或者是内存碎片导致大片连续内存分配不出来。不过现在大多数云主机、单个容器场景里,遇到的多半还是第二种。
cgroup级OOM是Docker容器场景最常踩的。你用docker run -m 512m限制了容器内存上限,容器内进程加一起超过512MB且无法回收时,内核就会直接把相关进程杀掉。这时候在宿主机用free -h看,可能整体内存还有富余,但容器自己已经触顶了。更隐蔽的是,Docker Desktop或WSL2这类虚拟机环境里,容器跑的其实是一台被隔离的Linux虚拟机,如果这台虚拟机本身分配的内存不够,也会触发容器被OOM杀掉,而且你在Windows宿主机上直接看free是看不出来的。
不管是系统级还是cgroup级,一旦被OOM Killer选中,进程收到SIGKILL,退出码就是137。明白了这一点,排查思路就清晰了:我们要做的就是确认到底是谁的内存不够了。
2. 排查exited(137)的标准动作
2.1 第一步:看容器日志和ExitCode
遇到容器挂掉,我的习惯是先跑这几条命令:
# 看最近处于exited状态的容器 docker ps -a --filter "status=exited" # 看具体容器的详细信息,重点看ExitCode和OOMKilled docker inspect <容器名或ID> | grep -E "ExitCode|OOMKilled" # 快速只读OOMKilled字段 docker inspect -f '{{.State.OOMKilled}}' <容器名或ID>第二个和第三个命令里的OOMKilled字段特别关键。如果输出是true,基本可以确定容器是因为内存超标被杀的,后面的排查就能直奔内存问题去了。但要注意,OOMKilled是Docker在cgroup层面能感知到的OOM,如果OOM发生在宿主机层面、杀的是容器里的主进程但Docker没来得及标记,这个字段也可能是false。所以它只能作为第一判断依据,不能当唯一证据。
这时候很多人会顺手看一眼docker logs,我也是这么做的。但请做好心理准备:大概率看不到有价值的错误信息,日志最后几条可能完全正常。这不是日志系统坏了,而是SIGKILL根本不给应用写日志的机会。如果你硬要在日志里找"异常",只会浪费时间。正确的做法是把它当成一条辅助信息,真正要去找的是内核层的证据。
2.2 第二步:查内核日志和dmesg
确认完容器状态,下一步就该去宿主机上翻内核日志了。最常用的是这条:
# 过滤内核日志里的OOM相关记录 dmesg -T | grep -i -E "out of memory|oom|killed process"如果是系统级OOM,你会看到类似Out of memory: Killed process 12345 (java) total-vm:...这样的记录,还会附带当时各进程的内存占用排名。这一条信息能直接告诉你:是你的容器内存大,还是别人的进程抢了内存。
如果是cgroup级OOM,输出通常是Memory cgroup out of memory: Killed process...,同时会带上cgroup路径,你能从中看到容器ID。不过这里有个坑:如果你用的是Docker Desktop或WSL2,容器的内核日志在虚拟机里面,你在宿主机的终端跑dmesg可能什么都看不到。这种情况别慌,后面我会专门讲桌面版Docker怎么排查。
在部分系统里,dmesg需要root权限,或者kernel.dmesg_restrict为1导致普通用户看不到,那就用sudo dmesg。如果日志已经被刷掉了,也可以用journalctl -k | grep -i oom查持久化的内核日志。我个人更喜欢journalctl -k,因为它有时间戳,方便和容器退出时间精确对齐。
2.3 第三步:判断是不是cgroup内存限制
拿到OOM证据后,最关键的判断来了:到底是被容器自身的限制卡的,还是被宿主机整体内存不足拖累的?
先看容器的限制配置:
# 读取容器的内存限制,单位是字节 docker inspect <容器名> | grep -i memory # 也可以看compose项目里的mem_limit或deploy.resources.limits再看当前内存使用情况,用docker stats实时观察:
docker stats --no-stream这个命令会列出每个容器实时的CPU、内存占用。如果某个容器的内存使用率已经跑到99%,而且LIMIT又写着一个很紧张的值,那基本破案了:容器死在自身限制上。如果容器内存明明没到限制,但宿主机free -h显示可用内存非常少,那就要看是不是宿主机资源不足,需要从系统层做腾挪。
还有一个小技巧:在宿主机或容器内(视cgroup版本而定)查看内存事件统计。Linux cgroup v2下,可以看容器对应cgroup目录里的memory.events文件:
# 在容器内执行,或者找到宿主机上对应cgroup路径 cat /sys/fs/cgroup/memory.events文件里有oom计数字段,只要发生过一次cgroup级别的OOM,计数就会变成非零。这个指标比dmesg还稳,因为它直接记录在你的cgroup控制组里,即使dmesg被刷了也能看到。
这三步走完,137的"罪魁祸首"基本就定位了。接下来要做的,是拿不同场景下的具体解法去处理。
3. 最容易踩的五个137场景和对应的解决办法
3.1 Java应用容器频繁137
Java服务是容器137的重灾区,原因在于JVM对内存的默认态度和容器限制天生有冲突。很多同学用docker run -m 512m跑一个Spring Boot应用,结果JVM启动时默认堆大小可能直接取到物理内存的四分之一。如果你的宿主机有8G内存,JVM默认堆就可能想占2G,而容器只允许512M,内核不杀你杀谁。
解决办法是给JVM显式设置内存参数。老派的做法是-Xmx256m -Xms256m,但现在的JDK版本更推荐按容器百分比来:
docker run -m 512m -e JAVA_OPTS="-XX:MaxRAMPercentage=60 -XX:InitialRAMPercentage=40 -XX:MaxMetaspaceSize=128m" your-java-serviceMaxRAMPercentage=60的意思是让JVM最多使用容器可用内存的60%,剩下40%留给Metaspace、线程栈、堆外内存、临时文件等。这个比例不能拍脑袋设到95%,因为JVM除了堆还有很多别的内存区域,尤其是用了NIO、Netty这种堆外内存大户时,比例调得太高照样会OOM。
还有一点:JDK 8u191之前的版本对容器内存感知不完整,会把宿主机的物理内存当成可用内存。如果你还在用老JDK跑容器,升级到8u191以上,或者在启动参数里加上-XX:+UseContainerSupport。这个参数新版本默认就是开的,但旧镜像里很可能没开。我自己处理过一个老项目,升级JDK后加了一行参数,容器内存占用直接从触顶降到稳定的60%,问题当场解决。
3.2 MySQL、Redis等数据库容器137
数据库容器137也特别常见,尤其是官方镜像在默认配置下会随着连接数和缓存增长逐渐吃满内存。以MySQL 8.0为例,innodb_buffer_pool_size默认值是128M,但如果你的表数据和索引大了、并发连接多了,performance_schema、临时表、排序缓冲区都会往上加内存,达到容器上限并不难。
我自己遇到过一台1G内存限制的MySQL容器,在业务高峰期直接退出137。当时的处理分两步:先临时用docker update --memory 2g把它放大一点,让服务马上恢复;然后改MySQL配置,把innodb_buffer_pool_size从226M调小到512M,把max_connections从151降到80,performance_schema也关掉了。改完以后,内存峰值从1.3G降到800M,之后再没触发过OOM。
Redis的容器137又是另一个逻辑。Redis主进程本身不大,但执行BGSAVE或BGREWRITEAOF时会fork出一个子进程,fork用的是写时复制,如果此时父进程内存很大,fork出的子进程在页面被修改前会共享物理内存,一旦大量写入,子进程需要的内存可能接近父进程的一倍。这就是为什么明明平时内存占用不高,突然大写入时Redis容易被OOM杀掉的原因。
针对Redis有两个建议:一是给容器预留的内存至少是redis-cli info memory里used_memory峰值的一倍以上;二是适当设置--vm.overcommit_memory=1,让fork更不容易失败。但要注意,overcommit_memory=1只是允许内核超额分配给fork用,不是让你无限开内存,还是得给容器加足余量。
3.3 编译打包镜像时进程被kill
还有一种137发生在构建阶段,不是运行阶段。你用docker build构建镜像时,如果Dockerfile里执行了mvn clean package、npm run build这类资源密集型命令,构建容器里的编译器很容易把内存吃满,然后整个构建容器被OOM杀掉。这个报错不会显示在正常的应用日志里,而是直接表现为docker build失败,查看构建日志时莫名其妙地在某行戛然而止。
Maven构建的话,可以限制plugin线程数、降低JVM内存:
FROM maven:3.8-jdk-11 AS builder ENV MAVEN_OPTS="-Xmx512m -XX:MaxMetaspaceSize=256m" RUN mvn clean package -T 2 -DskipTestsNode.js构建的话,问题往往出在Node默认堆大小上。可以用环境变量固定:
FROM node:18 AS builder ENV NODE_OPTIONS="--max_old_space_size=1024" RUN npm ci && npm run build如果是Docker Desktop或CI Runner这类共享环境,多个构建任务同时跑,内存叠加也会把构建节点打爆。这时候最好给构建任务设置独立的资源配额,比如GitLab Runner的memory_limit,或者Docker Desktop里的内存设置,再不行就错峰执行构建。
3.4 Docker Desktop和WSL2环境下的特殊问题
Windows电脑上用Docker Desktop的人,遇到137的频率比Linux服务器用户高不少。因为Docker Desktop的Linux容器跑在一个虚拟化环境里,如果这个虚拟机本身内存给得不够,你在容器里跑稍微重点的服务就容易被杀。
最常见的场景:你同时启动了MySQL、Redis、Nginx、好几个Java服务,Docker Desktop只分了8G内存给虚拟机,跑着跑着某个容器就137,另一个可能还在正常运行。这时候在容器里或WSL2终端里看free -h,会发现物理内存已经所剩无几。
解决办法是修改.wslconfig文件,在用户目录下新建或编辑这个文件,给虚拟机分配更大的内存。比如你在Windows上的用户目录是C:\Users\你的用户名,这个文件就放在那里:
[wsl2] memory=12GB processors=8 swap=4GB改完以后要彻底重启WSL,不是重启Docker Desktop就生效的。在PowerShell或CMD里执行:
wsl --shutdown然后重新打开Docker Desktop。这个方法对很多"Windows下容器跑着跑着就退出137"的问题特别管用。顺带一提,Docker Desktop如果是用Hyper-V后端而不是WSL2,内存是在Docker Desktop的Settings → Resources里设置的,改完也要完全退出再启动。
还有一类冷门情况是Windows主机本身内存不高,但Docker Desktop分配过多,导致Windows系统卡顿,间接引发容器进程被杀。遇到这种情况反而要调小Docker Desktop内存,给宿主系统留够余量。总之WSL2的内存分配要结合你Windows总内存、同时跑的容器数来综合调整。
3.5 docker-compose编排整体被OOM
用docker-compose跑一套业务时,多个容器同时启动的瞬间内存峰值特别大,如果宿主机内存撑不住,就会出现"整体崩盘"的惨剧。我见过最夸张的例子:一台4G内存的云主机,compose文件里定义了MySQL、Redis、ElasticSearch、Kibana、后端服务、前端Nginx,一共6个容器。启动时ES和Kibana先抢内存,后端Java再抢,整台机器瞬间触发系统级OOM,最后所有容器一起退出。
这种情况必须按服务划分内存上限。compose v2里可以这么写:
version: "3.9" services: mysql: image: mysql:8.0 mem_limit: 1g redis: image: redis:7 mem_limit: 512m app: image: my-backend:latest mem_limit: 1g environment: JAVA_OPTS: "-XX:MaxRAMPercentage=60"mem_limit是compose的直接写法,新版也支持deploy.resources.limits.memory,不过在单机模式下得配合docker-compose --compatibility使用才生效。我更推荐直接用mem_limit,简单不绕。记住一个原则:compose里所有容器的mem_limit总和,不要超过宿主机内存的80%,留一点给文件缓存和系统本身。
另外,elasticsearch这类组件特别吃内存,官方镜像里默认就给你设了ES_JAVA_OPTS=-Xms512m -Xmx512m,如果你不加限制直接硬跑,很容易和其他服务打架。部署之前先看一眼每个镜像的默认内存参数,该覆盖的覆盖,该砍的砍。
4. 从系统层面做内存兜底
4.1 先搞懂swap和overcommit的取舍
在解决问题的过程中,你迟早会遇到两个概念:swap和overcommit。很多人一看到OOM,第一反应是"加块swap是不是就行了"。理论上swap能延缓OOM,但在容器场景里,swap是一把双刃剑。
如果宿主机配置了swap,容器内进程在内存压力下会把一些冷数据换到磁盘上,这确实能降低被OOM杀掉的概率。但代价是性能断崖式下降——当内存频繁和swap交换时,服务响应时间可能从10毫秒涨到几百毫秒甚至更高,数据库查询卡顿个几秒都算正常。我自己运维过一个MySQL容器,加了swap后OOM没了,但业务侧开始反馈"数据库偶尔很慢",排查发现就是swap在疯狂换页。于是我又把数据量大、延迟敏感的服务调成不换swap,宁可让OOM快速杀死,也不让性能雪崩。
Linux还有个vm.overcommit_memory参数,控制内核是否允许超额分配内存。默认值是0,就是内核按照估算策略分配,大多数情况下够用。某些应用,比如Redis,官方建议在启动时加sysctl -w vm.overcommit_memory=1,允许超过物理内存的分配,这样fork子进程不容易失败。但注意,overcommit=1只影响分配,不影响物理内存不足时的OOM,所以你该限制容器内存还是得限制。
4.2 用docker run和compose合理设置内存限制
如果让我给一个经验值,我会说:单容器内存上限不要拍脑袋定,先跑一遍应用压测,观察稳定运行的内存峰值,然后在这个基础上留出50%到100%的余量。比如你的Java服务平稳运行时占用800M,容器mem_limit给到1.5G或者2G是比较合理的。给得太小,流量一上来就137;给得太大,多个容器叠加又把宿主机挤爆,最后谁也活不了。
常用命令如下:
# 启动时限制内存 docker run -d --name myapp -m 2g --memory-swap 2g myimage # 容器运行中动态调整 docker update --memory 3g --memory-swap 3g myapp这里--memory-swap有个容易踩的坑:如果不设置,默认和--memory相同?实际不是,--memory-swap默认是-1,也就是不限制swap的总量,等于容器可以写任意多的swap。如果你想彻底禁止这个容器用swap,就把--memory-swap设成和--memory一样;如果你希望它有一倍的swap做缓冲,就设成两倍。比如-m 2g --memory-swap 4g表示物理内存最多2G,swap最多2G。
在compose里,对应写法是:
services: app: image: myapp mem_limit: 2g memswap_limit: 4g设置的时候还有一个隐藏问题:如果你的镜像里包含多个子进程(比如通过entrypoint脚本同时起Nginx和PHP-FPM),容器内存是所有进程一起算的。你以为一个进程只占800M,实际上两个进程加起来可能已经1.6G了,这时候按单进程估算mem_limit就很容易翻车。
4.3 宿主机内存监控与报警
很多137问题其实是可以提前预防的,前提是你对宿主机的内存水位有一个持续的观察。free -h和docker stats适合临时排查,但不适合长期盯。我在生产环境一般会装一个轻量的node_exporter+ Prometheus,把内存指标收集起来,配上告警规则:当可用内存低于20%持续5分钟,或者某个具体容器的内存使用率超过90%持续10分钟,就发告警。这样不等容器被杀,你就能提前介入。
如果不想上Prometheus这么重的方案,也可以用glances或htop做临时监控,配合一个简单的crontab脚本记录内存日志:
*/5 * * * * echo "$(date) $(free -g | grep Mem)" >> /var/log/mem.log同时我强烈建议你记录容器层面的memory.events。cgroup v2的memory.events文件里有oom和oom_kill两个计数字段,隔一段时间看一眼,如果计数在涨,说明系统正在"擦边飞行"。这个方式比看dmesg更持久,也更适合放在巡检脚本里。
监控的意义在于:当你发现一个容器内存使用率持续走高、离mem_limit越来越近的时候,就说明应用可能存在内存增长趋势,不是临时流量造成的。这时候就要果断进入代码排查阶段,看看是缓存没清理、连接池过大还是真内存泄漏。
5. 实战排查记录:一个Java容器每天挂一次的问题
5.1 第一次排查为什么走了弯路
上个月我帮朋友排查过一个非常典型的案例。他们的Java后端容器每天凌晨四点多定时变成Exited (137),业务低谷期,流量很小,谁也想不到是内存问题。他们第一反应是定时任务报错,结果代码翻了一遍,没有发现任何能导致进程退出的逻辑。后来换了思路,去查容器状态,发现OOMKilled为true,这才转到内存排查上。
这个案例最值得说的地方是:应用日志里真的什么都看不出来。因为凌晨有定时任务在跑,任务处理完后恰好进程被杀,日志就停在"任务执行成功"这种位置,特别容易让人误以为是后续业务代码的问题。如果当初早一点执行docker inspect -f '{{.State.OOMKilled}}',能省出至少一天的时间。
之后他们在宿主机执行journalctl -k | grep -i oom,看到了类似Memory cgroup out of memory: Killed process的记录,cgroup路径里带着容器ID,至此彻底确认是容器内存限制触发的。
5.2 锁定OOM之后怎么调参
确认方向后,我们开始在内存上做文章。先看docker stats,常态占用在1.1G左右,容器上限是1.5G,看起来余量不小,但凌晨任务会把内存推到接近1.5G,在某次GC还没来得及回收的窗口里直接触顶被杀。
朋友的第一反应是"把mem_limit改成2G",我没有直接让他改。我们先拉了一份GC日志,发现老年代一直在缓步增长,年轻代GC后释放得还算正常,说明应用里很可能缓存了一部分数据没清,或者某个定时任务在批量加载数据时创建了大量对象。把mem_limit调大只能延缓问题,不解决根本。
最后我们做了两个动作。第一,把JVM参数从-Xmx1g -Xms1g改成-XX:MaxRAMPercentage=80,让JVM能使用容器1.5G上限里更多的内存,因为之前-Xmx1g是写死的,应用实际最多才能用1G,但其他堆外内存又堆到了1.4G,调度空间很挤。第二,把凌晨定时任务做成分批处理,每批处理1000条而不是一次性加载全部数据,这样峰值内存明显下降。改完后那个定时任务的内存峰值从1.45G降到了1.1G。
5.3 稳定运行两周后的数据对比
调优后我让他持续观察了两周。第一周内存曲线整体平稳,峰值始终没有超过1.2G;第二周跑了压力测试,把并发调到平时的两倍,内存峰值在1.35G左右,离限制线还有余量。对比之前"每天凌晨定时触顶"的情况,完全是两个状态。
这个案例里最值得借鉴的不是某条具体的调优命令,而是排查顺序:先看OOMKilled确认方向,再看内核日志找直接证据,然后结合应用层GC日志和业务访问特点调参,最后用持续监控验证。顺序一旦反了,特别容易陷入反复改配置、反复被OOM的循环里。
6. 几个没人写在文档里的心得
6.1 别急着加内存,先看GC和线程数
遇到137,很多人第一反应就是给容器加内存。大部分情况下这确实能解决当前问题,但从长期运维角度看,我更建议你先花半天时间看看应用本身有没有内存增长趋势。
拿Java来说,如果容器内存使用率稳定在一个水位,属于正常;如果整体趋势是缓慢爬坡,比如一周内从60%涨到90%,那基本可以判断有缓存、连接或线程没有释放。跑一下jstat -gcutil <pid> 1000,看Old区是否持续增长;再抓一次线程dump,看线程数是不是异常膨胀。其他语言也一样,Node.js用--heapsnapshot,Python用tracemalloc,思路都是先排除内存泄漏,再谈扩内存。否则你加了内存,只是把爆发时间从一周延长到了一个月。
6.2 容器内free命令和宿主机free命令是两码事
这是一个特别容易误导新手的坑。默认情况下,容器内的free -h看到的其实是宿主机的全部内存,和容器自己的memory.max根本没有关系。你进到容器里执行free -h,发现"明明还有好多G空闲",但容器还是被137了,想破头都想不明白。
要准确知道容器自己用了多少内存,请在宿主机侧看docker stats,或者在容器内读cgroup文件:
# cgroup v2 cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/memory.max # cgroup v1 cat /sys/fs/cgroup/memory/memory.usage_in_bytes cat /sys/fs/cgroup/memory/memory.limit_in_bytes只有这两个数字才是容器真实的内存使用和限制,free -h在容器里只是一个"宿主机视角"的参考,别把它当成容器的真实占用。
6.3 swap到底要不要开
关于swap,我的个人经验可以分成三句话:开发环境可以开一点,用于防止突发流量把容器打挂;生产环境的数据库类容器不要依赖swap,宁可让它快速OOM重启,也不能让性能卡到不可用;生产环境的无状态应用可以留少量swap做缓冲,但监控指标还是以物理内存为主。
开swap不是一劳永逸的方案,它只是把"立刻死"变成"慢慢卡"。真正想要稳定,还是要靠合理设置容器内存上限、调好应用层参数、做好监控三件事。这套组合拳打下来,exited(137)自然会离你远远的。
最后再说一个我自己踩坑踩出来的习惯:改完内存参数,不要只看一两天没事就放心了。持续观察至少两周,把docker stats或监控曲线拉出来对比,确认内存峰值没有悄悄逼近限制,才算真正收敛。这种问题说不准什么时候就会在某个流量高峰卷土重来,提前盯住,总比凌晨被群消息炸醒要好。