Skip to content

OpenResty 学习笔记(4) - OpenResty 原理和 API

Published: at 12:40 PM30 min read

目录

OpenResty 的原理和基本概念

OpenResty 的 master 和 worker 进程中,都包含一个 LuaJIT VM。在同一个进程内的所有协程,都会共享这个 VM,并在这个 VM 中运行 Lua 代码。

在同一个时间点上,每个 worker 进程只能处理一个用户的请求,也就是只有一个协程在运行。看到这里,你可能会有一个疑问:NGINX 既然能够支持 C10K (上万并发),不是需要同时处理一万个请求吗?

当然不是,NGINX 实际上是通过 epoll 的事件驱动,来减少等待和空转,才尽可能地让 CPU 资源都用于处理用户的请求。毕竟,只有单个的请求被足够快地处理完,整体才能达到高性能的目的。如果采用的是多线程模式,让一个请求对应一个线程,那么在 C10K 的情况下,资源很容易就会被耗尽的。

在 OpenResty 层面,Lua 的协程会与 NGINX 的事件机制相互配合。如果 Lua 代码中出现类似查询 MySQL 数据库这样的 I/O 操作,就会先调用 Lua 协程的 yield 把自己挂起,然后在 NGINX 中注册回调;在 I/O 操作完成(也可能是超时或者出错)后,再由 NGINX 回调 resume 来唤醒 Lua 协程。这样就完成了 Lua 协程和 NGINX 事件驱动的配合,避免在 Lua 代码中写回调。

我们可以来看下面这张图,描述了这整个流程。其中,lua_yieldlua_resume 都属于 Lua 提供的 lua_CFunction

如果 Lua 代码中没有 I/O 或者 sleep 操作,比如全是密集的加解密运算,那么 Lua 协程就会一直占用 LuaJIT VM,直到处理完整个请求。

下面提供了 ngx.sleep 的一段源码,可以帮你更清晰理解这一点。 这段代码位于 ngx_http_lua_sleep.c 中,可以在 lua-nginx-module 项目的 src 目录中找到它。

ngx_http_lua_sleep.c 中,我们可以看到 sleep 函数的具体实现。需要先通过 C 函数 ngx_http_lua_ngx_sleep,来注册 ngx.sleep 这个 Lua API:

void
ngx_http_lua_inject_sleep_api(lua_State *L)
{
lua_pushcfunction(L, ngx_http_lua_ngx_sleep);
lua_setfield(L, -2, "sleep");
}

下面便是 sleep 的主函数,这里只摘取了几行主要的代码:

static int ngx_http_lua_ngx_sleep(lua_State *L)
{
coctx->sleep.handler = ngx_http_lua_sleep_handler;
ngx_add_timer(&coctx->sleep, (ngx_msec_t) delay);
return lua_yield(L, 0);
}

当 sleep 操作完成后, ngx_http_lua_sleep_handler 这个回调函数就被触发了。它里面调用了 ngx_http_lua_sleep_resume, 并最终使用 lua_resume 唤醒了 Lua 协程。更具体的调用过程,你可以自己去代码里面检索,这里我就不展开描述了。

ngx.sleep 只是最简单的一个示例,不过通过对它的剖析,你可以看出 lua-nginx-module 模块的基本原理。

基本概念

OpenResty 每个阶段的作用:

注意,OpenResty 的 API 是有阶段使用限制的。

ngx.sleep 为例。通过查阅文档,我知道它只能用于下面列出的上下文中,并不包括 log 阶段:

context: rewrite*by_lua*, access*by_lua*, content_by_lua*, ngx.timer.*, ssl*certificate_by_lua*, ssl_session_fetch_by_lua\*\*

而如果你不知道这一点,在它不支持的 log 阶段使用 sleep 的话:

location / {
log_by_lua_block {
ngx.sleep(1)
}
}

在 NGINX 的错误日志中,就会出现 error 级别的提示:

[error] 62666#0: *6 failed to run log_by_lua*: log_by_lua(nginx.conf:14):2: API disabled in the context of log_by_lua*
stack traceback:
[C]: in function 'sleep'

非阻塞,首先明确一点,由 OpenResty 提供的所有 API,都是非阻塞的。

