了解 OpenResty XRay 是如何做到帮助企业定位应用程序存在的问题以及优化其效率的。

了解更多 LIVE DEMO

在 OpenResty 或 Nginx 服务器中运行 Lua 代码如今已经变得越来越常见,因为人们希望他们的非阻塞的 Web 服务器能够兼具超高的性能和很大的灵活性。有些人使用 Lua 完成一些非常简单的任务,比如检查和修改某些请求头和响应体数据,而有些人则利用 Lua 创建非常复杂的 Web 应用、 CDN 软件和 API 网关等等。Lua 以简单、内存占用小和运行效率高而著称,尤其是在使用 LuaJIT 这样的的即时编译器 (JIT) 的时候。但有些时候,在 OpenResty 或 Nginx 服务器上运行的 Lua 代码也会消耗过多的 CPU 资源。通常这是由于程序员的编程错误,比如调用了一些昂贵的 C/C++ 库代码,或者其他原因。

要想在一个在线的 OpenResty 或 Nginx 服务器中快速地定位所有的 CPU 性能瓶颈,最好的方法是使用 OpenResty XRay 产品提供的 Lua 语言级别 CPU 火焰图的采样工具。这个工具 需要对 OpenResty 或 Nginx 的目标进程做任何修改,也不会对生产环境中的进程产生任何可觉察的影响。

本文将解释什么是火焰图,以及什么是 Lua 级别的 CPU 火焰图,会穿插使用多个小巧且独立的 Lua 代码实例来做演示。我们将利用 OpenResty XRay 来生成这些示例的火焰图来进行讲解和分析。我们选择小例子的原因是,它们更容易预测和验证各种性能分析的结果。相同的分析方法和工具也适用于那些最复杂的 Lua 应用。过去这几年,我们使用这种技术和可视化方式,成功地帮助了许多拥有繁忙网站或应用的企业客户。

什么是火焰图

火焰图是由 Brendan Gregg 发明的一种可视化方法,用于展示某一种系统资源或性能指标,是如何定量分布在目标软件里所有的代码路径上的。

这里的“系统资源”或指标可以是 CPU 时间、off-CPU 时间、内存使用、硬盘使用、延时,或者任何其他你能想到的资源。

而“代码路径”可以定义为目标软件代码中的调用栈轨迹。 调用栈轨迹通常是由一组函数调用帧组成的,通常出现在 GDB 命令 bt 的输出中,以及 Python 或 Java 程序的异常错误信息当中。比如下面是一个 Lua 调用栈轨迹的样例:

C:ngx_http_lua_ngx_timer_at
at
cache.lua:43
cache.lua:record_timing
router.lua:338
router.lua:route
v2_routing.lua:1214
v2_routing.lua:route
access_by_lua.lua:130

在这个例子中,Lua 栈是从基帧 access_by_lua.lua:130 一路生长到顶帧 C:ngx_http_lua_ngx_timer_at。它清晰地显示了不同的 Lua 或 C 函数之间是如何相互调用的,从而构成了“代码路径”的一种近似表示。

而上文中的“所有代码路径”,实际上是从统计学的角度来看,并不是要真的要去枚举和遍历程序中的每一条代码路径。显然在现实中,后者的开销极其高昂,因为组合爆炸的问题。我们只要确保所有那些开销不太小的代码路径,都有机会出现在我们的图中,并且我们能以足够小的误差去量化他们的开销。

本文会聚焦在一种特定类型的火焰图上面。这种火焰图专用于展示 CPU 时间(或 CPU 资源)是如何定量分布在 所有的 Lua 代码路径上的。特别地,我们这里只关注 OpenResty 或 Nginx 目标进程里的 Lua 代码。自然地,这类火焰图被我们命名为“Lua 级别 CPU 火焰图”(Lua-land CPU Flame Graphs)。

本文标题图片是一个火焰图示例,后文将提供更多示例。

为什么需要火焰图

