Skip to content

OpenResty 学习笔记(8) - 动态调试

Published: at 08:11 AM28 min read

目录

动态调试

测试环境稳定复现

打日志

线上才可复现

如果是线上环境才会复现的 bug,是否有调试的方法呢?

推荐一个工具 —— Mozilla RR,可以把它当作是一个复读机,可以把程序的行为录制下来,然后反复地重放。

如何定位问题在哪个组件?

土方法:二分注释代码

正经可视化方法: OpenTracing、Zipkin、Apache SkyWalking

OpenTracing 可以在系统的各处埋点,通过 Trace ID 把多个 Span 组成的调用链和埋点数据上报到服务端,进行分析和图形化的展现。这样就可以发现很多隐藏的问题,而且历史数据都会保存下来,方便我们随时对比和查看。

另外,如果你的系统比较复杂,比如是在微服务的环境下,那么 Zipkin、Apache SkyWalking 都是不错的选择。

线上偶现 bug

Dtrace,专门用于动态调试。

动态调试,也叫做活体调试。和 GDB 这种静态调试工具不同,动态调试可以调试线上的服务,而对调试的程序而言,整个调试过程是无感知、无侵入的,不用你修改代码,更不用重启。打一个比方,动态调试就像 X 光,可以在病人无感知的情况下检查身体,而不需要抽血和胃镜。

Systemtap,这个重要

Systemtap

安装

Terminal window
sudo apt install systemtap

Systemtap 的 hello world

Terminal window
# cat hello-world.stp
probe begin
{
print("hello world!")
exit()
}

需要使用 sudo 权限才可以运行:

Terminal window
sudo stap hello-world.stp

在大部分场景下,我们都不需要自己写 stap 脚本来进行分析,因为 OpenResty 已经有了很多现成的 stap 脚本来做常规的分析。

probe 就是一个探针。begin 会在探测的最开始运行,与之对应的是 end,所以上面的 hello world 程序也可以写成下面的这种方式:

probe begin
{
print("hello ")
exit()
}
probe end
{
print("world!")
}

了解更多,推荐阅读: 《Systemtap tutorial》

当然,对于内核和性能分析工程师来说,只有 Systemtap 还是不够用的。首先, Systemtap 并没有默认进入系统内核;其次,它的工作原理决定了它的启动速度比较慢,而且有可能对系统的正常运行造成影响。

在 OpenResty 中有两个开源项目:openresty-systemtap-toolkitstapxx 。它们是基于 Systemtap 封装好的工具集,用于 Nginx 和 OpenResty 的实时分析和诊断。它们可以覆盖 on CPU、off CPU、共享字典、垃圾回收、请求延迟、内存池、连接池、文件访问等常用的功能和调试场景。

(这两个已经很久不维护了,项目开发者去开发维护 OpenResty XRay 了,花钱买服务或者自强吧)

其他动态追踪框架

eBPF(extended BPF)则是最近几年 Linux 内核中新增的特性。相比 Systemtap,eBPF 有内核直接支持、不会死机、启动速度快等优点;同时,它并没有使用 DSL,而是直接使用了 C 语言的语法,所以也大大降低了它的上手难度。

VTune 也可以尝试

火焰图

perf 和 Systemtap 等工具产生的数据,都可以通过火焰图的方式,来进行更加直观的展示。下面这张图就是火焰图的示例:

在火焰图中,色块的颜色和深浅都是没有意义的,只是为了对不同的色块儿做出简单的区分。火焰图其实是把每次采样的数据进行叠加,所以,真正有意义的是色块的宽度和长度。

对于 on CPU 火焰图来说,色块的宽度是函数占用的 CPU 时间百分比,色块越宽,则说明性能消耗越大。如果出现一个平顶的山峰,那它就是性能的瓶颈所在。而色块的长度,代表的是函数调用的深度,最顶端的框显示正在运行的函数,在它之下的都是这个函数的调用者。所以,在下面的函数是上面函数的父函数,山峰越高,则说明调用的函数层级越深。

OpenResty 动态加载代码

loadstring

Terminal window
resty -e 'local s = [[ngx.say("hello world")]]
local func, err = loadstring(s)
func()'

loadfile 可以加载指定的文件 loadfile("foo.lua")

基于此可以扩展出如下功能

功能一:FaaS

FaaS 函数即服务

local s = [[
return function()
ngx.say("hello world")
end
]]

函数在 Lua 中是一等公民,这段代码便是返回了一个匿名函数。在执行这个匿名函数时,我们使用 pcall 做了一层保护。pcall 会在保护模式下运行函数,并捕获其中的异常,如果正常就返回 true 和执行的结果,如果失败就返回 false 和错误信息,也就是下面这段代码:

