Docker API 网关退出码 137 就是内存不足吗?OOM 证据与重启排查
区分退出码 137、OOMKilled、停止超时与宿主机内存压力,按容器 ID 和启动时间关联证据,核对实际内存限制,并用离线样例避免把新运行误当成旧故障。
API 网关突然断流,容器显示 Exited (137),先不要直接把内存翻倍。137 常见于进程被 SIGKILL 终止,但内存不足、人工强制停止和停止超时都可能走到这一步。要回答的是“哪个容器、哪次运行、为什么被终止”,而不是只解释一个数字。
本文适用于 Linux 容器上的 Docker Engine 与 Compose。Docker Desktop 还要考虑其 Linux 虚拟机的资源边界;Windows 容器和其他编排平台需要按各自规则核对。示例只经过本地离线验证,没有制造真实 OOM、操作生产容器或调用付费模型。
1. 137、OOMKilled 与内存曲线各能证明什么
Docker 官方把退出状态 137 与 SIGKILL(9) 联系起来,并列出人工终止等可能原因。[1] docker stop 通常先发送终止信号;进程在宽限时间内未退出,会再收到 SIGKILL。[2] 所以“刚部署完出现 137”和“高并发时出现 137”需要不同的证据。
| 看到的证据 | 可以支持什么判断 | 仍不能确认什么 |
|---|---|---|
| 只有退出码 137 | 应检查强制终止路径 | 谁发出信号、是否 OOM |
同一运行的 OOMKilled: true | Docker 记录过容器内进程因内存不足被杀 | 一定是主进程、一定是泄漏、一定要扩容 |
故障时有匹配的 oom、die 事件 | 支持还原容器事件先后 | 没有更多指标时,不能区分所有根因 |
| 重启后内存很低 | 新运行当前占用较低 | 旧运行从未达到限制 |
OOMKilled: false 或找不到事件 | 当前读取没有对应证据 | 不能据此排除其他运行或已丢失历史中的 OOM |
Engine API 对 OOMKilled 的定义是:自容器上次启动以来,容器内是否有进程因内存不足被杀。[3] 子进程被杀后容器可能仍在运行;看到 true 也不能不看时间线就把它当成本次退出的唯一原因。应用自身报告堆内存不足并退出,也不必表现为 Docker 的 OOM 标记或 137。
2. 先保存同一容器、同一次运行的证据
在重建或反复重启前,先确认 Docker CLI 连接的是故障所在环境。以下命令只读;将 gateway-container 换成核对过的目标名称或 ID。第一行拿到 ID 后,后续优先使用该完整 ID,避免名称已被新容器复用。[4]
docker inspect --type container --format 'id={{.Id}} created={{.Created}} restarts={{.RestartCount}}' gateway-container
docker inspect --type container --format '{{json .State}}' gateway-container
docker inspect --type container --format 'memory={{.HostConfig.Memory}} reservation={{.HostConfig.MemoryReservation}} memorySwap={{.HostConfig.MemorySwap}}' gateway-container
保存采集时间、StartedAt、FinishedAt、Status、ExitCode 和 OOMKilled。这几次读取不是原子快照;容器正在频繁重启时,字段可能跨越两次运行,要重新确认身份和启动时间。State 也可能包含健康探针输出与错误信息,发给别人前仍要脱敏,不要改成导出包含环境变量的整份 inspect。
再查故障附近一个明确的历史窗口。以下时间是假设案例,必须替换为自己的故障时间,并让结束时间位于当前时间之前;FULL_CONTAINER_ID 也要替换。+08:00 明确使用北京时间,避免客户端默认时区造成错位。[5]
docker events --since '2026-11-04T16:20:00+08:00' --until '2026-11-04T16:30:00+08:00' --filter 'container=FULL_CONTAINER_ID' --format '{{json .}}'
关注 oom、kill、die、stop、start 及其时间,和发布记录、维护操作、应用错误对齐。保留有限窗口内的脱敏摘要,不无限导出事件。Docker 只返回最近 256 条历史事件;--since 不是永久审计库,没有查到不证明没发生。[5] 需要更久的证据,应使用事先配置的事件采集和宿主机监控。
若旧容器已删除、旧运行字段已被后续启动更新,明确记录“历史证据不足”。用同名新容器的正常状态补填旧故障,会得到错误结论。
3. 区分容器上限、宿主机压力和应用自身错误
HostConfig.Memory 表示该容器配置的内存硬限制,单位为字节;值为 0 表示未设置这项容器限制,不是容器不能用内存,也不保证宿主机或上层资源组没有限制。[3] Linux 主机整体内存不足时,内核也可能杀进程,受影响的不一定只有网关。[6]
对已经恢复运行的实例,可以取一次当前统计:
docker stats --no-stream gateway-container
这不是历史峰值。Linux 上 Docker CLI 显示的内存用量还会减去部分缓存,而 API 原始统计提供总量与缓存字段;不能把不同口径的数直接放在一起判定“还有很多余量”。已停止容器也不能靠此命令恢复故障前的用量。[7]
按证据选择下一步:
- 容器限制附近持续有压力,且同一运行出现 OOM:检查大请求缓冲、并发、积压队列和资源释放,再评估限额是否过低。
- 多个服务同一时段异常:检查宿主机或 Docker Desktop 虚拟机的可用内存、内核 OOM 记录和其他进程,不能只扩大网关额度。
- 正好发生在停止或发布期间:核对优雅退出、在途请求排空和停止宽限时间;延长宽限期只在有理由且有上限时使用。
- 只有应用的堆分配错误,没有容器 OOM 证据:检查该语言运行时的堆限制及应用日志,容器额度与堆大小不是同一个设置。
压力随请求结束回落,和空闲后基线持续增长,是两种不同线索;一次峰值不足以证明内存泄漏。负载估算方法见网关容量与并发测量。
4. 调整内存配置前,先确定要解决哪一层
下面是合并进已有 Compose 服务的示意片段,不是完整部署文件,也不是推荐规格。保留原镜像版本、卷、网络、鉴权和其他配置;gateway 要换成真实服务名。[8]
services:
gateway:
mem_limit: 1g
mem_reservation: 512m
mem_limit 是容器内存上限;mem_reservation 是软约束,不能理解成系统已经独占预留了 512 MiB,也不能保证用量不超过它。软限制应低于硬限制。[6] 如果还设置了 deploy.resources 中的内存属性,要与这些服务级属性保持一致。[8] 网关、数据库、代理及操作系统要一起分配预算;提高一个容器限额可能把压力转移到主机。
memory-swap / memswap_limit 的正值代表内存与 swap 的合计,不是“额外 swap”;其效果还依赖内存限制和主机支持。不要为了止住报错盲目设为无限,也不要用禁用 OOM killer 掩盖资源不足。[6] 本片段不调整 swap,实际值仍需检查。
先在隔离环境保存旧配置并验证方案,再按既有部署流程应用目标服务的变更。改配置文件不等于运行容器已采用新限制;修改后回读 HostConfig.Memory 等实际字段。若流程涉及重建,先保护持久化数据、处理在途请求并准备回滚,不要同时升级镜像或删除卷。重启后的状态是否真正可服务,还需健康检查与就绪验收。
5. 用离线样例避免把新运行当成旧故障
下面的 Node.js 函数只审查已经取得的快照。incident.id 和 incident.startedAt 必须来自故障运行的原始记录,而不是从当前快照再抄一遍;未知时返回待核查,不猜测。它不连接 Docker、不取证,也不自动决定重启或扩容。
function reviewExit(snapshot, incident) {
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v)
const text = v => typeof v === 'string' && v.trim().length > 0
if (!object(snapshot) || !object(incident) || !object(snapshot.State)) {
return 'invalid-input'
}
const state = snapshot.State
if (![snapshot.Id, incident.id, state.StartedAt, incident.startedAt].every(text)) {
return 'missing-run-identity'
}
if (snapshot.Id !== incident.id || state.StartedAt !== incident.startedAt) {
return 'different-run'
}
if (!['created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead']
.includes(state.Status)) return 'invalid-status'
if (!['exited', 'dead'].includes(state.Status)) return 'not-an-exit-snapshot'
if (!Number.isInteger(state.ExitCode) || state.ExitCode < 0 || state.ExitCode > 255 ||
typeof state.OOMKilled !== 'boolean') return 'invalid-exit-fields'
if (state.OOMKilled) return 'oom-evidence-review-timeline'
if (state.ExitCode === 137) return 'exit-137-cause-unconfirmed'
return 'other-exit-review-logs'
}
const incident = { id: 'demo-container', startedAt: '2026-11-04T08:00:00Z' }
const snapshot = {
Id: 'demo-container',
State: {
StartedAt: incident.startedAt, Status: 'exited', ExitCode: 137, OOMKilled: false
}
}
console.log(reviewExit(snapshot, incident))
示例输出 exit-137-cause-unconfirmed,不会把 137 自动归为 OOM。换成新容器 ID 或新启动时间得到 different-run;已在运行的快照返回 not-an-exit-snapshot,不能用其中退出码推断当前退出原因。oom-evidence-review-timeline 只提示有 OOM 证据,仍需要第 2 节的时间线。
本地验证覆盖了这些场景,以及空值、数组、缺失身份、字符串退出码、越界数字和非布尔 OOM 标记。身份字符串按原始值精确比较;这不是时间格式校验器,也不会证明证据真实。运行中子进程 OOM 的监控需要另行处理,函数只用于退出事件审查。Docker 命令和 Compose 片段按官方资料核对;当前验证环境没有 Docker CLI,没有执行真实容器演练。
6. 恢复验收不要只看“不再重启”
先降低或暂停新增负载,避免自动重试继续堆积。网关连接断开不代表上游生成已经停止,也不代表请求没有计费;对故障窗口内的请求按编号核对结果,不把所有任务直接重放。涉及账务时参考请求明细与重试对账。
在隔离环境使用无敏感数据的本地模拟上游,固定请求大小、响应持续时间和最大并发,每次只改变一个变量。预先设置数量、时长与内存停止阈值;不用“压到 OOM”为成功条件。验收至少包含:请求和流式响应能正常结束,取消后资源释放,队列回落,无新增匹配 OOM 证据,数据库和账单状态一致。
保存修复前后容器身份、实际限制、同口径内存趋势、负载条件、错误与未验证项。短时间没有重启只说明观察窗口内没有复现;如果没有拿到旧证据,就保留“根因未确认”,不要把一次扩容后的偶然恢复写成定论。
7. 官方资料与核对日期
资料核对日期:2026-11-04。参数及可用字段以实际 Docker、Compose、内核版本为准。
- Docker container ls:按退出信号筛选:137 与强制终止的多种来源。
- Docker container stop:终止信号、超时后的 SIGKILL。
- Docker Engine API v1.47 官方定义:
ContainerState.OOMKilled、退出与启动时间、内存配置字段;这是固定版本的定义,不声称是最新版本。 - Docker inspect:按类型和模板读取对象。
- Docker events:事件类型、时间过滤、时区与历史条数限制。
- Docker resource constraints:内存硬/软限制、宿主机 OOM 与 swap 边界。
- Docker stats:当前统计、Linux 缓存扣减及停止容器的限制。
- Compose Specification:
mem_limit、mem_reservation与部署资源属性的一致性。