ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RocketMQ 5 Broker 容器被 Killed:Docker OOM 排查与 JVM 调优实践

RocketMQ 5 Broker 容器被 Killed:Docker OOM 排查与 JVM 调优实践 有段时间没碰 RocketMQ 了这几天想在本地快速起一套 RocketMQ 5 的环境图省事直接用 docker compose 一把梭一个 namesrv一个 broker镜像用的官方 apache/rocketmq:5.x。namesrv 起来了broker 的日志也在滚我心想这下稳了。结果出去倒了杯水回来docker compose ps 一看broker 已经 exiteddocker compose logs broker 的最后一行干干净净躺着一个单词Killed。这个报错很顽固我在网上至少见过几十个人问broker 没崩之前没有任何异常堆栈没有 OutOfMemory 的 Java 报错没有配置报错信息日志里就只有这一句 Killed。遇到这种问题十有八九不是 RocketMQ 配置写错了而是进程被操作系统“行刑”了也就是 OOM Killer 介入的结果。这篇文章就把我的完整排查过程、内存分析和最终能稳定跑起来的 docker compose 配置原样放出来给卡在这道坎上的朋友抄个作业。1. 先搞清楚“Killed”到底是被谁杀的1.1 三分钟定位容器有没有被 OOM Killer 选中Linux 下如果一个进程是被正常关闭或者自己崩溃日志里通常会有线索比如 Java 的异常栈、Spring 的 shutdown 日志或者至少有个 exit code。但 “Killed” 这个单词非常特殊它一般意味着进程收到了 SIGKILL根本没机会做任何清理工作直接被系统从进程列表里抹掉了。在容器场景里最常见的 SIGKILL 来源就是内核的 OOM Killer。你可以用下面几步快速确认我建议按顺序来# 1. 看容器的退出状态 docker inspect broker容器名 --format {{.State.ExitCode}} {{.State.OOMKilled}} # 2. 看内核日志里有没有 OOM 记录 dmesg -T | grep -E Out of memory|Killed process | tail -n 20 # 3. 看宿主机当前内存状况 free -h先说第一条命令。如果OOMKilled输出是true那基本实锤了容器超过了 Docker 或系统给它限制的内存上限被 cgroup 的 OOM 机制干掉了。如果输出是false但 ExitCode 是 137也别急着排除还有可能是宿主机整体内存不足内核在全局层面选中了这个 Java 进程这种情况在 docker inspect 里不一定显示 OOMKilled。第二条命令是终极确认手段。内核日志里会留下类似这样的记录Out of memory: Killed process 721 (java) total-vm:15000000kB, anon-rss:2457600kB。看到Killed process和java同时出现动机和凶手就全清楚了。第三条free -h也很重要它帮你判断你当前这台机器到底还有多少可用内存。很多人以为 docker compose 启动失败是配置文件问题结果一看free -h可用内存只剩下几百 MB这还跑什么 RocketMQ。注意docker compose logs里那行 “Killed” 往往不是 Java 打印的而是容器里的 shell 进程打印的。因为command: sh mqbroker这种方式会让 shell 作为父进程当子进程 java 被 SIGKILL 杀掉时bash 会顺手输出一个 “Killed” 提示。所以日志越干净越说明它死得越突然。1.2 为什么日志里看不到任何 Java 堆栈很多新手到这里会陷入一个误区觉得只要 Java 进程内存出问题一定会先报java.lang.OutOfMemoryError: Java heap space然后打印一堆堆栈。这是理解 OOM 的最大偏差。Java 自身的 OutOfMemoryError 是 JVM 在堆内无法分配对象时抛出的异常这时候 JVM 还活着能打日志、能 dump、能响应 shutdown hook。但操作系统层面的 OOM Killer 是另一回事内核发现系统或 cgroup 的内存已经突破极限它会直接挑选一个进程发送不可捕获、不可忽略的 SIGKILL 信号。Java 进程连异常都来不及抛线程栈也来不及打印就像你正打着游戏电脑突然断电一样没有任何遗言。所以当 broker 日志只有孤零零一个Killed时别花时间翻 broker 配置文件了那是在错误的方向上努力。正确方向是检查内存配额和 JVM 内存参数。2. RocketMQ 5 的内存为什么这么容易爆2.1 一条 compose 命令背后其实藏了好几个“吃内存大户”如果你只用 docker compose 启动了 namesrv 和 broker表面上只有两个容器但这两个容器里跑的都是 Java 进程。而且 RocketMQ 官方脚本里的默认 JVM 参数是给“至少 16G 内存的服务器”准备的不是给开发机准备的。先看 namesrv。runserver.sh里的默认配置通常会把堆内存顶到 4G 左右类似-Xms4g -Xmx4g -Xmn2g。namesrv 只是一个路由注册中心保存的是 Broker 地址、Topic 配置这些轻量级元数据平时空闲时内存占用真没那么高但 JVM 拿到-Xms4g以后会一口气向操作系统申请并提交大块内存。在只有 2G、4G 内存的机器上光 namesrv 一个进程就已经让系统喘不过气了。再看 broker。runbroker.sh里的默认参数更加夸张很多历史版本都是-Xms8g -Xmx8g -Xmn4g。也就是说即使你的容器里一条消息都没发JVM 启动后也会为堆预留 8G 的空间。如果你的开发机总内存只有 8G再算上 namesrv、Docker 本身、系统缓存内存瞬间见底OOM Killer 不杀你杀谁。注意RocketMQ 5 如果开启了 Proxy 模式broker 进程内部还会加载 Proxy 相关模块内存占用会继续上涨。如果你用sh mqbroker --enable-proxy这种方式启动表面上看还是两个容器实际内存模型已经比 4.x 时代更加复杂。排查 Killed 问题时第一反应必须是这个 Java 进程到底拿走了多少内存。2.2 不只是堆内存Broker 还有一堆看不见的开销很多人以为把-Xmx调低就万事大吉其实不够。RocketMQ broker 是典型的“堆内 堆外 文件映射”三位一体内存模型任何一个环节失衡都可能触发 OOM。堆内存Heap是最直观的部分存放消息对象、消费进度、各种缓存结构。Broker 的默认堆设置是 8G但真实使用中堆内并不需要那么大很多数据其实落在页缓存里。堆外部分至少包括这四块Metaspace存放类元数据默认情况下理论最大值受本地内存限制RocketMQ 启动时会加载很多类这部分一不注意就能吃掉几百 MB。Direct MemoryNetty 收发消息会使用堆外直接缓冲区RocketMQ 的网络通信底层就是 Netty。线程栈Broker 会创建大量线程每个线程默认栈大小加上 JIT 开销积少成多。mmap 文件映射CommitLog、ConsumeQueue 的读写通过 mmap 映射文件虽然文件页属于 Page Cache系统内存紧张时可以被回收但它依然会影响操作系统的内存水位。这也是为什么你用docker stats看到的容器内存占用往往比-Xmx设置的值高出不少。真实世界的 Java 进程不是只有堆堆只是冰山一角。2.3 Docker 容器里 JVM 拿内存的规则和你想象的不一样从 JDK 8u191 开始JVM 默认支持UseContainerSupport意思是你只写-Xmx不写别的参数时JVM 会尝试读取 cgroup 的内存限制按比例计算默认堆大小。这条规则看起来很美但有个致命前提如果你在命令行里显式指定了-Xmx8gJVM 就会放弃通过容器限制自动推算老老实实按 8G 来。RocketMQ 的启动脚本恰恰就显式指定了-Xms和-Xmx。所以你在 compose 文件里给 broker 容器设了memory: 2gJVM 还是可能尝试把堆撑到 8G。容器限制只是在内存用量达到 2G 时触发内核把整个容器杀掉并不能阻止 JVM 申请超过限制的内存。这就能解释一个很反直觉的现象明明给容器设置了内存限制也配置了下限为什么 broker 还是被杀因为限制只是“死刑线”不是“刹车片”。你必须先手动把 JVM 的堆参数压到和容器限制匹配的程度限制才有意义。3. 一份实测能稳定启动的 docker compose 配置3.1 最小可运行版本含内存参数覆盖下面这份配置我实测过在一台总内存 4G 的云服务器上能正常把 namesrv 和 broker 拉起来并且可以发送、消费消息。关键点在于用JAVA_OPT_EXT环境变量覆盖 RocketMQ 启动脚本里的默认 JVM 参数。services: namesrv: image: apache/rocketmq:5.3.0 container_name: rocketmq-namesrv command: sh mqnamesrv ports: - 9876:9876 environment: JAVA_OPT_EXT: -Xms256m -Xmx256m -Xmn128m -XX:MaxMetaspaceSize128m -XX:MaxDirectMemorySize128m deploy: resources: limits: memory: 768m broker: image: apache/rocketmq:5.3.0 container_name: rocketmq-broker command: sh mqbroker -n namesrv:9876 depends_on: - namesrv ports: - 10911:10911 - 10909:10909 - 10912:10912 environment: JAVA_OPT_EXT: -Xms512m -Xmx512m -Xmn256m -XX:MaxMetaspaceSize256m -XX:MaxDirectMemorySize512m deploy: resources: limits: memory: 1536m这段配置里我把 namesrv 的堆压到 256Mbroker 的堆压到 512M同时为容器设置了明确的内存上限namesrv 768Mbroker 1536M。为什么容器上限要比堆上限宽裕这么多因为前面说过Java 进程除了堆还有 Metaspace、Direct Memory、线程栈这些堆外开销。如果memory: 1g但-Xmx512m -XX:MaxDirectMemorySize512m那理论上堆外一旦跑满整个容器就贴着危险线了所以内存上限必须留出 buffer。JAVA_OPT_EXT是 RocketMQ 启动脚本预留的追加参数入口脚本会把JAVA_OPT_EXT的内容原样追加到最终 java 命令后面。JVM 参数有一个特点同类参数以最后一个为准。RocketMQ 脚本先加了-Xms8g -Xmx8g后来追加的-Xms512m -Xmx512m会覆盖前面的值所以能正常生效。注意不同版本的 RocketMQ 镜像对JAVA_OPT_EXT的支持细节可能有差异。如果你改了环境变量后 docker logs 里看到的进程参数没变可以进容器手动确认一下启动脚本内容。比如用docker run --rm --entrypoint sh apache/rocketmq:5.3.0 -c grep -n JAVA_OPT_EXT bin/*.sh看看脚本里有没有引用这个变量如果版本特殊直接改command也是绕路之一。3.2 各个参数为什么要这样调先看堆的初始大小-Xms和最大值-Xmx。我把两个值设成一样避免 JVM 运行过程中动态扩容带来的内存抖动。你可能觉得开发和测试场景不需要这么较真但我的经验是JVM 在扩容堆时会触发 STW在小内存机器上反而更容易造成瞬时内存尖峰。从一开始就把堆固定在某个舒适区对容器环境更友好。-Xmn是新生代大小。我习惯把新生代设为堆的一半因为 RocketMQ broker 的很多临时对象都是短命对象比如网络收发时的消息包装对象。新生代太小会导致对象频繁晋升到老年代触发 Full GC新生代太大又会挤压老年代空间。测试环境取一个保守比例就好不需要追求极致。-XX:MaxMetaspaceSize这个参数很多人会漏。默认情况下 Metaspace 只受本机物理内存限制在容器里如果不显式限制一旦加载类特别多它就可能和堆抢内存把容器拖进 OOM。256M 对于 RocketMQ 5 的 broker 来说足够日常跑了。-XX:MaxDirectMemorySize是用来限制 Netty 这类堆外缓冲区上限的。RocketMQ 5 内部通信、文件传输都依赖直接内存。如果你不管它默认值会和堆上限一样大也就是 512M 的堆会默认允许分配 512M 的直接内存两个一叠加就是 1G 起步。这里我显式把它限制在 512M保证和容器上限比例合理。3.3 启动后如何确认 broker 真的活了改完配置重新执行docker compose up -d不要只看容器状态变成 running 就以为成功了。OOM Killer 往往是在进程运行几十秒甚至几分钟后才动手所以你要学会看真正的启动成功标志。# 查看 broker 日志最后 20 行 docker compose logs broker | tail -n 20如果能看到类似The broker[broker-a, 172.x.x.x] boot success的字样说明 broker 已经成功启动并注册到了 namesrv。再多等一两分钟执行docker compose ps确认 broker 没有再次退出。我个人的习惯是启动后立刻发一条测试消息等 10 分钟再回来看看进程还在不在这样才算真正验证通过。另外再提醒一个常见翻车点broker 连接 namesrv 的地址写的是namesrv:9876这是 compose 服务名在容器内可以解析。但如果你在broker.conf里额外配置了namesrvAddr它的优先级可能会覆盖-n参数导致 broker 连不上注册中心甚至启动异常。测试环境我建议就别写 broker.conf 了直接命令行传-n参数越少越不容易踩坑。4. 不同内存机器上的参数建议4.1 先看一张速查表我见过不少朋友一台 2G 内存的轻量服务器也硬要跑 RocketMQ不是说完全不行但参数必须精打细算。下面这张表是我按不同宿主内存整理的参考值你可以直接对号入座。宿主机内存namesrv 堆broker 堆broker 容器 limit适合场景2G128M-256M256M-512M1G本地学习、单机验证4G256M1G2G开发联调、小压力测试8G512M2G-4G4G预发环境、中等流量测试16G 以上512M-1G4G-8G8G生产或压测环境这套经验值背后的逻辑很简单namesrv 只是“登记处”不是“仓库”堆内存给太多纯属浪费。broker 的堆内主要用来做业务逻辑和少量缓存真正的消息存储是靠 Page Cache 完成的。所以与其堆内给 8G不如给操作系统多留些 Page Cache 空间让消息文件的读写更快。4.2 为什么不要盲目照着最大堆设置很多人有个误区生产服务器内存 16G那我给 broker 设置-Xmx8g不正好吗表面看合适但 RocketMQ Broker 所在的主机还需要运行操作系统、namesrv、监控代理、日志采集而且消息文件落盘后操作系统需要用 Page Cache 来加速读写。如果你把内存全部分给 JVM 堆系统里连文件缓存的空间都没有了消息写入性能反而会很难看极端情况下写盘变慢内存压力一上来照样 OOM。还有一个容易被忽视的坑宿主机上除了 RocketMQ 容器可能还跑着 Prometheus、Grafana、日志收集器或者其他 Java 应用。free -h 看起来还有 4G实际上其他组件一波动内存又见底了。所以我的建议是JVM 堆大小不要超过宿主机总内存的一半除非你明确知道这台机器只跑 RocketMQ。4.3 如果确实要开 Proxy 或控制台RocketMQ 5 的完整生态还包括 Proxy 和 Dashboard。如果只是学习消息收发我不建议一开始就全部拉起来。先跑通 namesrv broker确认发送消费没问题再逐个增加组件这样每次内存抖动都能很快定位到谁在捣乱。如果你一定要用 RocketMQ 5 的 Proxy 模式broker 进程内嵌的模块会吃额外的内存建议在表里数值的基础上再把-Xmx往上加 20% 到 30%或者干脆单独用独立进程部署 Proxy避免所有压力都堆在一个容器里。5. 这次排障过程复盘与更多避坑点5.1 排查 Killed 问题的标准操作流程遇到这种“日志干净得像什么都没发生”的故障最忌讳的就是到处改配置乱试。我总结了一套固定排障流程每次照着走一遍基本都能定位到根因。第一步看状态。执行docker compose ps记录容器的退出码。退出码 137 是 128 9也就是被 SIGKILL 杀的信号码。看到 137你就要立刻把思路切到“进程被系统杀”这个方向。第二步看 OOM 标记。执行docker inspect 容器名 --format {{.State.OOMKilled}}。输出true说明容器超过 cgroup 内存限制输出false也别放弃去查宿主全局 OOM。第三步看内核日志。执行dmesg -T | grep -iE killed process|out of memory | tail -n 20。如果出现 java 相关进程被选中的记录你就拿到了实锤证据。这一步输出会告诉你当时这个进程占用了多少内存帮助你反推参数设置是不是合理。第四步看家底。执行free -h和docker stats --no-stream看看宿主机还有多少可用内存其他容器是不是也在偷偷占内存。这套流程走完结论基本就清楚了是容器限制太小还是 JVM 参数太大还是宿主机本身就快满了。原因不同改法也不一样。5.2 几种特别容易误判的场景有一种情况是容器内存上限给得很足比如 4GJVM 参数也调到了 1G但 broker 启动后还是被 Killed。这时候不要只盯着 broker 看很可能问题
返回列表