容器化 Java 应用 CPU 使用率监控口径解析:node exporter vs cAdvisor vs JMX
本文是线上问题实战录系列的第 5 篇 叙事框架现象 → 排查过程 → 根因 → 修复 → 预防问题现象容器化迁移完成后同一个 Java 进程在不同监控工具中呈现出截然不同的 CPU 使用率数值node exporter 报告 82%占宿主机 8 核、cAdvisor 报告 43%占容器 limit 2 核、Spring Boot Actuator 报告 6.8%JVM 视角。三种采集方式使用了不同的分母与计算口径导致对相同工作负载的评估相差 10 倍以上。本文从三个监控工具的实现原理出发分析各自的采集范围、计算公式和适用场景并给出容器环境下 Java 进程 CPU 监控的最佳实践。排查过程第一步宿主 top — 179%$sshrootk8s-node-03 $top-b-n1|head-30刘浩然看到 java 进程的 CPU 占179.3%心里一沉——这都快翻倍了。但等等容器 limit 不是 2 核吗179% 是相对什么的第二步docker stats — 只用了 0.86 核$dockerstats order-service-pod --no-streamdocker stats 说容器只用了86.42%的 CPU换算成绝对值就是0.86 核。同样一个进程top说 179.3%docker stats说 86.42%0.86 核差了 2 倍多。第三步kubectl top — 43m43% of 2 核$ kubectltoppod-nprod|greporder-service第三套数据来了kubectl top显示43m43 millicores。43m 的意思是占容器 limit 2 核的43%也就是0.86 核。这跟 docker stats 的 86.42% 说的事实上是同一件事——只是 docker stats 的基数是一颗核100% 1 核kubectl top 的基数是总 limit100% 2 核。第四步cgroup — 真相在这里三个数字一个说 179%一个说 86%一个说 43%。刘浩然意识到——不同的工具用的分母不同导致百分比天差地别。他决定从源头看起cgroup 给这个容器到底限了多少 CPU$dockerexeca7f3b9c2e1d5cat/sys/fs/cgroup/cpu/cpu.cfs_period_us100000$dockerexeca7f3b9c2e1d5cat/sys/fs/cgroup/cpu/cpu.cfs_quota_us200000$dockerexeca7f3b9c2e1d5cat/sys/fs/cgroup/cpu/cpu.shares2048cfs_period_us100000100mscfs_quota_us200000200ms——每 100ms 周期最多用 200ms CPU等价于2 核。确认容器 limit 确实是 2 核。但这引出了更关键的问题容器内的 Java 知道自己只有 2 核吗第五步JVM 视角 — availableProcessors 8$curl-shttp://localhost:8080/metrics/cpu|python3-mjson.toolJVM 报告说availableProcessors: 8。容器 limit 2 核JVM 却以为有 8 核。这解释了所有問題的根源ForkJoinPool.commonPool()初始化了 7 个并行线程availableProcessors - 1processCpuLoad: 0.06826.82%——但它的分母是 8 核按实际容器 2 核修正后6.82% × (8÷2) 27.28%JVM 完全不知道自己在容器里。$dockerexeca7f3b9c2e1d5java-XX:PrintFlagsFinal2/dev/null|grepUseContainerSupport bool UseContainerSupportfalse{product}UseContainerSupportfalse——JDK 8 的默认行为。根因分析为什么同一个进程有这么多不同的 CPU 数字工具值分母绝对值说明top179.3%宿主单核1.79 核占宿主 8 核之一的比例容器不可见docker stats86.42%内核0.86 核占容器 1 核 limit 的比例kubectl top43m容器总 limit0.86 核43% of 2 核与 docker stats 等价JVMprocessCpuLoad6.82%宿主 8 核0.54 核JDK 8 无容器感知分母错了修正后 JVM27.28%容器 2 核0.54 核手动换算后的修正值核心矛盾同样的 0.86 核实际消耗因为每个工具使用的分母不同呈现出差异巨大的百分比。为什么 JDK 8 不感知容器Linux 内核通过 cgroup 限制容器 CPU 主要有两种机制CFS 配额cpu.cfs_quota_us限制 CPU 时间总量——这是 Docker --cpus 操作的参数Cpus_allowed 掩码/proc/self/status | grep Cpus_allowed限制可运行哪些 CPU 核Docker 的--cpus2只设置 CFS 配额不修改Cpus_allowed 掩码。所以容器内cat /proc/self/status看到的Cpus_allowed: ff低 8 位全 1意味着所有 8 个宿主核都可见。JDK 8 的Runtime.getRuntime().availableProcessors()读取的是/proc/self/status的 Cpus_allowed——返回8不是 2。JDK 8u131 引入了-XX:UseContainerSupport参数增加了对 cgroupcpu.cfs_quota_us的读取逻辑。但这个参数在 JDK 8 中默认关闭需要显式开启。JDK 10 才默认开启。影响面不止监控数字availableProcessors返回 8 而不是 2 的后果远不止监控面板的混乱组件默认行为后果ForkJoinPool.commonPool()池大小 availableProcessors - 1 7容器 2 核跑 7 个并行线程上下文切换飙升parallelStream()并行度 availableProcessors - 1同上Executors.newWorkStealingPool()池大小 availableProcessors 8线程数远超容器承载能力一些连接池初始化默认 minIdle availableProcessors心跳连接数翻 4 倍Tomcat acceptor/processor默认依赖 availableProcessors请求处理线程数不合理修复方案方案 A升级 JDK 11推荐JDK 11 默认开启UseContainerSupporttrue升级后零配置解决问题。$java-XX:PrintFlagsFinal2/dev/null|grepUseContainerSupport bool UseContainerSupporttrue{product}升级后的变化availableProcessors()正确返回容器 limit 的2不是宿主 8processCpuLoad的分母从 8 变成 2数字与kubectl top对齐ForkJoinPool.commonPool()初始化 1 个线程2-1而不是 7 个方案 BJDK 8 加参数如果无法升级FROM eclipse-temurin:8-jre # 显式开启容器感知 ENTRYPOINT [java, -XX:UseContainerSupport, -XX:ActiveProcessorCount2, -jar, app.jar]代码侧加固即使 JDK 版本正确也建议做防御性编程// 不依赖 Runtime 的默认值可能因为 JDK 版本或配置问题仍返回错误值privatestaticfinalintCONTAINER_CPUresolveContainerCpuCores();privatestaticintresolveContainerCpuCores(){try{// 读取 cgroup 限定的核数PathquotaPathPaths.get(/sys/fs/cgroup/cpu/cpu.cfs_quota_us);PathperiodPathPaths.get(/sys/fs/cgroup/cpu/cpu.cfs_period_us);intquotaInteger.parseInt(Files.readString(quotaPath).trim());intperiodInteger.parseInt(Files.readString(periodPath).trim());if(quota0period0){returnquota/period;}}catch(IOExceptionignored){}// fallback 到 RuntimereturnRuntime.getRuntime().availableProcessors();}业务线程池一律显式指定 corePoolSize不要依赖默认值。验证结果升级 JDK 11 并重新部署后所有数字统一了指标JDK 8修复前JDK 11修复后availableProcessors8 ❌2 ✅processCpuLoad6.82%分母 842.15%分母 2kubectl top pod43m42m两数字偏差6 倍差距几乎一致 ✅ForkJoinPool 并行线程71JVM 的processCpuLoad与kubectl top pod的偏差从6 倍差降到了几乎一致。避坑建议分清宿主指标和容器指标top和/proc/stat在容器内看到的仍是宿主数据。容器视角的 CPU 使用率应该通过 cAdvisor 或 kubelet metrics API 获取。不要在监控面板里混用 node exporter宿主级和 cAdvisor容器级的数据作为同一告警口径。JDK 版本决定了容器兼容性JDK 8u131 以下完全不感知 cgroup8u131 需要显式加-XX:UseContainerSupportJDK 10 默认开启。容器化部署时务必确认 JDK 版本和容器感知开关状态。availableProcessors的副作用不止监控ForkJoinPool、parallelStream、某些连接池和线程库都用这个值初始化默认大小。容器场景下值翻 4 倍宿主 8 核 vs 容器 2 核导致线程数过多、上下文切换和性能劣化。Docker --cpus 只改 CFS 配额不改 /proc 可见性容器内cat /proc/cpuinfo仍然显示宿主所有核Cpus_allowed掩码仍是全 1。这是底层机制决定的不是 bug。JDK 的容器感知是通过读取/sys/fs/cgroup/cpu/实现的不是/proc。做防御性编程关键线程池、连接池的 corePoolSize 不要依赖Runtime.getRuntime().availableProcessors()应通过环境变量或配置中心显式注入。即便 JDK 版本没问题也保不齐哪天平台升级改了 cgroup 驱动版本。附完整命令清单CPU 视图对比top-b-n1|grepjava# 宿主视角 Java CPUdockerstatscontainer--no-stream# 容器视角 CPUkubectltoppod-nns|greppod# K8s 视角 CPUcgroup 配额检查cat/sys/fs/cgroup/cpu/cpu.cfs_quota_us# CPU 配额微秒cat/sys/fs/cgroup/cpu/cpu.cfs_period_us# CPU 周期微秒cat/proc/self/status|grepCpus_allowed# 进程可见核数JVM 容器感知验证java-XX:PrintFlagsFinal2/dev/null|grepUseContainerSupport# 容器感知是否开启java-XX:PrintFlagsFinal2/dev/null|grepActiveProcessorCount# JVM 识别的活跃核数java-version21# JDK 版本kubectl describe podpod|grep-A2Limits# Pod 资源限制

相关新闻