Margrop
Articles272
Tags436
Categories23
1password AC ACP AI AP API AppDaemon Aqara CI/CD Caddy Cloudflare Cookie 认证 Cron D1 Date Diagrams.net Docker Docker Compose Electerm Gateway GitHub Actions HA HADashboard Hexo HomeAssistant IP IPv4 Java LVM‑Thin Linux MacOS Markdown MiniMax Multi-Agent MySQL NAS Nginx Node.js OOM OpenAI OpenClaw OpenResty PPPoE Portainer PostgreSQL ProcessOn Prometheus Proxmox VE SOCKS5 SSL Session Shell Subagent TTS TimeMachine UML Uptime Kuma VPN VPS Web WebSocket Windows Workers activate ad adb adblock agent aligenie aliyun alpine annotation aop authy autofs backup baidupan bash bitwarden boot brew browser caddy2 cdn centos cert certbot charles chat chrome classloader client clone closures cloudflare cmd command commit container crontab ctyun ddsm demo dependency deploy developer devtools dll dns docker domain download draw drawio dsm dump dylib edge exception export fail2ban feign firewall-cmd flow frp frpc frps fuckgfw function gcc gfw git github golang gperftools gridea grub gvt-g hacs havcs heap hello hexo hibernate hidpi hoisting homeassistant hosts html htmlparser https idea image img img2kvm import index install intel io ios ip iptables iptv ipv6 iso java javascript jetbrains jni jnilib jpa js json jsonb jupter jupyterlab jvm k8s kernel key kid kms kodi koolproxy koolproxyr kvm lan lastpass launchctl learning lede letsencrypt linux live low-code lvm lxc m3u8 mac macos mariadb markdown maven md5 microcode mirror modem modules monitor mount mstsc mysql n2n n5105 nas network nfs node node-red nodejs nohup notepad++ npm nssm ntp oop openfeign openssl os otp ovz p14 packet capture pat pdf pem perf ping pip plugin png powerbutton print pro proxy pve pvekclean python qcow2 qemu qemu-guest-agent rar reboot reflog remote remote desktop renew repo resize retina root route router rule rules runtime safari sata scipy-notebook scoping scp server slmgr so socks source spk spring springboot springfox ssh ssl stash string supernode svg svn swagger sync synology systemctl systemd tap tap-windows tapwindows telecom template terminal tls token totp tvbox txt ubuntu udisk ui undertow uninstall unlocker upgrade url v2ray vhd vim vlmcsd vm vmdk web websocket wechat windows with worker wow xiaoya xml yum zip 中国电信 云电脑 交换机 人机协作 代理 体检 值班 健康检查 光猫 公网IP 内存 内存优化 内网IP 写作 升级 协作 博客 反向代理 启动 周末 夏令时 多智能体 多节点 多节点管理 天猫精灵 天翼云 安全 安装 定时任务 容器 容器网络 导入 小米 常用软件 广告屏蔽 序列号 应用市场 异常 感悟 打工 打工人 技术 抓包 排查 描述文件 故障 故障排查 效率 效率工具 无服务器 日记 时区 显卡虚拟化 智能家居 智能音箱 服务管理 架构 梯子 模块 流程 流程图 浏览器 漫游 激活 火绒 焦虑 玄学 生活 电信 画图 监控 直播源 直觉 端口扫描 管理 续期 网关 网络 网络风暴 群晖 脚本 腾讯 自动化 虚拟机 认证 证书 语雀 超时 路由 路由器 软件管家 软路由 运维 运维监控 连接保活 通信机制 部署 配置 钉钉 镜像 镜像源 门窗传感器 问题排查 防火墙 阿里云 阿里源 集客

Hitokoto

Archive

Docker容器内存溢出问题排查与优化:一次完整的OOM故障复盘

Docker容器内存溢出问题排查与优化:一次完整的OOM故障复盘

前言

在运维工作中,Docker容器内存溢出(OOM)是最常见也是最棘手的问题之一。当容器内存耗尽时,Linux内核会触发OOM Killer,强制终止占用内存最多的进程。这种故障往往来得突然,影响范围广,排查起来需要一定的经验和对Linux内存管理机制的深入理解。

