资讯详情

Windows常驻服务内核池内存泄漏排查:从PoolMon到RAMMap定位ndu.sys

📅 2026/10/8 10:36:46 | 华诺云谱 👁 阅读
Windows常驻服务内核池内存泄漏排查:从PoolMon到RAMMap定位ndu.sys
1. 故障现场一次被“常态化”的内存增长先说结论这次排查的不是那种一夜之间进程崩溃的急性故障而是典型的“温吞水”式内存泄漏——GODService服务的内存占用每天涨一点重启后回落再过几天又涨上去。这种问题最容易被忽视因为它不致命、不打断业务但会随着时间慢慢侵蚀服务器性能等到某个月发现物理内存快被打满时已经是连续运行几十天的结果了。GODService是我们这边一套终端管理平台的核心常驻服务负责客户端注册、策略下发、健康状态上报这类基础通讯工作。理论上它应该是一个内存占用非常平稳的服务——启动后吃到几十MB然后长期维持在一个区间内波动。但从七月底开始监控平台上的内存趋势图就不太对劲了进程工作集从不到100MB起步以每天大约80MB~100MB的幅度稳定爬升连续运行一周后能涨到接近1GB。当时团队里的第一反应是“是不是哪块业务逻辑写了缓存没清”或者“是不是终端上报的频率太高导致并发堆积”。我最初也顺着这个方向查——翻日志、查连接数、压测模拟终端上报折腾了两天什么有价值的东西都没发现。日志里没有任何异常报错连接数和内存增长也没有直接关联哪怕把上报频率调低到原来的十分之一内存曲线依旧岿然不动地往上走。真正让我改变方向的是任务管理器里一个不太起眼的细节GODService的内存类型里“内核池”相关数值出现了明显异常。这里先给基础薄一点的读者解释一下。Windows下进程的内存分为用户态和内核态两块平时我们用任务管理器看的“内存(活动工作集)”大多指用户态部分也就是进程自己malloc、new出来的堆内存。但一个服务如果频繁调用网络通讯、文件IO或注册表操作内核态会同步分配对应的内核池内存——分页池和非分页池。很多内存泄漏排查之所以绕弯路就是因为只盯着用户态工作集看忽略了真正在漏的其实是内核池部分。GODService的“池非分页”数值以肉眼可见的速度在涨这就说明泄漏大概率不在我们的托管代码或C业务逻辑里而在它依赖的某些底层内核态组件上。这次故障的排查链路就是从“进程内存涨”到“内核池涨”再到“某一个特定内核驱动”的逐步收窄过程。这篇文章我会把完整的定位思路、用到的工具组合和最终的修复验证方案都写下来希望能给同样在做Windows常驻服务、又避不开内核交互的人一些参考。2. 工具链组合拳从PoolMon到RAMMap的定位路径2.1 先用PoolMon锁定内核池标签确认方向后我做的第一件事是拉 PoolMon 出来。PoolMon是Windows Driver Kit里自带的一个命令行工具也可以单独从WDK里提取专门用来监控系统内核池的内存分配。它的工作方式很简单——系统里每一次内核池分配和释放都会带上一个四个字符的Pool TagPoolMon把这些Tag按分配量排序展示一眼就能看出来到底是哪个类型的分配在疯涨。操作上我推荐用“按分页池和非分页池分别排序”的方式跑poolmon /p /g # 按非分页池Tag聚合按差值排序跑起来之后等待大约5分钟然后按b键切换到“按字节数变化量排序”模式。这个变化量是关键它像是给内存分配拍了一张延时摄影能过滤掉那些“虽然总量大但是有进有出”的正常Tag直接暴露出“只进不出”的异常Tag。当时的结果让我愣了一下排在变化量第一位的Tag是Ndu变化率大约每秒增长37KB左右。这个Tag属于ndu.sys——Windows Network Data Usage Service的内核驱动。等一下这里面有个容易误解的点我当初也差点被绕进去先给你理清楚。ndu.sys不是第三方驱动它是Windows系统自身的组件全称是Network Data Usage负责统计每个进程、每个网络接口的流量使用数据。你在任务管理器“应用历史记录”里看到的“网络使用”数字就是靠这个驱动收集的。从Windows 8开始它就一直存在是一个标准的微软签名的系统驱动。一个系统自带的驱动为什么会在GODService运行期间出现内存泄漏按常理说系统驱动应该无条件正常工作才对。但实际排查下来问题恰恰出自“系统驱动和一个常驻服务的交互方式”上。2.2 用RAMMap验证内存去向PoolMon给出的信号还只是“可疑”我接着用了 RAMMap 做交叉验证。RAMMap是Sysinternals套件里的内存分析利器可以按“进程”、“驱动”、“内核池”等维度对整个物理内存的使用做快照。操作上没有太多技巧分三步走打开RAMMap等待左下角统计信息稳定。点击顶部“进程”标签找到GODService记录它的“工作集”和“提交”数值。切到“驱动”标签按“非分页池”列从大到小排序看ndu.sys的内存占用。我第一次做这个快照时ndu.sys的非分页池是大概120MB。过了两个小时再看是大概180MB。又过了4小时变成了接近280MB。这个增速和PoolMon观测到的Tag增长速率是能对上的到此我基本确认内存泄漏发生在ndu.sys驱动的非分页池分配里而GODService只是触发因素不是真正的Bug来源。这里有一个通用经验值得记一下当某个服务的用户态内存正常但内核池数值异常上涨时优先用PoolMon找Tag再用RAMMap做快照交叉验证基本可以一步到位锁定具体驱动省去大量瞎猜的时间。比上来就写一大堆代码审查脚本要高效得多。3. 元凶现身ndu.sys和网络数据统计机制的内在缺陷3.1 ndu.sys在GODService运行期间到底做了什么锁定了ndu.sys之后接下来的问题是为什么GODService会让这个驱动持续泄漏我先解释一下ndu.sys的工作机制。这个驱动维护了一张全局的网络流量统计表表里的每条记录对应一个“网络应用”的聚合数据例如某个进程名协议远程地址的组合。每当一个进程有网络收发操作时ndu.sys会更新对应的统计条目同时维护一个到用户态WinRT API的接口保证像GetNetworkUsageAsync这类API能实时读到数据。问题出在它更新统计条目的算法上。ndu.sys内部一段核心逻辑是这样的简化流程新流量进来驱动程序先检查“网络应用”记录是否已存在。如果不存在分配一个新的非分页池条目把进程ID、进程名、协议、端口等信息填进去。如果存在更新字节数和包数。这套逻辑本身没毛病但它有一个隐含假设——正常的客户端进程会在通讯结束后主动断开连接、关闭句柄。当进程关闭所有与该网络流量统计相关的句柄时ndu.sys会回收对应的统计条目释放分配的非分页池。GODService作为常驻服务它的网络行为是“长连接高频心跳持续的数据上报”。服务进程一旦启动Socket句柄几乎从不断开连接状态长期保持。理论上长连接也只是一条统计记录不会导致内存持续增长。但这里真正麻烦的是ndu.sys在统计“每个进程的流量”时维护的是按进程ID 服务ID组合拆分的多条记录。每当你更新一下网络流量统计订阅我们服务有一个定时器每隔一段时间向系统查询一次流量统计信息系统会重新创建一条新的统计流上下文旧的上下文如果因为异步回调没被及时释放就会有一小段非分页池内存变成“孤儿内存”——没有任何句柄引用它但也永远不会被释放。GODService的运行模式恰好踩中了这个雷区它每隔几秒就要查询一次网络使用情况用于状态上报这个“订阅—查询—释放”的循环在ndu.sys内部触发了一次又一次的上下文创建。正常情况下每一次循环的泄漏量只有几KB但如果循环频率足够高、服务运行时间足够长累积起来就是一个可观的数字。我们观测到的每天80~100MB增速换算下来大约每秒增长1KB左右和上面的机制完全吻合。3.2 用Windbg验证泄漏点理论归理论要落地方案之前还得在实体机上拿出证据。我用Windbg做了一次内核转储分析在内存增长到大约700MB的时候给目标机器强制生成一个完整内核转储CtrlScroll Lock触发或者用NotMyFault工具来触发。打开Windbg加载转储文件执行!pool命令查看非分页池的Tag分布。关键的命令是!poolused 2这条命令会把所有非分页池Tag按使用量从高到低排列。输出里NduTag的“Bytes”数值已经远超其他Tag进一步确认了泄漏源。然后我用!poolfind Ndu定位具体的内存块再用!address看归属能看到大量Ndu标记的内存块处于Internal状态——既没有关联到任何进程页表也没有被标记为释放候选。如果已经装了最新的WinDbg也可以用!nduskill之类的不存在的命令——网上一度有人调侃过这个梗但真实情况是ndu.sys的内存块内部结构微软没有公开分析到“驱动TagNdu状态”这一步已经足够支撑修复决策了不需要去逆向它的内部数据结构。到这里定位阶段结束。结论可以暂时固化为一句话GODService的高频网络统计查询和ndu.sys的异步上下文管理缺陷组合在一起产生了一个低速但持续的泄漏。接下来要做的是找到可行的修复和绕行方案。4. 修复与验证代码整改、系统配置和监控确认4.1 修复思路的选择这类“系统驱动缺陷应用层触发”的泄漏修复手段通常有三条路我逐一评估过修改ndu.sys的行为不现实驱动是微软签名的系统组件没有源码级修改的可能也绝不应该用第三方工具去hook掉它。放弃使用网络统计API最彻底但也最浪费等于因噎废食。调整GODService的调用频率和方式可行且务实在保留网络监控能力的前提下把可能触发泄漏的循环次数降到一个足够安全的水平同时把连接生命周期管理得更干净。我选了第三条路并结合了系统层面的一个额外调整。4.2 代码层面的整改先说说GODService原本的实现。伪代码大概是这样的// 修复前的简化逻辑每5秒查询一次网络用量并追加到状态缓存 private async Task ReportNetworkUsage() { while (!_cancellationToken.IsCancellationRequested) { var usage await NetworkUsageManager.GetNetworkUsageAsync(); AppendStatusPayload(usage); await Task.Delay(5000); } }问题就出在这个“每5秒查询一次”上。NetworkUsageManager.GetNetworkUsageAsync()底层会走到ndu.sys的上下文每调用一次就会在驱动层产生一个新的流量统计上下文。正常的Windows API设计下这些上下文会被回收但碰上高频调用异步清理路径就会频繁出现来不及回收的情况。修复后的实现做了三处改变// 修复后的简化逻辑30秒查询一次且使用缓存实例而不是反复创建新查询上下文 private NetworkUsageManager _usageManager new NetworkUsageManager(); // 复用实例 private DateTime _lastUsageQueryTime DateTime.MinValue; private async Task ReportNetworkUsage() { while (!_cancellationToken.IsCancellationRequested) { if ((DateTime.Now - _lastUsageQueryTime).TotalSeconds 30) { // 使用同一个查询句柄避免反复创建/销毁统计上下文 var usage await _usageManager.GetUsageSnapshot(); AppendStatusPayload(usage); _lastUsageQueryTime DateTime.Now; } await Task.Delay(3000); } }改动点拆开说查询频率从5秒改为30秒。这个频率对状态上报场景完全够用终端的流量统计并不需要精确到秒级。仅仅这一项就把ndu.sys上下文创建的频率降为原来的1/6。复用NetworkUsageManager实例。原先的代码每次循环都new一个查询器等于每次都要求系统创建一个新的统计流上下文改成复用同一个查询实例后Windows API可以正确地在同一个上下文里做增量更新减少驱动层的条目创建和销毁。把连续查询改为有状态的时间间隔控制。即使服务有多个业务线程同时触发了上报逻辑也能把实际的驱动层查询请求合并成最少次数尽量降低并发交错的概率。这几行代码看起来不起眼但针对性很强因为内核池泄漏和“调用次数”强相关而不是和“数据量大小”强相关。一个每次传输1MB数据的长连接并不会比10次只传1KB数据的短连接泄漏得更多——真正驱动泄漏速率的是上下文创建的频次也就是调用次数。4.3 系统层面的辅助调整除了代码之外我还顺手对系统服务做了一个调整作为辅助措施不解决根因但能降低风险。Windows的网络数据使用服务叫“Network Usage Service”对应的可执行文件是NetworkUsageService。这个服务并不总是启用它取决于是否有应用实际调用流量统计API。我查了故障机器上的服务状态发现它处于“手动启动”后的持续运行状态。为了确保这个统计链路不会被意外的第三方应用频繁唤醒和释放我把它从“手动”启动调成“禁用”——前提是我们服务器上根本不依赖任何用户态流量统计应用GODService是我们自己写的我们可以控制它不去调用相关API。禁用掉这个服务后sndu.sys的加载也会跟着不加载相当于釜底抽薪。不过得提醒一句这个操作要慎重不是在每台机器上都适合这么做。如果机器上有任何需要依赖任务管理器“应用历史记录”或UWP流量统计功能的程序禁用服务会导致那些数据全部不可用。我们这批服务器是纯后台服务用途没有这类需求所以才能这么操作。最终生效的配置组合是措施影响范围效果查询频率改30秒仅GODService驱动上下文创建频率降低为原来的1/6复用查询实例仅GODService避免重复创建/销毁统计流上下文禁用Network Usage Service整机直接遏制ndu.sys的加载与活动4.4 验证过程和数值对比修复上线后我没有直接压完就说“修复成功”而是按小时级和天级两个维度做了长期观察4小时观测修复后第4小时从RAMMap看ndu.sys非分页池维持在约85MB没有继续上涨且偶有回落。12小时观测非分页池曲线呈现正常波动增长和释放交替出现不再是一条直线上扬。48小时观测GODService整体内存占用从高峰期的700多MB回落到约120MB且48小时内没有超过130MB基本恢复到了泄漏前的正常水位。同时我注意到PoolMon里NduTag的增长速率从每秒37KB降到了每秒不到1KB偶尔还会出现负值——也就是释放多于分配。这说明泄漏闭环被打破了。为了更严谨我特意保留了修复前一个晚高峰时段的转储数据作为对比用!poolused 2命令统计了当时的Ndu占比。修复后的相同命令输出中Ndu已经从非分页池榜首掉出了前五。数据摆在那里比任何嘴上的“应该修好了”都更有说服力。5. 举一反三还有哪些“系统驱动服务交互”的组合容易踩同样的坑写完这篇修复报告我更想说的是这种故障模式并不罕见。Windows环境下类似ndu.sys这样“看起来人畜无害的系统组件 某个高频调用的业务服务”的组合坑过很多人。我盘点了几个同类场景方便你在未来排查时少走弯路TCP/IP协议栈的非分页池增长当服务频繁创建短连接、尤其是带TLS握手的那种tcpip.sys驱动可能会因为连接处于TIME_WAIT状态堆积而增加非分页池占用。常见的处理手段不是改驱动而是调整注册表里的TcpTimedWaitDelay和MaxUserPort把TIME_WAIT状态的连接尽快回收。HTTP.sys的内核缓存增长如果你的服务基于http.sys比如Kestrel运行在Windows上时复用HTTP连接、调整Http503Verbose之类的配置都能影响内核缓存。某些版本下HTTP.sys的URI缓存如果不做主动清理也会表现为服务进程“间接”吃内存。WFPWindows Filtering Platform相关驱动的内存占用装了安全软件、流量过滤工具的机器上WfpConnectionTracking相关的池Tag经常成为内存泄漏大户。这时候的排查思路就不是改业务代码了而是要找安全软件厂商更新驱动版本。共通的做法可以总结成一句话当应用层内存正常、内核池上涨时先区分池类型再用PoolMon定Tag、RAMMap交叉验证、Windbg取证据——这条链路在大多数“系统驱动交互型”泄漏面前都适用。这类问题往往不是“修一行代码”那么简单也不会像崩溃那种故障有一声巨响。它更像一个安静的漏水点白天看不出来放一个月才水漫金山。但好处是只要盯住正确的指标维度找到泄漏的方向并不难。起码这次的GODService从发现到修复再到验证整个链路走完之后我对“慢速内存泄漏”这类问题的容忍度已经比以前低了很多——趋势图只要出现持续两周以上的单调递增不管多慢都值得坐下来认真查一轮。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