在 Moby 中用好 prometheus/procfs:Go 语言从 /proc 与 /sys 采集 Linux 系统与进程指标
【免费下载链接】mobyThe Moby Project - a collaborative project for the container ecosystem to assemble container-based systems项目地址: https://gitcode.com/GitHub_Trending/mo/moby
导读
procfs README 所描述的是 Prometheus 生态中一个以纯 Go 编写的核心基础库github.com/prometheus/procfs。它以只读的伪文件系统/proc与/sys为数据源,把内核导出的文本文件解析成强类型的 Go 结构体,为系统、内核与进程级指标采集提供了统一入口。在本仓库(Moby / moby)中它作为间接依赖以v0.21.1版本内嵌于 vendor/github.com/prometheus/procfs,服务于容器生态中的监控与指标链路。读完本文,你将掌握该库的组织方式、FS核心抽象、基于FS的典型用法、构建与测试流程,以及如何在自己的 Linux 监控程序中安全使用它。
一、库定位:为什么容器生态离不开 /proc 与 /sys 的解析
在 Linux 上,内核并不通过常规 API 暴露大部分运行态信息,而是把数据「导出」为两个只读伪文件系统:
/proc:以进程为中心的系统与进程信息,例如/proc/stat(CPU 汇总统计)、/proc/meminfo(内存)、/proc/[pid]/stat(单进程状态);/sys:设备、内核子系统等结构化信息,例如块设备、网络与硬件相关的内核对象。
prometheus/procfs的价值在于把这些以「空格分隔文本」形式存在的内核文件封装成类型安全的 Go API。其包注释(doc.go)这样概括它的职责:
"Package procfs provides functions to retrieve system, kernel and process metrics from the pseudo-filesystem proc."
需要特别留意的是该库自带的一条重要声明(原文照录自 README 开头):
WARNING: This package is a work in progress. Its API may still break in backwards-incompatible ways without warnings. Use it at your own risk.
即:该库属于「进行中」状态,API 可能在无通知的情况下发生不向后兼容的变更。这一点决定了工程接入时必须把版本锁定并随之升级,正如本仓库在 go.mod 中固定github.com/prometheus/procfs v0.21.1 // indirect的方式。
在 Moby 仓库中的角色
在 go.mod 中可以看到,moby 将github.com/prometheus/procfs v0.21.1标记为indirect(间接依赖),同时引入了github.com/prometheus/client_golang v1.24.1、github.com/prometheus/common v0.70.1等 Prometheus 家族依赖。也就是说,moby 自身并不直接 import 该库,而是由上游指标采集与 Prometheus 客户端链路传递引入,这正是go mod vendor之后把完整库目录裁剪进 vendor/github.com/prometheus/procfs 的原因。vendor 目录中保留了库的源码、internal/内部包以及构建工具ttar,但按依赖裁剪规则省略了测试夹具(fixtures)等开发期文件。
二、核心抽象FS:把挂载点变成类型安全的句柄
整个库的设计围绕一个贯穿始终的抽象展开:FS类型代表某个伪文件系统(通常是/proc或/sys)的路径。数据从哪来、如何拼接路径、如何校验挂载点,都收敛在这一层。
2.1 根包的FS结构
以库根包的实现 fs.go 为例:
// FS represents the pseudo-filesystem sys, which provides an interface to // kernel data structures. type FS struct { proc fs.FS isReal bool }源码还导出了两个与 Linux 语义强相关的常量:
DefaultMountPoint:proc 文件系统的默认挂载点,值为/proc;SectorSize = 512:单扇区字节数,专用于 Linux 块 I/O 计算,供读取磁盘统计的模块换算使用。
构造函数有两个:
// NewDefaultFS 使用默认挂载点 /proc;若该目录不可读或不是目录则返回错误。 func NewDefaultFS() (FS, error) { return NewFS(DefaultMountPoint) } // NewFS 使用给定的 proc 挂载点;同样会校验路径合法性。 func NewFS(mountPoint string) (FS, error) { ... }NewFS内部首先调用内层 internal/fs/fs.go 的fs.NewFS,后者通过os.Stat校验传入路径必须是一个可读的目录,否则分别返回 "could not read %q" 或 "mount point %q is not a directory" 的错误;随后还会通过isRealProc检查该挂载点是否指向真实的 proc 文件系统。
内层internal/fs包统一定义了所有伪文件系统的默认挂载点,可直接查阅 internal/fs/fs.go:
DefaultProcMountPoint = "/proc"DefaultSysMountPoint = "/sys"DefaultConfigfsMountPoint = "/sys/kernel/config"DefaultSelinuxMountPoint = "/sys/fs/selinux"
此外FS.Path(p ...string)通过filepath.Join把相对路径安全地拼接为形如/proc/stat的完整读取路径,统一了目录拼接逻辑。
2.2 典型用法:读取 CPU 统计
README 给出的是最小可运行范式:先初始化挂载点,再读取对应统计。以读取 CPU 汇总统计(/proc/stat)为例:
fs, err := procfs.NewFS("/proc") stats, err := fs.Stat()fs.Stat()返回的Stat结构体(定义见 stat.go)聚合了内核级统计,主要包括:
| 字段 | 来源 | 含义 |
|---|---|---|
BootTime | /proc/stat的btime行 | 自 Unix 纪元以来的启动时间(秒) |
CPUTotal | cpu汇总行 | 全核累计 CPU 时间 |
CPU map[int64]CPUStat | cpu0、cpu1… 行 | 按 CPU 编号的独立统计 |
IRQTotal/IRQ []uint64 | intr行 | 中断总次数与按编号中断次数 |
ContextSwitches | ctxt行 | 上下文切换次数 |
ProcessCreated | processes行 | 已创建进程总数 |
ProcessesRunning/ProcessesBlocked | procs_running/procs_blocked | 当前运行 / 阻塞(等待 I/O)的进程数 |
SoftIRQ | softirq行 | 软中断分类型统计 |
其中CPUStat定义了User / Nice / System / Idle / Iowait / IRQ / SoftIRQ / Steal / Guest / GuestNice共 10 个维度,类型均为float64,单位为秒(内核原始单位是 USER_HZ 时钟滴答,读取后除以常量换算而来,详见下文第六节)。CPU 读取的解析实现位于 stat.go 中的parseCPUStat:若行首 token 为cpu,表示聚合行(CPU id 记为 -1);否则取cpu前缀后的数字作为 CPU 编号。
2.3 双文件系统场景:同时需要 /proc 与 /sys 的子包
并非所有指标都能只从/proc拿到。README 特别举例:部分子包(例如blockdevice)需要同时访问 proc 与 sys 两个伪文件系统,于是对应构造函数显式接收两个挂载点:
fs, err := blockdevice.NewFS("/proc", "/sys") stats, err := fs.ProcDiskstats()这种「一个FS管一个挂载点、双挂载点则用两个参数」的设计,让磁盘等横跨两类内核接口的指标也能以统一方式获取。需要说明的是,moby 本次 vendor 的快照按依赖裁剪只保留了库根包与internal/包(从 vendor/github.com/prometheus/procfs 的目录结构可见),blockdevice等上游子包存在于完整版上游工程中,这里作为官方用法示例引用。
三、包组织结构:按「数据来源 + 信息类型」两个维度划分
README 明确指出库的组织遵循两条原则:
- 数据来源:信息来自
/proc、/sys,还是两者都要; - 信息类型:是进程级信息、系统级统计,还是块设备等子系统信息。
3.1 根包procfs:进程 + 系统两级指标
「大多数进程信息都可以由根procfs包中的函数获得」(README 原文)。从 vendor/github.com/prometheus/procfs 的源码文件清单可以直观印证这一组织原则:
- 进程级(process):以
proc_*.go命名的文件覆盖单个进程的各方面——proc.go(进程句柄与目录枚举)、proc_stat.go(/proc/[pid]/stat)、proc_status.go、proc_statm.go、proc_maps.go、proc_smaps.go、proc_limits.go、proc_environ.go、proc_io.go、proc_cgroup.go、proc_fdinfo.go、proc_ns.go、proc_psi.go、proc_interrupts.go、proc_snmp.go、proc_snmp6.go、proc_netstat.go、proc_sys.go等; - 系统级(kernel/system):
stat.go(CPU、中断、上下文切换)、meminfo.go(内存)、loadavg.go(负载)、cpuinfo.go(CPU 详情,并按架构拆分出cpuinfo_x86.go、cpuinfo_armx.go、cpuinfo_riscvx.go、cpuinfo_s390x.go、cpuinfo_ppcx.go、cpuinfo_mipsx.go、cpuinfo_loong64.go)等。
doc.go中给出了一个可直接照搬的进程级示例,演示如何拿到「当前进程」并读取其状态:
func main() { p, err := procfs.Self() if err != nil { log.Fatalf("could not get process: %s", err) } stat, err := p.Stat() if err != nil { log.Fatalf("could not get process stat: %s", err) } fmt.Printf("command: %s\n", stat.Comm) fmt.Printf("cpu time: %fs\n", stat.CPUTime()) fmt.Printf("vsize: %dB\n", stat.VirtualMemory()) fmt.Printf("rss: %dB\n", stat.ResidentMemory()) }procfs.Self()即解析/proc/self(当前进程自身)的快捷方式,返回的ProcStat结构体从 proc_stat.go 可见,依次携带PID、Comm(可执行文件名)、State(进程状态)、父进程 PID 等字段,并派生CPUTime()、VirtualMemory()、ResidentMemory()等便捷方法。
3.2 网络与子系统专项文件
根包中还有大量针对具体内核子系统的独立.go文件,几乎是一份「/proc 与 /sys 指标速查表」:
| 文件 | 数据来源 | 内容 |
|---|---|---|
net_dev.go | /proc/net/dev | 网卡收发流量 |
net_tcp.go/net_udp.go/net_unix.go | /proc/net/tcp等 | 各协议套接字表 |
net_sockstat.go/netstat.go/net_conntrackstat.go | /proc/net/* | socket、连接跟踪等 |
mdstat.go | /proc/mdstat | 软件 RAID 状态 |
swaps.go | /proc/swaps | swap 设备 |
buddyinfo.go、zoneinfo.go、slab.go | /proc/buddyinfo等 | 内存分配器细粒度信息 |
fscache.go、ipvs.go、nfnetlink_queue.go、arp.go | 对应/proc条目 | 文件缓存、IPVS、netfilter、ARP 等 |
每个文件都遵循同一个「FS句柄 → 一次文件读取 → 类型化结构体」的模式,学习成本极低。
四、构建与测试:无二进制产物、以 ttar 夹具驱动单元测试
README 强调一个工程事实:procfs 是库而非可执行程序,没有可分发的二进制产物,它总是作为其他应用的一部分被编译。其质量保障方式,是「绝大部分 API 都带有单元测试,通过make test运行」。
4.1 测试夹具(fixtures)与 ttar
测试需要一个关键前提:CI 与开发机上不一定存在完整的/proc、/sys内容。因此项目维护了一套来自真实内核文件系统的样例文件(fixtures),并在测试运行时把它们当作/proc、/sys的快照喂给解析器。
这些夹具以 ttar 中的规则如实呈现了这一机制:
%/.unpacked: %.ttar @echo ">> extracting fixtures $*" ./ttar -C $(dir $*) -x -f $*.ttar touch $@ fixtures: testdata/fixtures/.unpacked update_fixtures: rm -vf testdata/fixtures/.unpacked ./ttar -c -f testdata/fixtures.ttar -C testdata/ fixtures/ .PHONY: test test: testdata/fixtures/.unpacked common-test要点拆解:
make test先把testdata/fixtures/.unpacked作为前置依赖触发解包,再执行Makefile.common中的common-test(Go 测试套件);- 解包动作依赖仓库自带的
ttar可执行文件(位于 vendor/github.com/prometheus/procfs/ttar),-x表示解包、-c表示打包; - 每次解包成功后通过
touch生成.unpacked标记文件,避免重复解包。
4.2 更新测试夹具的标准流程
当新功能需要新的样例内核文件时,README 给出五步操作流:
- 确保
testdata/fixtures目录是最新状态——先删除旧目录再重新解包:
rm -rf testdata/fixtures make test- 在解包出的
testdata/fixtures中修改/新增样例文件,例如为某个新解析函数补一条真实的/proc/xxx内容; - 运行
make update_fixtures,由ttar -c根据目录内容重新生成fixtures.ttar; - 用
git diff testdata/fixtures.ttar复核改动是否符合预期,再随代码一起提交。
4.3 在 vendor 目录下的现实约束
需要指出的是:当前 moby 仓库的 vendor 裁剪版(vendor/github.com/prometheus/procfs)属于「只供编译使用的依赖快照」,并没有携带testdata/fixtures与fixtures.ttar这些开发期资源。因此上述测试流程针对的是 procfs 自身作为独立仓库开发时的场景;在 moby 中以 vendor 方式使用时,验证重点转为「能通过编译 + 在目标 Linux 环境运行集成测试」,而不是在 vendor 目录内跑make test。
五、源码级纵深:解析精度与 USER_HZ 之谜
把 README 的「使用说明」落到源码上,最能体现这个库工程取舍的地方是时间单位的换算。
Linux 内核在/proc/stat的cpu行中导出的是以 USER_HZ 为单位的时钟滴答(jiffies)计数。procfs 为了让调用方拿到「秒」单位的浮点数,在 stat.go 的parseCPUStat中用fmt.Sscanf按固定格式一次性解析 10 个字段:
count, err := fmt.Sscanf(line, "%s %f %f %f %f %f %f %f %f %f %f", &cpu, &cpuStat.User, &cpuStat.Nice, &cpuStat.System, &cpuStat.Idle, &cpuStat.Iowait, &cpuStat.IRQ, &cpuStat.SoftIRQ, &cpuStat.Steal, &cpuStat.Guest, &cpuStat.GuestNice)随后统一除以常量userHZ:
cpuStat.User /= userHZ ...关键问题随之而来:USER_HZ到底是多少?proc_stat.go 顶部的大段注释完整记录了这段工程史:
Originally, this USER_HZ value was dynamically retrieved via a sysconf call which required cgo... After much research it was determined that USER_HZ is actually hardcoded to 100 on all Go-supported platforms as of the time of this writing.
也就是说,最初该常量希望通过sysconf动态获取,但这会引入cgo依赖,严重阻碍交叉编译。经过调研,作者最终确认在 Go 官方支持的所有平台上USER_HZ都被硬编码为 100,于是在无 cgo 的前提下直接写死:
const userHZ = 100注释同时坦承了最坏后果——如果真的存在异类平台,最多导致两个指标数值失真。这是一个典型的「用精确常量换取零 cgo、支持交叉编译」的设计决策,也是把 procfs 广泛嵌入容器监控链路(跨架构交叉构建是刚需)的技术前提。
解析层之下,internal/util子包(internal/util)承担通用的文本解析:parse.go提供ParseUint32s、ParseUint64s、ParsePInt64s(返回[]*int64,用于内核可空缺字段)等辅助函数,valueparser.go提供键值对解析,readfile.go/sysreadfile.go封装文件读取与错误包装。全部解析器都不依赖 cgo,这保证了库的可移植编译性。
六、接入指引:在 Moby 及自研 Linux 程序中引入
6.1 在 Go 工程中声明依赖
在 moby 仓库里,该库作为 Prometheus 监控链路的间接依赖出现(go.mod):
github.com/prometheus/procfs v0.21.1 // indirect对普通 Go 模块工程,如需要直接使用,则显式引入并通过go mod vendor拉取到本地 vendor 目录:
go get github.com/prometheus/procfs@v0.21.1引入路径与 moby 内嵌的 vendor 快照保持一致的版本即可复用 vendor/github.com/prometheus/procfs 内的同一份代码。
6.2 一个覆盖系统 + 进程的可运行示例
综合前文,一个典型的采集程序会同时覆盖系统级与进程级指标:
package main import ( "fmt" "log" "github.com/prometheus/procfs" ) func main() { // 系统级:/proc/stat 的 CPU 与系统统计 fs, err := procfs.NewFS("/proc") if err != nil { log.Fatalf("failed to open /proc: %s", err) } stats, err := fs.Stat() if err != nil { log.Fatalf("failed to read /proc/stat: %s", err) } fmt.Printf("boot time: %d\n", stats.BootTime) fmt.Printf("ctx switches: %d\n", stats.ContextSwitches) fmt.Printf("cpu total: user=%.2fs system=%.2fs idle=%.2fs\n", stats.CPUTotal.User, stats.CPUTotal.System, stats.CPUTotal.Idle) // 进程级:当前进程的 stat p, err := procfs.Self() if err != nil { log.Fatalf("failed to open self: %s", err) } pstat, err := p.Stat() if err != nil { log.Fatalf("failed to read process stat: %s", err) } fmt.Printf("process: %s (pid=%d)\n", pstat.Comm, pstat.PID) fmt.Printf("cpu time: %.2fs\n", pstat.CPUTime()) }6.3 使用前提与限制
- 平台限制:该库本质是对 Linux 伪文件系统的封装,只能在挂载了
/proc、/sys的 Linux(含容器内)环境中返回真实数据;非 Linux 平台无法工作。 - 挂载点可定制:借助
NewFS(自定义路径)与FS.Path,可读取 chroot、容器根文件系统或测试快照中的伪文件系统,这也是其测试夹具得以工作的基础。 - API 稳定性:务必记住 README 开篇的警告——库处于演进期、API 可能破坏性变更,升级版本时应回归验证所有字段与签名。
七、小结
prometheus/procfs以「一个FS句柄 = 一个伪文件系统挂载点」的简洁抽象,把繁琐的/proc、/sys文本解析收敛为强类型结构体与方法调用,并按「数据来源 + 信息类型」组织成易于扩展的包结构。它牺牲了一定 API 稳定性,换来的是无 cgo、支持交叉编译与跨架构的纯 Go 实现,因此在 Moby 这类容器平台中被 Prometheus 监控链路以间接依赖方式内嵌。理解其 fs.go 的挂载点抽象、stat.go 的字段语义、USER_HZ=100的换算决策,以及 Makefile 的 ttar 夹具驱动测试流程,即可在自己的监控与系统工具中稳健地驾驭它。
本文以 moby 仓库 vendor 快照(vendor/github.com/prometheus/procfs/README.md)中的库文档为主干撰写;库本身的版本信息以 go.mod 为准(
v0.21.1),读者可将文中代码直接用于自研项目的 Linux 主机监控场景。
【免费下载链接】mobyThe Moby Project - a collaborative project for the container ecosystem to assemble container-based systems项目地址: https://gitcode.com/GitHub_Trending/mo/moby
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考