local func1, err = loadstring(s)
local ret, func = pcall(func1)

把上面的两部分结合起来,就会得到完整的、可运行的示例:

Terminal window
resty -e 'local s = [[
return function()
ngx.say("hello world")
end
]]
local func1 = loadstring(s)
local ret, func = pcall(func1)
func()'

更深入一步,我们还可以把 s 这个包含函数的字符串,改成可以由用户指定的形式,并加上执行它的条件,这样其实就是 FaaS 的原型了, Apisix 的实现

功能二:边缘计算

得益于 Nginx 和 LuaJIT 良好的多平台支持特性,OpenResty 不仅能运行在 X86 架构下,对于 ARM 的支持也很完善。同时, OpenResty 支持七层和四层的代理,这样一来,常见的各种协议都可以被 OpenResty 解析和代理,这其中也包括了 IoT 中的几种协议。

因为这些优势,我们便可以把 OpenResty 部署到,联网设备、CDN 边缘节点、路由器等最靠近用户的边缘节点上去。

以 CDN 的边缘节点为例,OpenResty 的最大使用者 CloudFlare 很早就借助 OpenResty 的动态特性,实现了对于 CDN 边缘节点的动态控制。

CloudFlare 的做法和上面动态加载代码的原理是类似的,大概可以分为下面几个步骤:

这样,下一个被处理的终端请求,就会走更新后的代码逻辑。

当然,实际的应用要比上面的步骤考虑更多的细节,比如版本的控制和回退、异常的处理、网络的中断、边缘节点的重启等,但整体的流程是不变的。

动态上游

lua-resty-core 提供了 ngx.balancer 这个库来设置上游,它需要放到 OpenResty 的 balancer 阶段来运行:

balancer_by_lua_block {
local balancer = require "ngx.balancer"
local host = "127.0.0.2"
local port = 8080
local ok, err = balancer.set_current_peer(host, port)
if not ok then
ngx.log(ngx.ERR, "failed to set the current peer: ", err)
return ngx.exit(500)
end
}

set_current_peer 函数,就是用来设置上游的 IP 地址和端口的。不过要注意,这里并不支持域名,需要使用 lua-resty-dns 库来为域名和 IP 做一层解析。

不过,ngx.balancer 还比较底层,虽然它有设置上游的能力,但动态上游的实现远非如此简单。所以,在 ngx.balancer 前面还需要两个功能:

而 OpenResty 官方的 lua-resty-balancer 这个库中,则包含了 resty.chashresty.roundrobin 两类算法来完成第一个功能,并且有 lua-resty-upstream-healthcheck 来尝试完成第二个功能。

不过,这其中还是有两个问题。

第一点,缺少最后一公里的完整实现。把 ngx.balancerlua-resty-balancerlua-resty-upstream-healthcheck 整合并实现动态上游的功能,还是需要一些工作量的,这就拦住了大部分的开发者。

第二点,lua-resty-upstream-healthcheck 的实现并不完整,只有被动的健康检查,而没有主动的健康检查。

简单解释一下,这里的被动健康检查,是指由终端的请求触发,进而分析上游的返回值来作为健康与否的判断条件。如果没有终端请求,那么上游是否健康就无从得知了。而主动健康检查就可以弥补这个缺陷,它使用 ngx.timer 定时去轮询指定的上游接口,来检测健康状态。

通常推荐使用 lua-resty-healthcheck 这个,来完成上游的健康检查。它的优点是包含了主动和被动的健康检查,而且在多个项目中都经过了验证,可靠性更高。

APISIX 的实现

OpenResty 常用的第三方库

首选去awesome-resty 仓库寻找,还可以去 luarocks、opm 和 GitHub 碰碰运气。有一些开源时间不长的、或者关注不多的库,可能就藏在其中。

搭建 API 网关

API 网关功能概览

另外,在这些核心组件之上,我们还需要抽象出几个 API 网关的常用概念,它们在不同的 API 网关之间都是通用的。

Route。路由会包含三部分内容,即匹配的条件、绑定的插件和上游

我们可以直接在 Route 中完成所有的配置,这样最简单。但在 API 和上游很多的情况下,这样做就会有很多重复的配置。这时候,我们就需要 Service 和 Upstream 这两个概念来做一层抽象。

核心组件设计

存储

