要在运行中的 OpenResty(LuaJIT)进程里定位泄漏的 Lua table,就从 GC root 出发追它的引用路径:GC roots => registry => _LOADED => <模块> => <table>。OpenResty XRay 的 lj-gco-ref 分析器会把这条路径画成火焰图,精准定位到持有内存的那张表——无需改代码、无需重启。

在本文中,我们将演示如何使用 OpenResty XRay 的命令行工具来快速定位正在运行的 OpenResty Lua 应用程序中泄漏的 Lua table。

LuaJIT 如何管理内存

LuaJIT 里的垃圾回收器(GC),是其内存管理的一部分。它是基于 mark-sweep 算法的增量 GC,旨在释放未使用的内存对象。它定期收集所有 Lua GC 对象,这些未使用的对象从 GC root 不可达。GC root 是特殊对象,它们作为 GC 跟踪和标记内存图中的活动对象的起点。在 Lua 中,GC root 包括注册表、全局字符串表、全局变量等。换句话说,Lua GC 对象如果从 GC root 不可达,则会被 GC 清理。在 Lua 的世界中,如果一个 Lua GC 对象有来自 GC root 的直接或间接引用,则处于 “存活” 状态;否则,它处于 “死亡” 状态。随着 GC 的工作,这个对象最终会被清理并释放。Lua 参与 GC 的对象包括 table、函数、模块、线程(协程)、字符串等。

LuaJIT 中,以下内容被视为“GC 对象”:

  • string:Lua 字符串
  • upvalue:Lua Upvalue
  • thread:Lua 线程(即 Lua 协程)
  • proto:Lua 函数原型
  • function:Lua 函数(Lua 闭包)和 C 函数
  • cdata:由 Lua 中的 FFI API 创建的 cdata
  • table:Lua 表

OpenResty XRay 的命令行工具

OpenResty XRay 有一个名为 orxray 的命令行工具。如果您还没有安装这个工具,可以参照 OpenResty XRay™ CLI 用户手册中的安装部分中的步骤进行操作。

当您面临内存方面的问题时,OpenResty XRay 为您提供了综合分析解决方案。在本文中,我们将向您展示如何分析 OpenResty Nginx Worker 进程由于在 Lua 中创建了太多的 Lua 对象而使用了大量内存的情况。

一个 Lua table 泄漏示例

我们将使用一个简单的 OpenResty Lua 模块来演示 OpenResty Lua 的内存如何不断增长,从而导致 Nginx Worker 进程占用大量内存。我们将使用的 demo.lua 模块的源代码如下:

local _M = {}

local foo = {}