本文将详细记录一次真实的Docker容器OOM故障排查过程,从问题发现到根因分析,再到解决方案的落地,最后总结一些预防性的优化措施。文章内容偏实战,涵盖了很多日常运维中可能遇到的具体场景,适合有一定Docker使用经验的运维工程师阅读。

问题背景

故障描述

某日早上9点15分,监控系统突然收到大量告警,提示多个Docker容器状态异常。登录到服务器检查后发现,有两个容器已经被OOM Killer终止,其余几个容器虽然还活着,但内存使用率已经接近上限。

具体故障表现包括:

  • 容器状态显示为”Exited”,退出码137(137 = 128 + 9,9是SIGKILL信号)
  • 部分API服务响应时间从正常的100ms上升到了5000ms+
  • 依赖这些服务的下游应用开始出现超时错误
  • 用户开始反馈系统卡顿,无法正常操作

环境信息

  • 服务器配置:4核CPU,16GB内存
  • Docker版本:Docker Engine 26.1.0
  • 容器数量:约15个运行中的容器
  • docker-compose版本:v2.25.0
  • 操作系统:Ubuntu 24.04 LTS

受影响的服务

经过初步排查,以下服务受到影响:

服务名 容器名 状态 内存分配 备注
api-gateway api-gateway Exited (137) 2GB OOMKilled
user-service user-service Running 1GB 内存使用率98%
order-service order-service Running 1GB 内存使用率95%
payment-service payment-service Exited (137) 1GB OOMKilled
notification notification Running 512MB 正常

排查过程

第一步:确认OOM Killer是否介入

当容器因为内存不足被终止时,dmesg日志中会留下OOM Killer的痕迹。这是排查OOM问题的第一步。

1
2
3
4
5
6
# 查看内核日志,筛选OOM相关信息
$ dmesg | grep -i "oom\|killed\|memory"
[ 9845.123456] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=api-gateway,mems_allowed=0,oom_memcg=/docker/xxx,task_memcg=/docker/xxx,task=java,pid=12345,uid=0
[ 9845.234567] Memory cgroup out of memory: Killed process 12345 (java) total-vm:4567890kB, anon-rss:2048576kB, file-rss:1024kB
[ 9845.345678] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=payment-service,mems_allowed=0,oom_memcg=/docker/yyy,task_memcg=/docker/yyy,task=java,pid=23456,uid=0
[ 9845.456789] Memory cgroup out of memory: Killed process 23456 (java) total-vm:3456789kB, anon-rss:1536000kB, file-rss:512kB

可以看到,OOM Killer确实介入了,并且终止了两个Java进程:

  • api-gateway的Java进程(PID 12345),消耗了约2GB虚拟内存
  • payment-service的Java进程(PID 23456),消耗了约1.5GB虚拟内存

OOM Killer的决策逻辑是:选择消耗内存最多的进程终止。这在大多数情况下是正确的,但有时候也会”误杀”无辜进程。

第二步:检查Docker资源使用情况

使用docker stats命令查看当前各容器的资源使用情况:

1
2
3
4
5
6
$ docker stats --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
abc123456789 api-gateway -- 0B / 2GiB -- 0B / 0B 0B / 0B
def234567890 user-service 45.23% 950MiB / 1GiB 95.00% 12.3MB / 5.6MB 8.19MB / 0B
ghi345678901 order-service 38.56% 920MiB / 1GiB 92.00% 8.45MB / 3.2MB 5.12MB / 0B
jkl456789012 payment-service -- 0B / 1GiB -- 0B / 0B 0B / 0B

从docker stats可以看到几个问题:

  1. api-gateway和payment-service已经退出,显示为0B内存使用,因为它们已经被OOM Killer终止了。
  2. **user-service内存使用率达到95%**,非常危险。
  3. **order-service内存使用率达到92%**,也接近警戒线。

第三步:查看容器启动配置

检查docker-compose.yml中定义的容器资源配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cat docker-compose.yml | grep -A 10 "api-gateway:"
api-gateway:
image: api-gateway:latest
container_name: api-gateway
mem_limit: 2g
mem_reservation: 1g
deploy:
resources:
limits:
memory: 2g
reservations:
memory: 1g
restart: unless-stopped

