前阵子线上服务连续两个晚上报警,一次是容器被 OOM Kill,另一次是 Full GC 直接把接口拖到大面积超时。两次排查下来,我最大的感受是:容器里的 JVM 监控,跟物理机时代完全不是一个套路。我见过不少团队把 Java 应用容器化之后,第一反应是进容器敲 jstat,结果发现基础镜像里根本没有这些工具;也见过堆内存参数不调整,容器明明只有 4G 内存,JVM 却按宿主机几十G来算,最后 Pod 直接被系统杀死。
这篇文章我会把线上 Docker 容器里 JVM 监控的完整链路拆开讲清楚,重点说三件事:一是为什么容器场景不能照搬老一套,二是怎么用 Prometheus 这套生态把指标采上来,三是这几年我踩过的坑和排障方法。适合正在给线上 Java 服务做容器化监控、或者正被 GC 和 OOM 问题折腾的开发和运维朋友,内容不挑团队规模,哪怕你只有一台服务器也能落地。
1. 容器场景下 JVM 监控的难点与整体思路
1.1 容器里的 JVM 为什么那么难监控
先说一个很反直觉的点:JVM 本身是自带监控能力的,JMX(Java Management Extensions)就是它的“体检接口”,可以暴露堆内存、GC、线程、类加载等一系列指标。问题在于,容器化之后这个接口变得非常难用。
原因有三个。第一,容器有独立的网络命名空间,JVM 的 JMX 服务如果监听了某个端口,外部访问时需要做端口映射,而 JMX 内部的 RMI 协议还会随机开端口,经常出现“端口明明映射了,指标就是拉不到”的情况。第二,生产环境的基础镜像为了精简体积,很多用的是裁剪版 JRE,连 jps、jstat、jcmd 这些 JDK 自带工具都没有,你想进去看一眼“现场”都无从下手。第三,JVM 对容器资源限制的感知能力,是拆分 JDK 版本演进的,早一点的版本根本不知道 cgroup 的存在,会按宿主机物理内存来算最大堆,这一条直接引发了我开头说的容器被杀死的事故。
用一句生活化的话来理解:JVM 像一个租客,容器是它租的单间。以前租整栋楼的时候,租客知道整栋楼有多大,想怎么用都行;现在住进单间,如果不明确告诉它房间的实际面积,它还按照整栋楼的标准来布置家具,结果必然把房间拆了。
1.2 我把监控体系拆成了四层
解决容器监控问题,首先要建立分层思维,不要一上来就纠结用哪个工具。一套完整的 JVM 监控体系,我习惯拆成四层:
- 采集层:负责从 JVM 进程里把指标“掏”出来,比如 JMX Exporter、Micrometer。
- 存储层:负责把这些带时间戳的指标存下来,生产里几乎都是时序数据库,Prometheus 是事实标准。
- 展示层:把指标画成图,最常用的是 Grafana。
- 告警层:基于指标阈值或趋势触发通知,对应 Prometheus 的 Alertmanager。
这四层更像是一条流水线,每一层的选择都影响上下游。不过这里我不建议一开始就追求大而全,很多人第一步就想去搞 APM(应用性能监控),那是另一套复杂度。最务实的方法是先把采集层和存储层做通,让指标在 Grafana 里能看到,再逐步加告警规则。只要底层指标是准的,后面加告警、做报表都只是配置的事。
1.3 方案选型讲究“够用 + 好扩展”
在选型上,我踩过一次弯路:最早在生产环境部署监控时,团队选择自己写脚本定时执行 jstat,然后把输出解析存进文本文件,再用 Python 脚本画图。结果就是三天两头出问题——容器重建后脚本跑不起来、存量数据没有时序、报警只能靠人工盯。后来换了 Prometheus 方案,整个体系才算稳定下来。
那套临时方案的问题在于,jstat 本质上是“命令行的瞬时快照”,适合人工看一眼,不适合做长期数据积累。长期监控必须解决三个问题:指标怎么持续暴露、怎么被自动发现、怎么存得下。Prometheus 生态之所以能成为主流,是因为它用 Pull 模型主动抓取目标,容器无论漂移到哪台机器,只要服务发现能拿到地址,指标就能持续入库,不需要像 Push 模式那样时刻维护发送方和接收方的连接关系。
2. 采集层方案选型:四种方式各有各的适用场景
2.1 首选方案:JMX Exporter 以 Agent 方式注入
采集层是整套监控的地基,我这里用的标配是 JMX Exporter,更准确说是官方提供的 Java Agent 方式。原理很简单:启动 JVM 时通过 -javaagent 参数把这个 jar 注入到应用进程里,它通过 JMX 的 MXBean 读取 JVM 运行数据,再在本地暴露一个 HTTP 端口输出 Prometheus 格式的指标。
选择 Agent 方式而不是独立进程方式,是因为 Agent 和业务进程同生命周期,配置跟着镜像走。应用发版,监控配置也跟着原子更新,不存在“进程起来了但监控进程挂了”的尴尬。而且它规避了 RMI 随机端口问题——JMX Exporter 自己就是 Java 进程的一部分,直接把数据转成 HTTP 输出,Prometheus 只需要访问一个固定端口。
启动方式非常直白,在 JAVA_TOOL_OPTIONS 环境变量里加一行:
JAVA_TOOL_OPTIONS="-javaagent:/path/to/jmx_prometheus_javaagent.jar=9091:/path/to/config.yaml"其中 9091 是监控端口,config.yaml 是采集规则配置,用来过滤和裁剪指标。实际使用中千万别小看这个配置文件,默认全量抓取 JMX 域会导致指标爆炸,后面我会讲怎么收敛。
2.2 备选方案:Micrometer 直接暴露 Prometheus 指标
如果你的服务已经基于 Spring Boot 3.x 或 Cloud 全家桶,还有一个更轻的接入方式:引入 Micrometer 的 Prometheus 注册表依赖。这样应用直接通过 actuator 接口在 /actuator/prometheus 上暴露指标,根本不需要额外挂 agent。
这个方案的好处是侵入性小,依赖一加、配置一开就能跑,而且你能在同一端口同时拿到 HTTP 请求延迟、线程池状态等应用层指标。但它也有一个坑:actuator 接口本身是一个具有信息泄露风险的管理端点,一旦暴露到公网,等于把 JVM 内部状态拱手送人。我的安全基线是:必须放到内网网段,并且通过网段限制或认证保护起来,否则别开。
从长期维护角度,我并不认为 Micrometer 和 JMX Exporter 是二选一的关系。很多团队两个都用,比如核心链路服务用 agent 拿 JVM 层指标,轻量 Web 服务用 actuator 一笔带过。这完全可行,只要最终指标名能统一进 Prometheus 就行。
2.3 应急方案:jstat 和 Arthas 只能救急,不能当长期依赖
说一个容易被忽略的事实:容器里靠 jstat 排查问题的时代已经过去了,主要原因不是命令不好用,而是新版本的 JDK 对 attach 机制的限制越来越多,容器里 PID 1 是 Java 进程时尤其麻烦。后来更多团队转向 Arthas(一款 Java 诊断工具),在 JVM 内做线程栈、方法耗时、反编译等操作确实方便,像 watch 一个线上方法的入参出参,几分钟就能定位问题。
但 Arthas 的思路是“手动诊断”,适合你在报警之后进去看现场,不适合做持续监控。你要是拿它监控七天的心率曲线,先不说数据怎么存,光是每次连接一个生产容器这件事就有很高的操作风险。所以我的总结是:长期监控用 JMX Exporter 或 Micrometer,应急排查用 Arthas,两者缺一不可,但功能绝不能互相替代。
2.4 四种方案横向对比
| 方案 | 接入成本 | 持续监控 | 适用场景 | 注意风险 |
|---|---|---|---|---|
| JMX Exporter Agent | 引入 jar + 改启动参数 | 强 | 生产环境默认首选 | 配置文件需裁剪指标量 |
| Micrometer + Actuator | 添加依赖 + 配置 | 强 | Spring Boot 应用 | 管理端点需做安全收敛 |
| jstat 手动命令 | 零接入 | 弱 | 临时看现场 | JDK 工具链可能不存在 |
| Arthas 诊断 | 按需启动 | 弱 | 单次问题定位 | 有侵入风险,需谨慎使用 |
方案这东西,没有绝对的好坏,只有适不适合当前环境。我见过只靠 actuator 就跑了几年的公司,也见过所有非 Spring Boot 服务全挂 agent 的团队。只要你能说得清每个方案解决了什么问题、牺牲了什么,选哪个都说得过去。
3. 实操落地:从 JVM 参数到 Grafana 面板
3.1 第一个坑就藏在镜像构建里:JVM 参数必须显式设置
真正动手搭建时,第一件事不是装 Grafana,而是检查 JVM 对容器内存的感知。这里有一个很多教程不会重点提的版本分界线:Java 8u191 之前的版本没有开启容器支持,JVM 在容器里看到的内存是宿主机内存,4G 的容器限额形同虚设,堆会一直增长到宿主机物理内存的 1/4 或更高,最后的结果就是 Pod 被 OOM Killer 杀掉,而你去看 Java 日志根本找不到 OutOfMemoryError——因为进程是被 CGroup 层直接杀的。
Java 8u191 及以上版本默认开启 -XX:+UseContainerSupport,但这并不意味着你什么参数都不用管。我推荐在启动命令里显式加上下面这组参数,用百分比而不是固定大小,这样能应对容器内存动态调整的场景:
java -XX:+UseContainerSupport \ -XX:InitialRAMPercentage=50.0 \ -XX:MaxRAMPercentage=75.0 \ -XX:MinRAMPercentage=50.0 \ -jar my-service.jar这里 MaxRAMPercentage 取 75.0 是有讲究的。容器里除了 JVM 堆,还有 Metaspace、线程栈、直接内存(Direct Buffer)、JIT 编译器内存等一堆“堆外开销”,如果堆直接吃到 90%,剩下的区域稍一波动就可能触发 OOM。留出大约 25% 给堆外和系统保留,是经历多次事故后比较稳妥的经验值。注意不要把 -Xmx 和 MaxRAMPercentage 混用,一旦手动指定了 -Xmx,容器感知和百分比配置就都会失效。
3.2 把 JMX Exporter 装进镜像
接下来把监控探针打进镜像。这里我会把步骤说细一点,因为镜像构建阶段少一个文件,后面调试成本会加倍。先在应用部署目录下放好 agent jar 和配置文件,我通常单独建一个监控目录,避免和其它文件混在一起:
# 目录结构 /opt/app/ ├── my-service.jar ├── jmx/ │ ├── jmx_prometheus_javaagent.jar │ └── config.yaml └── start.shconfig.yaml 的关键作用是裁剪指标。官方默认配置会暴露所有 JMX 域,量非常大,我一般是把和故障排查强相关的指标全放行,其它杂项过滤掉。一个可用的最小配置长这样:
rules: - pattern: 'java.lang<type=Memory><(HeapMemoryUsage|NonHeapMemoryUsage)>(committed|init|max|used)' name: jvm_memory_$1_$2 - pattern: 'java.lang<type=GarbageCollector><name=(.*)>:<(CollectionCount|CollectionTime)>' name: jvm_gc_$1_$2启动脚本里直接读取环境变量,这样同一份镜像在测试环境和线上环境可以共用:
#!/bin/bash export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS} \ -javaagent:/opt/app/jmx/jmx_prometheus_javaagent.jar=9091:/opt/app/jmx/config.yaml" exec java $JAVA_OPTS -jar /opt/app/my-service.jar启动之后,先在容器内部验证指标是否正常暴露:
docker exec -it <container_id> sh curl http://127.0.0.1:9091/metrics | head -20这一步非常关键。如果在容器内 curl 能看到 java_lang 前缀的指标,说明 agent 注入成功;如果看不到,优先检查 JAVA_TOOL_OPTIONS 是否真的传到了 Java 进程,可以直接在启动命令里加 -verbose:gc 看启动日志验证。
提示:jmx_prometheus 的指标名在不同版本之间兼容性很好,但如果你以后在 Grafana 里用到别人的面板,注意确认指标前缀和 label 是否匹配。
3.3 配置 Prometheus 抓取容器指标
指标已经暴露出来了,接下来让 Prometheus 找到它。网络拓扑上尽量保持简单:Prometheus 部署在和业务容器相同的网络环境里。如果你用的是 Kubernetes,想都不用想,直接通过 Pod 注解做服务发现:
apiVersion: v1 kind: Pod metadata: annotations: prometheus.io/scrape: "true" prometheus.io/port: "9091" prometheus.io/path: "/metrics"然后在 prometheus.yml 里写一段自动发现配置:
scrape_configs: - job_name: 'jvm-app' kubernetes_sd_configs: - role: pod relabel_configs: - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] action: keep regex: true - source_labels: [__address__] action: replace regex: ([^:]+)(?::\d+)? target_label: __address__ replacement: '$1:9091'如果只是单机 Docker Compose 环境,配置更简单,直接在 compose 里把 9091 端口映射到宿主机,再用静态配置:
scrape_configs: - job_name: 'jvm-app' static_configs: - targets: ['192.168.1.10:9091'] labels: env: production配好后去 Prometheus 的 Targets 页面看状态,Up 就代表连通了。如果显示 Down,先别急着怀疑 Prometheus,去容器里 curl 一下地址,大概率是网络不通或者 agent 没起来。排查时记住一句话:永远分步验证,每一层通了再往上走。
3.4 用 Grafana 把数字变成图
Prometheus 只是把指标存下来了,真正让团队愿意用的还是图形化界面。Grafana 接入 Prometheus 数据源后,在官方面板市场里搜 “JVM” 关键字,找一个下载量最高的 Micrometer 或 JMX Exporter 面板导入,就能直接看到堆内存、GC 次数、线程数量这些图。
不过这里我必须提醒一句:第三方面板导入后,十有八九会有部分图表无数据。原因不是面板有问题,而是面板的变量名、指标前缀和你实际采集到的不一致。比如有人用 Micrometer 风格指标(jvm_memory_used_bytes),有人用 JMX Exporter 风格指标(java_lang_memory),两个风格在面板里对应的 label 完全不一样。正确的做法是先导入,然后在 Grafana 的 Explore 页面查一下实际指标名,再手动改面板的 PromQL 查询。
我自己的习惯是不用第三方面板,直接自己建几个核心图:堆内存使用率趋势、GC 暂停时间、Full GC 次数、Old 区利用率、活跃线程数。五个图足够应对 90% 的排查场景,图画得再漂亮,关键看报警后能不能快速定位根因。
4. 告警配置的实战经验:指标怎么选、阈值怎么定
4.1 先把这几个指标盯住
监控的目的是提前发现问题,不是事后看报表。所以告警指标的选取必须和故障模式强相关。我根据自己的踩坑经历,把优先级排了一个序:
- 容器内存 / 堆内存使用率:直接决定会不会 OOM,最重要,没有之一。
- GC 暂停时间(特别是 Full GC):决定了接口会不会突然变慢、超时。
- Old 区/老年代使用率:堆内存问题的第二观察窗口,如果 Old 区持续上涨且回落不明显,大概率是内存泄漏。
- 线程数:线程泄漏在高并发服务里很常见,线程数持续增长且不回落,需要立刻关注。
- Metaspace:容器镜像经常动态生成类,Metaspace 涨满会触发 Full GC。
判断手段上,Grafana 里“堆使用率”和“GC 耗时”这两条曲线一定要放在同一个时间轴上。经验是:如果 GC 变长时堆内存使用率同时在爬升,说明内存压力较大,需要扩容或者排查泄漏;如果 GC 变长但堆内存很平稳,优先级排在 Native 内存或线程方面。
4.2 阈值不能拍脑袋,先跑一段基线
告警阈值是我见过团队分歧最大的一块。有人喜欢拍一个固定数,比如堆使用率超过 80% 就报警,结果业务高峰每天例行报警,最后大家把告警当成背景音,真正出事反而没人看。我的做法是分两步走。
第一步,先让监控跑一到两周,把正常业务波峰波谷的基线记录下来。不同服务的差异非常大,一个是计算密集型服务,堆使用率常年 70% 上下波动;另一个是缓存型服务,平时 20%,促销期直冲 90%,套同一套阈值显然不合理。第二步,阈值设置要带时间和趋势维度,不要做单点判断。比如“堆使用率超过 80%”这个条件可以加上“持续 15 分钟”,避免瞬时抖动打扰;Full GC 频率可以按分钟发生次数来算,而不是看一次事件就报警。
我自己的经验阈值(仅供参考,不同服务请自行调整)是这样的:
| 指标 | 建议阈值 | 持续时长 | 级别 |
|---|---|---|---|
| 堆内存使用率 | > 80% | 15 分钟 | Warning |
| 堆内存使用率 | > 90% | 5 分钟 | Critical |
| Full GC 次数 | > 2 次/分钟 | 5 分钟 | Warning |
| GC 单次暂停时间 | > 1000 ms | 1 分钟 | Critical |
| 活跃线程数 | 超过基线 2 倍 | 10 分钟 | Warning |
4.3 一套可以拿着改的 Prometheus 告警规则
最后给出一套我在生产环境里实际用过的规则片段,指标名以 JMX Exporter 风格为主。需要提醒的是,不同 exporter 版本指标名会有细微差别,务必先在自己环境里确认指标真实名称,再套用规则。具体的 PromQL 写法如下:
groups: - name: jvm-alerts rules: - alert: JVM Heap Usage High expr: | sum(jvm_memory_used_bytes{area="heap"}) by (instance) / sum(jvm_memory_max_bytes{area="heap"}) by (instance) > 0.8 for: 15m labels: severity: warning annotations: summary: "JVM 堆内存持续高位" description: "实例 {{ $labels.instance }} 堆内存使用率超过 80% 已持续 15 分钟" - alert: JVM Full GC Too Frequent expr: | increase(jvm_gc_collection_seconds_count{action="end of major GC"}[5m]) > 10 for: 5m labels: severity: warning annotations: summary: "Full GC 过于频繁" description: "实例 {{ $labels.instance }} 最近 5 分钟内 Full GC 超过 10 次"规则写完之后,可以在 Prometheus 的 Alerts 页面看到触发状态。Alertmanager 再通过 Webhook 把消息推给钉钉或企业微信,整条链路就闭环了。但记住:规则不是越多越好。告警数量每增加一条,就意味着有人要起来处理一次。宁缺毋滥,先保住上面那几条核心的,再根据事故复盘逐步补充。
5. 高频问题与排障实录
5.1 容器里的监控端口连不上,怎么办
Prometheus 的 Targets 显示 Down,或者 Grafana 里全是“No data”,这是最常遇到的情况。先按层排查,我在现场一般按下面的顺序操作:
- 第一步:进容器里 curl http://127.0.0.1:9091/metrics,确认 Agent 是否真的在监听。如果命令报 Connection refused,问题出在 Java 进程层面,去查启动命令和日志。
- 第二步:如果容器内正常,再去宿主机 curl http://容器IP:9091/metrics,这一层验证网络策略。在 Kubernetes 里尤其注意 NetworkPolicy 是否放行。
- 第三步:检查 Prometheus 的 target 配置和 labels,看 Job 名是否匹配,服务发现是否拿到正确的地址。
这层问题绝大多数出在“容器内已通、容器外不通”,基本就是端口映射漏了或网络策略卡了,没什么玄学。只记一条:不到最后一步,不要去 Prometheus 里改来改去。
5.2 容器被 OOM Kill 但应用日志没有 OutOfMemoryError
这是容器场景最恐怖的一个问题:你的应用日志干干净净,里面没有任何 OOM 异常,但容器重启了。原因大概率不是 Java 堆内存溢出,而是进程占用内存超过容器 CGroup 限制,被内核直接杀死,JVM 根本没有机会输出任何日志。
回头看 Java 进程,默认最大堆是宿主机内存的 1/4。如果你的容器只有 4G 而宿主机是 32G,JVM 会认为堆上限是 8G,实际内存一逼近 4G 时,CGroup 直接动刀。排查方法是先看容器状态:
docker inspect <container_id> --format '{{.State.OOMKilled}}'如果返回 true,说明就是这层原因。解决办法就是我前面说的,用 MaxRAMPercentage 把堆限制在容器内存的 75% 以内,并同时盯容器整体内存监控曲线。记住一个原则:容器里跑 JVM,宁可让 JVM 因为堆内存不够抛出 OutOfMemoryError,也不要让它被内核无声杀死,前者至少能在日志里留下证据。
5.3 GC 日志也能进 Docker 日志吗
可以,而且强烈建议这么做。容器场景下,日志采集都是围绕 stdout 展开的,docker logs、EFK、Loki 都是默认从标准输出抓。如果你把 GC 日志写到 /tmp/gc.log,那它大概率会被日志系统忽略,应用出问题时才发现缺少关键数据。
Java 8 里这样做:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/dev/stdout从 Java 11 开始,日志参数做了统一化改造,/dev/stdout 这个写法失效了,改用:
java -Xlog:gc*:stdout:time,uptime,level,tags这样一个docker logs <container_id>就能直接看到 GC 详细输出,配合监控曲线的异常点,复盘时信息非常完整。
5.4 指标有数但 Grafana 面板空荡荡
这个问题的根因大多是信息“型号”不匹配。Prometheus 里明确查得到 jvm_memory_used_bytes,但 Grafana 面板硬是画不出来,十次有八次是面板里的 PromQL 用了另一个指标名。还有一个隐蔽原因是 label 不匹配,面板用 job="xxx" 过滤,但你的采集配置里 job 叫 releases, 自然就查不到。
排查路径很明确:在 Grafana Explore 里手动执行指标查询,能查到数据说明存储链路没问题,问题一定在面板变量层。依次检查数据源、 panel 的 PromQL、变量里的 label 条件。第三方面板在容器环境里尤其容易踩这个坑,所以我前面才建议大家干脆自己建面板。
5.5 生产出问题时,正确的排查顺序是什么
监控装好以后,真正考验人的是生产出问题时,你能不能稳住心态按顺序排查。我的习惯是这样的:
- 先看监控大盘的“三件套”:堆内存、GC 耗时、容器内存。十秒钟内判断问题是内存趋势型(持续上升)、瞬时冲击型(流量突刺)还是 GC 暂停型。
- 如果内存异常,下一个动作是抓堆 dump 或用 jmap -histo 看对象分布,目标行业是找实例数暴涨的对象类型。
- 如果线程异常,用 jstack 看线程状态,尤其关注 BLOCKED 和 WAITING 状态占比。
- 如果都不明确,再上 Arthas 做在线诊断,看请求链路里哪些方法耗时异常。
这个顺序的正确之处在于,它把“成本从低到高”的操作排在最前。看监控是零成本的,抓 dump 有停顿风险,Arthas 在线诊断是侵入操作,所以这些动作必须靠后,在确认需要时再执行。
绕了这么大一圈,最后分享一个我的个人习惯:监控系统做完之后,一定要主动做一次演练——手动把堆内存调小几个百分点,或者模拟一次内存泄漏,看看告警会不会在五分钟内响起来。只有真的把你叫醒过,这套系统才不算白装。容器里跑 JVM 并不玄,核心就是把资源边界告诉它,把指标通道打开,再把日志交出来,剩下的事就是不断在实战里修正阈值和规则。这套链路你按上面步骤搭完,基本就比大多数团队的监控体系要靠谱了。