目录
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_yield
和 lua_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:
下面便是 sleep 的主函数,这里只摘取了几行主要的代码:
- 这里先增加了
ngx_http_lua_sleep_handler
这个回调函数; - 然后调用
ngx_add_timer
这个 NGINX 提供的接口,向 NGINX 的事件循环中增加一个定时器; - 最后使用
lua_yield
把 Lua 协程挂起,把控制权交给 NGINX 的事件循环。
当 sleep 操作完成后, ngx_http_lua_sleep_handler
这个回调函数就被触发了。它里面调用了 ngx_http_lua_sleep_resume
, 并最终使用 lua_resume
唤醒了 Lua 协程。更具体的调用过程,你可以自己去代码里面检索,这里我就不展开描述了。
ngx.sleep
只是最简单的一个示例,不过通过对它的剖析,你可以看出 lua-nginx-module
模块的基本原理。
基本概念
OpenResty 每个阶段的作用:
set_by_lua
,用于设置变量;rewrite_by_lua
,用于转发、重定向等;access_by_lua
,用于准入、权限等;content_by_lua
,用于生成返回内容;header_filter_by_lua
,用于应答头过滤处理;body_filter_by_lua
,用于应答体过滤处理;log_by_lua
,用于日志记录。
注意,OpenResty 的 API 是有阶段使用限制的。
以 ngx.sleep
为例。通过查阅文档,我知道它只能用于下面列出的上下文中,并不包括 log 阶段:
而如果你不知道这一点,在它不支持的 log 阶段使用 sleep 的话:
在 NGINX 的错误日志中,就会出现 error 级别的提示:
非阻塞,首先明确一点,由 OpenResty 提供的所有 API,都是非阻塞的。
我继续以 sleep 1 秒这个需求为例来说明。如果你要在 Lua 中实现它,你需要这样做:
因为标准 Lua 没有直接的 sleep 函数,所以这里我用一个循环,来不停地判断是否达到指定的时间。这个实现就是阻塞的,在 sleep 的这一秒钟时间内,Lua 正在做无用功,而其他需要处理的请求,只能在一边傻傻地等待。
不过,要是换成 ngx.sleep(1)
来实现的话,根据上面我们分析过的源码,在这一秒钟的时间内,OpenResty 依然可以去处理其他请求(比如 B 请求),当前请求(我们叫它 A 请求)的上下文会被保存起来,并由 NGINX 的事件机制来唤醒,再回到 A 请求,这样 CPU 就一直处于真正的工作状态。
变量和生命周期
在 OpenResty 中,除了 init_by_lua
和 init_worker_by_lua
这两个阶段外,其余阶段都会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在这两个可以定义全局变量的阶段,也应该尽量避免去定义全局变量。
尽量用用模块的变量来替代
在一个名为 hello.lua 的文件中定义了一个模块,模块包含了 color 这个 table。然后,又在 nginx.conf 中增加了对应的配置:
这段配置会在 content 阶段中 require 这个模块,并把 green 的值作为 http 请求返回体打印出来。
在同一 worker 进程中,模块只会被加载一次;之后这个 worker 处理的所有请求,就可以共享模块中的数据了。我们说“全局”的数据很适合封装在模块内,是因为 OpenResty 的 worker 之间完全隔离,所以每个 worker 都会独立地对模块进行加载,而模块的数据也不能跨越 worker。
访问模块变量的时候,你最好保持只读,而不要尝试去修改,不然在高并发的情况下会出现 race。这种 bug 依靠单元测试是无法发现的,它在线上偶尔会出现,并且很难定位。
跨阶段的变量:
NGINX 中 $host
、$scheme
等变量,虽然满足跨越阶段的条件,但却无法做到动态创建,你必须先在配置文件中定义才能使用它们。
OpenResty 提供了 ngx.ctx
,来解决这类问题。它是一个 Lua table,可以用来存储基于请求的 Lua 数据,且生存周期与当前请求相同。
ngx.ctx
也有自己的局限性:
- 比如说,使用
ngx.location.capture
创建的子请求,会有自己独立的ngx.ctx
数据,和父请求的ngx.ctx
互不影响; - 再如,使用
ngx.exec
创建的内部重定向,会销毁原始请求的ngx.ctx
,重新生成空白的ngx.ctx
。
这两个局限,在官方文档中有详细的代码示例。
使用文档和测试案例
shdict get API
shared dict(共享字典)是基于 NGINX 共享内存区的 Lua 字典对象,它可以跨多个 worker 来存取数据,一般用来存放限流、限速、缓存等数据。 文档链接。
在 Lua 代码中使用 shared dict 之前,需要在 nginx.conf 中用 lua_shared_dict
指令增加一块内存空间,它的名字是 dogs,大小为 10M。修改完 nginx.conf 后,需要重启进程,用浏览器或者 curl 访问才能看到结果。
使用 resty CLI 的这种方式,和在 nginx.conf 中嵌入代码的效果是一致的。
哪些阶段不能使用共享内存相关的 API ?
文档中专门有一个 context
(即上下文部分),里面列出了在什么环境下可以使用这个 API:
可以看出, init
和 init_worker
两个阶段不在其中,也就是说,共享内存的 get API 不能在这两个阶段使用。需要注意的是,每个共享内存的 API 可以使用的阶段并不完全相同,比如 set API 就可以在 init
阶段使用。
不可想当然,多看文档,和用实际测试代码验证。
OpenResty 的测试案例都放在 /t
目录下,并且命名也是有规律的,即自增数字-功能名.t
。搜索shdict
,可以找到 043-shdict.t
,而这就是共享内存的测试案例集了,它里面有接近 100 个测试案例,包含各种正常和异常情况的测试。
把 content 阶段改为 init 阶段,并精简掉无关代码,看看 get 接口能否运行。
--ONLY
标记表示忽略其他所有测试案例,只运行这一个
用 prove 命令,就可以运行这个测试案例:
你会得到一个报错,这也就印证了文档中描述的阶段限制。
get 函数何时会有多个返回值?
文档对这个接口的syntax
语法描述部分:
正常情况下,
- 第一个参数
value
返回的是字典中 key 对应的值;但当 key 不存在或者过期时,value
的值为 nil。 - 第二个参数
flags
就稍微复杂一些了,如果 set 接口设置了 flags,就返回,否则不返回。
一旦 API 调用出错,value
返回 nil,flags
返回具体的错误信息。
local v = dogs:get("Jim")
这种只有一个接收参数的写法并不完善,可以把它修改为下面这样:
到测试案例集里搜索一下,印证下我们对文档的理解:
get 函数的入参是什么类型?
文档里并没有注明 key 的合法类型有哪些。这时该怎么办呢?
从上一节的测试用例可以得知,key 可以是字符串类型,并且不能为 nil。
除了字符串和 nil,还有数字、数组、布尔类型和函数。后面两个显然没有作为 key 的必要性,我们只需要验证前两个。
先去测试文件中搜索一下,是否有数字作为 key 的案例:
数字也可以作为 key ,内部会将数字转为字符串。那么数组呢?测试用例没有覆盖到,自己手动试一下
不出意料,果然报错了:
综上,我们可以得出结论:get API 接受的 key 类型为字符串和数字。
那么入参 key 的长度是否有限制呢?这里其实也有一个对应的测试案例,我们一起来看一下:
OpenResty 的 API
请求阶段
请求行
HTTP 的请求行中包含请求方法、URI 和 HTTP 协议版本。在 NGINX 中,可以通过内置变量的方式,来获取其中的值;而在 OpenResty 中对应的则是 ngx.var.*
这个 API。我们来看两个例子。
$scheme
这个内置变量,在 NGINX 中代表协议的名字,是 “http” 或者 “https”;而在 OpenResty 中,你可以通过ngx.var.scheme
来返回同样的值。$request_method
代表的是请求的方法,“GET”、“POST” 等;而在 OpenResty 中,你可以通过ngx.var. request_method
来返回同样的值。
那么问题就来了:既然可以通过ngx.var.*
这种返回变量值的方法,来得到请求行中的数据,为什么 OpenResty 还要单独提供针对请求行的 API 呢?
这其实是很多方面因素的综合考虑结果:
- 首先是对性能的考虑。
ngx.var
的效率不高,不建议反复读取; - 也有对程序友好的考虑,
ngx.var
返回的是字符串,而非 Lua 对象,遇到获取 args 这种可能返回多个值的情况,就不好处理了; - 另外是对灵活性的考虑,绝大部分的
ngx.var
是只读的,只有很少数的变量是可写的,比如$args
和limit_rate
,可很多时候,我们会有修改 method、URI 和 args 的需求。
获取 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:
set 时候传值混淆的情况还好,API 会崩溃报出 500 的错误;但如果是下面这种判断逻辑的代码,是可以正常运行的,不会报出任何错误,在 code review 时也很难发现。
这类情况,需要自己多小心,或者再多一层封装,也没其它好办法了。在自己设计 API 中,不要做这么反人类的设计!
ngx.req.set_uri
和 ngx.req.set_uri_args
这两个 API,可以用来改写 uri 和 args
如下 Nginx 配置
等价方式:
ngx.req.set_uri
还有第二个参数:jump,默认是 false。如果设置为 true,就等同于把 rewrite 指令的 flag 设置为 last
,而非上面示例中的 break
。
请求头
在 OpenResty 中,可以使用 ngx.req.get_headers
来解析和获取请求头,返回值的类型则是 table:
这里默认返回前 100 个 header,如果请求头超过了 100 个,就会返回 truncated
的错误信息。(涉及到一个安全漏洞 CVE-2018-9230 )
修改和删除请求头:
CVE-2018-9230:
OpenResty 中的
ngx.req.get_uri_args
、ngx.req.get_post_args
和ngx.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 会把内容保存在磁盘的临时文件中。
读取请求体的完整流程是下面这样的:
这段代码中有读取磁盘文件的 IO 阻塞操作。
应该根据实际情况来调整 client_body_buffer_size
配置的大小(64 位系统下默认是 16 KB),尽量减少阻塞的操作;也可以把 client_body_buffer_size
和 client_max_body_size
配置成一样的,完全在内存中来处理,当然,这取决于内存的大小和处理的并发请求数。
改写请求体 API:ngx.req.set_body_data
和 ngx.req.set_body_file
,分别接受字符串和本地磁盘文件做为输入参数。
响应阶段
状态行
HTTP 状态码是 200 对应内置常量ngx.HTTP_OK
。
终止请求:
特别的常量:ngx.OK
。
当 ngx.exit(ngx.OK)
时,请求会退出当前处理阶段,进入下一个阶段,而不是直接返回给客户端。
也可以不退出,这样改写状态码
响应头
设置方法一:
设置方法二:
与第一种方法的不同之处在于,add header 不会覆盖已经存在的同名字段。
响应体
输出响应体 ngx.say
和 ngx.print
,功能是一致的,唯一的不同在于, ngx.say
会在最后多一个换行符。
ngx.say / ngx.print
都支持数组格式:
这样就在 Lua 层面就跳过了字符串的拼接,丢给了 C 函数去处理。
ngx.log
用于将日志消息写入 Nginx 的错误日志中。
正则
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,也就是不做限制。)
如果使用的正则引擎是基于回溯的 NFA 来实现的,那么就有可能出现灾难性回溯(Catastrophic Backtracking),即正则在匹配的时候回溯过多,造成 CPU 100%,正常服务被阻塞。
一旦发生灾难性回溯,我们就需要用 gdb 分析 dump,或者 systemtap 分析线上环境才能定位,而且事先也不容易发现,因为只有特别的请求才会触发。这显然就给攻击者带来了可趁之机,ReDoS(RegEx Denial of Service)就是指的这类攻击。
时间 API
ngx.now
,可以打印出当前的时间戳
ngx.now
包括了小数部分。
ngx.time
则只返回了整数部分的值
ngx.localtime
、ngx.utctime
、ngx.cookie_time
和 ngx.http_time
,主要是返回和处理时间的不同格式。
这些返回当前时间的 API,如果没有非阻塞网络 IO 操作来触发,便会一直返回缓存的值,而不是像我们想的那样,能够返回当前的实时时间。可以看看下面这个示例代码:
在两次调用 ngx.now
之间,我们使用 Lua 的阻塞函数 sleep 了 1 秒钟,但从打印的结果来看,这两次返回的时间戳却是一模一样的。
那么,如果换成是非阻塞的 sleep 函数呢?比如下面这段新的代码:
它就会打印出不同的时间戳了。
另外,长时间占用 CPU 代码中可以穿插 ngx.sleep(0)
,使这段代码让出控制权,让其他请求也可以得到处理。
原因:
Nginx 是以性能优先作为设计理念的,它会把时间缓存下来
ngx.now()
这个获取当前时间函数的背后,隐藏的其实是 Nginx 的 ngx_timeofday
函数。而ngx_timeofday
函数,其实是一个宏定义:
这里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 之前,先做一个简单的判断:
就能实现只启动一个 timer 的目的了。这里注意,worker id 是从 0 开始返回的,这和 Lua 中数组下标从 1 开始并不相同,千万不要混淆了。
真值和空值
Lua 中真值的定义:除了 nil 和 false 之外,都是真值。
所以,真值也就包括了:0、空字符串、空表等等。
再来看下 Lua 中的空值(nil),它是未定义的意思,比如你申明了一个变量,但还没有初始化,它的值就是 nil:
而 nil 也是 Lua 中的一种数据类型。
ngx.null
因为 Lua 的 nil 无法作为 table 的 value,所以 OpenResty 引入了 ngx.null
,作为 table 中的空值:
ngx.null
被打印出来是 null,而它的类型是 userdata。
但是,不能把它当作假值,ngx.null
的布尔值为真:
重复:只有 nil 和 false 是假值。
比如:在使用 lua-resty-redis 的时候,做了下面这个判断:
如果返回值 res 是 nil,就说明函数调用失败了;如果 res 是 ngx.null,就说明 redis 中不存在 dog
这个 key。那么,在 dog
这个 key 不存在的情况下,这段代码就 500 崩溃了。
cdata:NULL
当通过 LuaJIT FFI 接口去调用 C 函数,而这个函数返回一个 NULL 指针,那么你就会遇到另外一种空值,即cdata:NULL
。
和 ngx.null
一样,cdata:NULL
也是真值。但更让人匪夷所思的是,下面这段代码,会打印出 true,也就是说cdata:NULL
是和 nil
相等的:
对于这种奇怪定义,最好做二次封装,不让调用者知道这些细节!
cjson.null
cjson 中出现的空值。cjson 库会把 json 中的 NULL,解码为 Lua 的 lightuserdata
,并用 cjson.null
来表示:
Lua 中的 nil,被 json encode 和 decode 一圈儿之后,就变成了 cjson.null
。它引入的原因和 ngx.null
是一样的,因为 nil 无法在 table 中作为 value。