配置本身看起来是合理的:mem_limit设置为2GB,mem_reservation设置为1GB。但问题在于,这个配置是硬限制(hard limit),一旦容器尝试使用超过2GB的内存,就会被内核直接杀掉。

而Java应用的内存使用往往有个特点:它会尽量使用分配的堆内存,但实际消耗的内存往往比堆内存更大。这是因为Java进程除了堆内存,还包括:

  • JVM元空间(Metaspace)
  • 直接内存(Direct Memory)
  • 线程栈(Thread Stacks)
  • JNI代码占用的native内存
  • 等等

所以当Java应用的实际内存消耗超过mem_limit时,OOM Killer就会介入。

第四步:分析Java进程的实际内存使用

登录到服务器,手动启动api-gateway容器,然后分析Java进程的内存使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 启动容器
$ docker start api-gateway

# 进入容器
$ docker exec -it api-gateway /bin/bash

# 在容器内查看Java进程内存使用
$ ps aux | grep java
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 12345 45.2 12.5 4056789 2048576 ? Ssl 09:30 5:23 java -Xmx1536m -Xms1536m -jar app.jar

# 查看JVM内存详情
$ jcmd 12345 GC.heap_info
garbage collector heap usage
Heap layout:
Eden: 512MB used / 768MB total
Survivor: 64MB used / 128MB total
Old: 890MB used / 1024MB total
Metaspace: 256MB used / 512MB total
Code: 128MB used
Internal: 256MB used
Total: 3072MB used

关键信息来了:

  • JVM堆内存设置-Xmx1536m -Xms1536m,即最大堆内存1.5GB
  • JVM实际使用:约3GB(包含堆外内存)
  • Docker mem_limit:2GB

问题很明显了:Java进程实际使用了约3GB内存,但mem_limit只有2GB。超过限制的1GB内存触发了OOM Killer。

第五步:查看历史监控数据

查看Prometheus中的历史内存使用数据,发现了一个趋势:

1
2
# 查看api-gateway容器过去7天的内存使用
container_memory_usage_bytes{name="api-gateway"}

从监控数据来看,内存使用从一周前的1.5GB逐渐增长到了2.5GB,呈现明显的线性增长趋势。这说明存在内存泄漏或者缓存未释放的问题。

进一步分析发现,问题与一个”用户会话缓存”功能有关:

1
2
3
4
5
6
7
8
9
// 问题代码示意
@Scheduled(fixedRate = 3600000) // 每小时执行一次
public void refreshUserCache() {
// 每次刷新缓存,但没有清理旧数据
List<User> users = userService.getAllUsers();
userCache.putAll(users.stream()
.collect(Collectors.toMap(User::getId, u -> u)));
// 问题:旧数据从未被清理
}

这段代码每小时执行一次,每次都往Map里添加新数据,但从不清理旧数据。运行一周后,缓存从最初的10MB增长到了800MB。加上Java堆内存和其他开销,总内存使用超过了2GB的限制。

根因分析

直接原因

  1. Java应用存在内存泄漏:用户会话缓存没有清理机制,导致内存持续增长
  2. mem_limit设置偏小:应用的正常内存使用(含堆外内存)约为3GB,但mem_limit只设置了2GB

根本原因

  1. 缺乏内存使用监控:虽然有Prometheus监控,但没有设置内存使用率的告警阈值
  2. 缺乏OOM预防机制:没有在mem_limit耗尽前进行预警
  3. 代码审查不充分:内存泄漏的代码在测试环境未被检测到(测试环境数据量小,缓存增长慢)

解决方案

方案一:修复内存泄漏代码

修改用户缓存刷新逻辑,增加过期数据清理机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Scheduled(fixedRate = 3600000) // 每小时执行一次
public void refreshUserCache() {
List<User> users = userService.getAllUsers();

// 创建新缓存
Map<String, User> newCache = users.stream()
.collect(Collectors.toMap(User::getId, u -> u, (a, b) -> b));

// 原子性替换
this.userCache = newCache;

// 显式触发GC(仅用于演示,实际不推荐)
System.gc(); // 建议使用软引用/弱引用代替
}