Kong 是把数据储存在 PostgreSQL 或者 Cassandra 中,而同样基于 OpenResty 的 Orange,则是存储在 MySQL 中。缺点:

  1. 储存需要单独做高可用方案。需要 DBA 和机器资源,在发生故障时也很难做到快速切换。
  2. 只能轮询数据库来获取配置变更,无法做到推送。
  3. 需要自己维护历史版本,并考虑回退和升级。系统升级时候可能会修改表结构,代码层面需要考虑新旧版本兼容。回归需要自己在两个版本直接做 diff。
  4. 提高了代码的复杂度。需要为上面的三个缺陷打补丁,代码可读性会因此下降不少
  5. 增加了部署和运维的难度。难以快速扩缩容。

etcd 就是一个恰到好处的选型了:

路由

lua-resty-radixtree 支持根据 uri、host、http method、http header、Nginx 变量、IP 地址等多个维度,作为路由查找的条件;同时,基数树的时间复杂度为 O(K),性能远比现有 API 网关常用的“遍历+hash 缓存”的方式,来得更为高效。

local radix = require("resty.radixtree")
local rx = radix.new({
{
path = "/aa",
host = "foo.com",
method = {"GET", "POST"},
remote_addr = "127.0.0.1",
},
{
path = "/bb*",
host = {"*.bar.com", "gloo.com"},
method = {"GET", "POST", "PUT"},
remote_addr = "fe80:fe80::/64",
vars = {"arg_name", "jack"},
}
})
ngx.say(rx:match("/aa", {host = "foo.com",
method = "GET",
remote_addr = "127.0.0.1"
}))

schema

lua-resty-jsonschema

local jsonschema = require 'jsonschema'
-- Note: Cache the result of the schema compilation as this is quite expensive
local myvalidator = jsonschema.generate_validator {
type = 'object',
properties = {
foo = { type = 'string' },
bar = { type = 'number' },
},
}
print(myvalidator{ foo='hello', bar=42 })

插件

插件在设计的时候,主要有三个方面需要我们考虑清楚。

  1. 如何挂载。我们希望插件可以挂载到 rewriteaccessheader filerbody filterlog阶段,甚至在 balancer 阶段也可以设置自己的负载均衡算法。所以,我们应该在 Nginx 的配置文件中暴露这些阶段,并在对插件的实现中预留好接口。

  2. 如何获取配置的变更。由于没有关系型数据库的束缚,插件参数的变更可以通过 etcd 的 watch 来实现,这会让整体框架的代码逻辑变得更加明了易懂。

  3. 插件的优先级。具体来说,比如,身份认证和限流限速的插件,应该先执行哪一个呢?绑定在 route 和绑定在 service 上的插件发生冲突时,又应该以哪一个为准呢?这些都是我们需要考虑到位的。

插件内部的一个流程图

架构

当微服务 API 网关的这些关键组件都确定了之后,用户请求的处理流程,也就随之尘埃落定了。

从这个图中我们可以看出,当一个用户请求进入 API 网关时,

实现

Nginx 配置和初始化

我们知道,API 网关是用来处理流量入口的,所以我们首先需要在 Nginx.conf 中做简单的配置,让所有的流量都通过网关的 Lua 代码来处理。

server {
listen 9080;
init_worker_by_lua_block {
apisix.http_init_worker()
}
location / {
access_by_lua_block {
apisix.http_access_phase()
}
header_filter_by_lua_block {
apisix.http_header_filter_phase()
}
body_filter_by_lua_block {
apisix.http_body_filter_phase()
}
log_by_lua_block {
apisix.http_log_phase()
}
}
}

在这个示例中,我们监听了 9080 端口,并通过 location / 的方式,把这个端口的所有请求都拦截下来,并依次通过 accessrewriteheader filterbody filterlog 这几个阶段进行处理,在每个阶段中都会去调用对应的插件函数。其中, rewrite 阶段便是在 apisix.http_access_phase 函数中合并处理的。

而对于系统初始化的工作,我们放在了 init_worker 阶段来处理,这其中包含了读取各项配置参数、预制 etcd 中的目录、从 etcd 中获取插件列表、对于插件按照优先级进行排序等。我这里列出了关键部分的代码并进行讲解,当然,你可以在 GitHub 上看到更完整的初始化函数

function _M.http_init_worker()
-- 分别初始化路由、服务和插件这三个最重要的部分
router.init_worker()
require("apisix.http.service").init_worker()
require("apisix.plugin").init_worker()
end

匹配路由

在最开始的 access 阶段里面,我们首先需要做的就是匹配路由,根据请求中携带 uri、host、args、cookie 等,来和已经设置好的路由规则进行匹配:

router.router_http.match(api_ctx)

