关于后端:从-1-秒到-10-毫秒在-APISIX-中减少-Prometheus-请求阻塞

2次阅读

共计 6063 个字符,预计需要花费 16 分钟才能阅读完成。

本文介绍了 Prometheus 插件造成长尾申请景象的起因,以及如何解决这个问题。

作者屠正松,Apache APISIX PMC Member。

原文链接

景象

在 APISIX 社区中,曾有局部用户陆续反馈一种神秘景象:局部申请提早较长。具体表现为:当流量申请进入一个失常部署的 APISIX 集群时,偶然会呈现局部申请有 1 ~ 2 秒的提早。用户的 QPS 规模大略在 1 万,然而这种异样申请十分少见,每隔几分钟就会呈现 1 ~ 3 次。一些用户在 issue 中也提供了捕捉到的提早较长的申请。从这些截图中能够看出,的确有申请提早较高,甚至能够达到秒级别。

这种景象随同着另一种景象:某个 worker 过程的 CPU 占用率达到了 100%。

开发团队通过不同渠道与这些反馈的用户沟通得悉,这个景象产生的条件是:

  1. 开启 prometheus 插件,并且有 Prometheus Exporter 拜访 APISIX 的 endpoint /apisix/prometheus/metrics 来采集指标;
  2. prometheus 插件统计的 metrics 的数量达到肯定规模,通常是上万级别;

这个景象是在业界称为 “ 长尾申请 ”,是指在一个申请群体中,大部分申请响应工夫较短,但有少部分申请响应工夫较长的状况。它可能是因为后端系统的性能瓶颈、资源有余或其余起因导致的。它不是一个致命的 bug,然而它重大影响了终端用户的体验。

抽丝剥茧

APISIX 基于一个开源的 Lua 库 nginx-lua-prometheus 开发了 Prometheus 插件,提供跟踪和收集 metrics 的性能。当 Prometheus Exporter 拜访 APISIX 裸露的 Prometheus 指标的 endpoint 时,APISIX 会调用 nginx-lua-prometheus 提供的函数来裸露 metrics 的计算结果。

开发团队从社区用户,企业用户等渠道收集汇总了长尾申请产生的条件,根本定位了问题所在:nginx-lua-prometheus 中用于裸露 metrics 指标的函数 prometheus:metric_data()

不过这只是初步推断,还须要间接的证据来证实长尾申请与此有关,并且须要搞清楚以下问题:

  1. 这个函数具体做了什么?
  2. 这个函数为什么会造成长尾申请景象?

开发团队结构了本地复现环境,这个复现环境次要模仿以下场景:

  1. 模仿客户端发送失常申请,被 APISIX 代理到上游
  2. 模仿 Prometheus Exporter 每隔 5 秒拜访 /apisix/prometheus/metrics,触发 APISIX 运行 prometheus:metric_data() 函数

复现环境示意图:

在执行复现测试时,咱们会察看 wrk2 的测试后果中的 P100 等指标来确认是否产生了长尾申请景象,并且会对运行中的 APISIX 生成火焰图,来观测产生长尾申请时,CPU 资源耗费在哪里。

wrk2 的测试后果如下:

  Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
   50.000%    1.13ms
   75.000%    2.56ms
   90.000%    4.82ms
   99.000%   14.70ms
   99.900%   27.95ms
   99.990%   74.75ms
   99.999%  102.78ms
  100.000%  102.78ms

依据这个测试后果能够失去论断:在测试期间,99% 的申请在 14.70 毫秒内实现了,然而还有很少一部分申请耗费了 100 多毫秒。并且咱们用 metrics 数量作为变量,进行了屡次测试,发现 metrics 数量与 P100 的提早呈线性增长。如果 metrics 达到 10 万级别,P100 将达到秒级别。

生成的火焰图如下:

从火焰图的函数堆栈能够看到,prometheus:metric_data() 占据了最长的横轴宽度,这证实了大量 CPU 耗费在这里。这也直接证明了 prometheus:metric_data() 造成长尾申请景象。

上面咱们来简略剖析一下 prometheus:metric_data() 函数做了什么。prometheus:metric_data() 将会从共享内存中获取指标,对指标进行分类,并加工成 Prometheus 兼容的文本格式。在这个过程中,会对所有 metrics 依照字典序进行排序,会用正则解决 metrics 的前缀。依据教训,这些都是十分低廉的操作。

不够完满的优化

当定位到有问题的代码后,下一步就是联合火焰图,详细分析代码,寻找优化空间。