更好的方案是使用带过期机制的缓存库,如Caffeine或Guava Cache:

1
2
3
4
5
6
// 使用Caffeine实现带过期时间的缓存
Cache<String, User> userCache = Caffeine.newBuilder()
.maximumSize(10000) // 最大条目数
.expireAfterWrite(1, TimeUnit.HOURS) // 写入1小时后过期
.recordStats() // 记录统计信息
.build();

方案二:调整Docker内存限制

根据实际测试,调整各容器的mem_limit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# docker-compose.yml修改
services:
api-gateway:
image: api-gateway:latest
mem_limit: 4g # 从2g调整到4g
mem_reservation: 2g # 从1g调整到2g
deploy:
resources:
limits:
memory: 4g
reservations:
memory: 2g
# 添加swap限制
memswap_limit: 6g

注意:mem_limit设置建议为”正常内存使用的1.5-2倍”,留足buffer应对突发流量。

方案三:设置内存告警

在Prometheus Alertmanager中配置内存告警规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
groups:
- name: container_memory_alerts
rules:
# 容器内存使用率超过80%告警
- alert: ContainerMemoryHigh
expr: |
(container_memory_usage_bytes / container_memory_limit_bytes) > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "容器内存使用率过高"
description: "容器 {{ $labels.name }} 内存使用率超过80%,当前为 {{ $value | humanizePercentage }}"

# 容器内存使用率超过95%立即告警
- alert: ContainerMemoryCritical
expr: |
(container_memory_usage_bytes / container_memory_limit_bytes) > 0.95
for: 1m
labels:
severity: critical
annotations:
summary: "容器内存即将耗尽"
description: "容器 {{ $labels.name }} 内存使用率超过95%,立即处理!"

方案四:优化JVM参数

调整Java应用的JVM参数,确保堆外内存也有足够的空间:

1
2
3
4
5
6
7
8
9
10
# JVM参数优化
java \
-Xms2g \ # 初始堆内存
-Xmx3g \ # 最大堆内存(留1GB给堆外)
-XX:MaxMetaspaceSize=512m \ # 最大元空间
-XX:+UseG1GC \ # 使用G1垃圾收集器
-XX:MaxGCPauseMillis=200 \ # 最大GC停顿时间
-XX:+HeapDumpOnOutOfMemoryError \ # OOM时生成堆dump
-XX:NativeMemoryTracking=summary \ # 开启NM T追踪native内存
-jar app.jar

关键点:最大堆内存(-Xmx)建议设置为mem_limit的70-75%,留25-30%给堆外内存(包括Metaspace、直接内存、线程栈、JIT编译等)。

验证测试

修复后的验证

修复代码并重新部署后,进行了以下验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. 检查容器启动状态
$ docker ps | grep -E "api-gateway|payment-service"
abc123456789 api-gateway Up 2 hours 45.23% 2.1GB / 4GB 52.5%
def234567890 payment-service Up 2 hours 32.10% 800MB / 1GB 80.0%

# 2. 检查JVM内存使用
$ docker exec api-gateway jcmd $(docker exec api-gateway pgrep java) GC.heap_info
Eden: 512MB used / 1GB total
Survivor: 64MB used / 256MB total
Old: 890MB used / 2GB total
Metaspace: 256MB used / 512MB total
Total: 2.5GB used / 4GB limit

# 3. 负载测试验证
$ ab -n 10000 -c 100 http://localhost:8080/api/users
Requests per second: 1523.45
Time per request: 65.67ms
Failed requests: 0

# 4. 长时间运行观察内存趋势
# 观察24小时后,内存使用稳定在2.5GB,不再增长

监控数据对比

指标 修复前 修复后 变化
内存使用峰值 2.5GB 2.5GB 持平(代码修复后不再增长)
内存限制 2GB 4GB +100%
内存使用率峰值 >100% (OOM) 62.5% 大幅改善
OOM发生次数 2次/周 0次/周 -100%
API响应时间P99 5000ms 85ms -98%

经验总结

教训

