摘要:退出码 137 是 Docker 运维中最容易被误读的信号之一。它只说明容器主进程被信号 9 终止,却不区分信号由内核 OOM killer、Docker 守护进程、编排系统还是进程自身发出。本文以“信号来源—资源约束—进程行为”三层归因为主线,把 137 拆解为可观测、可复现的判别链条:先确认信号发出者,再区分内存压力类型(cgroup 级 OOM 与节点级 OOM),最后回到启动命令与入口脚本的语义缺陷。文中给出 cgroup v1/v2 下的取证命令、阈值设定、启动探针与资源配额模板,并讨论在 Kubernetes 与 systemd 混合环境下的边界条件,力求把“猜”变成“证”。
目录
一、问题的本质:137 不是一个原因,而是一个结果
在 POSIX 进程语义中,进程退出状态由两部分构成:低 8 位为终止信号编号,高 8 位为正常退出码。Docker 在容器主进程(PID 1)被信号终止时,会把 128 加上信号编号作为退出码上报,因此 137 = 128 + 9,对应 SIGKILL。这一点在 Docker 官方文档与 Linux 手册中均有明确说明。换言之,看到 137 时,我们唯一能确定的事实是:容器主进程收到了一个不可捕获、不可忽略的强制终止信号。
问题在于,SIGKILL 的发出者可能有很多:内核 OOM killer、Docker 守护进程(例如 docker stop 超时后的强制 kill)、Kubernetes kubelet(Pod 驱逐或探针失败后的重启)、systemd(服务单元超时)、甚至容器内某个拥有足够权限的进程。不同发出者对应完全不同的根因与修复路径。把 137 直接等同于 OOM,是工程实践中最常见的归因谬误。
本文评述:退出码是“结果层”的信息,而排障需要的是“机制层”的证据。把 137 当作诊断终点,等于把体温计读数当作病因。真正有价值的分析主线,是沿着信号传播链向上游回溯,直到找到第一个施加约束或做出决策的组件。
因此,本文提出一条贯穿全文的三层归因主线:信号来源层(谁发的 SIGKILL)、资源约束层(是否存在内存、CPU、PID 等 cgroup 限制被触发)、进程行为层(应用自身或启动命令是否主动走向了被终止的路径)。三层之间存在明确的因果顺序:先有约束或决策,后有信号;先有信号,后有退出码。排障时逆序回溯,设计时正序防御。
二、第一层归因:谁发出了 SIGKILL
2.1 内核 OOM killer 的特征
Linux 内核在内存严重不足时,会调用 out_of_memory() 选择牺牲进程并发送 SIGKILL。在 cgroup 内存控制器启用的情况下,OOM 可能发生在两个层级:cgroup 级(容器内存超限)与节点级(宿主机整体内存耗尽)。两者的取证位置不同,但都会在 dmesg 或内核日志中留下痕迹。典型日志形如 “Memory cgroup out of memory: Killed process 12345 (java)”,其中会标明触发的是 cgroup OOM 还是全局 OOM。
需要强调的是,cgroup v1 与 v2 的观测接口差异显著。v1 下内存限制与统计分散在 /sys/fs/cgroup/memory/ 下,包含 memory.limit_in_bytes、memory.usage_in_bytes、memory.oom_control 等文件;v2 则统一到 /sys/fs/cgroup/ 的 memory.max、memory.current、memory.events。memory.events 中的 oom 与 oom_kill 计数器是判断 cgroup OOM 最直接的证据,且是单调递增的,适合做长期观测。
2.2 Docker 与编排系统的主动终止
Docker 的 docker stop 默认先发送 SIGTERM,等待 10 秒(可通过 --time 或 stop-timeout 配置)后发送 SIGKILL。如果应用没有正确处理 SIGTERM,或优雅退出耗时超过阈值,就会表现为 137。Kubernetes 中 terminationGracePeriodSeconds 的默认值为 30 秒,超时后 kubelet 同样会发送 SIGKILL。此外,liveness probe 连续失败触发的容器重启,也会在事件记录中留下 Killing 记录。
这类场景的判别要点是时间相关性:137 是否总是出现在部署、扩缩容、节点维护或探针失败之后?如果是,那么根因更可能在终止流程而非内存。工程上应把 docker events 与容器日志按时间轴对齐,观察 kill 事件与退出时间的间隔。
| 信号发出者 | 典型触发条件 | 关键证据位置 |
|---|---|---|
| cgroup OOM killer | 容器内存超过 memory.max | memory.events 的 oom_kill 计数、dmesg |
| 节点级 OOM killer | 宿主机可用内存耗尽 | dmesg 全局 OOM 记录、/proc/meminfo |
| Docker 守护进程 | stop 超时、docker rm -f | docker events、dockerd 日志 |
| kubelet | 优雅期超时、探针失败、驱逐 | Pod events、kubelet 日志 |
| 容器内进程 | 入口脚本显式 kill -9、supervisor 策略 | 容器 stdout/stderr、脚本源码 |
三、第二层归因:内存约束的两种形态与取证方法
3.1 容器内存的构成与“看不见的”开销
容器内存用量并不等于应用堆内存。cgroup 统计的 memory.current 包含匿名页、文件页缓存(page cache)、内核栈、socket 缓冲等。Java 应用的堆外内存(DirectByteBuffer、Metaspace、线程栈)、Go 运行时的 arena 与 GC 元数据、Node.js 的 Buffer 池,都会计入 cgroup 用量。实践中常见的情形是:JVM 堆设置为容器内存的 80%,再叠加 Metaspace 与线程栈后突破上限,触发 cgroup OOM。
本文评述:内存配额的规划不应以“应用自报用量”为依据,而应以 cgroup 口径的峰值用量为基准,并预留 15%–25% 的缓冲。对于 JVM,应显式设置 -XX:MaxRAMPercentage 或 -Xmx,并配合 -XX:+UseContainerSupport(JDK 10 起默认开启),避免 JVM 依据宿主机内存推导堆大小。
3.2 取证命令与判读
在容器仍存活或刚退出时,可通过以下命令采集证据。注意 cgroup 路径需根据运行时实际挂载点调整,容器 ID 可从 docker inspect 获取。
# 查看容器退出码与 OOMKilled 标记
docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' <container_id>
# cgroup v2:查看内存上限、当前用量与 OOM 事件计数
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.events
# cgroup v1:查看限制、用量与 OOM 控制状态
cat /sys/fs/cgroup/memory/docker/<id>/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/docker/<id>/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/docker/<id>/memory.oom_control
# 内核日志中检索 OOM 记录
dmesg -T | grep -i -E 'oom|killed process'
判读逻辑如下:若 docker inspect 的 OOMKilled 为 true,且 memory.events 中 oom_kill 计数增加,可基本确认 cgroup OOM;若 OOMKilled 为 false 但 dmesg 存在全局 OOM 记录,则可能是节点级内存压力导致,需检查宿主机整体内存与同节点其他容器。需要提醒的是,OOMKilled 字段在某些运行时版本与重启场景下可能不可靠,因此应以 cgroup 计数器与内核日志为准,二者交叉验证。
3.3 内存压力的间接信号
在 OOM 发生前,通常存在可观测的前兆:memory.current 持续逼近 memory.max、memory.pressure(cgroup v2 的 PSI 指标)中 some/full 的累计时间快速增长、容器内 page fault 与直接回收(direct reclaim)增加导致延迟抖动。PSI(Pressure Stall Information)自 Linux 4.20 引入,为内存、CPU、IO 三类资源提供压力量化,是比“剩余内存”更灵敏的预警信号。工程上可将 memory.pressure 的 full 累计时间作为扩容或限流触发条件。
此外,swap 的配置会显著改变 OOM 行为。cgroup v2 下 memory.swap.max 控制容器可用 swap;若宿主机启用 swap 且容器未限制,内存压力可能先表现为严重换页与延迟上升,而非立即 OOM。这解释了为何部分容器在“内存不足”时表现为超时而非 137——它们先被延迟拖垮,再被探针或编排系统终止。
四、第三层归因:启动命令与入口脚本的语义缺陷
4.1 PID 1 与信号处理的特殊性
容器内的 PID 1 与普通进程在信号语义上存在差异:内核不会对 PID 1 应用默认的信号处置动作(除非显式注册处理函数),这意味着若入口进程未处理 SIGTERM,docker stop 的 SIGTERM 可能被忽略,最终只能等待超时后的 SIGKILL。这直接把“应用未处理信号”转化为“137 退出”。使用 shell 形式的 CMD(如 CMD java -jar app.jar)会经由 /bin/sh -c 启动,shell 成为 PID 1,而 shell 通常不会转发信号给子进程,进一步加剧问题。
本文评述:exec 形式的 ENTRYPOINT/CMD 是基础要求,但并非充分条件。真正稳妥的做法是让应用自身作为 PID 1 并注册 SIGTERM 处理,或引入 tini、dumb-init 等轻量 init 进程负责信号转发与僵尸回收。这一改动成本极低,却能消除大量“莫名 137”的噪声。
4.2 启动命令的常见缺陷模式
启动命令本身导致 137 的路径往往更隐蔽。第一类是“脚本自杀”:入口脚本在健康检查失败或依赖未就绪时执行 kill -9 $$ 或 kill -9 1,表面上退出码是 137,实则是脚本逻辑缺陷。第二类是“进程替换失败”:脚本中使用 exec 但参数拼写错误,导致 exec 失败后 shell 以异常状态退出。第三类是“前台进程缺失”:启动脚本末尾未使用 wait 或前台运行,主进程退出后容器随之结束,若此时被外部信号捕获则表现为 137。
第四类值得单独讨论:资源限制与启动参数的错配。例如 JVM 在容器内依据 cgroup 限制推导堆大小,但若 -Xmx 显式设置过大,或 -XX:MaxMetaspaceSize 未设上限,启动阶段即可能触发 OOM。又如某些应用在启动时预分配大块内存(如缓存预热),若配额不足,表现为启动即 137,而非运行中 137。这类“启动期 OOM”与“运行期 OOM”的区分,对修复策略影响很大:前者应调整启动参数或配额,后者应排查内存泄漏与流量突增。
# 反例:shell 形式,PID 1 为 /bin/sh,信号不转发
CMD java -jar /app/app.jar
# 正例:exec 形式,应用直接作为 PID 1
ENTRYPOINT ["java","-XX:MaxRAMPercentage=70","-jar","/app/app.jar"]
# 正例:使用 tini 作为 init,负责信号转发与僵尸回收
ENTRYPOINT ["/sbin/tini","--","/app/entrypoint.sh"]
五、工程化判别流程:从 137 到根因的七步法
把上述三层归因落成可执行流程,是本文的核心工程贡献。以下七步按“先排除、再定位、后验证”的顺序组织,每一步都给出判定条件与所需证据,避免在信息不足时过早下结论。
| 步骤 | 动作 | 判定条件 |
|---|---|---|
| 1 | 确认退出码与重启策略 | ExitCode=137,记录重启间隔与次数 |
| 2 | 对齐事件时间轴 | kill 事件是否先于退出、间隔是否接近优雅期 |
| 3 | 检查 OOMKilled 与 cgroup 计数 | oom_kill 计数递增则判为 cgroup OOM |
| 4 | 检索内核日志 | 区分 cgroup OOM 与全局 OOM |
| 5 | 复现启动路径 | 以相同命令本地运行,观察是否启动即退出 |
| 6 | 审查入口脚本与信号处理 | 是否存在显式 kill、exec 形式、tini |
| 7 | 施加对照实验 | 调整配额或命令后是否稳定,验证因果 |
第 7 步常被忽略,却是区分相关性与因果性的关键。例如观察到“137 总在流量高峰出现”,这只是相关性;只有通过提高内存配额或修复信号处理后 137 消失,才能确认因果。对照实验应一次只改变一个变量,并记录变更前后的退出码分布与内存峰值,形成可复核的证据链。
在工具层面,可结合 docker stats、cAdvisor、Prometheus 的 container_memory_working_set_bytes 指标做长期观测。working_set 扣除了可回收的 page cache,比 usage 更贴近真实内存压力,是 Kubernetes 驱逐决策的重要依据。读者可参考 Docker 官方文档中关于资源约束的说明(Docker Resource Constraints)与 Kubernetes 内存管理文档(Kubernetes Resource Management)以获取最新参数语义。
六、防御性设计:配额、探针与可观测性
6.1 配额规划的经验法则
配额规划应基于实测峰值而非理论值。建议流程:在预发环境以无限制方式运行,采集至少一个完整业务周期的 memory.current 峰值;按峰值上浮 20% 设定 memory.max;同时设置 requests(调度依据)与 limits(硬约束),避免 requests 远小于 limits 导致的节点超卖。对于 JVM 应用,堆上限建议为 limits 的 60%–70%,为 Metaspace、线程栈、直接内存与 page cache 留出空间。
本文评述:把 limits 设得过大与过小同样有害。过大会让 OOM 延迟到节点级爆发,波及同节点其他负载;过小则频繁触发容器级 OOM。合理的做法是配合 PSI 与 working_set 做动态评估,而非一次性拍定。对于有状态服务,还应考虑内存增长趋势,设置告警阈值在 limits 的 85% 处。
6.2 探针与优雅退出的协同
liveness probe 用于判断是否需要重启,readiness probe 用于判断是否接入流量,startup probe 用于保护慢启动应用。三者配置不当会制造伪 137:例如 startup probe 阈值过小,应用尚未就绪即被判定失败并重启;又如 liveness probe 在内存压力下超时,触发重启,而重启本身又加剧内存竞争。建议 startup probe 的 failureThreshold 与 periodSeconds 乘积覆盖 P99 启动时间,liveness 的阈值应显著宽于 readiness。
优雅退出方面,应用应在收到 SIGTERM 后停止接收新请求、等待在途请求完成、刷新缓冲并关闭连接,总耗时控制在 terminationGracePeriodSeconds 之内。对于消息消费者,应先停止拉取再提交位点;对于 Web 服务,应配合 preStop hook 摘除流量。任何超过优雅期的清理动作,都会把 SIGTERM 升级为 SIGKILL,重新制造 137。
6.3 可观测性三件套
指标、日志、事件三者缺一不可。指标层关注 memory.current/max 比值、oom_kill 累计值、PSI full 时间、容器重启次数;日志层关注应用退出前的最后输出与信号处理记录;事件层关注 docker events 与 Kubernetes events 中的 kill、evict、probe failed。将三者按容器 ID 与时间戳关联,才能形成完整证据链。开源工具如 cAdvisor、node_exporter 的 cgroup 采集器可提供基础指标,商业与自建方案可在此基础上做告警规则。
延伸阅读可参考 Linux 内核文档中关于 cgroup v2 内存控制器的说明(cgroup v2 Documentation)以及 PSI 的官方介绍(Pressure Stall Information)。视频类资料可参考 Docker 官方 YouTube 频道中关于资源限制与排障的系列讲解,便于建立直观认识。
七、边界条件、常见误判与前沿趋势
第一类误判是“OOMKilled=true 即应用内存泄漏”。实际上,page cache 膨胀、并发突增、批处理任务、JIT 编译峰值都可能导致瞬时超限,未必是泄漏。区分方法是观察内存曲线形态:泄漏通常呈单调上升,突增呈阶跃后回落,周期性任务呈锯齿。第二类误判是“137 一定是内存问题”,忽略了 stop 超时与探针失败,前文已详述。第三类误判是“调大内存就能解决”,这在节点级 OOM 场景下可能把问题转移给邻居容器,属于负外部性。
边界条件方面,需注意 cgroup v1 与 v2 混部环境、容器运行时从 Docker 迁移到 containerd 后的路径变化、rootless 容器对 cgroup 访问权限的限制,以及 Windows 容器不适用上述 Linux 信号语义。在混合架构中,统一的观测口径比单一工具更重要。
前沿趋势上,eBPF 正在改变容器排障的粒度。基于 eBPF 的工具可以在不修改应用的前提下追踪内存分配、OOM 触发点与信号传递路径,把“事后日志”变为“事中追踪”。同时,内核社区持续改进 cgroup 内存回收与 PSI 精度,Kubernetes 侧则在推进更细粒度的资源管理与拓扑感知调度。笔者认为,未来 137 的排障重心将从“猜原因”转向“读证据”,可观测性建设的前置投入会显著降低此类问题的平均修复时间。
八、结论与展望
回到标题的问题:137 反复重启,是 OOM、应用崩溃还是启动命令本身有问题?本文的答案是:三者都可能,但判别不能靠猜,而要靠“信号来源—资源约束—进程行为”的三层证据链。先确认谁发了 SIGKILL,再判断是 cgroup OOM 还是节点 OOM,最后审查启动命令与信号处理。七步法提供了可执行的路径,配额规划、探针协同与可观测性三件套提供了防御手段。
工程实践中,最经济的改进往往不是加内存,而是修正入口脚本的 exec 形式、引入 tini、合理设置优雅期与探针阈值。这些改动成本低、收益确定,能消除大量伪 137。对于确属内存约束的场景,应以 cgroup 口径的峰值与 PSI 压力为依据做容量规划,而非凭经验拍数。随着 eBPF 与 cgroup v2 的普及,排障将越来越依赖可验证的运行时证据,这正是本文强调“从猜 to 证”的用意所在。
主要参考文献
- Docker Inc. Docker Docs: Resource constraints. 2024. https://docs.docker.com/config/containers/resource_constraints/
- Linux Kernel Documentation. Control Group v2. 2024. https://docs.kernel.org/admin-guide/cgroup-v2.html
- Linux Kernel Documentation. Pressure Stall Information. 2024. https://docs.kernel.org/accounting/psi.html
- Kubernetes Authors. Manage Resources for Containers. 2024. https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
- Kubernetes Authors. Configure Liveness, Readiness and Startup Probes. 2024. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Open Container Initiative. Runtime Specification: Process and Signals. 2023.
- cAdvisor Project. Container Resource Metrics. 2024. https://github.com/google/cadvisor
- systemd Project. systemd.exec Manual: KillMode and TimeoutStopSec. 2024.