从火焰图能够定位到 fix_histogram_bucket_labels 函数。通过 review 这个函数,咱们发现了两个比拟敏感的函数:string:matchstring:gsub。这两个函数都不能被 LuaJIT 所 JIT 编译,只能解释执行。

LuaJIT 是一个针对 Lua 编程语言的 JIT 编译器,能够将 Lua 代码编译成机器码并运行。这相比于应用解释器来运行 Lua 代码,能够提供更高的性能。
应用 LuaJIT 运行 Lua 代码的一个劣势是,它能够大幅晋升代码的执行速度。这使得 APISIX 在解决大量申请时能够放弃较低的提早,并且能够在高并发环境下体现出较好的性能。
对于 LuaJIT 的更多介绍能够参考:什么是 JIT?

因而不能被 LuaJIT 编译的代码必然会成为潜在的性能瓶颈。

咱们整顿以上信息并提交了 issue: optimize the long-tail request phenomenon 到 nginx-lua-prometheus,与这个我的项目的作者 knyar 一起探讨能够优化的空间。knyar 响应很及时,咱们沟通后明确了能够优化的点。于是提交了 PR:chore: use ngx.re.match instead of string match to improve performance 进行优化。
在这个 PR 中,次要实现了:

  • ngx.re.match 代替 string:match
  • ngx.re.gsub 代替 string:gsub

在实现这个优化后,咱们其实十分感性地晓得,这个优化只能晋升一些性能,但不能基本解决问题。基本问题是:

Nginx 是一种多过程单线程的架构。所有的 worker 过程都会监听 TCP 连贯,但一旦连贯进入了某个 worker 过程,就不能再被迁徙到其余 worker 过程去解决了。
这意味着,如果某个 worker 过程十分繁忙,那么该 worker 过程内的其余连贯就可能无奈及时取得解决。另一方面,过程内的单线程模型意味着,所有 CPU 密集型和 IO 密集型的工作都必须按程序执行。如果某个工作执行工夫较长,那么其余工作就可能被疏忽,导致工作解决工夫不平均。

prometheus:metric_data() 占据了大量的 CPU 工夫片进行计算,挤压了解决失常申请的 CPU 资源。这也是为什么会看到某个 worker 过程的 CPU 占用率达到 100%。

基于这个问题,咱们在实现上述优化后持续剖析,抓取了火焰图:

下面火焰图 builtin#100 示意的是 luajit/lua 的库函数(比方 string.find 这种),能够通过 https://github.com/openresty/openresty-devel-utils/blob/master/ljff.lua 这个我的项目里的脚本来失去对应的函数名称。

应用形式:

$ luajit ljff.lua 100
FastFunc table.sort

因为计算 metrics 时占用了适量的 CPU,所以咱们思考在计算 metrics 时适当让出 CPU 工夫片。

对于 APISIX 来说,解决失常申请的优先级是最高的,CPU 资源该当向此歪斜,而 prometheus:metric_data() 只会影响 Prometheus Exporter 获取指标时的效率。

在 OpenResty 世界,有一个隐秘的让出 CPU 工夫片的形式:ngx.sleep(0)。咱们在 prometheus:metric_data() 中引入这种形式,当解决所有的 metrics 时,每解决固定数目(比方 200 个)的 metrics 后让出 CPU 工夫片,这样新进来的申请将有机会失去解决。

咱们提交了引入这个优化的 PR:feat: performance optimization。

在咱们的测试场景中,当 metrics 的总数量达到 10 万级别时,引入这个优化之前用 wrk2 测试失去的后果:

  Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
 50.000%   10.21ms
 75.000%   12.03ms
 90.000%   13.25ms
 99.000%   92.80ms
 99.900%  926.72ms
 99.990%  932.86ms
 99.999%  934.40ms
100.000%  934.91ms

引入这个优化后,用 wrk2 测试失去的后果:

  Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
 50.000%    4.34ms
 75.000%   12.81ms
 90.000%   16.12ms
 99.000%   82.75ms
 99.900%  246.91ms
 99.990%  349.44ms
 99.999%  390.40ms
100.000%  397.31ms

能够看到 P100 的指标大概是优化前的 1/3 ~ 1/2。

不过这并没有完满解决这个问题,通过剖析优化后的火焰图:

