当 Prometheus 指标写满共享内存:一次 APISIX CPU 100% 问题的复现与分析

前几天在 APISIX 的 issue 列表里看到一个很有意思的故障(apache/apisix#12275):一套由 38 台网关组成的集群里,有 2 台突然 CPU 飙到 100%,彻底不干活了。运维的第一反应是把这两台的流量切走——奇怪的是,没有新的流量了,CPU 却还是死死钉在 100%,像被什么东西困住了一样。

排查一圈下来,唯一的异常线索是:负责存放监控指标的那块 100MB 共享内存,被海量路由产生的指标数据写得满满当当。提 issue 的人最后附了一个朴素的问题:“我把这块内存调大,是不是就没事了?”

这个问题勾起了我的好奇:内存写满和 CPU 空转,中间到底有什么关联?于是我决定在测试环境把这个故障从头到尾复现一遍,用火焰图把 CPU 到底在忙什么看个明白,顺便回答:调大内存,到底算不算解决了问题。

一、问题背景:一个字典,三方争抢

APISIX 的 prometheus 插件基于 nginx-lua-prometheus 实现,所有指标数据最终都集中在一块 nginx 共享内存字典(lua_shared_dict prometheus-metrics)里。围绕这块共享内存有三类角色在工作:

  1. 业务请求:每个开启了 prometheus 插件的路由,在 log 阶段记录 http_statushttp_latencybandwidth 等指标。新版本的 nginx-lua-prometheus 会先把计数攒在 worker 本地表里,由每秒一次的 sync 定时器批量刷进共享字典;

  2. 指标抓取:Prometheus server 定期访问 /apisix/prometheus/metrics,exporter 要对整个字典做 get_keys(0)(一次性拷出全部 key,期间持有锁),然后逐 key get 并格式化成文本输出;

  3. 锁:以上所有共享字典操作都要过同一把 ngx_shmtx 互斥锁——先原子自旋,自旋耗尽后通过 POSIX 信号量睡眠等待,这一过程体现在系统调用上就是 futex。

关键在于指标的数量:每一个“路由 × 状态码 × 直方图桶”的组合都是字典里一个独立的 key。路由多、状态码杂,key 的数量是乘法增长的。字典写满后,新指标写入全部失败,error.log 里出现 no memory,但已有的几万个 key 还在,每次抓取仍要在锁内全量遍历一遍。

二、复现设计:把“量变”压缩成“质变”

原始故障要 38 个实例、海量路由才能积累出来,测试环境在此基础上等比例缩小:字典从 100M 缩到 4M,再用可控的手段把基数打上去。

环境:一台 Ubuntu 24.04(x86_64,4 核)服务器,docker 跑 etcd + apache/apisix。配置里只改三处:

nginx_config:
  worker_processes: 4
  http:
    lua_shared_dict:
      prometheus-metrics: 4m   # 默认 10m,缩小以加速复现
plugins:
  - prometheus
  - serverless-pre-function

制造高基数有个小技巧:创建 400 条路由,每条挂一个 serverless 函数,按查询参数直接返回不同状态码,不需要真实上游,一条 curl 就能实现一个新的指标组合:

return function(conf, ctx)
    local e = tonumber(ngx.var.arg_e) or 0
    ngx.exit(200 + (e % 100))
end

然后 400 条路由 × 100 个状态码灌一遍流量(约 4 万个请求、4 万个 label 组合),字典很快就耗尽了。判定字典耗尽的信号有三个:

  • error.log 开始刷 prometheus.lua: Unexpected error adding a key: no memory(十分钟内 4 万多条);

  • 指标 apisix_nginx_metric_errors_total 一路飙升;

  • /apisix/prometheus/metrics 的输出变短了,4M 字典只装下约 8000 条数据,后来的全部丢失。指标静默不全,往往比报错更危险。

最后加压:多路并发循环抓取 metrics 端点 + 多路业务流量,4 个 worker 的 CPU 全部拉起来。故障条件就绪。

三、采样:一条命令拿到 Lua + native 融合火焰图

复现只是第一步,接下来得看清 CPU 在处理什么——而 OpenResty 的性能剖析恰恰是个老大难的问题:C 栈里看不到 Lua,Lua 栈里看不到 C。传统做法是拿 SystemTap 脚本来做,环境依赖一大堆,GC64 的 LuaJIT 还经常不配合。这次我换了个省事的方法:APISIX 社区新推出的 debug 工具 ngxdig,它可以基于 eBPF 在一次栈回溯里同时拿到 Lua 帧和 native 帧。在做好之前的准备工作之后,一条命令对 nginx 工作进程采样:

sudo ngxdig cpu-on --pid <worker_pid> --duration 90s --kernel stack --format html

90 秒后得到一个自包含的 HTML 报告,火焰图可以直接在浏览器里交互。

四、读图:CPU 到底消耗在哪

火焰图总览

火焰图的形状非常清晰,结论几乎是从图里自己跳出来的:

  • 62% 的样本落在 [phase:content]content_by_luangx_http_lua_run_thread——也就是 /apisix/prometheus/metrics 这个端点本身。注意此时 Prometheus 甚至还没开始抓,是我们的并发抓取在互相踩踏。

  • 展开往上看,热点集中在 exporter 的两处 Lua 代码:exporter.lua:423/460(遍历全部指标)和 prometheus.luametric_data()get_keys(0) + 逐 key get + 拼接文本)。字典里的 key 无论有没有写满都在,每次抓取都是一次全量 O(N) 的遍历。

  • LuaJIT 的 lj_str_newlj_str_hash 也排进了前列——几万个 metric key 字符串在每次抓取时反复驻留,这是高基数的隐性问题。

放大后的 exporter 调用栈

火焰图里的 phase 归属非常清晰,得益于 ngxdig 会给栈补充一个合成的 [phase:content][phase:log] 根帧,我们一眼就能把“CPU 花在指标导出”和“CPU 花在业务请求”分开,这正是这次诊断最需要的切分维度。

五、结论

回到最初的问题:“是不是把 prometheus 的共享字典调大就行?”现在我们可以确定:

调大字典是临时方案,不能治本。

这次复现给出的完整因果链是:

指标基数失控 → 字典写满 → 几万 key 的字典在每次抓取时被锁内全量遍历 → 抓取端点吃满 worker CPU,业务请求在同一把锁上排队 → 服务失去响应。

当前这个 bug 已经得到了社区的及时响应,修复代码已经合并进 master 分支。