线上服务器一旦跑在Docker容器里,原先对裸机JVM那套监控手段基本都要重来。端口要重新规划,内存要重新算账,连jstat的用法都得变着花样来。这篇就把我这几年来在容器JVM监控上踩过的坑、验证过的方案整理成一份可以直接参考的操作手册,适用对象是负责线上应用的后端开发、运维和SRE同学。我不会只丢一堆命令给你,而是把每个环节“为什么要这样做”也讲清楚,这样你拿到自己环境里改一改就能用。
1. 先搞清楚:容器里的JVM监控,到底卡在哪
1.1 容器化给JVM监控带来的三个变化
第一是资源感知问题。JVM本身是个“吃内存大户”,它在启动时会尝试读取宿主机或者容器的内存总量来动态调整堆大小。但容器里的内存总量是通过cgroup控制的,如果JDK版本太老,默认不感知cgroup限制,就会直接读到宿主机的全部内存,然后按宿主机内存的1/4来设置堆大小。结果就是容器明明限制了512MB,JVM却觉得自己有64GB可以用,堆空间分配一大半,容器直接被OOMKilled。这个问题在JDK 8u131之后的版本才通过-XX:+UseCGroupMemoryLimitForHeap(实验性)和后续的容器感知机制缓解,JDK 10之后基本默认开启。但“开启”不等于“合理”,监控时必须把自己设置的堆参数和容器限制对齐,否则监控面板上看着堆占用才50%,容器已经被杀了一遍。
第二是端口和网络模式的变化。裸机上监控JVM,直接jstat -gc <pid>就行,只要进程号在手天下我有。容器里通常不建议SSH进去折腾,核心原因是容器本身是“一次性”的,重启就没了,而且生产环境为了安全也不会给每个容器开SSH。远程监控就必须暴露JMX端口,这又带来了端口规划、鉴权配置、防火墙规则一大堆新问题。更麻烦的是编排平台上Pod的IP是动态的,今天监控的是10.0.1.5,明天重启变成了10.0.2.8,静态配IP的方式直接作废。
第三是生命周期变短。容器频繁扩缩容、发版重建,JVM进程存活时间可能从“几个月”变成“几十分钟”。传统的“发现主机—拉取指标—看趋势”模式来不及反应,慢悠悠的定时任务还没抓到快照,实例已经没了。这就要求监控体系必须是动态发现、持续拉取,并且指标本身要带上实例的维度,否则排查问题时分不清是哪个副本在报警。
1.2 内存可见性:宿主、容器、JVM三者的关系
这三个层面的内存概念如果不分清楚,监控时一定会被数据搞晕。宿主机层面看到的是整台机器所有进程的内存占用,包括内核、其他容器、系统服务。容器层面看到的是cgroup统计的当前容器实际使用内存,包括JVM堆、JVM堆外内存(Metaspace、线程栈、直接内存、JIT编译器缓存等)、容器里的运行进程、以及文件页缓存。JVM层面看到的只是JVM自身管理的那部分内存,而且JDK的-Xmx设置的是堆上限,不代表实际占用。
监控时真正要盯的是容器的实际内存(container_memory_usage_bytes或memory.usage_in_bytes),因为cgroup是根据这个值来判断要不要OOMKilled的。JVM堆占用只是其中一个大头,不是全部。我碰到过一个很典型的案例:堆设置得很合理,堆占用也只有50%,但容器反复被杀。最后查出来是堆外内存里的直接内存(Direct ByteBuffer)没限制住,Netty和异步日志都在用它。所以监控JVM一定不能只盯堆,必须联合容器层的指标一起看,两个视角互补才能还原全貌。
2. 自己要盯的指标有哪些
2.1 必看的五类核心指标
堆内存使用量是基础,但不该只盯当前值。我习惯把堆拆成“老年代占用”和“新生代对象晋升速率”来看,单一数值很容易掩盖GC波动。实际监控时我会把以下五类指标放进看板:
- 堆内存:已用堆、最大堆、老年代占用趋势。老年代持续上升且GC后不下降,基本可以断定有对象在漏,迟早Full GC。
- GC情况:Young GC频率、Full GC次数和停顿时间。GC次数翻倍往往是流量变化或者内存分配过快,停顿时间变长通常和堆大小或GC算法相关。
- 线程情况:线程总数、活跃线程数、死锁检测。线程数长时间不回落基本是线程泄漏,HTTP线程池满了往往表现为活跃线程打满而CPU不高。
- CPU:JVM进程自身CPU占用,以及GC线程CPU、JIT编译线程CPU。JIT编译线程长时间高占用说明有热点方法在反复触发热点编译。
- 堆外内存与文件描述符:Direct Buffer容量、Metaspace占用、打开的文件句柄数。这几个是容器里最容易出问题的隐形杀手,经典故障是Metaspace缓慢增长直到占满,或者文件句柄打到上限导致IO报错。
2.2 这些指标背后对应的问题
指标不是看个热闹,而是要能反推问题。Young GC次数急剧增多,但每次回收时间很短,通常说明对象分配速率高,常见的诱发点是一次性批量数据处理、日志框架按字节数组频繁分配缓冲区。老年代缓慢增长但GC还能回收,说明存活对象在增多,要么是缓存未设上限,要么是连接池对象没有合理释放。如果老年代占用平稳,但Full GC反而变频繁,就要怀疑是Metaspace加载了大量类,典型场景是热部署或者反射生成代理类。线程数暴涨但CPU占用平稳,大概率是线程在等锁、等IO,如果线程堆积的同时CPU还很高,就得怀疑是无限循环或者空转逻辑。
我不管哪个指标报警,第一反应都不是改参数,而是先结合最近的变化做一次“变更前vs变更后”对比。JVM的很多指标在发版后才会异常,纯粹看平均值没有意义。
2.3 为监控指标设定基线
没有基线的监控全是噪音。一开始我把所有指标都配上告警,结果每天半夜被GC次数告警轰炸,等真出故障时反而被阈值刷掉。后来我学乖了,先让监控体系裸跑两周,不配告警,只收集数据。两周后用P95分位数做基线,再以基线向上浮动30%作为告警阈值。比如Young GC频率基线的P95是每分钟20次,就设成30次告警;Full GC原本一周一次,只要一天超过三次就要人工介入,而不是每分钟都看阈值。这样既不会漏告警,也不会被正常波动烦死。
3. 监控工具怎么选
3.1 JDK自带工具:排查现场的第一手段
别迷信复杂工具,线上容器里第一时间能用的往往还是JDK自带的。jstat可以看GC统计,jstack可以抓线程栈,jmap可以导堆,jcmd可以综合诊断。但容器里用这些工具有几个坑:容器内可能没有这些命令(如果你的基础镜像是裁剪版的JRE),容器中没有jstack的确不能解决全部问题,很多时候需要用docker exec配合完整的JDK镜像,或者把工具拷进去。另外jmap -F对容器场景几乎不可用,因为需要停掉进程做堆转储,生产环境没几个敢停。我现在的习惯是:能用jcmd就不用jmap,jcmd <pid> GC.heap_dump /path/to/dump.hprof支持在线转储,虽然也会触发STW,但比jmap对容器的兼容性要好。
一个容易忽略的点是PID问题。容器里的Java进程PID通常是1,因为很多基础镜像里Java是直接作为主进程启动的。所以docker exec <container> jcmd 1 Thread.print比在宿主机上jps再找PID靠谱得多,也更适应PID namespace隔离。
3.2 长效指标采集:Micrometer + Prometheus + Grafana
这是目前最主流、坑最少、社区生态最完整的方案。应用侧引入micrometer-registry-prometheus,暴露一个/actuator/prometheus端点,Prometheus自动抓取,Grafana负责展示。整个过程本质上就是用HTTP把运行时指标暴露出来,不依赖JMX动态连接,容器重启也无所谓,因为是按目标地址抓取的。
对于老项目不想改代码,还有一个备选方案:JMX Exporter。这个是一个单独的Java agent,跟随应用启动,把JMX的MBean信息转换成Prometheus格式。它的好处是不改造代码,坏处是架构上多了一个进程,而且JMX自身的MBean数量庞大,指标维度可能会非常冗杂。我来回对比后,新的服务优先上Micrometer,老旧服务才让JMX Exporter顶上。
3.3 动态诊断:Arthas
Arthas是唯一一个让我在容器场景里觉得“救了大命”的工具。它直接在Java进程上做动态诊断,不需要重启应用,不需要提前埋点。类排查常用的是:trace命令追踪线上方法耗时分布、watch命令观察特定方法的入参和返回值、dashboard命令实时看线程、内存、GC全貌。
容器里用Arthas有几个前提。一是进程必须开了JMX或者Arthas本身能通过attach方式连上去,容器里如果PID不同,需要进容器再exec。二是docker容器的默认配置可能限制了attach操作,最好在Dockerfile里预留一个JAVA_TOOL_OPTIONS=-javaagent的入口,或者至少在排障时保证有权限做docker exec。三是Arthas自身会占用一定内存,压榨很紧的容器会因此OOM,建议只在排障时挂载,不要常驻。
3.4 工具选型对比表
| 维度 | JDK自带工具 | Micrometer+Prometheus+Grafana | Arthas |
|---|---|---|---|
| 适用场景 | 已有监控体系的加固 | 持续可观测性建设 | 线上动态问题定位 |
| 接入成本 | 低,但需要容器内有完整JDK | 中,需要改代码或加agent | 低,按需注入 |
| 实时性 | 高,命令即时反馈 | 中,取决于采集周期 | 高,命令即时反馈 |
| 对运行时影响 | jstack会短暂影响,jmap较大影响 | 极小,暴露指标本身很轻 | 有attach和字节码增强开销 |
| 核心能力 | 按需看单点快照 | 持续看趋势、做告警 | 深挖方法级调用链 |
实际环境里这三个不是二选一,而是叠加使用的。持续监控交给Prometheus和Grafana,接到告警后进容器用JDK工具或Arthas深入排查。
4. 实操过程与关键配置
4.1 用Micrometer暴露指标的具体步骤
以Spring Boot应用为例,最先要做的是在pom.xml里加上依赖:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>然后确认application.yml里把/actuator/prometheus端点暴露出来:
management: endpoints: web: exposure: include: health,info,prometheus metrics: tags: application: ${spring.application.name}这里有个容易被忽略的细节:给指标打上application这个Tag。在Prometheus里拉取多个服务时,如果没有Tag,不同服务同名指标聚合在一起会算错,看板会变成“全体服务混成一锅粥”。我碰到过新服务接入后GC指标翻了几倍,以为是业务问题,仔细查才发现是把两个环境的指标混在一起了。
接下来用Docker启动应用,注意docker run不需要单独的端口映射给JMX或Prometheus,Prometheus会走容器网络自动抓取:
docker run -d --name order-service \ --memory=1g \ --cpus=1.0 \ -e JAVA_OPTS="-Xms512m -Xmx512m -XX:MaxMetaspaceSize=128m" \ -p 8080:8080 \ order-service:latest这里把-Xmx和--memory设置成1:2的关系比较稳妥,给堆外内存留出空间,后面讲怎么传参。
4.2 Prometheus动态发现容器目标
Prometheus要能自动发现容器实例,不能用静态写IP的方式。通常用Consul做服务注册,或者直接用Kubernetes的服务发现。这里给一个偏静态但小规模完全够用的方案,写进prometheus.yml:
scrape_configs: - job_name: 'order-service' metrics_path: '/actuator/prometheus' static_configs: - targets: ['order-service:8080'] labels: application: 'order-service'如果是在Docker Compose环境里跑,可以用docker-compose ps拿到服务名作为地址。如果是K8s环境,可以让Prometheus通过Pod注解自动发现:
metadata: annotations: prometheus.io/scrape: "true" prometheus.io/path: "/actuator/prometheus" prometheus.io/port: "8080"采集周期我建议设成15秒。太频繁对JVM本身和Prometheus压力都大,太稀疏看GC的小幅波动看不清。每次采集本身是轻量HTTP请求,实测对应用性能影响可以忽略。
4.3 GC日志开启与堆转储配置
监控只是“知”,真正要“治”的时候得靠日志。JVM的GC日志一定要开,而且要输出到容器的stdout,这样K8s或Docker的日志收集器能统一捞走。
JAVA_OPTS="$JAVA_OPTS -Xlog:gc*,gc+age=trace,safepoint:file=/dev/stdout:tags,uptime,level"JDK 11+推荐上面这种-Xlog写法。JDK 8还是老写法:
JAVA_OPTS="$JAVA_OPTS -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -Xloggc:/dev/stdout"同时把OOM时的堆转储打开,这个看似基础,但在容器里执行要特别注意路径。容器一重启,宿主机上挂载的dump文件路径如果不映射出来就全丢了:
JAVA_OPTS="$JAVA_OPTS -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/dumps"然后Docker启动时加:
-v /data/dumps:/opt/dumps这样OOM发生时堆文件落盘到宿主机,才有机会事后分析。顺带一提,-XX:+ExitOnOutOfMemoryError和-XX:+CrashOnOutOfMemoryError这两种参数要慎重,前者在OOM时直接退出进程,后者会在堆溢出时生成crash日志并退出。如果你有集群快速扩容机制,直接退出比拖死其他请求更合理;如果你只有一个实例,退出会当场挂掉整个服务。
4.4 容器里手动排查怎么操作
先看基础状态,进容器里看Java进程和资源占用:
docker exec -it order-service bash ps -ef | grep java top -H -p <pid> # 线程级别CPU占用容器里通常没有完整的操作系统工具,top可能不存在,可以用jstack抓线程状态,再用jcmd看汇总。
看堆内存和GC:
docker exec order-service jcmd 1 GC.heap_info docker exec order-service jcmd 1 GC.class_histogram docker exec order-service jstat -gcutil 1 1000 5这里1指的是容器内Java进程的PID。jstat -gcutil 1 1000 5的含义是从进程1开始,每1000毫秒打印一次,总共打印5次。注意容器里这个命令需要JVM的统计模块能正常工作,有些精简镜像会把/proc映射限制掉,可能会导致jstat失败。
排查CPU飙高时用Arthas最顺手:
docker exec -it order-service curl -sO https://arthas远程包地址/arthas-boot.jar docker exec -it order-service java -jar arthas-boot.jar 1进入Arthas后先敲dashboard看线程状态,再用thread -n 3列出CPU占用最高的线程,然后直接trace那个线程的方法调用。整个过程不需要改代码,完全在线上操作,这也是Arthas能在容器场景用起来的关键原因。
4.5 告警规则建议
告警规则写得不好等于没有告警。我的经验是第一周先别配告警,纯收集基线数据。有了基线后,核心告警规则按以下思路配:
| 告警项 | 建议阈值 | 说明 |
|---|---|---|
| Full GC次数 | 连续2个采集周期超过3次/小时 | 一次可能是抖动,持续则异常 |
| GC停顿时间 | 单次达到300ms | 容器里的响应追求应该更高 |
| 老年代占用 | 90%持续10分钟 | 离Full GC一步之遥 |
| 容器内存被杀 | 事件级别 | 这个要直接告警到人 |
| 线程活跃数 | 持续超过基线30% | 排查线程池和锁 |
Prometheus的告警规则语法如下:
groups: - name: jvm-alerts rules: - alert: FullGCTooFrequent expr: rate(jvm_gc_pause_seconds_count{action="end.of.major.gc"}[5m]) > 0.01 for: 10m labels: severity: warning annotations: summary: "Full GC频率过高" description: "实例{{ $labels.instance }}最近5分钟内Full GC次数达到{{ $value | humanize }}"注意表达式里的rate()函数会自动处理容器重启和指标清零的情况,比直接拿counter做差集要稳。
5. 常见问题与排查实录
5.1 容器反复被杀,但JVM堆显示正常
这个坑我踩了不止一次。现象是监控面板上JVM堆占用只有40%,Prometheus的container_memory_usage_bytes却在持续上涨,然后某一天容器突然被OOMKilled,重启后又从0开始涨。排查路径是:先看cgroup的历史内存记录,再看堆外内存在哪。重点怀疑对象是直接内存。Netty和异步日志buffer都依赖DirectByteBuffer,这个内存不受-Xmx限制,JVM也不做主动回收,只能靠-XX:MaxDirectMemorySize和Full GC顺带清理。
我那次定位得很辛苦,最后是靠Arthas的dashoboard里看到堆外内存飙升才锁定的方向。接着排除了Metaspace,用jcmd 1 VM.native_memory看到了DirectByteBuffer占了很大一块。修复方案是在JAVA_OPTS里加-XX:MaxDirectMemorySize=64m,同时把日志的buffer大小降下来。这里提醒一句:VM.native_memory要配合JVM参数-XX:NativeMemoryTracking=summary才能用,所以如果你没开这个参数,临时排查会很被动,建议生产环境的JDK都加上,代价几乎可以忽略。
5.2 JMX端口暴露了,但连不上
有的团队沿用裸机时代的JMX监控方式,在容器里开了com.sun.management.jmxremote.port=1099,也做了端口映射,宿主机上的监控客户端却连不上。原因通常是JMX的RMI绑定地址的问题,JMX在远程模式下,注册中心会在服务端自动选择IP,在容器里这个IP经常是容器内部IP(172.x.x.x),宿主机客户端访问不到。解决办法是强制指定RMI的hostname:
-Dcom.sun.management.jmxremote.rmi.port=1099 \ -Dcom.sun.management.jmxremote.hostname=<宿主机IP> \另外还要手动开放RMI随机端口的映射,非常繁琐。这也是我推荐优先走HTTP暴露指标而不是JMX远程连接的重要原因,容器场景下HTTP几乎是零成本,JMX是纯折磨。
5.3 日志一天涨了几个GB,把磁盘打满了
容器化之后,GC日志输出到stdout虽然便于收集,但也会带来日志量爆炸。如果没做日志轮转,一个高流量应用的GC日志一天可以涨到几GB,最终导致磁盘满、Pod驱逐、甚至整个节点不稳定。我在GC日志的-Xlog里加上了:file=/dev/stdout并且用日志框架自带大小限制,但最有效的还是在收集侧强制轮转。比如用Splunk或ELK的filebeat,配置按大小或时间切割。还有一点容易被忽略:GC日志的级别要控制好,gc+age=trace这类详细日志在排查问题时才需要临时开,平时只要gc*就好,否则每天日志量的差距是数量级的差别。
5.4 监控面板有数据,但报警不准确
报警不准确的八成原因是指标重叠和阈值不科学。比如你给Full GC设的阈值是“5分钟内3次”,但Prometheus抓取的指标经过rate()计算后,可能把容器重启时的瞬时值也算进去,结果发版时必然触发一堆假报警。解决方式是加for: 10m持续观察条件,也就是说指标连续10分钟超过阈值才告警,短暂抖动会被过滤掉。另外同一个PID在容器重启后会复用,Prometheus的指标里如果带了instance标签,旧数据和新数据混在一起算也会产生噪音。解决办法是在Deployment的探针里给容器起一个固定的Prometheus标签,或者用job标签区分不同版本,尽量减少重启造成的指标语义断裂。
另一个容易踩的坑是grafana模板的metric名字对不上。Micrometer暴露的指标名带了jvm_前缀,Prometheus里叫jvm_gc_pause_seconds、jvm_memory_used_bytes,如果你网上随便扒一个模板,里面可能是旧版本的命名,直接导入看板全是空。这种问题排查起来很恶心,所以建议每次导入模板后先点一个面板看看指标预览有没有数据,别等到告警配完了才发现全都没接通。
6. 关于监控体系的个人经验补充
每次带新人做JVM监控,我都会强调一句话:监控要服务于排查,而不是服务于“好看”。一个满屏绿油油、指标齐全网状图的监控大屏,大概率是一个没人看的监控大屏。真正有用的监控是能回答三个问题的:你有几次Full GC、老年代还剩多少、容器有没有被杀过。把这三个问题答得准、答得快,比堆100个指标都管用。
实践中的做法是,我会在每个服务启动后第一次发版前,手动做一次全链路演练:人为构造一个内存泄漏场景,比如循环往List里塞数据,让堆上涨,确认监控面板能在30秒内看到异常曲线并触发告警。演练完把这个泄漏代码撤掉,放一版正常代码上线。这样做的价值是你确信告警链路是通的,而不是等真出事故才发现Grafana数据源断了一个星期。
还有一点经验是:别把所有监控的事都交给研发团队。容器JVM监控的瓶颈往往不在于工具,而在于职责不清。开发写完指标采集,运维负责Prometheus运维,SRE负责告警响应,每一环都要有明确的责任人。我见过太多团队,架构搭得漂亮,但告警邮件发出来谁都不认领,最后变成“有监控、无处置”。
最后再分享一个实用的小技巧:如果你用的是Spring Boot应用,Micrometer暴露的上下文信息里自带一个process_start_time_seconds指标,这个指标的值就是进程启动的时间戳。排查“容器重启但不知道为什么”时,用这个指标和容器层的创建时间做对照,能快速判断JVM进程是新是旧,省得对着容器重启记录猜来猜去。我自己习惯在Grafana的变量里预留一个process_start_time_seconds的隐藏面板,确认真的是新进程后再往前翻日志,排查效率比直接开着两套系统来回切要快得多。