能够间接看到 builtin#100(即 table.sort)和 builtin#92(即 string.format)等,依然占据了相当宽度的横轴,这是因为:

  1. prometheus:metric_data() 中首先会对所有的 metrics 调用 table.sort 进行排序,当 metrics 到 10 万级别时,相当于对 10 万个字符串进行排序,并且 table.sort 不能够被 ngx.sleep(0) 中断。
  2. 应用 string.format 的中央,以及 fix_histogram_bucket_labels 无奈优化,通过与 knyar 交换后得悉,这些步骤必须存在以保障 prometheus:metric_data() 能够产出格局正确的 metrics。

至此,代码层面的优化伎俩曾经用完了,但遗憾的是,还是没有完满解决问题。P100 的指标依然有显著的提早。

怎么办?

让咱们再回到外围问题:prometheus:metric_data() 占据了大量的 CPU 工夫片进行计算,挤压了解决失常申请的 CPU 资源。

在 Linux 零碎中,CPU 调配工夫片的单位是线程还是过程?精确来说是线程,线程才是理论的工作单元。不过 Nginx 是多过程单线程的架构,理论在每个过程中只有一个线程。

此时咱们会想到一个优化方向:将 prometheus:metric_data() 转移到其余线程,或者说过程。于是咱们调研了两个方向:

  1. ngx.run_worker_thread 来运行 prometheus:metric_data() 的计算工作,相当于将 CPU 密集型工作交给线程池;
  2. 用独自的过程来解决 prometheus:metric_data() 的计算工作,这个过程不会解决失常申请。

通过 PoC 后,咱们否定了计划 1,采纳了计划 2。否定计划 1 是因为 ngx.run_worker_thread 只适宜运行与申请无关的计算工作,而 prometheus:metric_data() 显著是与申请无关的。

计划 2 的实现:让 privileged agent(特权过程)来解决 prometheus:metric_data()。然而特权过程自身不监听任何端口,也不会解决任何申请。因而,咱们须要对特权过程进行一些革新,让它监听端口。

最终,咱们通过 feat: allow privileged agent to listen port 和 feat(prometheus): support collect metrics works in the priviledged agent 实现了计划 2。

咱们应用带上了这个优化的 APISIX 来测试,发现 P100 的指标提早曾经升高到正当的范畴内,长尾申请景象也不存在了。

  Latency Distribution (HdrHistogram - Uncorrected Latency (measured without taking delayed starts into account))
 50.000%    3.74ms
 75.000%    4.66ms
 90.000%    5.77ms
 99.000%    9.99ms
 99.900%   13.41ms
 99.990%   16.77ms
 99.999%   18.38ms
100.000%   18.40ms

这个计划有些奇妙,也解决了最外围的问题。咱们在生产环境中察看并验证了这个计划,它打消了长尾申请景象,也没有造成其余额定的异样。
与此同时,咱们发现社区中也有相似的修复计划,有趣味的话能够延长浏览:如何批改 Nginx 源码实现 worker 过程隔离。

瞻望

在咱们修复这个问题的时候,产生了一个新的思考:nginx-lua-prometheus 这个开源库适宜 APISIX 吗?

咱们在 APISIX 侧解决了 prometheus:metric_data() 的问题,同时,咱们也发现了 nginx-lua-prometheus 存在的其余问题,并且修复了。比方修复内存透露,以及修复 LRU 缓存淘汰。

nginx-lua-prometheus 刚开始是被设计为 Nginx 应用,并不是为了 OpenResty 以及基于 OpenResty 的利用所设计的。OpenResty 生态内没有比 nginx-lua-prometheus 更成熟的对接 Prometheus 生态的开源我的项目,因而 nginx-lua-prometheus 一直被开源社区的力量推动成为适宜 OpenResty 生态的方向。

兴许咱们应该将视线放得更宽阔一些,寻找不必批改 APISIX 底层的形式来对接 Prometheus 生态。比方设计一个更适宜 APISIX 的依赖库,或者用某种形式对接 Prometheus 生态中成熟的我的项目,将收集和计算 metrics 的过程转移到那些成熟的我的项目中。

后续

该问题曾经在 Apache APISIX 3.1 版本中修复。https://github.com/apache/apisix/pull/8434

对于 API7.ai 与 APISIX

API7.ai 是一家提供 API 解决和剖析的开源根底软件公司,于 2019 年开源了新一代云原生 API 网关 — APISIX 并捐献给 Apache 软件基金会。尔后,API7.ai 始终踊跃投入反对 Apache APISIX 的开发、保护和社区经营。与千万贡献者、使用者、支持者一起做出世界级的开源我的项目,是 API7.ai 致力的指标。

正文完
 0