在 OpenResty 或 Nginx 进程中追踪最慢的 PCRE 正则表达式
正则表达式在 Web 应用中无处不在,包括在 OpenResty 或 Nginx(使用 OpenResty 的核心组件,如 Lua Nginx 模块)上运行的 Lua 应用。OpenResty 提供了 ngx.re.*
Lua API 函数,如 ngx.re.match 和 ngx.re.find,将 PCRE 正则表达式库的 C API 暴露给 Lua 环境。PCRE 为兼容 Perl 的正则表达式提供了一个非常高效的实现,并配备即时(JIT)编译器。PCRE 被各种著名的系统软件广泛使用,包括 Nginx 核心和 PHP 运行时。
然而,许多 Web 开发人员很少关注他们的正则表达式的效率。对于编写不当的正则表达式,特别是在像 PCRE 这样的回溯实现中,可能会出现病态行为。更糟糕的是,这种病态行为可能只有在具有特定特征的相对较大的数据输入时才会触发。一些 DoS 攻击者甚至可能专门针对这些易受攻击的正则表达式模式。
幸运的是,我们的 OpenResty XRay 产品提供了动态追踪工具,可以实时分析在线 OpenResty 和 Nginx 进程,并快速定位那些易受攻击和运行缓慢的正则表达式。目标 OpenResty 或 Nginx 应用不需要特殊的插件或模块,而且当 OpenResty XRay 不进行采样时,绝对不会产生任何开销。
在本教程中,我们将通过实际例子演示这些高级分析器。最后,我们还将探讨具有较少病态行为的替代方案,包括 RE2 和 OpenResty Regex 引擎。
系统环境
在此,我们以 RedHat Enterprise Linux 7 系统为例。任何受 OpenResty XRay 支持的 Linux 发行版都应该同样适用,如 Ubuntu、Debian、Fedora、Rocky、Alpine 等。
我们使用开源的 OpenResty 二进制构建作为目标应用。您可以使用任何 OpenResty 或 Nginx 二进制文件,包括自行编译的版本。您现有的服务器安装或进程无需特殊的构建选项、插件或库。这正是 动态追踪 技术的优势所在。它是真正非侵入式的。
我们还在同一系统上运行 OpenResty XRay 的 Agent 守护进程,并已 安装和配置 了 openresty-xray-cli
包中的命令行工具。
一个示例 OpenResty/Nginx Lua 应用
我们有一个效率低下的 OpenResty/Nginx Lua 应用,在服务器中 CPU 使用率达到 100%,这一点可以通过 top
命令行工具得到证实:
$ top -p 3584441
...
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3584441 nobody 20 0 41284 7184 4936 R 100.0 0.0 1:43.97 nginx
请注意,Nginx 进程的 CPU 使用率为 100.0%,其 PID 为 3584441。我们将在后续的动态跟踪分析中引用此 PID。
无需猜测,精准定位问题根源
C 语言级别的 CPU 火焰图
为分析任何具有高 CPU 使用率的 C/C++ 进程(包括 Nginx 进程),我们首先应该从该进程获取 C 语言级别的 CPU 火焰图1。尽管 OpenResty XRay 可以自动检测 OpenResty/Nginx 应用,对其进行采样并生成各种类型的火焰图,但在此,我们将仅出于演示目的使用手动方式运行命令行工具。
$ orxray analyzer run lj-c-on-cpu -p 3584441
Start tracing...
Go to https://x5vrki.xray.openresty.com/targets/68/history/664712 for charts.
我们使用 -p
选项来指定目标 Nginx 工作进程的 PID。然后我们运行标准的 OpenResty XRay 工具,名为 lj-c-on-cpu
。我们在此不使用更通用的 c-on-cpu
工具,因为前者可以自动展开来自 LuaJIT 和 PCRE 即时编译器(JIT)的动态机器代码帧。
从这个 C 语言级别的 CPU 火焰图中,我们可以看出:
- 几乎所有的 CPU 时间都用于执行 Lua 代码(注意火焰图中高亮显示的
ngx_http_lua_run_thread
C 函数帧)。 - Lua 代码也在执行 PCRE 匹配器(注意图中的
pcre_exec
函数帧)。 - 此外,PCRE JIT 编译的代码正在被执行,这可以从图中的
_pcre_jit_exec
函数帧看出。
请注意这个动态跟踪工具的强大之处,它可以在不需要目标应用任何帮助的情况下透明地展开 JIT 编译的代码帧2。
下一步是采样并生成一个 Lua 级别的 CPU 火焰图,这样我们就可以看到 Lua 代码中发生了什么。
Lua 级别的 CPU 火焰图
为了生成 Lua 级别的 CPU 火焰图,我们可以使用标准分析器 lj-lua-on-cpu
,如下所示:
$ orxray analyzer run lj-lua-on-cpu -p 3584441
Start tracing...
Go to https://x5vrki.xray.openresty.com/targets/68/history/664870 for charts.
Lua 级别的火焰图还显示了经过 JIT 编译的 Lua 代码,这一点从图中的 trace#3:regex.lua:721
帧可以看出。这表示一个 LuaJIT 跟踪对象,其跟踪 ID 为 3
,起始于 regex.lua
源文件的第 721 行 Lua 代码。我们可以看到,几乎所有的 CPU 时间都集中在单一的 Lua 代码路径上。在这个主导的 Lua 代码路径中,最值得关注的 Lua 函数帧是 regex.lua:re_match
。在该 Lua 文件中,我们有:
local re_match = ngx.re.match
因此,re_match
符号名指向 ngx.re.match
API 函数。接下来的问题是:哪个正则表达式如此耗时?我们可以直接查看火焰图中显示的 content_by_lua(nginx.conf:69):10
所对应的 Lua 源代码行。但如果 Lua 代码是通用的,仅仅是遍历包含多个正则表达式的 Lua 表格进行匹配,这还不足以精确定位问题所在的正则表达式。
最慢的正则表达式
既然我们知道正则表达式匹配占用了大部分 CPU 时间,现在我们可以使用另一个标准分析器,名为 lj-slowest-ngx-re
,来找出当前活跃进程中最慢的正则表达式匹配,方法如下:
$ orxray analyzer run lj-slowest-str-match-find -p 3584441 --y-var threshold_ns=1 --y-var max_hits=1000
Start tracing...
sub_regex_cache_count: 0
http: match
cache: ptr: 0x154bbc0, type: match, pat: "\w"
cache: ptr: 0x1517890, type: match, pat: "(?:.*)*css"
cache: ptr: 0x154a3a0, type: match, pat: ".*?js"
http: substitution
http: function substitution
...WARNING: reach samples count: 1000
=== start ===
max latency: 15844343 ns, ptr: 0x1517890
max latency: 13555 ns, ptr: 0x154a3a0
max latency: 8075 ns, ptr: 0x154bbc0
=== finish ===
Go to https://x5vrki.xray.openresty.com/targets/68/history/799249 for charts.
我们在网络浏览器中打开 URL,以查看生成的条形图,如下所示。
此条形图显示了在采样时间窗口内运行的最慢正则表达式的最大执行延迟。延迟以纳秒为单位。显然,(?:.*)*css
是绝对最慢的正则表达式,耗时超过 20.6 毫秒(或 20,671,282 纳秒)。它也远比其他正则表达式慢得多。有经验的用户可以迅速识别出,在正则表达式引擎中,使用贪婪量词 .*
和常量后缀可能导致疯狂的回溯。我们可以使用量词的非贪婪版本来优化正则表达式:(?:.*?)css
。
优化最慢正则表达式后
在将最慢的正则表达式 (?:.*)*css
优化为 (?:.*?)css
后,我们重新加载应用并使用优化后的正则表达式重新生成新的 C 语言级别的 CPU 火焰图。
我们可以看到新火焰图的形状和特征与之前的有很大不同。瓶颈不再是执行 Lua 代码的路径。相反,现在是负责写出 HTTP 响应数据的 writev
系统调用(如上图中红色高亮所示)。请注意图中的 ngx_http_output_filter
C 函数帧。该函数负责调用 Nginx 输出过滤器链来发送 HTTP 响应头和正文数据。
让我们对优化后的应用重新运行 lj-slowest-ngx-re
工具。结果图表如下。
我们可以看到,正则表达式的最大延迟大大缩短,仅为 58,660 纳秒,约 59 微秒。
限制 PCRE 的执行开销
OpenResty 的 Lua Nginx 模块提供了 lua_regex_match_limit 指令,用于为单个正则表达式匹配设置基本操作的硬限制。
例如,我们可以在 nginx.conf
文件中添加以下行:
lua_regex_match_limit 100000;
And make sure we log any error returned by the ngx.re.match
API function call:
local m, err = re_match(content, regexes[i], "jo")
if err ~= nil then
ngx.log(ngx.ERR, "re_math failed, re: ", regexes[i], ", error: ", err)
return
end
并确保我们记录 ngx.re.match
API 函数调用返回的任何错误:
2022/07/13 21:05:13 [error] 3617128#3617128: *1 [lua] content_by_lua(nginx.conf:69):11: re_math failed, re: (.*)*css, error: pcre_exec() failed: -8, client: 127.0.0.1, server: localhost, request: "GET / HTTP/1.1", host: "127.0.0.1"
非回溯正则表达式引擎
PCRE 几乎完全支持 Perl 正则表达式。这种灵活性伴随着回溯带来的成本。用户可能还想考虑其他非回溯的正则表达式实现,如 Google 的 RE2 或 OpenResty Inc 的商业 OpenResty Regex 引擎。这些引擎通常采用基于自动机理论的算法。遗憾的是,对于典型的正则表达式,RE2 的平均匹配时间比 PCRE 慢约 50%,尽管它在慢速匹配方面几乎没有病态情况。另一方面,OpenResty Regex 引擎实现了与 PCRE 相似的平均性能(有时甚至更快),并且也没有回溯问题。
Lua 的内置字符串模式
标准 Lua 5.1 语言定义了自己的正则表达式语言语法,由其标准 API 函数 string.match 和 string.find 支持。OpenResty XRay 还提供了用于分析此类模式匹配操作的动态跟踪工具。我们将在另一个教程中介绍这个主题。
跟踪容器内的应用
OpenResty XRay 工具支持透明地跟踪容器化应用。Docker 和 Kubernetes (K8s) 容器都可以透明地工作。与普通应用进程一样,目标容器不需要任何应用或额外权限。OpenResty XRay Agent 守护进程应该在目标容器外部运行(如直接在主机操作系统中或在其自己的特权容器中)。
让我们看一个例子。我们首先使用 docker ps
命令检查容器名称或容器 ID。
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4465297209d9 openresty/openresty:1.19.3.1-2-alpine-fat "/usr/local/openrest…" 18 months ago Up 45 minutes angry_mclaren
在这里,容器名称是 angry_mclaren
。然后我们可以在这个容器中找出目标进程的 PID。
$ docker top angry_mclaren
UID PID PPID C STIME TTY TIME CMD
root 3605106 3605085 0 19:40 ? 00:00:00 nginx: master process /usr/local/openresty/bin/openresty -g daemon off;
nobody 3605692 3605106 0 19:44 ? 00:00:20 nginx: worker process
openresty
工作进程的 PID 是 3605692
。接下来,我们像往常一样对这个 PID 运行 OpenResty XRay 分析器。
$ orxray analyzer run lj-slowest-ngx-re -p 3605692
Start tracing...
...
Go to https://x5vrki.xray.openresty.com/targets/68/history/684552 for charts.
OpenResty XRay 还能够自动检测长时间运行的进程,将其识别为特定类型的“应用”(如 “OpenResty”、“Python” 等)。
工具的实现方式
所有工具都是用 Y 语言实现的。OpenResty XRay 通过 Stap+2 或 eBPF3 后端执行这些工具,这两种后端都使用了 100% 非侵入式的动态追踪技术,基于 Linux 内核的 uprobes
和 kprobes
功能。
我们不需要目标应用和进程的任何配合。不使用也不需要任何日志数据或指标数据。我们直接以严格的只读方式分析运行中进程的进程空间。而且我们绝不向目标进程注入任何字节码或其他可执行代码。这是 100% 干净和安全的。
工具的开销
本教程中演示的动态追踪工具 lj-slowest-ngx-re
非常高效,适合在线执行。
当工具未运行且不进行主动采样时,对系统和目标进程的开销严格为零。我们从不向目标应用和进程注入任何额外的代码或插件;因此,不存在固有的开销。
在采样期间,在典型的服务器硬件上,请求延迟平均仅增加约 7 微秒(us)。对于每个 CPU 核心每秒处理数万个请求的最快 OpenResty/Nginx 服务器来说,最大请求吞吐量的降低是不可测量的。
关于作者
章亦春是开源 OpenResty® 项目创始人兼 OpenResty Inc. 公司 CEO 和创始人。
章亦春(Github ID: agentzh),生于中国江苏,现定居美国湾区。他是中国早期开源技术和文化的倡导者和领军人物,曾供职于多家国际知名的高科技企业,如 Cloudflare、雅虎、阿里巴巴, 是 “边缘计算“、”动态追踪 “和 “机器编程 “的先驱,拥有超过 22 年的编程及 16 年的开源经验。作为拥有超过 4000 万全球域名用户的开源项目的领导者。他基于其 OpenResty® 开源项目打造的高科技企业 OpenResty Inc. 位于美国硅谷中心。其主打的两个产品 OpenResty XRay(利用动态追踪技术的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最适合微服务和分布式流量的全能型网关软件),广受全球众多上市及大型企业青睐。在 OpenResty 以外,章亦春为多个开源项目贡献了累计超过百万行代码,其中包括,Linux 内核、Nginx、LuaJIT、GDB、SystemTap、LLVM、Perl 等,并编写过 60 多个开源软件库。
关注我们
如果您喜欢本文,欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:
翻译
我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!
-
OpenResty XRay 的 C 语言级别 CPU 火焰图比大多数开源解决方案更为强大。它支持在不需要目标进程协作的情况下展开 JIT 编译的机器代码,并且支持内联 C 函数以及详细的源文件名和行号。 ↩︎
-
开源的 Linux
perf
工具链对 JIT 编译的代码展开支持有限,它需要目标应用创建一个特殊的perf.map
文件。这需要目标应用的特殊帮助和协作,即使用户不需要采样和分析,也可能导致文件数据泄露和额外的运行时开销。 ↩︎