前阵子调一个共享内存占用告警,平台反映某台机器 /dev/shm 快满了,可用内存掉得很快。第一反应是 ipcs 看一眼 System V 共享内存,结果什么都没有,再看监控图,内存一直挂在 tmpfs 上彻底下不来。后来查了一圈才发现,业务用的是 POSIX 共享内存,文件落在 /dev/shm 下面,只有从 /proc/PID/maps 里才能看到对应的映射关系。也就是从那天起,我开始认真琢磨“shmem 的映射监控按照绝对路径过滤”这件事。
说实话,这个需求看起来就是个过滤条件:监控共享内存的时候,按绝对路径把不关心的映射丢掉。但在实际落地的过程中,它牵扯到 /proc 文件系统解析、tmpfs 底层的 shmem 实现、容器命名空间、甚至文件被删除后的映射残留,坑一个接一个。这篇文章把我自己的完整思路和踩坑过程整理出来,给做运维监控、容器平台、数据库自查的同学一个可以直接抄作业的参考。
1. 先搞清楚监控对象:/dev/shm 里的共享内存到底长什么样
1.1 tmpfs、shmem 和 /dev/shm 的关系
很多人把 /dev/shm 当成普通目录,其实它是一个 tmpfs 文件系统,背后是内核的 shmem 模块。tmpfs 的核心特点:文件内容存在内存里,而不是磁盘上,读写速度接近内存,重启或者 umount 之后数据清空。Linux 发行版默认把它挂载在 /dev/shm,大小默认是物理内存的一半,可以通过 df -h /dev/shm 查看。正是因为“内容全部在内存”,tmpfs 在监控、限额方面几乎是裸奔的:普通磁盘有 quota,tmpfs 虽然也可以挂载时指定 inode/block 上限,但现实中很多业务没有人去设置它。
在共享内存的体系里,/dev/shm 又承担着 POSIX 共享内存落地路径的角色。glibc 的 shm_open 实现本质上是在 /dev/shm 下创建文件,shm_unlink 是删除对应文件,一个进程打开这个文件、mmap 成 MAP_SHARED,其他进程通过同一个路径再次打开映射到同一块物理内存,这样就实现了跨进程通信。所以你会看到,凡是用了 shm_open 或直接 open("/dev/shm/xxx") 再 mmap 的进程,在 /proc/PID/maps 里的映射路径就是 /dev/shm/xxx,这个“路径”就是我们做按绝对路径过滤的根本依据。
1.2 不按路径过滤,共享内存监控会失控
假设一台主机上跑了三个业务模块:order_service、pay_service、report_service,每个模块都往 /dev/shm 里写自己的缓存文件。如果监控只做总量统计,一旦 /dev/shm 占用超过了 80% 触发告警,事情就来了:三个模块哪个是罪魁祸首?进到机器上盲目去看,可能 order_service 只是被动缓存了 GB 级别小文件,report_service 才是罪魁祸首,但总量指标看不出来。更麻烦的是,如果监控脚本只是把 /proc/*/smaps 全部解析一遍然后求和,不区分路径,那么 total_shmem_usage 这个指标根本没法作为自动扩缩容、自动清理策略的输入。
有了按绝对路径过滤以后,事情就变成了:order_service 的前缀匹配到多少,pay_service 匹配到多少,report_service 匹配到多少。每一类业务的共享内存占用是独立的,告警可以精确到业务目录级别,清理动作也可以只针对超过阈值的那个目录。对于需要细粒度定位的场景,比如某个服务在 /dev/shm 下建立的共享缓存、消息中间件的 mmap 索引文件、数据分析引擎的跨进程结果集,这个维度尤其重要,因为它们的共享内存文件往往有明确的路径前缀,最容易实现自动化巡检。
1.3 绝对路径过滤的本质:把路径当业务命名空间
我要特意强调一个观点:绝对路径过滤不只是一个字符串匹配的工程技巧,它本质上是把 tmpfs 的目录层级当成了业务命名空间来用。运维规范一旦约定好“每个业务模块必须在 /dev/shm/ 下建立自己的子目录”,这个路径体系就能承担起资源归属、配额管理、告警分级的功能。监控工具要做的,就是把这个约定变成可执行的过滤规则。
在过滤的设计上,我的习惯是维护一个配置化的前缀列表,例如 /dev/shm/order_service、/dev/shm/pay_service,而不是把所有路径都打印出来让人肉眼筛。同时保留一个 UNMATCHED 桶,用来收集没有匹配到任何前缀的 shmem 映射,防止过滤规则更新滞后导致的漏监控。有了这个设计,后续的报表、告警、自动清理,都只需要消费这一份“按路径维度聚合”的输出。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. /proc/PID/maps 与 smaps 里的 shmem:从路径到内存占用量的完整链路
2.1 maps 中一个映射行的字段到底代表什么
打开 /proc/PID/maps,每一行典型格式是:
code复制7f8a2c000000-7f8a2e000000 rw-s 00000000 00:05 173015 /dev/shm/order_service/cache_1
从左到右分别是:虚拟地址范围、权限、文件偏移、设备号、inode、映射路径。对于 shmem 映射,设备号一般是 00:05(tmpfs 的伪设备),inode 是对应 shmem 文件的 inode 号,路径就是 /dev/shm 下的完整路径。权限里的 s 表示 shared,这是识别共享映射的重要特征。
判断一个映射是否属于 shmem,最直观的指标是路径以 /dev/shm 开头。但要注意,如果一个进程直接 mmap 了别处的 tmpfs 文件(比如把 tmpfs 挂载到 /data/tmpfs),路径就不是 /dev/shm 开头了。在做绝对路径过滤的时候,不要只写死 /dev/shm,更好的做法是先读取 /proc/mounts,找出所有 tmpfs 类型的挂载点,再把它们都加入候选路径前缀。
2.2 POSIX 共享内存、System V 共享内存与匿名共享映射的区别
这里必须区分三种容易混淆的情况,因为它们的 maps 表现完全不同:
- POSIX 共享内存:路径以 /dev/shm 开头(默认挂载前提下),maps 中可直接看到,能够被按绝对路径过滤。这是本文的核心对象。
- System V 共享内存(shmget/shmat):maps 中通常是一条没有路径的映射,无法直接用路径过滤,需要借助
ipcs -m命令才能看到 key、shmid 和占用大小。 - MAP_SHARED | MAP_ANONYMOUS 匿名共享映射:这是最“隐蔽”的一种,多个进程通过 fork 继承同一个匿名映射实现共享,但 maps 里连路径都没有。它一样占用物理内存,但路径过滤完全覆盖不到。
换句话说,按绝对路径过滤只能解决“看得见路径”的那部分共享内存。一个完善的共享内存监控系统,应该是“按路径过滤的 POSIX shm 监控 + ipcs 的 System V shm 监控 + 匿名共享映射的总量兜底”三者叠加,缺一不可。我一开始只做了路径过滤,结果 System V 那部分完全漏了,告警数据对不上,后来才补上。
2.3 要拿到“这个路径到底占了多少内存”,必须解析 smaps 而不是 maps
maps 只给出虚拟地址范围,不直接给出物理内存占用。同一个文件可以被映射成多个 VMA,每个 VMA 的 RSS 还不一样。所以按路径聚合时,更准确的数据源是 /proc/PID/smaps,它会给每个 VMA 旁边的详细统计,包括:
- Rss:该映射实际驻留的物理内存。
- Pss:按共享比例分摊后的内存,适合用来做业务归属。
- Private_Clean / Private_Dirty / Shared_Clean / Shared_Dirty:区分私有与共享部分。
在做路径过滤聚合时,我一般以 Pss 作为指标,因为它更能反映“这个业务在共享内存里的真实份额”。如果只是看 Rss,多个进程共享同一个 shmem 文件时,会出现同一块物理内存被重复累加的问题,导致统计结果虚高。这个问题我实际遇到过,同一份共享内存文件被 order_service 五个副本映射,Rss 相加后数字大得离谱,换用 Pss 之后才恢复正常量级。
3. 一个按绝对路径过滤的 shmem 映射监控落地实现
3.1 过滤规则的设计:前缀、边界、UNMATCHED 兜底
过滤规则我建议用前缀匹配而不是包含匹配,因为 shmem 文件路径一般是有固定命名规范的。比如业务模块固定往 /dev/shm/order_service/ 里写文件,那前缀就是 /dev/shm/order_service。关键是要处理边界:/dev/shm/order_service 这个前缀,不应该匹配 /dev/shm/order_service_extra/cache 这样的路径,因为 order_service_extra 可能是另一套环境。边界判断的条件是:路径等于前缀本身,或者路径在去掉前缀之后紧接着是 /。
在配置上,我建议将这些过滤规则独立成一份 JSON 或 YAML,不要硬编码在监控脚本里。每条规则包含 name、path_prefixes、action(监控/忽略/告警)三个字段。默认情况下,所有匹配到的路径都要汇总;没有匹配到任何规则的 shmem 路径,统一归入 unmatched_bucket,单独输出一个指标。这样无论规则怎么变化,都不会出现“某块共享内存没被统计”的盲区,最多是归类到 unmatched。
3.2 可运行的 Python 脚本:解析 smaps 并按前缀聚合 PSS
第一版监控脚本,核心逻辑就是遍历 /proc/ 下的所有 PID,解析每个 PID 的 smaps 文件,把路径匹配到前缀的映射的 PSS 累加起来。下面这个脚本可以直接跑,我删掉了统计上报部分,保留了最核心的聚合逻辑:
python复制#!/usr/bin/env python3
import os
import re
import json
# 规则配置:name -> 路径前缀列表
RULES = {
"order_service": ["/dev/shm/order_service"],
"pay_service": ["/dev/shm/pay_service"],
}
LINE_RE = re.compile(r"^([0-9a-f]+)-([0-9a-f]+)\s+\S+\s+\S+\s+\S+\s+\S+\s*(.*)$")
def match_rule(path):
path = path.strip()
# 内核会对已被删除但仍映射的文件追加" (deleted)"
if path.endswith("(deleted)"):
return None
for rule, prefixes in RULES.items():
for prefix in prefixes:
prefix = prefix.rstrip("/")
if path == prefix or path.startswith(prefix + "/"):
return rule
return "__unmatched__"
def collect_path_pss(pid):
smaps = f"/proc/{pid}/smaps"
current_path = None
current_pss = 0
result = []
try:
with open(smaps) as f:
for line in f:
m = LINE_RE.match(line)
if m:
if current_path and current_pss:
result.append((current_path, current_pss))
current_path = m.group(3)
current_pss = 0
elif current_path and line.startswith("Pss:"):
current_pss += int(line.split()[1])
if current_path and current_pss:
result.append((current_path, current_pss))
except (FileNotFoundError, PermissionError):
pass
return result
def main():
agg = {}
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
for path, pss in collect_path_pss(pid):
rule = match_rule(path)
if rule is None:
rule = "__deleted__"
agg[rule] = agg.get(rule, 0) + pss
print(json.dumps(agg, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
有几个地方需要解释一下。smaps 里一个 VMA 的头部行格式和 maps 类似,但末尾 pathname 如果有空格,简单的 split 会截断,所以用正则把最后的路径字段提取出来。Pss 单位是 kB,聚合结果如果要给监控系统用,建议统一转成 MB 或 GB。遍历 /proc 时,不是所有进程都有权限读取 smaps(比如其他用户的进程),PermissionError 要静默跳过,否则一个权限错误就会打断整个遍历。
3.3 结合 inotify 的事件监控:从“定时扫描”升级为“事件+扫描”
上面的脚本是轮询模式,执行一次拿一次快照。如果希望第一时间知道某个业务新建了 shmem 文件,可以在 /dev/shm 上挂 inotify 监控。inotify 支持 IN_CREATE、IN_DELETE、IN_OPEN 等事件,当配置文件创建、被打开时,可以立刻感知。需要注意的是,inotify 并不能替代 smaps 解析,因为文件创建了不代表进程已经 mmap,只有映射建立了才会真正占内存。
我实际采用的方案是“inotify 触发 + 定向扫描”:inotify 发现新的文件或目录变化后,记录事件并打上时间戳,然后触发一次针对相关路径前缀的 smaps 扫描;如果没有新事件,就按默认周期扫描一次兜底。inotify 的 Python 示例(需要 pip install inotify):
python复制import inotify.adapters
def watch_shm():
i = inotify.adapters.Inotify()
i.add_watch(b"/dev/shm")
for event in i.event_gen(yield_nones=False):
(_, type_names, path, filename) = event
print(type_names, path, filename)
实际部署的时候,事件里拿到的 filename 就是共享内存文件名,配合规则前缀做一次快速白名单判断,只有命中规则的 shmem 变化才进入后续的 smaps 扫描。这样能避免频繁全量解析 smaps 造成性能浪费——全量解析本身压力不小,进程多的时候一次遍历可能要上百毫秒。
4. 路径过滤最容易踩的四个坑:每条我都线上遇到过
4.1 前缀匹配的边界:order_service 和 order_service_extra 会一起被匹配
这是最常见也最隐蔽的问题。如果过滤代码里用的是 startswith("/dev/shm/order_service"),那 /dev/shm/order_service_extra/cache 也会被匹配上,导致两个业务的数据混在一起。解决办法前面已经提过,就是比较边界字符,只有等于前缀本身,或者前缀之后紧跟 /,才算命中。有人喜欢在配置里把前缀写成 /dev/shm/order_service/,其实本质一样,关键是要意识到 startswith 天然就会漏掉这个边界问题。
我把这个 bug 写进测试用例里:在任何一次规则变更后,先跑一遍单测,确保 order_service 的前缀不会匹配到 order_service_extra。别觉得这是小题大做,这类问题在几千个进程、几十个前缀的生产环境里非常难发现,数据一旦错乱,排查成本极高。
4.2 unlink 之后,路径后面会出现“ (deleted)”,但内存还挂着
共享内存的释放流程经常是:进程打开 shmem 文件,mmap,使用,之后调用 shm_unlink 删除文件,但进程继续持有映射。此时从内核视角看,文件已经不在目录树里了,但物理页面仍然被映射占用。maps/smaps 里这一行路径会变成 /dev/shm/order_service/cache_1 (deleted)。
如果过滤逻辑只匹配 /dev/shm/order_service 开头的路径,那些带 (deleted) 后缀的路径会导致前缀匹配命中,或者匹配成功但路径带后缀,统计时会漏掉或归类错误。解决方法是:在解析路径字段时,先剥离末尾的 (deleted) 标记,保留真实路径用于匹配,同时单独记录一个 deleted_bucket,用于追踪那些“文件已删但内存未释放”的异常场景。这类对象往往意味着业务代码里有共享内存泄漏的嫌疑,非常值得单独监控。
4.3 /dev/shm 可能不是你以为的那个挂载点
默认情况下 /dev/shm 是 tmpfs,但生产环境很容易出现差异:有的机器把 /dev/shm 做成指向 /run/shm 的符号链接,有的把 tmpfs 挂到了 /data/shm,还有的用 mount -o remount,size=10G 调整了 /dev/shm 的上限。如果监控脚本硬编码 /dev/shm 前缀,换到这些环境就会失效。
我的做法是先解析 /proc/mounts,动态收集所有当前挂载的 tmpfs 路径(包括 /dev/shm 和其他自定义 tmpfs 挂载点),然后去重后作为候选前缀。虽然本文标题说的是按绝对路径过滤,但“绝对路径”本身是随环境变化的,写死反而失去了可移植性。另外 size 参数可以通过 /proc/mounts 的挂载选项读取,监控端最好把这个值也暴露出去,方便设置告警阈值。
