Lambda并非内存泄漏,而是你的监控指标在误导你
速览
文章指出Lambda函数常被误认为存在内存泄漏,但实际上这是由监控指标的计算方式导致的误解。开发者需要正确理解指标背后的逻辑,避免被错误的数据误导。这一澄清有助于优化云原生应用的资源管理和故障排查。
AI 深度解读
Lambda 没有内存泄漏,是你的监控指标在撒谎
背景
AWS Lambda 允许客户托管 ONNX 模型并在其上运行机器学习(ML)推理。对于拥有大量模型的大型客户而言,内存管理是一个关键挑战。
一家主要客户在 Lambda 中托管了 40 个 ONNX 模型,每个模型大小约为 250 MB。为了优化性能,他们使用 functools.lru_cache 缓存 ONNX 推理会话实例:
@functools.lru_cache(maxsize=16)
def _get(s3_client, bucket, model_id):
response = s3_client.client.get_object(Bucket=bucket, Key=f"prefix/{model_id}/model.onnx")
model_bytes = response["Body"].read()
return InferenceSession(model_bytes)
起初,该客户偶尔会遇到内存溢出(OOM)错误,频率约为每 10 万次请求中 1 次。直觉上,减少缓存大小(maxsize)应该能降低内存占用。团队将 maxsize 从 16 逐步降至 10,再降至 8。然而,这一操作并未带来预期的内存节省,反而导致情况恶化:在短短三小时内,Lambda 执行环境出现了 270 多次 SIGKILL。
每个执行环境的内存使用量从 400 MB 飙升至 9,000 MB,随后被杀死并重启,周而复始。减少缓存不仅没有减少内存,反而似乎加速了由更多加载/卸载循环引起的某种“泄漏”。
核心内容
初步修复与表象
团队首先进行了代码层面的快速修复。上述代码片段显示,模型在内存中短暂存在两份副本(model_bytes 和 InferenceSession(model_bytes)),增加了峰值内存 footprint。团队改为通过临时文件加载,让 ORT(ONNX Runtime)直接从磁盘读取。
这些修复将客户的 p50 内存使用量从约 7.5 GB 降至约 5 GB,p99 延迟也有所改善。然而,问题并未彻底解决:一个 19 MB 的 ONNX 模型在几次加载周期后,RSS(常驻集大小)仍约为 120 MB。看起来内存仍在泄漏。
指标陷阱:@maxMemoryUsed 的误导
团队开始调查 Lambda 在每次 REPORT 行中报告并通过 CloudWatch Logs Insights 暴露的内存指标 @maxMemoryUsed。业界普遍(包括 AWS 博客和许多 LLM 的回答)认为该指标代表**单次调用(per-invocation)**内的最大内存使用量。
然而,团队在多个区域对多个客户的 5,949 次调用进行绘图分析后,发现了一个反常现象:
- 该指标曲线只升不降,从未出现过下降。
- 即使是没有加载任何 ONNX 模型的客户,其内存曲线也呈现单调递增趋势(从 325 MB 增至 384 MB,历经 138 次调用)。
这种单调递增在内存泄漏的场景下都极不可能发生。团队向 AWS 提交工单后,AWS 确认:
“你关于 REPORT 行中报告的 Max Memory Used 值表现为执行环境的高水位标记(high water mark),而非每次调用重置的观点是正确的。”
这意味着 @maxMemoryUsed 记录的是该执行环境生命周期内的峰值内存,一旦升高,永远不会降低。因此,单调递增的 @maxMemoryUsed 是预期行为,而非内存泄漏的证据。
真相:glibc Arena 囤积(Hoarding)
既然指标不是泄漏的证明,为什么一个 20 MB 的模型会占用 120 MB 内存?
团队禁用了 ONNX Runtime 的自定义分配器,转而调试默认的 glibc 分配器,并引入 mallinfo2 日志来观察底层行为。mallinfo2 提供了关键数据:
uordblks(实际使用的块):40 MBfordblks(已释放但被 glibc 分配器囤积的块):188 MB
内存增长 100% 源于分配器行为。glibc 的 malloc 有两种策略:
- 小分配(< 128 KB):通过
sbrk从线程本地分配区(arena)服务。当调用free()时,内存保留在分配区中以便重用,RSS 保持高位。即使内存未使用,碎片化也可能导致无法释放大量内存。 - 大分配(> 128 KB):通过
mmap服务。当调用free()时,页面立即返回给操作系统,RSS 下降。
每个线程拥有独立的分配区。ONNX Runtime 使用多线程进行推理,线程越多,分配区越多,囤积的内存就越多。
解决方案:调整 mmap 阈值
glibc 中 arena 和 mmap 分配的阈值是可配置的,默认为 128 KB。团队将其设置为 32 KB:
import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.mallopt(ctypes.c_int(-3), ctypes.c_int(32768)) # M_MMAP_THRESHOLD = 32KB
这一调整将分配区囤积从 188 MB 降至 4 MB(减少 97%)。结合禁用 ONNX Runtime 内部内存分配区,稳态 RSS 从约 625 MB 降至约 415 MB。
权衡:p50 延迟增加了约 40 ms,因为 mmap/munmap 系统调用比分配区重用更昂贵。对于该用例而言,这是可接受的;但对于延迟敏感的路径,这可能不是最佳方案。
关键要点
- 执行环境隔离分析至关重要:在分析 AWS Lambda 内存使用情况时,必须按执行环境(Execution Environment)拆分绘制内存使用图。否则,执行环境的复用和状态累积会掩盖真实情况。
@maxMemoryUsed是生命周期高水位标记:Lambda 报告的@maxMemoryUsed是执行环境整个生命周期的峰值内存,不是单次调用的内存。它永远不会下降。用它来检测内存泄漏是无效的。- RSS 具有欺骗性:进程的 RSS 高并不一定意味着进程正在使用那么多内存。分配器(如 glibc)可能会囤积已释放的内存。使用
mallinfo2()等工具可以揭示分配器的真实行为。 - ONNX Runtime 的多线程特性加剧内存囤积:ONNX Runtime 使用多线程,每个线程拥有独立的 glibc 分配区。线程越多,小内存块释放后留在分配区中的碎片就越多。
- 调整
M_MMAP_THRESHOLD可有效减少 RSS:通过降低 glibc 使用mmap而非 arena 的阈值(例如从默认的 128 KB 降至 32 KB),可以迫使更多内存释放回操作系统,显著降低 RSS。代价是可能增加延迟。 - 避免频繁加载/卸载模型:理想情况下,模型应只加载一次并保留在内存中。频繁的加载和释放循环会触发分配器的碎片化和囤积行为,增加调试难度。
意义与影响
这篇文章揭示了一个在云原生和 Serverless 环境中常被忽视的深层问题:监控指标与底层系统行为之间的语义偏差。
- 对运维和 SRE 的警示:许多团队依赖 CloudWatch 等监控工具中的默认指标(如
MaxMemoryUsed)来诊断内存泄漏。如果未理解这些指标在 Lambda 执行环境上下文中的具体含义(即生命周期高水位而非调用级指标),可能会导致错误的故障排查方向,浪费大量时间。 - 性能调优的新维度:对于运行 ML 推理的 Serverless 应用,内存管理不仅涉及应用逻辑,还深度依赖于操作系统分配器(glibc)和运行时(ONNX Runtime)的交互。理解
mallinfo2和mmap阈值等底层机制,对于优化峰值内存和稳态内存至关重要。 - **延迟