function _M.go(self)
    foo[#foo + 1] = "hello " .. #foo
end

return _M

nginx.conf配置文件片段如下。

location = /t {
    content_by_lua_block {
        local demo = require("demo")
        demo:go()
        ngx.say("ok")
    }
}

该模块有一个 demo:go() 方法,每次调用时都会增加模块中的 foo table 的大小。

基准测试前的进程内存快照:

基准测试前 OpenResty Nginx worker 进程的内存快照

使用 wrk 进行 30 秒基准测试后的进程内存快照:

基准测试后 OpenResty Nginx worker 内存增长到 213MB

Nginx Worker 进程的内存占用量增加到了 213MB。为了确定内存消耗高的原因,使用 OpenResty XRay 命令行工具分析了 Nginx Worker 进程内存。

按 LuaJIT GC 对象类型拆解进程内存

使用以下命令在进程上运行了 resty-memory 分析器(假设目标进程 PID 是 7646):

$ orxray analyzer run resty-memory -p 7646
Goto https://8pu4z6.xray.openresty.com.cn/targets/1355/history/4375500223 for charts

在浏览器中打开命令输出的URL,以查看分析结果。

应用程序内存使用情况显示,HTTP LuaJIT Allocator 内存占用了总内存的 169.89MB(85.3%)。

应用级内存拆解:HTTP LuaJIT Allocator 占用 169.89MB、总内存 85.3%

单击 “HTTP LuaJIT Allocator managed Memory” 饼图显示了以下详细信息:

OpenResty XRay 中 HTTP LuaJIT Allocator 托管内存的明细拆解

“GC-managed (HTTP including all chunks)” 部分占用了 169.80MB 的内存。进一步的检查显示了以下LuaJIT GC 对象的分布:

按类型划分的 LuaJIT GC 对象大小:字符串 105.67MB、table 32.03MB、全局字符串表 32MB

分析结果表明,字符串对象是最大的内存消耗者,占用了 105.67MB(62.2%),其次是 table 对象,占用了 32.03MB(18.9%),全局字符串表占用了 32MB(18.8%)。

解读 lj-gco-ref 火焰图:registry、_LOADED 与 package.loaded

OpenResty XRay 提供了 lj-gco-ref 分析器,用于动态分析嵌入式 LuaJIT 程序进程,例如在 OpenResty Nginx Worker 进程中转储的 Lua 对象,并快速定位占用大量内存的 Lua 对象的路径。

lj-gco-ref 分析器非常适合查找臃肿的嵌入式 LuaJIT 进程中最大的 Lua 对象。我们可以像这样运行它(假设 nginx worker 目标进程的 PID 为 7646):

$ orxray analyzer run lj-gco-ref -p 7646
Goto https://8pu4z6.xray.openresty.com.cn/targets/1355/history/4375500985 for charts

在浏览器中打开此处提示的 URL,以查看相应的火焰图:

lj-gco-ref 火焰图:追踪 GC 引用路径 registry、_LOADED、demo、foo

如火焰图中的数据所示,最大的内存对象是 Lua 字符串。

从火焰图底部到顶部进行跟踪,我们得到以下路径:

GC roots => registry => ._LOADED => .demo => .foo

相应的 Lua 伪代码:

debug.getregistry()._LOADED.demo.foo

其中 debug.getregistry()._LOADED 对应于 package.loaded table,而由 require() 加载的 lua 模块被缓存在 package.loaded table 中。

转换为 Lua 源代码:

package.loaded.demo.foo

最终,我们了解到在 demo 模块中的 foo table 存有太多内存对象。基于此结果,我们可以进行针对性的优化。

正是这套 Lua GC 对象引用视图,帮我们在一个真实生产应用里把一次 LRU 缓存内存泄漏追到了被缓存的 SSL 证书

全自动 Lua 内存泄漏分析

上面,我们演示了如何通过命令行手动调用 lj-gco-ref 分析器来分析 LuaJIT GC 相关的内存使用问题。实际上,OpenResty XRay 可以按需自动调用该分析器对目标进程进行采样,并自动分析生成的火焰图,最终获得那些内存使用率最高的 GC 对象的数据引用路径,并以人类可读的形式在自动分析报告中给出结论。这极大地解放了我们的用户,他们不必等待服务器上出现内存问题,也不必手动运行相应的分析器,甚至不必自行解读分析器采样的结果。

OpenResty XRay 自动内存分析报告,含 GC 对象引用路径

结论

我们在上文中解释了 Lua GC 和 Lua 对象增长如何导致进程中的内存使用率偏高,并使用 OpenResty XRay 命令行工具进行了分析。我们还介绍了如何单独使用 lj-gco-ref 分析器。如果泄漏藏在 Nginx 自身更深处,可以看看我们如何在不重启的情况下揪出一次静默的内存泄漏

常见问题

Lua 的 debug.getregistry() 返回什么?

debug.getregistry() 返回 LuaJIT 内部的 registry 表——它是 GC root 之一,也就是垃圾回收器进行追踪的起点。正因为它是 GC root,OpenResty XRay 在追查某个 Lua 对象为何无法被回收时,就以它作为起点。本例中的引用路径就是 GC roots => registry => _LOADED => demo => foo

_LOADEDpackage.loaded 是什么关系?

debug.getregistry()._LOADEDpackage.loaded 是同一张表。用 require() 加载的每个模块都缓存在这里,因此一张泄漏的模块级 table 会一直通过它保持可达、永远无法被垃圾回收。这就是为什么 debug.getregistry()._LOADED.demo.foopackage.loaded.demo.foo 指向的是同一张 foo 表。

如何找出是哪张 Lua table 在泄漏内存?

对活着的进程运行 OpenResty XRay 的 lj-gco-ref 分析器。它把 Lua GC 对象的引用关系画成火焰图;自底向上读,就得到确切的持有路径——本例即 registry => _LOADED => demo => foo——于是你能精准定位要修哪张表,无需改代码、无需重启。

lj-gco-ref 分析器是什么?

lj-gco-ref 是 OpenResty XRay 的一个分析器,它动态检查运行中的嵌入式 LuaJIT 进程,定位占内存最大的 Lua GC 对象及其从 GC root 出发的引用路径。它非常适合定位是哪张 Lua table、字符串或其他对象在撑大一个 Lua 应用的内存。

关于作者

章亦春是开源 OpenResty® 项目创始人兼 OpenResty Inc. 公司 CEO 和创始人。

章亦春(Github ID: agentzh),生于中国江苏,现定居美国湾区。他是中国早期开源技术和文化的倡导者和领军人物,曾供职于多家国际知名的高科技企业,如 Cloudflare、雅虎、阿里巴巴, 是 “边缘计算“、”动态追踪 “和 “机器编程 “的先驱,拥有超过 22 年的编程及 16 年的开源经验。作为拥有超过 4000 万全球域名用户的开源项目的领导者。他基于其 OpenResty® 开源项目打造的高科技企业 OpenResty Inc. 位于美国硅谷中心。其主打的两个产品 OpenResty XRay(利用动态追踪技术的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最适合微服务和分布式流量的全能型网关软件),广受全球众多上市及大型企业青睐。在 OpenResty 以外,章亦春为多个开源项目贡献了累计超过百万行代码,其中包括,Linux 内核、Nginx、LuaJITGDBSystemTapLLVM、Perl 等,并编写过 60 多个开源软件库。

关注我们

如果您喜欢本文,欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!