手头接到一个需求:写一套Java采集程序,既能跑在Windows机器上,也能跑在Linux上,而且Linux这边还要支持部署在Docker容器里。采集的不是业务数据,而是硬件信息——CPU、内存、磁盘、网卡这些。
听起来不算复杂,实际动手才发现暗坑不少。最大的坑就是“Docker容器里看到的硬件,到底是不是物理机的硬件”。这个问题如果不提前想明白,做出来的上报数据会给监控平台带去一堆假指标,告警规则全都乱套。
这篇文章就完整记录一下这套采集模块的设计思路、代码实现,以及我在Windows和Docker容器里实测踩过的坑。如果你也在做类似的跨平台资源采集,建议先花十分钟把“采集谁的硬件信息”这个口径定清楚,再往下写代码。
1. 需求拆解与方案选型思路
1.1 指标范围的定义:先搞清楚要哪些数据
硬件信息这个词太宽泛,不同场景要的东西完全不一样。运维监控关心CPU使用率和内存水位;资产盘点关心CPU型号、序列号、物理核心数;容量规划关心磁盘总量和剩余空间;网络排查关心网卡速率和IP地址。
做之前一定要和需求方逐条确认指标清单,别自己脑补。我这次整理出来的核心指标分五个维度:
- CPU维度:逻辑处理器数量、物理封装数、CPU型号、主频、当前负载
- 内存维度:总容量、可用容量、已用容量、内存负载
- 磁盘维度:磁盘型号、容量、分区挂载点、剩余空间、读写累计量
- 网络维度:网卡名称、IPv4地址、MAC地址、收发累计字节数
- 系统维度:操作系统家族、版本、系统架构、机器启动时间
需要注意,磁盘读写量和网络收发量在OSHI里拿到的是累计值,不是实时速率。如果需要速率,就要定时采样两次,用差值除以时间间隔来计算。这点后面写代码时我会单独说明。
1.2 几种主流技术方案的取舍
Java获取硬件信息,业界基本就四条路:
第一条路是Java自带API。Runtime.getRuntime().availableProcessors()能拿到可用核心数,但这几乎是上限了。内存拿不到,磁盘拿不到,CPU型号拿不到。真要全用自带API,只能当玩具,生产环境完全不够用。
第二条路是Runtime.exec()执行命令行,然后解析输出。Windows下用wmic,Linux下用lscpu、cat /proc/meminfo,每条命令的输出格式都不一样,解析代码写起来又臭又长。而且wmic在某些精简版Windows上没有,权限不够时返回结果也不完整。维护成本极高。
第三条路是走JNI/JNA直接调原生库。Windows上调用Win32 API和WMI COM接口,Linux上解析/proc和/sys。这条路性能最好,但是代码复杂度爆炸,还要处理各平台的头文件和动态库。一个小团队根本维护不过来。
第四条路是使用OSHI,一个专门做跨平台系统信息采集的Java库。它底层用JNA加载原生代码,对Windows、Linux、macOS都封装了统一API。我要拿CPU信息,就调用hal.getProcessor(),不用关心底层是Windows的WMI还是Linux的/proc。我最后选的就是这条路。
1.3 我的整体方案:OSHI采集常规指标,cgroup补充容器配额
方案不是只用OSHI就完事了。Docker容器里运行Java程序时,OSHI读取/proc和/sys,很多数据反映的是宿主机视角,而不是容器配额视角。举个例子,容器明明只分配了2个CPU核心,但OSHI的getLogicalProcessorCount()返回的是物理机32核。
所以我的方案拆成两条链路:
- 常规硬件信息走OSHI,各平台统一API,满足资产上报类需求
- 容器资源配额单独处理,直接解析cgroup文件,获取CPU限额和内存限额
这两类数据语义完全不同,上报时要用不同字段区分,避免监控平台误读。
2. 核心原理:Windows、Linux、Docker三重视角下的数据来源
2.1 Windows平台底层机制:WMI是主要数据源
先看OSHI在Windows上做了什么。Windows获取硬件信息的标准接口是WMI,全称Windows Management Instrumentation。你可以把它理解成一个系统级的“数据库”,里面存放了CPU、内存、磁盘、网卡等硬件的管理数据。
CPU型号来自Win32_Processor,内存容量来自Win32_OperatingSystem,磁盘数据来自Win32_DiskDrive,网络信息来自Win32_NetworkAdapter。OSHI对这些封装得很彻底,咱们Java代码里一个SystemInfo实例就全拿到了。
不过Windows路径有两个隐含问题。第一是WMI查询确实慢,首次初始化可能要爬一秒多;第二是如果Java进程没有管理员权限,部分WMI类别返回空字段。后面排查章节会展开说。
2.2 Linux平台底层机制:一切皆文件的精髓
Linux路径则完全不同,它没有WMI这种集中式服务,而是通过虚拟文件系统暴露内核数据。操作系统运行状态全部可以通过读取伪文件系统获得——/proc/cpuinfo、/proc/meminfo、/proc/net/dev,这些文件并不是磁盘上真实存在的文件,而是内核动态生成的视图。读它们的时候,内核实时返回当前状态。
OSHI在Linux上就是靠读这些文件工作的。CPU信息解析/proc/cpuinfo,内存容量读/proc/meminfo,磁盘信息扫/sys/block目录下的设备文件。
这些文件的内容格式在不同发行版之间略有差异,OSHI做了大量的兼容适配。如果自己写命令行解析,这些坑全要自己踩。
2.3 Docker容器内的“可视差”:看到的不一定是自己的
Docker容器里跑Java,最大的认知陷阱就在这里。容器共享宿主机的内核,/proc和/sys这两个目录中,一部分内容会被Docker挂载成容器自己的视图,另一部分内容仍然是宿主机视图。
CPU信息就是典型的“宿主视角”。物理机的CPU型号、核心数,在容器里cat /proc/cpuinfo看到的基本就是宿主机的完整信息。内存总容量也一样,/proc/meminfo显示的通常是物理机的总内存。
那容器真实的资源限制在哪看?在cgroup里。cgroup是Linux内核的容器资源控制机制,Docker为每个容器都创建了一组控制文件。
这里打个比方:酒店大楼有100个房间,你在前台查楼况管理表,看到的是整栋楼的房间数、总用电量;但你自己房间门口写着“限用2度电/天”。这台服务器对楼里某个房间来说是整个大楼,cgroup才是那个房间门口的参数牌。
所以在容器里采集,CPU配额要看/sys/fs/cgroup/cpu.max(cgroup v2)或/sys/fs/cgroup/cpu/cpu.cfs_quota_us(cgroup v1),内存配额要看/sys/fs/cgroup/memory.max(cgroup v2)或/sys/fs/cgroup/memory/memory.limit_in_bytes(cgroup v1)。
3. 实战:Java采集跨平台硬件信息完整代码
3.1 引入OSHI依赖与版本选择
我用的是OSHI 6.6.5,Maven坐标如下:
<dependency> <groupId>com.github.oshi</groupId> <artifactId>oshi-core</artifactId> <version>6.6.5</version> </dependency>版本选择有一个点必须提醒:OSHI自己依赖JNA,如果你的项目里还有其他用JNA的库,可能出现版本冲突。症状是运行时抛出NoClassDefFoundError。解决思路是在Maven里排除OSHI传递进来的JNA依赖,然后统一使用项目里的JNA版本。
3.2 封装一个跨平台硬件信息采集类
下面这个类是完整可跑的。我把它做成了静态工具方法,返回一个Map结构,方便后续转JSON上报。
import oshi.SystemInfo; import oshi.hardware.CentralProcessor; import oshi.hardware.GlobalMemory; import oshi.hardware.HardwareAbstractionLayer; import oshi.hardware.NetworkIF; import oshi.hardware.PhysicalDisk; import oshi.software.os.FileSystem; import oshi.software.os.OSFileStore; import oshi.software.os.OperatingSystem; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class HardwareInfoCollector { // SystemInfo初始化很重,整个进程只要有这一个实例就够了 private static final SystemInfo SYSTEM_INFO = new SystemInfo(); public static Map<String, Object> collect() { HardwareAbstractionLayer hal = SYSTEM_INFO.getHardware(); OperatingSystem os = SYSTEM_INFO.getOperatingSystem(); Map<String, Object> result = new HashMap<>(); result.put("os", collectOsInfo(os)); result.put("cpu", collectCpuInfo(hal)); result.put("memory", collectMemoryInfo(hal)); result.put("disk", collectDiskInfo(os)); result.put("network", collectNetworkInfo(hal)); return result; } private static Map<String, Object> collectOsInfo(OperatingSystem os) { Map<String, Object> osInfo = new HashMap<>(); osInfo.put("family", os.getFamily()); osInfo.put("version", os.getVersionInfo().getVersion()); osInfo.put("buildNumber", os.getVersionInfo().getBuildNumber()); osInfo.put("arch", System.getProperty("os.arch")); return osInfo; } private static Map<String, Object> collectCpuInfo(HardwareAbstractionLayer hal) { CentralProcessor cpu = hal.getProcessor(); Map<String, Object> cpuInfo = new HashMap<>(); cpuInfo.put("name", cpu.getProcessorIdentifier().getName()); cpuInfo.put("physicalPackageCount", cpu.getPhysicalPackageCount()); cpuInfo.put("physicalProcessorCount", cpu.getPhysicalProcessorCount()); cpuInfo.put("logicalProcessorCount", cpu.getLogicalProcessorCount()); // 毫秒值,不同平台实现可能有一定波动,仅作参考 cpuInfo.put("maxFreq", cpu.getProcessorIdentifier().getMaxFreq()); return cpuInfo; } private static Map<String, Object> collectMemoryInfo(HardwareAbstractionLayer hal) { GlobalMemory memory = hal.getMemory(); Map<String, Object> memoryInfo = new HashMap<>(); memoryInfo.put("total", memory.getTotal()); memoryInfo.put("available", memory.getAvailable()); memoryInfo.put("used", memory.getTotal() - memory.getAvailable()); return memoryInfo; } private static List<Map<String, Object>> collectDiskInfo(OperatingSystem os) { List<Map<String, Object>> diskList = new ArrayList<>(); FileSystem fileSystem = os.getFileSystem(); OSFileStore[] fileStores = fileSystem.getFileStores(); for (OSFileStore store : fileStores) { // 跳过一些虚拟文件系统,避免上报一堆无意义数据 if (!store.getType().equalsIgnoreCase("ext4") && !store.getType().equalsIgnoreCase("xfs") && !store.getType().equalsIgnoreCase("ntfs")) { continue; } Map<String, Object> diskInfo = new HashMap<>(); diskInfo.put("name", store.getName()); diskInfo.put("mount", store.getMount()); diskInfo.put("totalSpace", store.getTotalSpace()); diskInfo.put("usableSpace", store.getUsableSpace()); diskList.add(diskInfo); } return diskList; } private static List<Map<String, Object>> collectNetworkInfo(HardwareAbstractionLayer hal) { List<Map<String, Object>> networkList = new ArrayList<>(); List<NetworkIF> networkIFs = hal.getNetworkIFs(); for (NetworkIF net : networkIFs) { // 跳过回环地址,减少无效数据 if (net.getName().equalsIgnoreCase("lo")) { continue; } Map<String, Object> netInfo = new HashMap<>(); netInfo.put("name", net.getName()); netInfo.put("displayName", net.getDisplayName()); String[] ipv4 = net.getIPv4addr(); netInfo.put("ipv4", ipv4.length > 0 ? ipv4[0] : ""); netInfo.put("mac", net.getMacaddr()); netInfo.put("bytesRecv", net.getBytesRecv()); netInfo.put("bytesSent", net.getBytesSent()); networkList.add(netInfo); } return networkList; } public static void main(String[] args) { long start = System.currentTimeMillis(); Map<String, Object> info = collect(); long cost = System.currentTimeMillis() - start; info.put("collectCostMs", cost); System.out.println(info); } }这套代码在Windows和Linux原生环境上运行都能拿到很漂亮的结果。不过注意磁盘部分我只放了ext4、xfs和ntfs三种文件系统类型,如果你的Linux发行版用了btrfs或者别的类型,可以按需调整。
3.3 Docker容器配额采集:手动读取cgroup
到了容器环境,前面这个类返回的数据需要做一次修正。其中CPU核心数、内存总量,应该以cgroup配额为准。下面这个方法兼容cgroup v1和v2。
Linux系统Docker容器默认已经使用cgroup v2,但老版本镜像或某些发行版仍然跑在v1上,所以两个路径都要兼容。
import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; public class ContainerQuotaReader { public static long readCpuQuota() throws Exception { // cgroup v2 List<String> lines = readLines("/sys/fs/cgroup/cpu.max"); if (!lines.isEmpty()) { String[] parts = lines.get(0).split("\\s+"); long quota = Long.parseLong(parts[0]); long period = Long.parseLong(parts[1]); if (quota > 0) { // 返回毫核数,比如quota=200000, period=100000,就是2个核心 return quota * 1000 / period; } } // cgroup v1 lines = readLines("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"); long quota = Long.parseLong(lines.get(0).trim()); long period = Long.parseLong(readLines("/sys/fs/cgroup/cpu/cpu.cfs_period_us").get(0).trim()); if (quota > 0) { return quota * 1000 / period; } return -1; } public static long readMemoryLimit() throws Exception { // cgroup v2 List<String> lines = readLines("/sys/fs/cgroup/memory.max"); if (!lines.isEmpty() && !lines.get(0).trim().equals("max")) { return Long.parseLong(lines.get(0).trim()); } // cgroup v1 lines = readLines("/sys/fs/cgroup/memory/memory.limit_in_bytes"); if (!lines.isEmpty()) { return Long.parseLong(lines.get(0).trim()); } return -1; } private static List<String> readLines(String path) throws Exception { if (!Files.exists(Paths.get(path))) { return new ArrayList<>(); } return Files.readAllLines(Paths.get(path), StandardCharsets.UTF_8); } }注意如果读取的quota是-1,表示容器没有限制CPU。没有限制的含义是可以用宿主机全部核心,这个语义在上报时要特别说明,不然监控平台会以为是数据错误。
4. 实操记录:我在Windows和Docker里跑出来的差异
4.1 Windows实测:首次采集偏慢,结果稳定
在Windows Server 2019物理机上跑上面的main方法,首次采集耗时大约800毫秒到1.5秒,主要消耗在SystemInfo初始化阶段。CPU型号、逻辑核心数、内存总量都准确。磁盘通过文件系统方式采集没问题,但如果直接调用hal.getDiskStores()拿物理磁盘型号,某些机器上序列号是空字符串。这个空值是因为OSHI读取Windows物理磁盘序列号需要WMI的MSFT_PhysicalDisk扩展类,部分OEM机器没提供这个数据。
另一个Windows特有的现象是杀毒软件会扫描JNA释放到临时目录的JNI动态库,导致首次采集会突然多出几百毫秒延迟。我遇到一次采集耗时飙到6秒,排查后确认是实时监控扫描。
性能上还有一个优化选择:SystemInfo初始化很重,但它是线程安全的,应该在应用启动时创建一次,然后整个进程复用。上面代码里我已经用静态字段做了。
4.2 Docker容器实测:OSHI返回的是宿主机视角数据
在一台32核128G内存的物理机上,我创建了一个限制为2核4G内存的Docker容器,在里面跑同一个Java采集类。结果如下:
- OSHI的logicalProcessorCount返回32,但cgroup的cpu.max显示quota/period刚好算出来是2核
- OSHI的memory.getTotal()返回约128GB,但cgroup的memory.max显示4G
- 磁盘部分能看到宿主机所有挂载点,容器自己的overlay文件系统只占其中一小块
- 网络部分能看到hed宿主机多个网卡,容器实际只用其中一张虚拟网卡
这个结果充分说明:Docker容器默认不隔离/proc和/sys里的这些“机器固有属性”,它只隔离资源使用配额。因此上报的时候,必须想清楚你的逻辑是要上报“物理机规格”,还是“容器规格”。
如果是要监控容器运行水位,建议用cgroup数据来算真实使用率。简单做法是读取/sys/fs/cgroup/cpu.stat的usage_usec字段,得到容器累计消耗的CPU时间,两次采样差值除以1000再除以CPU配额毫核数,就能算出容器真实的CPU使用率。内存则可以直接读/sys/fs/cgroup/memory.usage_in_bytes或者memory.current。
4.3 数据口径不一致的处理原则
我最后给出的上报结构是双层设计:外层带runtimeType字段,值可能是physical或container;CPU核心数、内存总量分别上报physicalXXX和containerXXX两组。这样监控平台可以按场景选指标,告警规则也清晰了。
举个例子,容器CPU告警用containerCpu核心数和containerCpuUsagePercent,而资产大盘展示用physicalCpuModel和physicalCoreCount。两套数据各有用途,不冲突。
5. 常见问题与排查技巧实录
5.1 六个高频问题速查表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| Linux下DiskStore的serial为null | 部分设备节点没有序列号,或进程权限不足 | 不依赖序列号做唯一键,用名称+挂载点组合 |
| Windows下OSHI初始化极慢 | 杀毒软件实时扫描JNA临时动态库 | 指定-Djava.io.tmpdir到非默认目录,或加白名单 |
| Docker容器里CPU核心数比配置大 | /proc/cpuinfo是宿主机视角 | 读cgroup的cpu.max计算容器配额 |
| Docker容器里内存总量不准确 | /proc/meminfo是宿主机视角 | 读cgroup的memory.max |
| 某些Linux发行版读不到CPU频率 | /sys/devices/system/cpu目录无对应文件 | 返回降级值-1,不影响主流程 |
| OSHI升级后Java类NoClassDefFoundError | JNA版本冲突 | 排除传递依赖,统一JNA版本 |
5.2 排查思路:先分环境再下结论
遇到采集数据不对,不要急着怀疑OSHI的代码。我的排查顺序是这样的:第一步确认运行环境,是物理机、虚拟机还是Docker容器。在容器里先看cgroup路径是v1还是v2,这决定了读哪些文件。第二步确认权限,Linux下很多硬件信息读取需要root或者有权限的用户组,Windows下部分WMI类需要管理员权限。第三步才核对API用法,是不是把累计值当成了瞬时值。
我还踩过一个隐蔽的坑:在容器里由Pipeline构建镜像时,基础镜像如果太老,/sys/fs/cgroup没有正确挂载,会导致cgroup读取抛NoSuchFileException。出现这种情况,采集代码要容忍文件不存在,返回-1并打一条warn日志,不要直接导致整个采集线程崩溃。
5.3 生产环境采集的两个好习惯
第一个习惯是给采集结果打环境标签。进程启动时通过系统属性传入environment=prod、container=true之类的标识,采集结果带上这些标签,排障时一眼能看出数据来源。
第二个习惯是设置合理的采集周期。像CPU使用率这类动态指标,1分钟采一次足够;硬件型号这种静态指标,启动时采一次,之后直接缓存。不要让每个线程都去new SystemInfo,那是性能灾难。
6. 扩展:把采集模块做成生产级基础设施
6.1 设计统一的数据模型
上面代码返回的是Map,直接在生产用会很难维护。建议定义DTO,字段也更好约束。下面是一个参考结构:
public class MachineMetric { private String hostname; private String runtimeType; // physical / container private Long timestamp; // OS信息 private String osFamily; private String osVersion; // CPU private Integer physicalCpuCount; private Integer logicalCpuCount; private Long containerCpuLimitMilliCores; // 内存 private Long memoryTotal; private Long memoryAvailable; private Long containerMemoryLimit; // 磁盘列表 private List<DiskMetric> disks; // 网络列表 private List<NetMetric> networks; // getter/setter 省略 }DTO的好处是字段类型明确、序列化方便,接入Spring Boot的Jackson后直接输出JSON,不用担心Map里字符串和数字混在一起的问题。
6.2 采集模块的线程模型
如果采集模块将来要接定时上报,建议定义独立的ScheduledExecutorService,所有采集任务统一由它调度。这样控制采集频率、任务异常捕获都比较集中。
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); scheduler.scheduleWithFixedDelay(() -> { try { MachineMetric metric = MetricCollectService.collect(); reportService.report(metric); } catch (Exception e) { Logger.warn("collect metric failed", e); } }, 0, 60, TimeUnit.SECONDS);注意scheduleWithFixedDelay的含义是任务结束后再等60秒,不是严格每60秒执行一次。如果采集耗时本身很长,间隔会拉大,但至少保证不会有并发重叠问题。
6.3 与监控系统对接的字段约定
对接监控平台时,指标命名建议统一为metric名称加单位后缀。核心数字字段注明单位,比如内存总容量用bytes还是MB,一定要写清楚,否则监控图上的数值会差百万级别。我习惯统一用bytes作为存储单位,展示层再自行换算成MB或GB。
另外网络收发字节数这类累计值,监控平台要算速率时必须连续上报两次才能计算出差值。如果你用了消息队列做削峰,丢了一条数据,速率就会跳变。对这类指标,上传前先在上报模块里算好速率再上传,是更稳的做法。
做一个采集模块不难,难的是把数据口径定义清楚。Windows、Linux、Docker容器,三种环境下同一个API拿到的数据含义不同。我个人在实际操作中的体会是:写代码两小时,理清楚“宿主视角”和“容器配额”这两个概念花了大半天。建议你动手之前,先把跑在容器里和跑在物理机上的两套指标分开设计,后面能少改很多代码。最后再分享一个小技巧:把SystemInfo实例设成进程级单例,并把采集结果缓存30秒以上。遇到采集耗时异常时,这两个动作能帮你筛掉大部分不必要的性能排查。