火焰图仅用一张小图,就可以定量展示所有的性能瓶颈的全景图,而不论目标软件有多么复杂。

传统的性能分析工具通常会给用户展示大量的细节信息和数据, 而用户很难看到全貌,反而容易去优化那些并不重要的地方,经常浪费大量时间和精力却看不到明显效果。传统分析器的另一个缺点是,它们通常会孤立地显示每个函数调用的延时,但很难看出各个函数调用的上下文,而且用户还须刻意区分当前函数本身运行的时间(exclusive time)和包括了其调用其他函数的时间在内的总时间(inclusive time)。

而相比之下,火焰图可以把大量信息压缩到一个大小相对固定的图片当中(通常一屏就可以显示全)。 不怎么重要的代码路径会在图上自然地淡化乃至消失,而真正重要的代码路径则会自然地凸显出来。越重要的,则会显示得越明显。火焰图总是为用户提供最适当的信息量,不多,也不少。

如何解读火焰图

对于新手而言,正确地解读火焰图可能不太容易。但通过一些简单的解释,用户就会发现火焰图其实很直观,很容易理解。火焰图是一张二维图。y 轴显示是代码(或数据)上下文,比如目标编程语言的调用栈轨迹,而 x 轴则显示的是各个调用栈所占用的系统资源的比例。整个 x 轴通常代表了目标软件所消耗的 100% 的系统资源(比如 CPU 时间)。x 轴上的各个调用栈轨迹的先后顺序通常并不重要,因为这些调用栈只是根据函数帧名的字母顺序来排列。当然,也会有一些例外,例如笔者发明了一种时序火焰图,其中的 x 轴实际上是时间轴,此时调用栈的先后顺序就是时间顺序。本文将专注于讨论经典的火焰图类型,即图中 x 轴上的顺序并不重要。

要学会读懂一张火焰图,最好的方法是尝试解读真实的火焰图样本。下文将提供多个火焰图实例,针对 OpenResty 和 Nginx 服务器上运行的 Lua 应用,并提供详细的解释。

简单的 Lua 样例

本节将列举几个简单的有明显性能特征的 Lua 样例程序,并将使用 OpenResty XRay 分析真实的 nginx 进程,生成 Lua 级别的 CPU 火焰图,并验证图中显示的性能情况。我们将检查不同的案例,例如开启了 JIT 即时编译的 Lua 代码、禁用了 JIT 编译的 Lua 代码(即被解释执行),以及调用外部 C 库代码的 Lua 代码。

JIT 编译过的 Lua 代码

首先,我们来研究一个开启了 JIT 即时编译的 Lua 样本程序(LuaJIT 是默认开启 JIT )。

考虑下面这个独立的 OpenResty 小应用。本节将一直使用这个示例,但会针对不同情形的讨论需求,适时对这个例子进行少许修改。

我们首先准备这个应用的目录布局:

mkdir -p ~/work
cd ~/work
mkdir conf logs lua

然后我们创建如下所示的 conf/nginx.conf 配置文件:

master_process on;
worker_processes 1;

events {
    worker_connections 1024;
}

http {
    lua_package_path "$prefix/lua/?.lua;;";

    server {
        listen 8080;

        location = /t {
            content_by_lua_block {
                require "test".main()
            }
        }
    }
}

在 location /t 的 Lua 处理程序中,我们加载了名为 test 的外部 Lua 模块,并立即调用该模块的 main 函数。我们使用了 lua_package_path 配置指令,来把 lua/ 目录添加到 Lua 模块的搜索路径列表中 ,因为我们会把刚提及的 test 这个 Lua 模块文件放到 lua/ 目录下。

这个 test Lua 模块定义在 lua/test.lua 文件中:

local _M = {}

local N = 1e7

local function heavy()
    local sum = 0
    for i = 1, N do
        sum = sum + i
    end
    return sum
end

local function foo()
    local a = heavy()
    a = a + heavy()
    return a