我继续以 sleep 1 秒这个需求为例来说明。如果你要在 Lua 中实现它,你需要这样做:

function sleep(s)
local ntime = os.time() + s
repeat until os.time() > ntime
end

因为标准 Lua 没有直接的 sleep 函数,所以这里我用一个循环,来不停地判断是否达到指定的时间。这个实现就是阻塞的,在 sleep 的这一秒钟时间内,Lua 正在做无用功,而其他需要处理的请求,只能在一边傻傻地等待。

不过,要是换成 ngx.sleep(1) 来实现的话,根据上面我们分析过的源码,在这一秒钟的时间内,OpenResty 依然可以去处理其他请求(比如 B 请求),当前请求(我们叫它 A 请求)的上下文会被保存起来,并由 NGINX 的事件机制来唤醒,再回到 A 请求,这样 CPU 就一直处于真正的工作状态。

变量和生命周期

在 OpenResty 中,除了 init_by_luainit_worker_by_lua 这两个阶段外,其余阶段都会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在这两个可以定义全局变量的阶段,也应该尽量避免去定义全局变量。

尽量用用模块的变量来替代

local _M = {}
_M.color = {
red = 1,
blue = 2,
green = 3
}
return _M

在一个名为 hello.lua 的文件中定义了一个模块,模块包含了 color 这个 table。然后,又在 nginx.conf 中增加了对应的配置:

location / {
content_by_lua_block {
local hello = require "hello"
ngx.say(hello.color.green)
}
}

这段配置会在 content 阶段中 require 这个模块,并把 green 的值作为 http 请求返回体打印出来。

在同一 worker 进程中,模块只会被加载一次;之后这个 worker 处理的所有请求,就可以共享模块中的数据了。我们说“全局”的数据很适合封装在模块内,是因为 OpenResty 的 worker 之间完全隔离,所以每个 worker 都会独立地对模块进行加载,而模块的数据也不能跨越 worker。

访问模块变量的时候,你最好保持只读,而不要尝试去修改,不然在高并发的情况下会出现 race。这种 bug 依靠单元测试是无法发现的,它在线上偶尔会出现,并且很难定位。

跨阶段的变量:

NGINX 中 $host$scheme 等变量,虽然满足跨越阶段的条件,但却无法做到动态创建,你必须先在配置文件中定义才能使用它们。

location /foo {
set $my_var ; # 需要先创建 $my_var 变量
content_by_lua_block {
ngx.var.my_var = 123
}
}

OpenResty 提供了 ngx.ctx,来解决这类问题。它是一个 Lua table,可以用来存储基于请求的 Lua 数据,且生存周期与当前请求相同。

location /test {
rewrite_by_lua_block {
ngx.ctx.foo = 76
}
access_by_lua_block {
ngx.ctx.foo = ngx.ctx.foo + 3
}
content_by_lua_block {
ngx.say(ngx.ctx.foo)
}
}

ngx.ctx 也有自己的局限性:

这两个局限,在官方文档中有详细的代码示例

使用文档和测试案例

shdict get API

shared dict(共享字典)是基于 NGINX 共享内存区的 Lua 字典对象,它可以跨多个 worker 来存取数据,一般用来存放限流、限速、缓存等数据。 文档链接

http {
lua_shared_dict dogs 10m;
server {
location /demo {
content_by_lua_block {
local dogs = ngx.shared.dogs
dogs:set("Jim", 8)
local v = dogs:get("Jim")
ngx.say(v)
}
}
}
}

在 Lua 代码中使用 shared dict 之前,需要在 nginx.conf 中用 lua_shared_dict 指令增加一块内存空间,它的名字是 dogs,大小为 10M。修改完 nginx.conf 后,需要重启进程,用浏览器或者 curl 访问才能看到结果。

使用 resty CLI 的这种方式,和在 nginx.conf 中嵌入代码的效果是一致的。

Terminal window
$ resty --shdict 'dogs 10m' -e 'local dogs = ngx.shared.dogs
dogs:set("Jim", 8)
local v = dogs:get("Jim")
ngx.say(v)
'

哪些阶段不能使用共享内存相关的 API ?

文档中专门有一个 context (即上下文部分),里面列出了在什么环境下可以使用这个 API:

context: set_by_lua*, rewrite_by_lua*, access_by_lua*, content_by_lua*, header_filter_by_lua*, body_filter_by_lua*, log_by_lua*, ngx.timer.*, balancer_by_lua*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*, ssl_session_store_by_lua*

可以看出, initinit_worker 两个阶段不在其中,也就是说,共享内存的 get API 不能在这两个阶段使用。需要注意的是,每个共享内存的 API 可以使用的阶段并不完全相同,比如 set API 就可以在 init 阶段使用。

不可想当然,多看文档,和用实际测试代码验证。

OpenResty 的测试案例都放在 /t 目录下,并且命名也是有规律的,即自增数字-功能名.t。搜索shdict,可以找到 043-shdict.t,而这就是共享内存的测试案例集了,它里面有接近 100 个测试案例,包含各种正常和异常情况的测试。

把 content 阶段改为 init 阶段,并精简掉无关代码,看看 get 接口能否运行。

=== TEST 1: string key, int value
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
init_by_lua '
local dogs = ngx.shared.dogs
local val = dogs:get("foo")
ngx.say(val)
';
}
--- request
GET /test
--- response_body
32
--- no_error_log
[error]
--- ONLY

--ONLY 标记表示忽略其他所有测试案例,只运行这一个

用 prove 命令,就可以运行这个测试案例:

Terminal window
$ prove t/043-shdict.t

你会得到一个报错,这也就印证了文档中描述的阶段限制。

Terminal window
nginx: [emerg] "init_by_lua" directive is not allowed here

get 函数何时会有多个返回值?

文档对这个接口的syntax 语法描述部分:

value, flags = ngx.shared.DICT:get(key)

正常情况下,

一旦 API 调用出错,value 返回 nil,flags 返回具体的错误信息。

local v = dogs:get("Jim") 这种只有一个接收参数的写法并不完善,可以把它修改为下面这样:

local data, err = dogs:get("Jim")
if data == nil and err then
ngx.say("get not ok: ", err)
return
end

到测试案例集里搜索一下,印证下我们对文档的理解:

=== TEST 65: get nil key
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua '
local dogs = ngx.shared.dogs
local ok, err = dogs:get(nil)
if not ok then
ngx.say("not ok: ", err)
return
end
ngx.say("ok")
';
}
--- request
GET /test
--- response_body
not ok: nil key
--- no_error_log
[error]

get 函数的入参是什么类型?

文档里并没有注明 key 的合法类型有哪些。这时该怎么办呢?

从上一节的测试用例可以得知,key 可以是字符串类型,并且不能为 nil。

除了字符串和 nil,还有数字、数组、布尔类型和函数。后面两个显然没有作为 key 的必要性,我们只需要验证前两个。

先去测试文件中搜索一下,是否有数字作为 key 的案例:

=== TEST 4: number keys, string values

数字也可以作为 key ,内部会将数字转为字符串。那么数组呢?测试用例没有覆盖到,自己手动试一下

Terminal window
$ resty --shdict 'dogs 10m' -e 'local dogs = ngx.shared.dogs
dogs:get({})
'

不出意料,果然报错了:

Terminal window
ERROR: (command line -e):2: bad argument #1 to 'get' (string expected, got table)

综上,我们可以得出结论:get API 接受的 key 类型为字符串和数字。

那么入参 key 的长度是否有限制呢?这里其实也有一个对应的测试案例,我们一起来看一下:

=== TEST 67: get a too-long key
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua '
local dogs = ngx.shared.dogs
local ok, err = dogs:get(string.rep("a", 65536))
if not ok then
ngx.say("not ok: ", err)
return
end
ngx.say("ok")
';
}
--- request
GET /test
--- response_body
not ok: key too long
--- no_error_log
[error]

OpenResty 的 API

请求阶段

请求行

HTTP 的请求行中包含请求方法、URI 和 HTTP 协议版本。在 NGINX 中,可以通过内置变量的方式,来获取其中的值;而在 OpenResty 中对应的则是 ngx.var.* 这个 API。我们来看两个例子。

完整的 NGINX 内置变量列表

那么问题就来了:既然可以通过ngx.var.* 这种返回变量值的方法,来得到请求行中的数据,为什么 OpenResty 还要单独提供针对请求行的 API 呢?