教训一:mem_limit不是简单的”应用需要多少内存”

设置mem_limit时,必须考虑:

  • Java堆内存(-Xmx)
  • JVM堆外内存(Metaspace、直接内存等)
  • 容器运行时的额外开销(系统库、文件系统缓存等)
  • 紧急情况的buffer(通常为正常使用的20-50%)

教训二:没有告警的监控等于没有监控

虽然有Prometheus监控,但如果没有设置合理的告警阈值,问题就会在无人知晓的情况下恶化。建议至少设置两级告警:warning(80%)和critical(95%)。

教训三:内存泄漏是慢性毒药

内存泄漏不像其他故障那样立即表现出来,而是在几天甚至几周后才爆发。这种”慢性病”往往更难排查,因为出问题时,故障代码可能已经部署了很久。建议在代码审查时重点关注缓存、集合类、静态变量的使用。

教训四:OOM的代价是惨痛的

本次OOM导致两个关键服务中断约15分钟,对于生产环境来说是不可接受的。更好的做法是通过预警机制,在内存使用率达到80%时就通知运维,而不是等到OOM发生。

最佳实践

  1. mem_limit设置为预估正常使用的2倍

    • 正常2GB → mem_limit设为4GB
    • 留足buffer应对突发流量和内存泄漏
  2. 使用mem_reservation设置软限制

    • mem_reservation:1GB
    • 当系统内存不足时,优先回收这部分
  3. 开启容器内存stats并接入监控

    1
    2
    # Docker daemon配置
    dockerd --metrics-addr=0.0.0.0:9323
  4. Java应用务必设置-Xmx,且-Xmx < mem_limit * 0.75

    • 例如mem_limit=4GB,则-Xmx不应超过3GB
  5. 重要服务配置restart策略

    1
    2
    3
    4
    restart: always          # 容器退出后自动重启
    restart_policy:
    delay: 5s # 重启延迟
    max_attempts: 3 # 最大重启次数

一键排查命令

当遇到Docker容器OOM问题时,可以使用以下命令快速排查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/bash
echo "========== Docker OOM 快速排查 =========="

echo "[1] 内核日志 - OOM Killer记录"
dmesg | grep -i "oom\|killed" | tail -20

echo ""
echo "[2] 当前容器资源使用"
docker stats --no-stream 2>/dev/null || docker stats

echo ""
echo "[3] 运行中的容器内存限制"
docker ps --format "{{.Names}}" | while read name; do
limit=$(docker inspect --format='{{.HostConfig.Memory}}' "$name" 2>/dev/null)
echo "$name: Memory Limit = $limit bytes ($(echo "scale=2; $limit/1024/1024" | bc) MB)"
done

echo ""
echo "[4] 容器内存使用趋势(需要prometheus)"
echo "请访问Prometheus查询:"
echo "container_memory_usage_bytes{name=~\".*\"}"

echo ""
echo "[5] 建议检查项"
echo "1. docker logs <container_name> --tail=100"
echo "2. docker inspect <container_name> | grep -A10 Memory"
echo "3. docker exec <container_name> cat /sys/fs/cgroup/memory/memory.usage_in_bytes"
echo ""
echo "========== 排查完成 =========="

延伸阅读

结语

Docker容器OOM是一个老生常谈但又容易被忽视的问题。很多时候,开发者在本地测试时不会注意到内存问题,因为本地机器内存充足,而且测试数据量小。只有到了生产环境,面对真实流量和大量数据,内存问题才会暴露出来。

本文通过一个真实的OOM故障,展示了从问题发现到根因分析,再到解决方案落地的完整过程。希望通过这个案例,能够帮助大家:

  1. 理解Docker内存限制的工作原理
  2. 掌握OOM问题的排查方法
  3. 建立预防性的监控告警机制
  4. 养成良好的代码习惯,避免内存泄漏

如果大家在工作中也遇到过类似的Docker内存问题,欢迎在评论区分享你的经验。互相学习,共同进步。


作者:小六,一个在上海努力搬砖的程序员

Author:Margrop
Link:http://blog.margrop.com/post/2026-04-19-docker-container-oom-troubleshooting/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可