对外暴露的,其实只有上面一行代码,这里的api_ctx 中存放的就是 uri、host、args、cookie 这些请求的信息。而具体的 match 函数的实现,就用到了我们前面提到过的 lua-resty-radixtree。如果没有命中,就说明这个请求并没有设置与之对应的上游,就会直接返回 404。

local router = require("resty.radixtree")
local match_opts = {}
function _M.match(api_ctx)
-- 从 ctx 中获取请求的参数,作为路由的判断条件
match_opts.method = api_ctx.var.method
match_opts.host = api_ctx.var.host
match_opts.remote_addr = api_ctx.var.remote_addr
match_opts.vars = api_ctx.var
-- 调用路由的判断函数
local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx)
-- 没有命中路由就直接返回 404
if not ok then
core.log.info("not find any matched route")
return core.response.exit(404)
end
return true
end

加载插件

local plugins = core.tablepool.fetch("plugins", 32, 0)
-- etcd 中的插件列表和本地配置文件中的插件列表进行交集运算
api_ctx.plugins = plugin.filter(route, plugins)
-- 依次运行插件在 rewrite 和 access 阶段挂载的函数
run_plugin("rewrite", plugins, api_ctx)
run_plugin("access", plugins, api_ctx)

在这段代码中,我们首先通过 table pool 的方式,申请了一个长度为 32 的 table,这是我们之前介绍过的性能优化技巧。然后便是插件的过滤函数。你可能疑惑,为什么需要这一步呢?在插件的 init worker 阶段,我们不是已经从 etcd 中获取插件列表并完成排序了吗?

事实上,这里的过滤是和本地配置文件来做对比的,主要有下面两个原因。

因此,在 access 阶段的最后,我们会把这些过滤好的插件,按照优先级逐个运行,如下面这段代码所示:

local function run_plugin(phase, plugins, api_ctx)
for i = 1, #plugins, 2 do
local phase_fun = plugins[i][phase]
if phase_fun then
-- 最核心的调用代码
phase_fun(plugins[i + 1], api_ctx)
end
end
return api_ctx
end

你可以看到,在遍历插件的时候,我们是以 2 为间隔进行的,这是因为每个插件都会有两个部分组成:插件对象和插件的配置参数。现在,我们来看上面示例代码中最核心的那一行代码:

phase_fun(plugins[i + 1], api_ctx)

单独看这行代码会有些抽象,我们用一个具体的 limit_count 插件来替换一下,就会清楚很多:

limit_count_plugin_rewrite_function(conf_of_plugin, api_ctx)

到这里,API 网关的整体流程,我们就实现得差不多了。这些代码都在同一个代码文件中。

编写插件

limit-count 这个限制请求数的插件为例,它的完整实现只有 60 多行代码。

首先,引入 lua-resty-limit-traffic ,作为限制请求数的基础库:

local limit_count_new = require("resty.limit.count").new

然后,使用 json schema ,来定义这个插件的参数有哪些:

local schema = {
type = "object",
properties = {
count = {type = "integer", minimum = 0},
time_window = {type = "integer", minimum = 0},
key = {type = "string",
enum = {"remote_addr", "server_addr"},
},
rejected_code = {type = "integer", minimum = 200, maximum = 600},
},
additionalProperties = false,
required = {"count", "time_window", "key", "rejected_code"},
}

插件的这些参数,和大部分 resty.limit.count 的参数是对应的,其中包含了限制的 key、时间窗口的大小、限制的请求数。另外,插件中增加了一个参数: rejected_code,在请求被限速的时候返回指定的状态码。

最后一步,把插件的处理函数挂载到 rewrite 阶段:

function _M.rewrite(conf, ctx)
-- 从缓存中获取 limit count 的对象,如果没有就使用 `create_limit_obj` 函数新建并缓存
local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx, create_limit_obj, conf)
-- 从 ctx.var 中获取 key 的值,并和配置类型和配置版本号一起组成新的 key
local key = (ctx.var[conf.key] or "") .. ctx.conf_type .. ctx.conf_version
-- 进入限制的判断函数
local delay, remaining = lim:incoming(key, true)
if not delay then
local err = remaining
-- 如果超过阈值,就返回指定的状态码
if err == "rejected" then
return conf.rejected_code
end
core.log.error("failed to limit req: ", err)
return 500
end
-- 如果没有超过阈值,就放行,并设置对应响应头
core.response.set_header("X-RateLimit-Limit", conf.count,
"X-RateLimit-Remaining", remaining)
end

上面的代码中,进行限制判断的逻辑只有一行,其他的都是来做准备工作和设置响应头的。如果没有超过阈值,就会继续按照优先级运行下一个插件。


Previous Post
OpenResty 学习笔记(7) - 性能优化和编码指南