end

local function bar()
    return (heavy())
end

function _M.main()
    ngx.say(foo())
    ngx.say(bar())
end

return _M

这里我们定义了一个计算量较大的 Lua 函数 heavy(),计算从 1 到 1000 万 (1e7)的数字之和。 然后我们在函数 foo() 中调用两次 heavy() 函数,而在 bar() 函数中只调用一次 heavy() 函数。 最后,模块的入口函数 _M.main() 先后调用 foobar 各 一次,并通过 ngx.say 向 HTTP 响应体输出它们的返回值。

显然,在这个 Lua 处理程序中,foo() 函数占用的 CPU 时间应当是 bar() 函数的两倍,因为 foo() 函数调用了 heavy() 函数两次,而 bar() 仅调用了一次。通过下文中由 OpenResty XRay 采样生成的 Lua 级别的 CPU 火焰图,我们可以很容易地验证这里的观察结果。

因为在这个示例中,我们并没有触碰 LuaJIT 的 JIT 编译器选项,因此 JIT 编译便使用了默认的开启状态,并且现代的 OpenResty 平台版本则总是只使用 LuaJIT(对标准 Lua 5.1 解释器的支持早已移除)。

现在,我们可以按下面的命令启动这个 OpenResty 应用:

cd ~/work/
/usr/local/openresty/bin/openresty -p $PWD/

假设 OpenResty 安装在当前系统的 /usr/local/openresty/ 目录下(这是默认的安装位置)。

为了使 OpenResty 应用忙碌起来,我们可以使用 abweighttp 这样的压测工具,向 URI http://localhost:8080/t 施加请求压力,或者使用 OpenResty XRay 产品自带的负载生成器。无论使用何种方式,当目标 OpenResty 应用的 nginx 工作进程保持活跃时,我们可以在 OpenResty XRay 的 Web 控制台里得到类似下面这张 Lua 级别的 CPU 火焰图:

我们从图上可以观察到下列现象:

  1. 图中的所有 Lua 调用栈都源自同一个入口点,即 content_by_lua(nginx.conf:24)。这符合预期。
  2. 图中主要显示了两个代码路径,分别是
    content_by_lua -> test.lua:main -> test.lua:bar -> test.lua:heavy -> trace#2:test.lua:8
    
    以及
    content_by_lua -> test.lua:main -> test.lua:foo -> test.lua:heavy -> trace#2:test.lua:8
    
    两个代码路径的唯一区别是中间的 foo 函数帧与 bar 函数帧。这也不出所料。
  3. 左侧涉及 bar 函数的代码路径的宽度,是右侧涉及 foo 的代码路径宽度的一半。 换言之,这两个代码路径在图中 x 轴上的宽度比为 1:2,即 bar 代码路径占用的 CPU 时间,只有 foo 代码路径的50%。将鼠标移动到图中的 test.lua:bar 帧(即方框)上,我们可以看到它占据总样本量(即总 CPU 时间)的 33.3%,而 test.lua:foo 所占的比例为66.7%. 显然,与我们之前的预测相比较,这个火焰图提供的比例数字非常精确,尽管它所采取的是采样和统计分析的方法。
  4. 我们在图中没有看到 ngx.say() 等其他代码路径,毕竟它们与那两个调用了 heavy() 的 Lua 代码路径相比,所占用的 CPU 时间微乎其微。在火焰图中,那些微不足道的代码路径本就是小噪音,不会引起我们的关注。我们可以始终专注于那些真正重要的部分,而不会为其他东西分心。
  5. 那两条热代码路径(即调用栈轨迹)的顶部帧是完全相同的,都是 trace#2:test.lua:8. 它并不是真正的 Lua 函数调用帧,而是一个“伪函数帧”,用于表示它正在运行一个被 JIT 编译了的 Lua 代码路径。按照 LuaJIT 的术语,该路径被称为”trace“(因为 LuaJIT 是一种 tracing JIT 编译器)。这个”trace“的编号为 2,而对应的被编译的 Lua 代码路径是从 test.lua 文件的第 8 行开始的。而 test.lua:8 所指向的 Lua 代码行是:
            sum = sum + i
    