这其实是很多方面因素的综合考虑结果:

获取 HTTP 协议版本号的 API ngx.req.http_version,和 NGINX 的 $server_protocol 变量的作用一样,都是返回 HTTP 协议的版本号。

不过这个 API 的返回值是数字格式,而非字符串,可能的值是 2.0、1.0、1.1 和 0.9,如果结果不在这几个值的范围内,就会返回 nil。

ngx.req.get_method 和 NGINX 的 $request_method 变量的作用、返回值一样,都是字符串格式的方法名。

但是,改写当前 HTTP 请求方法的 API,也就是 ngx.req.set_method,它接受的参数格式却并非字符串,而是内置的数字常量。比如,下面的代码,把请求方法改写为 POST:

ngx.req.set_method(ngx.HTTP_POST)
Terminal window
$ resty -e 'print(ngx.HTTP_POST)'
8

set 时候传值混淆的情况还好,API 会崩溃报出 500 的错误;但如果是下面这种判断逻辑的代码,是可以正常运行的,不会报出任何错误,在 code review 时也很难发现。

if (ngx.req.get_method() == ngx.HTTP_POST) then
-- do something
end

这类情况,需要自己多小心,或者再多一层封装,也没其它好办法了。在自己设计 API 中,不要做这么反人类的设计!

ngx.req.set_uringx.req.set_uri_args 这两个 API,可以用来改写 uri 和 args

如下 Nginx 配置

rewrite ^ /foo?a=3? break;

等价方式:

ngx.req.set_uri_args("a=3")
ngx.req.set_uri("/foo")

ngx.req.set_uri 还有第二个参数:jump,默认是 false。如果设置为 true,就等同于把 rewrite 指令的 flag 设置为 last,而非上面示例中的 break

请求头