我们很高兴地看到,这个非侵入的采样工具,可以从一个没有任何外挂模块、没有被修改过、也没有使用特殊编译选项的标准 OpenResty 二进制程序,得到如此准确的火焰图。这个工具没有使用 LuaJIT 运行时的任何特殊特性或接口,甚至没有使用它的 LUAJIT_USE_PERFTOOLS 特性或者 LuaJIT 内建的性能分析器。相反,该工具使用的是先进的动态追踪技术,仅读取原始目标进程中原有的信息。我们甚至可以从 JIT 编译过的 Lua 代码中获取足够多的有用信息。

解释执行的 Lua 代码

解释执行的 Lua 代码通常能够得到最完美的的调用栈轨迹和火焰图样本。 如果我们的采样工具能够正确处理 JIT 即时编译后的 Lua 代码,那么在分析解释的 Lua 代码时,效果只会更好。 LuaJIT 既有一个 JIT 编译器,又同时有一个解释器。它的解释器的有趣之处在于,几乎完全是用手工编写的汇编代码实现的(当然,LuaJIT 引入了自己的一种汇编语言记法,叫做 DynASM)。

对于我们一直在使用的那个 Lua 样例程序,我们需要在此做少许修改,即在 server {} 配置块中添加下面的 nginx.conf 配置片段:

    init_by_lua_block {
        jit.off()
    }

然后重新加载(reload)或重启服务器进程,并保持流量负载。

这回我们得到了下面这张 Lua 级别 CPU 火焰图:

这张新图与前一张图在以下方面都极其相似:

  1. 我们依旧只看到了两条主要的代码路径,分别是 bar 代码路径和 foo 代码路径。

  2. bar 代码路径依旧占用了总 CPU 时间的三分之一左右,而 foo 占用了余下的所有部分(即大约三分之二)。

  3. 图中显示的所有代码路径的入口都是 content_by_lua 那一帧。

然而,这张图与前图相比仍然有一个重要的区别:代码路径的顶帧不再是 “trace” 伪帧了。 这个变化也是预期的,因为这一回没有 JIT 编译过的 Lua 代码路径了,于是代码路径的顶部或顶帧变成为 lj_BC_IFORLlj_BC_ADDVV 等函数帧。而这些被 C: 前缀标记出来的 C 函数帧其实也并非 C 语言函数,而是属于汇编代码帧,对应于实现各个 LuaJIT 字节码的汇编例程,它们被标记成了 lj_BC_IFORL 等符号。自然地,lj_BC_IFORL 用于实现 LuaJIT 字节码指令 IFORL,而 lj_BC_ADDVV 则用于字节码指令 ADDVVIFORL 用于解释执行 Lua代码中的 for 循环, 而 ADDVV 则用于算术加法。这些字节码的出现,都符合我们的 Lua 函数 heavy() 的实现方式。另外,我们还可以看到一些辅助的汇编例程,例如如 lj_meta_arithlj_vm_foldarith

通过观察这些函数帧的比例数值,我们还得以一窥 CPU 时间在 LuaJIT 虚拟机和解释器内部的分布情况,为这个虚拟机和解释器本身的优化铺平道路。

调用外部 C/C++ 函数

Lua 代码调用外部 C/C++ 库函数的情况很常见。我们也希望通过 Lua 级别的 CPU 火焰图,了解这些外部的 C 函数所占用的 CPU 时间比例,毕竟这些 C 语言函数调用也是由 Lua 代码发起的。 这也是基于动态追踪的性能分析的真正优势所在:这些外部 C 语言函数调用在性能分析中永远不会成为盲点1

我们一直使用的 Lua 样例在这里又需要作少许修改,即需要将 heavy() 这个 Lua 函数修改成下面这个样子:

local ffi = require "ffi"
local C = ffi.C

ffi.cdef[[
    double sqrt(double x);
]]

local function heavy()
    local sum = 0
    for i = 1, N do
        -- sum = sum + i
        sum = sum + C.sqrt(i)
    end
    return sum
end

这里我们使用 LuaJIT 的 FFI API ,先声明了一下标准 C 库函数 sqrt(),并直接在 Lua 函数 heavy()内部调用了这个 C 库函数。它应当会显示在对应的 Lua 级别 CPU 火焰图中。

此次我们得到了下面这张火焰图:

有趣的是,我们果然在那两条主要的 Lua 代码路径的顶部,看到了 C 语言函数帧 C:sqrt 。 另外值得注意的是,我们在顶部附近依旧看到了 trace#N 这样的伪帧,这说明我们通过 FFI 调用 C 函数的 Lua 代码,也是可以被 JIT 编译的(这回我们从 init_by_lua_block 指令中删除了 jit.off() 语句)。

代码行层面的火焰图

上文展示的火焰图其实都是函数层面的火焰图,因为这些火焰图中所显示的所有调用帧都只有函数名,而没有发起函数调用的源代码行的信息。

幸运的是, OpenResty XRay 的 Lua 级别性能分析工具支持生成代码行层面的火焰图,会在图中添加 Lua 源代码行的文件名和行号,以方便用户在较大的 Lua 函数体中直接定位到某一行 Lua 源代码。下图是我们一直使用的那个 Lua 样例程序所对应的一张 Lua 代码行层面的 CPU 火焰图:

我们可以看到在每一个函数帧上方都多了一个源代码行的伪帧。例如,在函数 main 所在的 test.lua 源文件的第 32 行 Lua 代码,调用了 foo() 函数。而在 foo() 函数所在的 test.lua:22 这一行,则调用了 heave() 函数。

代码行层面的火焰图对于准确定位最热的 Lua 源代码行和 Lua 语句有非常大的帮助。当对应的 Lua 函数体很大的时候,代码行层面的火焰图可以帮助节约排查代码行位置的大量时间。

多进程

在多核 CPU 的系统上,为单个 OpenResty 或 Nginx 服务器实例配置多个 nginx 工作进程是很常见的做法。 OpenResty XRay 的分析工具支持同时对一个指定进程组中的所有进程进行采样。当进来的流量不是很大,并且可能分布在任意一个或几个 nginx 工作进程上的时候,这种全进程组粒度的采样分析是非常实用的。

复杂的 Lua 应用

我们也可以从非常复杂的 OpenResty/Lua 应用中得到 Lua 级别的 CPU 火焰图。 例如,下面的 Lua 级别 CPU 火焰图源自对运行了我们的 OpenResty Edge 产品的“迷你 CDN”服务器进行了采样。这是一款复杂的 Lua 应用,同时包含了全动态的 CDN 网关、地理敏感的 DNS 权威服务器和一个 Web 应用防火墙(WAF):

我们的迷你-CDN 服务器的 Lua-land CPU 火焰图

从图上可以看到,Web 应用防火墙(WAF)占用的 CPU 时间最多,内置 DNS 服务器也占用了很大一部分 CPU 时间。我们布署在全球范围的”迷你 CDN“网络为我们自己运营的多个网站,比如 openresty.orgopenresty.com提供了安全和加速支持。

它还可以分析那些基于 OpenResty 的 API 网关软件,例如 Kong 等等。

采样开销

我们使用的是基于采样的方法,而不是全量埋点,因此为生成 Lua 级别 CPU 火焰图所产生的运行时开销通常可以忽略不计。无论是数据量还是 CPU 损耗都是极小的,所以这类工具非常适合于生产环境和在线环境。