Accept: text/css,*/*;q=0.1
Accept-Encoding: gzip, deflate, br

在 OpenResty 中,可以使用 ngx.req.get_headers 来解析和获取请求头,返回值的类型则是 table:

local h, err = ngx.req.get_headers()
if err == "truncated" then
-- one can choose to ignore or reject the current request here
end
for k, v in pairs(h) do
...
end

这里默认返回前 100 个 header,如果请求头超过了 100 个,就会返回 truncated 的错误信息。(涉及到一个安全漏洞 CVE-2018-9230 )

修改和删除请求头:

ngx.req.set_header("Content-Type", "text/css")
ngx.req.clear_header("Content-Type")

CVE-2018-9230:

OpenResty 中的 ngx.req.get_uri_argsngx.req.get_post_argsngx.req.get_headers接口,默认只返回前 100 个参数。如果 WAF 的开发者没有注意到这个细节,就会被参数溢出的方式攻击。攻击者可以填入 100 个无用参数,把 payload 放在第 101 个参数中,借此绕过 WAF 的检测。

那么,应该如何处理这个 CVE 呢?

显然,OpenResty 的维护者需要考虑到向下兼容、不引入更多安全风险和不影响性能这么几个因素,并要在其中做出一个平衡的选择。

最终,OpenResty 维护者选择新增一个 err 的返回值来解决这个问题。如果输入参数超过 100 个,err 的提示信息就是 truncated。这样一来,这些 API 的调用者就必须要处理错误信息,自行判断拒绝请求还是放行。

其实,归根到底,安全是一种平衡。究竟是选择基于规则的黑名单方式,还是选择基于身份的白名单方式,抑或是两种方式兼用,都取决于你的实际业务场景。

请求体

出于性能考虑,OpenResty 不会主动读取请求体的内容,除非在 nginx.conf 中强制开启了 lua_need_request_body 指令。对于比较大的请求体,OpenResty 会把内容保存在磁盘的临时文件中。

读取请求体的完整流程是下面这样的:

ngx.req.read_body()
local data = ngx.req.get_body_data()
if not data then
local tmp_file = ngx.req.get_body_file()
-- io.open(tmp_file)
-- ...
end

这段代码中有读取磁盘文件的 IO 阻塞操作。

应该根据实际情况来调整 client_body_buffer_size 配置的大小(64 位系统下默认是 16 KB),尽量减少阻塞的操作;也可以把 client_body_buffer_sizeclient_max_body_size 配置成一样的,完全在内存中来处理,当然,这取决于内存的大小和处理的并发请求数。

改写请求体 API:ngx.req.set_body_datangx.req.set_body_file ,分别接受字符串和本地磁盘文件做为输入参数。

响应阶段

状态行

HTTP 状态码是 200 对应内置常量ngx.HTTP_OK

终止请求:

ngx.exit(ngx.HTTP_BAD_REQUEST)

特别的常量:ngx.OK

ngx.exit(ngx.OK) 时,请求会退出当前处理阶段,进入下一个阶段,而不是直接返回给客户端。

也可以不退出,这样改写状态码

ngx.status = ngx.HTTP_FORBIDDEN

状态码常量文档

响应头

设置方法一:

ngx.header.content_type = 'text/plain'
ngx.header["X-My-Header"] = 'blah blah'
ngx.header["X-My-Header"] = nil -- 删除

设置方法二:

local ngx_resp = require "ngx.resp"
ngx_resp.add_header("Foo", "bar")

与第一种方法的不同之处在于,add header 不会覆盖已经存在的同名字段。

响应体

输出响应体 ngx.sayngx.print ,功能是一致的,唯一的不同在于, ngx.say 会在最后多一个换行符。

ngx.say('hello, world')

ngx.say / ngx.print 都支持数组格式:

Terminal window
$ resty -e 'ngx.say({"hello", ", ", "world"})'
hello, world

这样就在 Lua 层面就跳过了字符串的拼接,丢给了 C 函数去处理。

ngx.log 用于将日志消息写入 Nginx 的错误日志中。

ngx.log(ngx.ERR, "This is an error message")
ngx.log(ngx.WARN, "This is a warning message")

正则

ngx.re.split

字符串切割,ngx.re.split 这个 API 并不在 lua-nginx-module 中,而是在 lua-resty-core 里面,文档在 lua-resty-core/lib/ngx/re.md

除了阅读 lua-resty-core 首页文档外,你还需要把 lua-resty-core/lib/ngx/ 这个目录下的 .md 格式的文档也通读一遍才行。

lua_regex_match_limit

它是 OpenResty 提供的 Nginx 指令,用来限制 PCRE 正则引擎的回溯次数的,如果出现灾难性回溯灾难性回溯,不会导致 CPU 满载。(这个指令的默认值是 0,也就是不做限制。)

lua_regex_match_limit 100000;

如果使用的正则引擎是基于回溯的 NFA 来实现的,那么就有可能出现灾难性回溯(Catastrophic Backtracking),即正则在匹配的时候回溯过多,造成 CPU 100%,正常服务被阻塞。

一旦发生灾难性回溯,我们就需要用 gdb 分析 dump,或者 systemtap 分析线上环境才能定位,而且事先也不容易发现,因为只有特别的请求才会触发。这显然就给攻击者带来了可趁之机,ReDoS(RegEx Denial of Service)就是指的这类攻击。

如何彻底避免正则表达式的灾难性回溯

时间 API

ngx.now,可以打印出当前的时间戳

Terminal window
resty -e 'ngx.say(ngx.now())'

ngx.now 包括了小数部分。

ngx.time 则只返回了整数部分的值

ngx.localtimengx.utctimengx.cookie_timengx.http_time ,主要是返回和处理时间的不同格式。

这些返回当前时间的 API,如果没有非阻塞网络 IO 操作来触发,便会一直返回缓存的值,而不是像我们想的那样,能够返回当前的实时时间。可以看看下面这个示例代码:

Terminal window
$ resty -e 'ngx.say(ngx.now())
os.execute("sleep 1")
ngx.say(ngx.now())'

在两次调用 ngx.now 之间,我们使用 Lua 的阻塞函数 sleep 了 1 秒钟,但从打印的结果来看,这两次返回的时间戳却是一模一样的。

那么,如果换成是非阻塞的 sleep 函数呢?比如下面这段新的代码:

Terminal window
$ resty -e 'ngx.say(ngx.now())
ngx.sleep(1)
ngx.say(ngx.now())'

它就会打印出不同的时间戳了。

另外,长时间占用 CPU 代码中可以穿插 ngx.sleep(0),使这段代码让出控制权,让其他请求也可以得到处理。

原因:

Nginx 是以性能优先作为设计理念的,它会把时间缓存下来

static int
ngx_http_lua_ngx_now(lua_State *L)
{
ngx_time_t *tp;
tp = ngx_timeofday();
lua_pushnumber(L, (lua_Number) (tp->sec + tp->msec / 1000.0L));
return 1;
}

ngx.now()这个获取当前时间函数的背后,隐藏的其实是 Nginx 的 ngx_timeofday 函数。而ngx_timeofday 函数,其实是一个宏定义:

#define ngx_timeofday() (ngx_time_t *) ngx_cached_time

这里ngx_cached_time 的值,只在函数 ngx_time_update 中会更新。

而从源码来看ngx_time_update 的调用都出现在事件循环中,所以阻塞的操作无法获取到的都是同一个缓存值。

worker 和进程 API

ngx.worker.* 获取 Nginx worker 进程相关信息

ngx.process.* 获取所有的 Nginx 进程信息(worker 进程、master 进程、特权进程等)。

如何保证在多 worker 的情况下,只启动一个 timer?

使用 ngx.worker.id API,在启动 timer 之前,先做一个简单的判断:

if ngx.worker.id == 0 then
start_timer()
end

就能实现只启动一个 timer 的目的了。这里注意,worker id 是从 0 开始返回的,这和 Lua 中数组下标从 1 开始并不相同,千万不要混淆了。

真值和空值

Lua 中真值的定义:除了 nil 和 false 之外,都是真值。

所以,真值也就包括了:0、空字符串、空表等等。

再来看下 Lua 中的空值(nil),它是未定义的意思,比如你申明了一个变量,但还没有初始化,它的值就是 nil:

$ resty -e 'local a
ngx.say(type(a))'

而 nil 也是 Lua 中的一种数据类型。

ngx.null

因为 Lua 的 nil 无法作为 table 的 value,所以 OpenResty 引入了 ngx.null,作为 table 中的空值:

Terminal window
$ resty -e 'print(ngx.null)'
null
$ resty -e 'print(type(ngx.null))'
userdata

ngx.null 被打印出来是 null,而它的类型是 userdata。

但是,不能把它当作假值,ngx.null 的布尔值为真:

Terminal window
$ resty -e 'if ngx.null then
ngx.say("true")
end'

重复:只有 nil 和 false 是假值

比如:在使用 lua-resty-redis 的时候,做了下面这个判断:

local res, err = red:get("dog")
if not res then
res = res + "test"
end

如果返回值 res 是 nil,就说明函数调用失败了;如果 res 是 ngx.null,就说明 redis 中不存在 dog 这个 key。那么,在 dog 这个 key 不存在的情况下,这段代码就 500 崩溃了。

cdata:NULL

当通过 LuaJIT FFI 接口去调用 C 函数,而这个函数返回一个 NULL 指针,那么你就会遇到另外一种空值,即cdata:NULL

Terminal window
$ resty -e 'local ffi = require "ffi"
local cdata_null = ffi.new("void*", nil)
if cdata_null then
ngx.say("true")
end'

ngx.null 一样,cdata:NULL 也是真值。但更让人匪夷所思的是,下面这段代码,会打印出 true,也就是说cdata:NULL 是和 nil 相等的:

Terminal window
$ resty -e 'local ffi = require "ffi"
local cdata_null = ffi.new("void*", nil)
ngx.say(cdata_null == nil)'

对于这种奇怪定义,最好做二次封装,不让调用者知道这些细节!

cjson.null

cjson 中出现的空值。cjson 库会把 json 中的 NULL,解码为 Lua 的 lightuserdata,并用 cjson.null 来表示:

Terminal window
$ resty -e 'local cjson = require "cjson"
local data = cjson.encode(nil)
local decode_null = cjson.decode(data)
ngx.say(decode_null == cjson.null)'

Lua 中的 nil,被 json encode 和 decode 一圈儿之后,就变成了 cjson.null。它引入的原因和 ngx.null 是一样的,因为 nil 无法在 table 中作为 value。


Previous Post
OpenResty 学习笔记(3) - LuaJIT
Next Post
OpenResty 学习笔记(5) - shared dict、cosocket、特权进程