如果我们通过固定速率的请求来访问 nginx 目标进程,并且 Lua 级别 CPU 火焰图工具同时在进行密集采样,则该目标进程的 CPU 使用率随时间的变化曲线如下所示:

该 CPU 使用率的变化曲线图也是由 OpenResty XRay 自动生成和渲染的。

在我们停止工具采样之后,同一个 nginx 工作进程的 CPU 使用量曲线仍然非常相似:

我们凭肉眼很难看出前后两条曲线之间有什么差异。 所以,工具进行分析和采样的开销确实是非常低的。

而当工具不在采样时,对目标进程的性能影响严格为零,毕竟我们并不需要对目标进程做任何的定制和修改。

安全性

由于使用了动态追踪技术,我们不会改变目标进程的任何状态,甚至不会修改其中哪怕一比特的信息2。 这样可以确保目标进程无论是在采样时,还是没有采样时,其行为(几乎)是完全相同的。这就保证了目标进程自身的可靠性(不会有意外的行为变化或进程崩溃),其行为不会因为分析工具的存在而受到任何影响。 目标进程的表现完全没有变化,就像是为一只活体动物拍摄 X 光片一样。

传统的应用性能管理(APM)产品可能要求在目标软件中加载特殊的模块或插件,甚至在目标软件的可执行文件或进程空间里强行打上补丁或注入自己的机器代码或字节码,这都可能会严重影响用户系统的稳定性和正确性。

因为这些原因,我们的工具可以安全应用到生产环境中,以分析那些在离线环境中很难复现的问题。

兼容性

OpenResty XRay 产品提供的 Lua 级别 CPU 火焰图的采样工具,同时支持 LuaJITGC64 模式 或非 GC64 模式,也支持任意的 OpenResty 或 Nginx 的二进制程序,包括用户使用任意构建选项自己编译的、优化或未优化的二进制程序。

OpenResty XRay 也可以对在 Docker 或 Kubernetes 容器内运行的 OpenResty 和 Nginx 服务器进程进行透明的分析,并生成完美的 Lua 级别的 CPU 火焰图,不会有任何问题。

我们的工具还可以分析由 restyluajit 命令行工具运行的那些基于控制台的用户 Lua 程序。

我们也支持较老的 Linux 操作系统和内核,比如使用 2.6.32 内核的 CentOS 6 老系统。

其他类型的 Lua 级别火焰图

如前文所述,火焰图可以用于可视化任意一种系统资源或性能指标,而不仅限于 CPU 时间。 因此,我们的 OpenResty XRay 产品中也提供了其他类型的 Lua 级别火焰图,比如 off-CPU 火焰图、垃圾回收(GC)对象大小和数据引用路径火焰图、新 GC 对象分配火焰图、Lua 协程弃权(yield)时间火焰图、文件 I/O 延时火焰图等等。

我们的博客网站将会发文详细介绍这些不同类型的火焰图。

结论

我们在本文中介绍了一种非常实用的可视化方法,火焰图,可以直观地分析任意软件系统的性能。 我们深入讲解了其中的一类火焰图,即 Lua 级别 CPU 火焰图。这种火焰图可用于分析在 OpenResty 和 Nginx 服务器上运行的 Lua 应用。我们分析了多个 Lua 样例程序,简单的和复杂的,同时使用 OpenResty XRay 生成的对应的 Lua 级别 CPU 火焰图,展示了动态追踪工具的威力。最后,我们检查了采样分析的性能损耗,以及在线使用时的安全性和可靠性。

关于作者

章亦春是开源 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. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

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


  1. 同样地,虚拟机中的任何原语例程也不会成为分析的盲点。所以,我们也可以同时对虚拟机本身进行性能分析。 ↩︎

  2. Linux 内核的 uprobes 机制,仍然会以一种确保安全的方式,轻微地改变目标进程中少数机器指令的内存状态以实现透明且安全的动态探针,而这种修改对目标进程是完全透明的。 ↩︎